V Volkanic
infrastructure

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.

13 min de lectura
awsecsfargatedockerinfraestructuradevops

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:

  1. Secrets desde SSM Parameter Store — nunca credenciales en variables de entorno plaintext
  2. Deployment circuit breaker con rollback — protección automática ante deploys fallidos
  3. 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.