StackPractices
intermediate Por Mathias Paulenko

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.

flowchart diagram: TD[

Cómo Funciona

  1. Fargate aprovisiona cómputo bajo demanda. Con networking awsvpc cada 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.
  2. 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.
  3. Service es el planificador. Mantiene vivas desired_count tareas, 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.
  4. 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_version en el servicio (p. ej. 1.4.0) en lugar de LATEST cuando necesites despliegues reproducibles; LATEST está 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

  1. 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.

  1. 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.

  2. Mantén min_capacity por 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.dkr y S3 — se quedan en PENDING hasta agotar el tiempo. El flag enable_nat_gateway del 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 apply tiene é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 environment en texto plano. Las variables de entorno planas las ve cualquiera con ecs:DescribeTaskDefinition. Todo lo sensible va en secrets respaldado 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-assume con OIDC en configure-aws-credentials para que ningún secreto estático viva en el repo.

Lectura Adicional

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.