Automatizar encendido y apagado de #ECS en #AWS

En el mundo de la computación en la nube, la eficiencia no es solo una cuestión técnica, sino también económica. Uno de los costos más comunes, pero evitables, proviene de mantener activos servicios que no están en uso durante ciertos periodos del día, por ejemplo en entornos de Desarrollo o Pruebas. ¿Por qué pagar por algo que no necesitas? Es aquí donde entra en juego la automatización del inicio y la detención de servicios ECS (Elastic Container Service), una práctica que optimiza tanto los recursos como el presupuesto.
Automatizar encendido y apagado de #ECS en #AWS

En el mundo de la computación en la nube, la eficiencia no es solo una cuestión técnica, sino también económica. Uno de los costos más comunes, pero evitables, proviene de mantener activos servicios que no están en uso durante ciertos periodos del día, por ejemplo en entornos de Desarrollo o Pruebas. ¿Por qué pagar por algo que no necesitas? Es aquí donde entra en juego la automatización del inicio y la detención de servicios ECS (Elastic Container Service), una práctica que optimiza tanto los recursos como el presupuesto.

La solución que he desarrollado con AWS CloudFormation tiene un propósito claro: simplificar la gestión de los servicios contenedores y garantizar que solo estén activos cuando realmente se necesita. Por ejemplo, si tienes servicios ECS que solo se usan durante horarios laborales, programar su detención fuera de ese periodo puede generar un impacto significativo en tu factura mensual.

Se ha integrado un registro histórico de los cambios en la configuración deseada de los servicios ECS en DynamoDB, esto con la finalidad de restaurar en el siguiente encendido la cantidad de tareas que tenía el contenedor previamente configurado. Adicionalmente estas funciones lambda evalúan si es festivo o no en COLOMBIA para validar si ejecuta o no el proceso, dependiendo de esto realizará o no la tarea de encendido (para ahorrarnos un día de consumo en los festivos).

Se debe tener en cuenta que aquí se utilizan varios componentes de AWS que puede que generen un mínimo consumo, respecto a tener los contenedores siempre corriendo, generando un ahorro considerable:

  • CloudWatch
  • DynamoDB
  • Lambda

Para realizar el despliegue, debes subir el siguiente YAML como una plantilla de CloudFormation, ya sea dentro de la cuenta de AWS o como un StackSet dentro de la organización en las OUs correspondientes.

AWSTemplateFormatVersion: '2010-09-09'
Description: CloudFormation template to manage ECS services and DynamoDB with scheduled events

