AWS ECS Fargate: despliega contenedores sin gestionar servidores
Guía práctica de ECS Fargate para backend engineers: task definitions, service deployment, auto-scaling y CI/CD con GitHub Actions desde cero.
ECS Fargate resuelve el problema que más tiempo consume a equipos pequeños de backend: gestionar la infraestructura de servidores. Con Fargate, defines cuánta CPU y memoria necesita tu contenedor, y AWS gestiona todo lo demás. Sin parchear instancias, sin gestionar grupos de autoescalado de EC2, sin preocuparte por la capacidad del host.
Este artículo es una guía práctica de los conceptos y patrones que uso para desplegar servicios backend en Fargate, incluyendo el pipeline de CI/CD que lo automatiza.
Los conceptos que necesitas tener claros
Antes de escribir una línea de Terraform o YAML, el modelo mental de ECS:
Cluster ECS
└── Service (mantiene N tasks corriendo)
└── Task Definition (blueprint del contenedor)
├── Container definition (imagen, CPU, memory, env vars)
├── IAM Task Role (permisos del contenedor)
└── Network configuration (VPC, subnets, security groups)
Un Cluster es solo un agrupador lógico. Un Service garantiza que siempre haya N instancias de tu tarea corriendo — si una muere, la reemplaza. Una Task Definition es la especificación inmutable de cómo debe correr tu contenedor. Cada deploy crea una nueva revisión de la task definition.
Task Definition con Terraform
resource "aws_ecs_task_definition" "api" {
family = "booking-api"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "512" # 0.5 vCPU
memory = "1024" # 1 GB
# El rol que asume el contenedor para acceder a servicios AWS
task_role_arn = aws_iam_role.ecs_task_role.arn
# El rol que ECS usa para gestionar el contenedor (pull de ECR, etc.)
execution_role_arn = aws_iam_role.ecs_execution_role.arn
container_definitions = jsonencode([
{
name = "api"
image = "${aws_ecr_repository.api.repository_url}:${var.image_tag}"
essential = true
portMappings = [{ containerPort = 8080, protocol = "tcp" }]
environment = [
{ name = "APP_ENV", value = "production" },
{ name = "APP_PORT", value = "8080" },
{ name = "DB_HOST", value = aws_db_instance.main.address },
]
# Secretos desde SSM Parameter Store (nunca en variables de entorno plaintext)
secrets = [
{ name = "DB_PASSWORD", valueFrom = aws_ssm_parameter.db_password.arn },
{ name = "APP_SECRET", valueFrom = aws_ssm_parameter.app_secret.arn },
{ name = "REDIS_URL", valueFrom = aws_ssm_parameter.redis_url.arn },
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/booking-api"
"awslogs-region" = var.aws_region
"awslogs-stream-prefix" = "ecs"
}
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
}
])
}
Los secrets desde SSM Parameter Store son críticos. Nunca pasar credenciales como variables de entorno plaintext — aparecerán en los logs y en la consola de AWS.
ECS Service con deployment rolling
resource "aws_ecs_service" "api" {
name = "booking-api"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.api.arn
desired_count = 2
launch_type = "FARGATE"
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.api.id]
assign_public_ip = false # Siempre en subnets privadas
}
load_balancer {
target_group_arn = aws_lb_target_group.api.arn
container_name = "api"
container_port = 8080
}
# Rolling deployment: mantiene disponibilidad durante deploys
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
# Circuit breaker: rollback automático si el deploy falla
deployment_circuit_breaker {
enable = true
rollback = true
}
# Esperar a que el load balancer confirme health antes de continuar
health_check_grace_period_seconds = 60
lifecycle {
ignore_changes = [task_definition] # CI/CD gestiona esto
}
}
El deployment_circuit_breaker con rollback = true es una de las configuraciones más importantes. Si el nuevo contenedor no pasa el health check en un número de intentos, ECS revierte automáticamente a la versión anterior.
Auto Scaling basado en CPU y memoria
resource "aws_appautoscaling_target" "api" {
max_capacity = 10
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.api.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
# Escalar por CPU
resource "aws_appautoscaling_policy" "cpu" {
name = "booking-api-cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.api.resource_id
scalable_dimension = aws_appautoscaling_target.api.scalable_dimension
service_namespace = aws_appautoscaling_target.api.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70.0 # Escala si CPU > 70%
scale_in_cooldown = 300 # 5 minutos antes de reducir
scale_out_cooldown = 60 # 1 minuto antes de aumentar
}
}
El cooldown de scale-in (300s) más largo que el de scale-out (60s) es intencional: escalar hacia arriba rápido ante carga, pero ser conservador al reducir para evitar oscilaciones.
Pipeline CI/CD con GitHub Actions
Este es el pipeline que uso para deploys automatizados en cada push a main:
name: Deploy to ECS
on:
push:
branches: [main]
env:
AWS_REGION: eu-west-1
ECR_REPOSITORY: booking-api
ECS_SERVICE: booking-api
ECS_CLUSTER: production
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: |
docker compose -f docker-compose.test.yml up --abort-on-container-exit
docker compose -f docker-compose.test.yml down
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Download current task definition
run: |
aws ecs describe-task-definition \
--task-definition ${{ env.ECS_SERVICE }} \
--query taskDefinition > task-definition.json
- name: Update task definition with new image
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: task-definition.json
container-name: api
image: ${{ steps.build-image.outputs.image }}
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true # Bloquea hasta que el deploy está completo
El step wait-for-service-stability: true hace que el pipeline espere a que el deploy se complete (o falle) antes de marcar el job como exitoso. Esto significa que el pipeline refleja el estado real del deploy, no solo el push de imagen.
Health checks: la clave del zero-downtime deploy
El health check del contenedor y el del ALB deben estar alineados:
// Laravel: endpoint de health check
Route::get('/health', function () {
$checks = [
'database' => DB::connection()->getPdo() !== null,
'cache' => Cache::store('redis')->ping(),
];
$healthy = collect($checks)->every(fn($v) => $v === true);
return response()->json([
'status' => $healthy ? 'ok' : 'degraded',
'checks' => $checks,
'timestamp' => now()->toIso8601String(),
], $healthy ? 200 : 503);
});
El health check del ALB debe apuntar a este endpoint. ECS no marcará una nueva task como healthy (y por tanto no retirará la vieja) hasta que el ALB confirme que responde con 200.
Conclusión
ECS Fargate cambia completamente el modelo operacional de equipos pequeños de backend. El time-to-market se reduce, la gestión de infraestructura baja drásticamente, y el modelo de escalado es predecible.
Los tres patrones que más impacto tienen:
- Secrets desde SSM Parameter Store — nunca credenciales en variables de entorno plaintext
- Deployment circuit breaker con rollback — protección automática ante deploys fallidos
- Health check bien definido — la base del zero-downtime rolling deployment
El paso de EC2 gestionado manualmente a ECS Fargate fue uno de los cambios operacionales más impactantes que hice en Sidetours.