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]