Resources:
  # Grupo de Logs para las Lambdas
  LogGroupStopECS:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/StopECSFunction
      RetentionInDays: 7

  LogGroupStartECS:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/StartECSFunction
      RetentionInDays: 7

  # Tabla DynamoDB
  ECSServiceDesiredCounts:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: ECSServiceDesiredCounts
      AttributeDefinitions:
        - AttributeName: ServiceClusterKey
          AttributeType: S
      KeySchema:
        - AttributeName: ServiceClusterKey
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  # Rol para la Lambda de Apagado
  StopECSLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: StopECSLambdaExecutionRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: StopECSPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: LOGS
                Effect: Allow
                Action: logs:CreateLogGroup
                Resource: arn:aws:logs:*:*:* 
              - Sid: LOGS2
                Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: arn:aws:logs:*:*:* 
              - Sid: ECS
                Effect: Allow
                Action:
                  - ecs:ListClusters
                  - ecs:ListServices
                  - ecs:UpdateService
                  - ecs:DescribeServices
                Resource: "*"
              - Sid: DYNAMODB
                Effect: Allow
                Action:
                  - dynamodb:GetItem
                  - dynamodb:PutItem
                Resource: "*"

  # Rol para la Lambda de Encendido  
  StartECSLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: StartECSLambdaExecutionRole
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: StartECSPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: LOGS
                Effect: Allow
                Action: logs:CreateLogGroup
                Resource: arn:aws:logs:*:*:* 
              - Sid: LOGS2
                Effect: Allow
                Action:
                  - logs:CreateLogStream 
                  - logs:PutLogEvents 
                Resource: arn:aws:logs:*:*:* 
              - Sid: ECS 
                Effect: Allow 
                Action:
                  - ecs:ListClusters 
                  - ecs:ListServices 
                  - ecs:UpdateService 
                  - ecs:DescribeServices 
                Resource: "*" 
              - Sid: DYNAMODB 
                Effect: Allow
                Action: dynamodb:GetItem
                Resource: "*"

  # Función Lambda para Apagar Servicios  
  StopECSFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: StopECSFunction  
      Description: "Función Lambda para detener servicios ECS y almacenar el conteo deseado en DynamoDB"
      Handler: index.lambda_handler  
      Timeout: 60
      Role: !GetAtt [StopECSLambdaExecutionRole, Arn]
      Runtime: python3.9
      Code:  
        ZipFile: |  
            import boto3
            import logging

            # Configuración del logger
            logger = logging.getLogger()
            logger.setLevel(logging.INFO)

            # Crea un manejador para enviar logs a CloudWatch
            ch = logging.StreamHandler()
            ch.setLevel(logging.INFO)
            formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
            ch.setFormatter(formatter)
            logger.addHandler(ch)

            # Inicializar recursos de AWS
            dynamodb = boto3.resource('dynamodb')
            ecs_client = boto3.client('ecs')

            # Nombre de la tabla DynamoDB
            DYNAMODB_TABLE_NAME = 'ECSServiceDesiredCounts'

            def get_desired_count(service_name, cluster_name):
                """Recupera el valor deseado del servicio desde DynamoDB usando ServiceClusterKey."""
                table = dynamodb.Table(DYNAMODB_TABLE_NAME)
                service_cluster_key = f"{service_name}:{cluster_name}"
                response = table.get_item(Key={'ServiceClusterKey': service_cluster_key})
                return response.get('Item', {}).get('DesiredCount', None)

            def set_desired_count(service_name, cluster_name, desired_count):
                """Almacena el valor deseado del servicio en DynamoDB usando ServiceClusterKey."""
                table = dynamodb.Table(DYNAMODB_TABLE_NAME)
                service_cluster_key = f"{service_name}:{cluster_name}"
                
                # Asegúrate de incluir DesiredCount en el ítem
                if desired_count is not None:
                    desired_count_int = int(desired_count)  # Asegúrate de que sea un int
                    table.put_item(Item={
                        'ServiceClusterKey': service_cluster_key,
                        'DesiredCount': desired_count_int  # Asegúrate de que este campo esté presente
                    })
                    logger.info(f"Se ha almacenado el desiredCount para {service_name} en {cluster_name}: {desired_count_int}")
                else:
                    logger.error(f"No se puede almacenar desiredCount porque es None para {service_name} en {cluster_name}.")

            def extract_service_name(service_arn):
                """Extrae el nombre del servicio del ARN."""
                return service_arn.split('/')[-1]

            def extract_cluster_name(cluster_arn):
                """Extrae el nombre del clúster del ARN."""
                return cluster_arn.split(':')[-1]

            def lambda_handler(event, context):
                logger.info("Iniciando la función Lambda para detener servicios en ECS.")
                
                try:
                    # Obtener la lista de clústeres
                    logger.info("Obteniendo la lista de clústeres.")
                    clusters_response = ecs_client.list_clusters()
                    clusters = clusters_response['clusterArns']
                    logger.info(f"Clusters encontrados: {[extract_cluster_name(c) for c in clusters]}")
                except Exception as e:
                    logger.error(f"Error al obtener clústeres: {str(e)}")
                    return {
                        'statusCode': 500,
                        'body': f"Error al obtener clústeres: {str(e)}"
                    }

                servicios_detener = []
                servicios_no_detener = []

                # Iterar sobre cada clúster y detener los servicios
                for cluster in clusters:
                    cluster_name = extract_cluster_name(cluster)
                    try:
                        logger.info(f"Listando servicios en el clúster: {cluster_name}.")
                        services_response = ecs_client.list_services(cluster=cluster)
                        services = services_response['serviceArns']
                        logger.info(f"Servicios encontrados en el clúster {cluster_name}: {[extract_service_name(s) for s in services]}")

                        # Detener cada servicio
                        for service_arn in services:
                            service_name = extract_service_name(service_arn)
                            
                            # Guardar el valor deseado actual antes de detenerlo
                            current_service_info = ecs_client.describe_services(cluster=cluster, services=[service_arn])
                            current_desired_count = current_service_info['services'][0]['desiredCount']
                            set_desired_count(service_name, cluster_name, current_desired_count)  # Almacena el valor actual
                            
                            try:
                                if current_desired_count == 0:
                                    logger.info(f"El servicio {service_name} ya está detenido en el clúster {cluster_name}.")
                                    continue
                                
                                logger.info(f"Deteniendo el servicio: {service_name}.")
                                ecs_client.update_service(cluster=cluster, service=service_arn, desiredCount=0)

                                # Verificar el estado del servicio después de la actualización
                                updated_service_after = ecs_client.describe_services(cluster=cluster, services=[service_arn])
                                if updated_service_after['services'][0]['desiredCount'] == 0:
                                    servicios_detener.append(service_name)
                                    logger.info(f"Servicio detenido correctamente: {service_name} en el clúster {cluster_name}.")
                                else:
                                    servicios_no_detener.append(service_name)
                                    logger.warning(f"El servicio {service_name} aún está activo en el clúster {cluster_name}.")

                            except Exception as e:
                                logger.error(f"Error al detener el servicio {service_name} en el clúster {cluster_name}: {str(e)}")
                                servicios_no_detener.append(service_name)

                    except Exception as e:
                        logger.error(f"Error al listar servicios en el clúster {cluster_name}: {str(e)}")

                if servicios_detener:
                    logger.info(f"Servicios detenidos correctamente: {servicios_detener}")
                if servicios_no_detener:
                    logger.warning(f"No se pudieron detener los siguientes servicios: {servicios_no_detener}")

                logger.info("Proceso de detención de servicios completado.")
                
                return {
                    'statusCode': 200,
                    'body': 'Proceso de detención de servicios completado.'
                }


  # Función Lambda para Encender Servicios  
  StartECSFunction:
    Type: AWS::Lambda::Function     
    Properties:
      FunctionName: StartECSFunction
      Description: "Función Lambda para encender servicios ECS con el conteo deseado almacenado en DynamoDB"      
      Handler: index.lambda_handler     
      Timeout: 60
      Role: !GetAtt [StartECSLambdaExecutionRole, Arn]
      Runtime: python3.9
      Code: 
        ZipFile: | 
            import boto3
            import logging
            import urllib.request
            import json
            from datetime import datetime

            # Configuración del logger
            logger = logging.getLogger()
            logger.setLevel(logging.INFO)

            # Crea un manejador para enviar logs a CloudWatch
            ch = logging.StreamHandler()
            ch.setLevel(logging.INFO)
            formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
            ch.setFormatter(formatter)
            logger.addHandler(ch)

            # Inicializar recursos de AWS
            dynamodb = boto3.resource('dynamodb')
            ecs_client = boto3.client('ecs')

            # Nombre de la tabla DynamoDB
            DYNAMODB_TABLE_NAME = 'ECSServiceDesiredCounts'

            # Función para verificar si hoy es festivo
            def is_holiday():
                today = datetime.now().strftime('%Y-%m-%d')
                year = datetime.now().year
                
                # Nueva URL para consultar días festivos
                url = f"https://api.generadordni.es/v2/holidays/holidays?country=CO&year={year}"
                
                logger.info(f"Consultando la URL: {url}")
                
                try:
                    with urllib.request.urlopen(url) as response:
                        data = response.read()
                        holidays = json.loads(data)
                    
                    # Verificar si la fecha actual está en la lista de días festivos
                    is_holiday = any(holiday['date'] == today for holiday in holidays)
                    return is_holiday
                except urllib.error.HTTPError as e:
                    error_message = f"HTTP Error {e.code}: {e.reason}. Response: {e.read().decode()}"
                    logger.error(f"Error al consultar los días festivos: {error_message}")
                    return False
                except Exception as e:
                    logger.error(f"Error desconocido al consultar los días festivos: {e}")
                    return False

            def get_desired_count(service_name, cluster_name):
                """Recupera el valor deseado del servicio desde DynamoDB usando ServiceClusterKey."""
                table = dynamodb.Table(DYNAMODB_TABLE_NAME)
                service_cluster_key = f"{service_name}:{cluster_name}"
                response = table.get_item(Key={'ServiceClusterKey': service_cluster_key})
                return response.get('Item', {}).get('DesiredCount', None)

            def extract_service_name(service_arn):
                """Extrae el nombre del servicio del ARN."""
                return service_arn.split('/')[-1]

            def extract_cluster_name(cluster_arn):
                """Extrae el nombre del clúster del ARN."""
                return cluster_arn.split(':')[-1]

            def lambda_handler(event, context):
                # Obtener el día de hoy
                today = datetime.now().strftime('%A, %d de %B de %Y')
                logger.info(f"Evaluando si hoy ({today}) es un día festivo.")
                
                # Verificar si hoy es festivo
                if is_holiday():
                    logger.info(f"Hoy es un día festivo ({today}). No se ejecutará el proceso.")
                    return {
                        'statusCode': 200,
                        'body': f"Hoy es un día festivo ({today}). No se ejecutará el proceso."
                    }

                logger.info(f"Hoy NO es un día festivo ({today}). Procediendo con la ejecución del proceso.")
                
                # Continuar con el proceso si no es festivo
                try:
                    # Obtener la lista de clústeres
                    logger.info("Obteniendo la lista de clústeres.")
                    clusters_response = ecs_client.list_clusters()
                    clusters = clusters_response['clusterArns']
                    logger.info(f"Clusters encontrados: {[extract_cluster_name(c) for c in clusters]}")
                except Exception as e:
                    logger.error(f"Error al obtener clústeres: {str(e)}")
                    return {
                        'statusCode': 500,
                        'body': f"Error al obtener clústeres: {str(e)}"
                    }

                servicios_iniciar = []
                servicios_no_iniciar = []

                # Iterar sobre cada clúster y restaurar los servicios
                for cluster in clusters:
                    cluster_name = extract_cluster_name(cluster)
                    
                    try:
                        logger.info(f"Listando servicios en el clúster: {cluster_name}.")
                        services_response = ecs_client.list_services(cluster=cluster)
                        services = services_response['serviceArns']
                        logger.info(f"Servicios encontrados en el clúster {cluster_name}: {[extract_service_name(s) for s in services]}")
                        
                        # Reiniciar cada servicio
                        for service_arn in services:
                            service_name = extract_service_name(service_arn)
                            desired_count = get_desired_count(service_name, cluster_name)
                            
                            if desired_count is not None:
                                try:
                                    desired_count_int = int(desired_count)  # Asegurarse de que sea un int
                                    logger.info(f"Iniciando el servicio: {service_name} con desiredCount: {desired_count_int}.")
                                    
                                    # Establecer desiredCount al valor original
                                    ecs_client.update_service(cluster=cluster, service=service_arn, desiredCount=desired_count_int)
                                    
                                    # Verificar el estado del servicio después de la actualización
                                    updated_service = ecs_client.describe_services(cluster=cluster, services=[service_arn])
                                    if updated_service['services'][0]['desiredCount'] == desired_count_int:
                                        servicios_iniciar.append(service_name)
                                        logger.info(f"Servicio iniciado correctamente: {service_name} en el clúster {cluster_name}.")
                                    else:
                                        servicios_no_iniciar.append(service_name)
                                        logger.warning(f"No se pudo iniciar el servicio {service_name} en el clúster {cluster_name}.")
                                except Exception as e:
                                    logger.error(f"Error al iniciar el servicio {service_name} en el clúster {cluster_name}: {str(e)}")
                                    servicios_no_iniciar.append(service_name)
                            else:
                                logger.warning(f"No se encontró un valor deseado para el servicio: {service_name}")
                    
                    except Exception as e:
                        logger.error(f"Error al listar servicios en el clúster {cluster_name}: {str(e)}")

                if servicios_iniciar:
                    logger.info(f"Servicios iniciados correctamente: {servicios_iniciar}")
                
                if servicios_no_iniciar:
                    logger.warning(f"No se pudieron iniciar los siguientes servicios: {servicios_no_iniciar}")

                logger.info("Proceso de inicio de servicios completado.")
                
                return {
                    'statusCode': 200,
                    'body': 'Proceso de inicio de servicios completado.'
                }




  # Regla EventBridge para Apagar Servicios (todos los días a las 8 PM hora Colombia)
  StopECSRule:
    Type: AWS::Events::Rule
    Properties:
      Description: "Regla para apagar ECS"
      ScheduleExpression: cron(0 1 ? * TUE-SAT *) # 8 PM hora Colombia (UTC-5)Type: Schedule
      State: ENABLED
      Targets:
        - Arn: !GetAtt [StopECSFunction, Arn]
          Id: "TargetFunctionV1"

  # Regla EventBridge para Encender Servicios (de lunes a viernes a las 7 AM hora Colombia)
  StartECSRule:
    Type: AWS::Events::Rule 
    Properties:
      Description: "Regla para encender ECS"
      ScheduleExpression: cron(0 12 ? * MON-FRI *) # 7AM hora Colombia (UTC-5)
      State: ENABLED
      Targets:
        - Arn: !GetAtt [StartECSFunction, Arn]
          Id: "TargetFunctionV1"

  #Asociar trigger de EventBridge a Lambda
  StopECSPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref StopECSFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt StopECSRule.Arn

  #Asociar trigger de EventBridge a Lambda
  StartECSPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref StartECSFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt StartECSRule.Arn          

Outputs:
  StopECSFunctionArn:
    Description: "ARN of the Stop ECS Lambda function"
    Value: !GetAtt [StopECSFunction, Arn]

  StartECSFunctionArn:
    Description: "ARN of the Start ECS Lambda function"
    Value: !GetAtt [StartECSFunction, Arn]