Desplegar Contenedores en ECS con Fargate
Cómo desplegar contenedores Docker en AWS ECS usando cómputo serverless de Fargate con Terraform y GitHub Actions
Resumen
AWS Fargate ejecuta tus contenedores sobre cómputo que nunca aprovisionas: no hay instancias EC2 que parchar, escalar ni pagar mientras están sin uso. Combinado con ECS para la orquestación, un Application Load Balancer para el tráfico y Terraform para la infraestructura, es uno de los caminos más cortos desde un Dockerfile hasta un servicio en producción en AWS.
Esta receta construye el camino completo: una VPC con subredes públicas y privadas, un cluster ECS sobre Fargate, una definición de tarea con logs en CloudWatch, un servicio detrás de un ALB y un pipeline de GitHub Actions que compila, sube y despliega en cada merge a main. La estructura refleja despliegues reales, así que puedes copiarla a tu repo y cambiar nombres, no la arquitectura.
Cuándo Usar Esto
- Quieres contenedores en producción sin gestionar una flota de EC2. Consulta Docker Basics para los fundamentos de contenedores.
- Necesitas que las tareas escalen por CPU o cantidad de peticiones sin escribir lógica de escalado. Consulta Load Testing para las líneas base contra las que escalar.
- Quieres cada entorno reproducible desde código. Consulta Terraform AWS VPC para la capa de red por separado.
- Despliegas con suficiente frecuencia como para que un pipeline de CI/CD se pague solo la primera semana.
- Ejecutas trabajos batch o bajo demanda donde pagar por segundo de tarea gana a mantener instancias activas.
Descarta Fargate cuando la carga necesita GPUs, volúmenes EBS o contenedores privilegiados — nada de eso está soportado. Si mantienes cientos de contenedores siempre activos, el sobreprecio de ~2x por vCPU frente a EC2 deja de ser despreciable y un cluster ECS sobre capacidad EC2 suele ganar en precio.
Requisitos Previos
- AWS CLI configurado con permisos IAM para ECS, EC2, IAM, ELB y Secrets Manager
- Docker instalado localmente
- Terraform 1.5+
- Un repositorio ECR para la imagen de la app (
aws ecr create-repository --repository-name myapp)
Solución: Terraform + ECS Fargate
1. Infraestructura Terraform
# main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "us-east-1" }
# VPC y networking
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "ecs-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
# Las tareas en subredes privadas necesitan salida a internet para traer imágenes de ECR.
# Un NAT gateway compartido cubre ambas AZ; añade uno por AZ si necesitas alta disponibilidad.
enable_nat_gateway = true
single_nat_gateway = true
enable_dns_hostnames = true
}
# Cluster ECS
resource "aws_ecs_cluster" "app" {
name = "production-cluster"
}
resource "aws_ecs_cluster_capacity_providers" "app" {
cluster_name = aws_ecs_cluster.app.name
capacity_providers = ["FARGATE"]
default_capacity_provider_strategy {
base = 1
weight = 100
capacity_provider = "FARGATE"
}
}
# Definición de tarea
resource "aws_ecs_task_definition" "app" {
family = "web-app"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_exec.arn
container_definitions = jsonencode([{
name = "web"
image = "nginx:alpine"
portMappings = [{ containerPort = 80, protocol = "tcp" }]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ecs"
}
}
}])
}
# Servicio
resource "aws_ecs_service" "app" {
name = "web-service"
cluster = aws_ecs_cluster.app.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = 2
launch_type = "FARGATE"
deployment_circuit_breaker {
enable = true
rollback = true
}
network_configuration {
subnets = module.vpc.private_subnets
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.app.arn
container_name = "web"
container_port = 80
}
}
# Application Load Balancer
resource "aws_lb" "app" {
name = "app-alb"
internal = false
load_balancer_type = "application"
subnets = module.vpc.public_subnets
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_target_group" "app" {
name = "app-tg"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "ip"
}
resource "aws_lb_listener" "app" {
load_balancer_arn = aws_lb.app.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
2. Desplegar
terraform init
terraform apply
3. CI/CD con GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to ECS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
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: us-east-1
- name: Login to ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker build -t myapp:$IMAGE_TAG .
docker tag myapp:$IMAGE_TAG $ECR_REGISTRY/myapp:$IMAGE_TAG
docker push $ECR_REGISTRY/myapp:$IMAGE_TAG
- name: Update ECS service
run: |
aws ecs update-service \
--cluster production-cluster \
--service web-service \
--force-new-deployment
Fargate elimina la capa EC2: defines la tarea (imagen, CPU, memoria, variables de entorno) y AWS la ejecuta en infraestructura compartida que nunca tocas. Dos archivos hacen el trabajo: la task definition (qué corre) y el service (cuántos y dónde). Ajusta bien el CPU/memoria a nivel de tarea y Fargate te cobra solo lo que la tarea usa — sin costo de EC2 en reposo.
Cómo Funciona
- Fargate aprovisiona cómputo bajo demanda. Con networking
awsvpccada tarea obtiene su propia ENI e IP privada dentro de tus subredes, así que las tareas son ciudadanas de red de primera clase, no procesos compartiendo un host. - Task Definition es el contrato: imagen, un par CPU/memoria válido (256 de CPU solo acepta 512–2048 MB en Fargate), puertos, variables de entorno, secretos y el driver de logs.
- Service es el planificador. Mantiene vivas
desired_counttareas, registra cada IP de tarea en el target group del ALB y despliega nuevas revisiones de la definición de tarea. Por defecto permite hasta 200% de tareas durante un deploy (maximum_percent), que es lo que hace posibles los rolling updates. - ALB hace health checks a cada IP de tarea y deja de enviarle tráfico a las que fallan, mientras el servicio las reemplaza.
Un despliegue es una cadena corta: el pipeline sube una imagen nueva, update-service dispara una nueva revisión de la task definition, el planificador lanza las tareas de reemplazo, el ALB las marca como sanas y las tareas viejas drenan y mueren. El circuit breaker de la configuración del servicio revierte todo automáticamente cuando las tareas nunca llegan a estado sano.
Configuración Avanzada
Roles IAM y Security Groups
Dos roles, dos trabajos: el execution role es infraestructura — trae la imagen de ECR, escribe en CloudWatch y obtiene secretos. El task role es tu aplicación — lleva los permisos de API de AWS que el código necesite (S3 aquí). Los security groups reflejan el camino del tráfico: el ALB acepta el puerto 80 desde internet, las tareas aceptan el puerto 80 solo desde el ALB.
# iam.tf
# Rol de ejecución de tareas
resource "aws_iam_role" "ecs_exec" {
name = "ecs-exec-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ecs_exec" {
role = aws_iam_role.ecs_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
# Rol de tarea (permisos de aplicación)
resource "aws_iam_role" "app_task" {
name = "app-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ecs-tasks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "app_task_s3" {
name = "app-s3-access"
role = aws_iam_role.app_task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::my-app-bucket/*"
}]
})
}
# Security groups
resource "aws_security_group" "alb" {
name = "alb-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "ecs" {
name = "ecs-sg"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Configuración de Auto Scaling
El target tracking mantiene el número de tareas proporcional a la carga real: añade tareas cuando la CPU media supera el 70%, las quita cuando la memoria baja del 80%. Los cooldowns importan — las tareas de Fargate tardan entre 30 y 60 segundos en arrancar, así que escalar con demasiada agresividad solo hace oscilar el conteo.
# autoscaling.tf
resource "aws_appautoscaling_target" "ecs" {
max_capacity = 10
min_capacity = 2
resource_id = "service/${aws_ecs_cluster.app.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "cpu-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 70
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "memory" {
name = "memory-scaling"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 80
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
Integración con Secrets Manager
Los valores bajo secrets se inyectan como variables de entorno al arrancar la tarea, traídos a través del execution role — por eso ecs_exec necesita permiso de lectura sobre el secreto. Ojo con el trade-off: los secretos solo se refrescan en un nuevo despliegue, así que rotar un valor implica redesplegar el servicio.
# secrets.tf
resource "aws_secretsmanager_secret" "db_password" {
name = "prod/db/password"
}
resource "aws_secretsmanager_secret_version" "db_password" {
secret_id = aws_secretsmanager_secret.db_password.id
secret_string = jsonencode({ password = var.db_password })
}
# Referencia en la definición de tarea
resource "aws_ecs_task_definition" "app_with_secrets" {
family = "web-app-secrets"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_exec.arn
task_role_arn = aws_iam_role.app_task.arn
container_definitions = jsonencode([{
name = "web"
image = "myapp:latest"
portMappings = [{ containerPort = 80, protocol = "tcp" }]
secrets = [{
name = "DB_PASSWORD"
valueFrom = aws_secretsmanager_secret.db_password.arn
}]
environment = [
{ name = "NODE_ENV", value = "production" },
{ name = "PORT", value = "80" }
]
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.app.name
awslogs-region = "us-east-1"
awslogs-stream-prefix = "ecs"
}
}
}])
}
Configuración de Health Checks
deregistration_delay = 30 es lo que el connection draining significa en un ALB: cuando una tarea sale del target group, el balanceador mantiene sus peticiones en vuelo durante 30 segundos en vez de cortarlas a mitad. Combínalo con un endpoint /health real — si la app no expone uno, las tareas oscilan entre sano y enfermo y el servicio nunca se estabiliza.
resource "aws_lb_target_group" "app" {
name = "app-tg"
port = 80
protocol = "HTTP"
vpc_id = module.vpc.vpc_id
target_type = "ip"
health_check {
enabled = true
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
path = "/health"
matcher = "200"
}
deregistration_delay = 30
}
Consideraciones de Producción
- Fija un
platform_versionen el servicio (p. ej.1.4.0) en lugar deLATESTcuando necesites despliegues reproducibles;LATESTestá bien mientras iteras. - Usa despliegues Blue/Green con CodeDeploy para actualizaciones sin downtime — configura
deployment_controller { type = "CODE_DEPLOY" }en el servicio y deja que CodeDeploy mueva el tráfico entre task sets. - Añade Fargate Spot a los capacity providers del cluster para tareas sin estado e interruptibles — hasta ~70% más barato, con aviso de interrupción de dos minutos.
- Habilita CloudWatch Container Insights en el cluster para métricas por servicio y por tarea; sin él solo obtienes agregados a nivel de cluster.
Consejos de Rendimiento
- Dimensiona las tareas según métricas de CloudWatch. Revisa la utilización de CPU y memoria durante 7 días y ajusta:
aws cloudwatch get-metric-statistics \
--namespace AWS/ECS \
--metric-name CPUUtilization \
--dimensions Name=ClusterName,Value=production-cluster \
--start-time $(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Average
En macOS usa date -u -v-7d en lugar de date -u -d '7 days ago'. Si las tareas se quedan por debajo del 30% de CPU toda la semana, baja un tamaño — 256/512 es el par válido más barato.
-
Deja que el deployment circuit breaker revierta los deploys fallidos. Sin él, una imagen rota reintenta indefinidamente mientras el servicio reporta un despliegue fallido. El bloque mostrado en el servicio de arriba detecta tareas que nunca llegan a estado sano y revierte a la última revisión estable automáticamente.
-
Mantén
min_capacitypor encima de cero ante ráfagas de tráfico. Las tareas de Fargate tardan ~30-60 segundos en llegar a estado running — la descarga de la imagen domina. Un suelo de 2 tareas absorbe los picos mientras el scale-out alcanza.
Errores Comunes
- Subredes privadas sin camino a ECR. Las tareas en subredes privadas no pueden traer imágenes sin un NAT gateway o VPC endpoints para
ecr.api,ecr.dkry S3 — se quedan enPENDINGhasta agotar el tiempo. El flagenable_nat_gatewaydel módulo VPC de arriba no es decoración (un NAT gateway cuesta ~$32/mes más procesamiento de datos; los interface endpoints son la alternativa). - Intercambiar los dos roles IAM. El execution role cubre la infraestructura (traer imagen, logs, secretos); el task role cubre las llamadas a APIs de AWS de tu app. Si la inyección de secretos falla con
ResourceInitializationError, el permiso que falta casi siempre está en el execution role. - Pares CPU/memoria inválidos.
terraform applytiene éxito y la tarea falla al arrancar porque Fargate solo acepta combinaciones concretas — 256 de CPU va con 512-2048 MB, 512 de CPU con 1024-4096 MB, y así sucesivamente. - Secretos en
environmenten texto plano. Las variables de entorno planas las ve cualquiera conecs:DescribeTaskDefinition. Todo lo sensible va ensecretsrespaldado por Secrets Manager o SSM Parameter Store. - Health check apuntando a una ruta que la app no sirve. El ALB marca todas las tareas como enfermas, el circuit breaker revierte, y el deploy parece roto cuando la app está bien.
- Claves AWS de larga duración en CI. El workflow de arriba usa access keys por legibilidad; en producción prefiere
role-to-assumecon OIDC enconfigure-aws-credentialspara que ningún secreto estático viva en el repo.
Lectura Adicional
- AWS ECS on Fargate — el modelo de cómputo gestionado
- Terraform AWS Provider — recurso ecs_service
- Proyecto complementario de ECS Fargate — los archivos Terraform y el workflow de GitHub Actions de esta receta, listos para ejecutar
Preguntas frecuentes
¿Cómo se compara el precio de Fargate con EC2?
Fargate cuesta ~2x el costo de cómputo de EC2 pero elimina la sobrecarga operativa. Para cargas de trabajo pequeñas, la diferencia es despreciable.
¿Puedo usar imágenes de contenedor privadas?
Sí. ECR funciona sin configuración extra; para Docker Hub u otros registros externos, configura repositoryCredentials en la definición de contenedor apuntando a un secreto de Secrets Manager con tus credenciales del registro.
¿Cómo depuro tareas fallidas?
Revisa primero CloudWatch Logs para la familia de tareas. Para depuración en vivo, habilita enable_execute_command en el servicio y usa aws ecs execute-command para abrir un shell dentro de un contenedor en ejecución.
¿Debería usar ECS con Fargate o EKS?
ECS + Fargate cuando solo necesitas contenedores corriendo sin internas de cluster. EKS justifica su complejidad cuando necesitas las APIs de Kubernetes, su ecosistema de herramientas o portabilidad entre nubes.
¿Fargate soporta capacidad spot?
Sí — el capacity provider FARGATE_SPOT ofrece hasta ~70% de descuento a cambio de posible interrupción con aviso de dos minutos. Buen encaje para colas, workers y servicios sin estado.
Recursos Relacionados
Infrastructure as Code — Terraform y Pulumi
Guía práctica para gestionar infraestructura como código: beneficios de enfoques declarativo vs imperativo, manejo de estado, módulos y testing de cambios de infraestructura.
DocChecklist de Hardening de Docker Images
Checklist para hardening de Docker container images para production: base image selection, user permissions, filesystem restrictions, network isolation, resource limits, secret management, vulnerability scanning y CI/CD integration con Dockerfile examples.
RecipeScripts de Bash para AWS CLI
Un conjunto de scripts de Bash que envuelven AWS CLI para aprovisionar, etiquetar, auditar y limpiar recursos de AWS, incluidos despliegues multi-cuenta.
RecipeInfraestructura Inmutable
Construye infraestructura inmutable con imágenes de máquina versionadas y containers para eliminar configuration drift y asegurar despliegues reproducibles.
RecipeConfigurar CI con GitLab Pipelines
Cómo configurar pipelines de GitLab CI/CD para testing, building y deployment usando .gitlab-ci.yml con stages, jobs, caching y runners.