DevOps desde Cero: Deployeando Tu API a AWS ECS con Fargate

2026-05-12 | Gabriel Garrido | 20 min de lectura
Share:

Apoya este blog

Si te resulta util este contenido, considera apoyar el blog.

Introduccion

Bienvenido al articulo ocho de la serie DevOps desde Cero. En el articulo anterior aprendimos como provisionar infraestructura AWS con Terraform. Ahora es momento de poner ese conocimiento en practica y deployear nuestra API TypeScript de tareas (la que construimos en el articulo dos) a un entorno cloud real usando AWS ECS con Fargate.


ECS (Elastic Container Service) es la plataforma de orquestacion de containers propia de AWS. Te permite correr containers Docker sin tener que gestionar la infraestructura subyacente vos mismo. Cuando lo combinas con Fargate, ni siquiera tenes que pensar en instancias EC2. Simplemente definis lo que necesita tu container y AWS se encarga del resto. Es un excelente punto de partida antes de meternos con Kubernetes mas adelante en la serie.


En este articulo vamos a cubrir los conceptos centrales de ECS, pushear nuestra imagen Docker a un registry, escribir codigo Terraform para provisionar todo (cluster, servicio, load balancer, auto-scaling), deployear la API y verificar que esta corriendo. Al final vas a tener un deployment listo para produccion que escala automaticamente segun la demanda.


Vamos a meternos de lleno.


Que es ECS?

Amazon Elastic Container Service (ECS) es un servicio de orquestacion de containers completamente gestionado. En lugar de instalar y gestionar tu propio orquestador (como Kubernetes), le das tus definiciones de container a ECS y el se encarga del scheduling, scaling y networking por vos.


Hay cuatro conceptos clave que necesitas entender:


  • Cluster: Un agrupamiento logico de recursos donde corren tus containers. Pensalo como el limite que mantiene todo junto. Un cluster puede contener multiples servicios.
  • Task Definition: Un plano de tu container. Especifica que imagen Docker usar, cuanta CPU y memoria asignar, que puertos exponer, que variables de entorno setear, y a donde enviar los logs. Esta versionado, asi que podes volver a una definicion anterior si es necesario.
  • Task: Una instancia en ejecucion de un task definition. Si el task definition es la receta, el task es el plato que se esta sirviendo. Cada task corre uno o mas containers.
  • Service: Un constructo de larga duracion que asegura que una cantidad especificada de tasks esten siempre corriendo. Si un task se cae, el servicio automaticamente inicia uno nuevo. Los servicios tambien manejan rolling deployments cuando actualizas tu task definition.

Asi es como encajan estas piezas:


ECS Cluster
  └── Service (mantiene la cantidad deseada de tasks)
        ├── Task 1 (container corriendo basado en task definition v3)
        ├── Task 2 (container corriendo basado en task definition v3)
        └── Task 3 (container corriendo basado en task definition v3)

Tipos de lanzamiento en ECS: Fargate vs EC2

Cuando creas un servicio ECS, elegis un tipo de lanzamiento que determina donde corren realmente tus containers:


  • Tipo de lanzamiento EC2: Vos gestionas una flota de instancias EC2. ECS programa los containers en esas instancias. Vos sos responsable de los parches, el scaling y el mantenimiento de las instancias. Mas control, mas trabajo.
  • Tipo de lanzamiento Fargate: AWS gestiona el computo. Solo especificas CPU y memoria para cada task, y Fargate provisiona la cantidad correcta de computo detras de escena. Sin servidores que gestionar, sin planificacion de capacidad, sin parches de SO.

Para este articulo usamos Fargate porque elimina una capa entera de complejidad. Pagas un poco mas comparado con EC2, pero te ahorras un monton de esfuerzo operativo. Para la mayoria de los equipos que estan empezando, Fargate es la eleccion correcta.


ECS vs EKS: una comparacion breve

Te podrias preguntar por que no vamos directo a Kubernetes. AWS ofrece EKS (Elastic Kubernetes Service) para eso. Aca va la comparacion rapida:


  • ECS es mas simple de configurar, esta integrado estrechamente con servicios AWS, y no tiene costo de control plane con Fargate. Si tus workloads son solo AWS, ECS te pone en marcha mas rapido.
  • EKS te da la API completa de Kubernetes, portabilidad entre nubes, y acceso al ecosistema masivo de Kubernetes. Es mas complejo pero mas flexible.

Vamos a cubrir EKS en profundidad mas adelante en la serie. Por ahora, ECS con Fargate es el escalon perfecto porque te ensenia conceptos de orquestacion de containers sin la curva de aprendizaje de Kubernetes.


Pusheando tu imagen Docker a ECR

Antes de que ECS pueda correr tu container, la imagen necesita estar almacenada en un container registry al que ECS pueda acceder. AWS provee ECR (Elastic Container Registry) para esto. Tambien podrias usar GitHub Container Registry (GHCR) o Docker Hub, pero ECR se integra transparentemente con ECS, asi que es la opcion mas simple.


Primero, crea un repositorio ECR usando el AWS CLI:


aws ecr create-repository \
  --repository-name task-api \
  --region us-east-1 \
  --image-scanning-configuration scanOnPush=true

El flag scanOnPush=true habilita escaneo automatico de vulnerabilidades en cada push. Es una funcionalidad gratuita y no hay razon para no usarla.


Ahora autenticate Docker con ECR, construi la imagen, etiquetala y pusheala:


# Obtener el token de login y pasarselo a docker login
aws ecr get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin \
  123456789012.dkr.ecr.us-east-1.amazonaws.com

# Construir la imagen (usando el Dockerfile del articulo 2)
docker build -t task-api .

# Etiquetar para ECR
docker tag task-api:latest \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest

# Pushear a ECR
docker push \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest

Reemplaza 123456789012 con tu ID de cuenta AWS real. Lo podes encontrar ejecutando aws sts get-caller-identity --query Account --output text.


Estructura del proyecto Terraform

Vamos a provisionar todo con Terraform, construyendo sobre las bases del articulo siete. Esta es la estructura del proyecto con la que vamos a terminar:


infra/
  ├── main.tf            # Configuracion del provider y backend
  ├── variables.tf       # Variables de entrada
  ├── outputs.tf         # Valores de salida
  ├── vpc.tf             # VPC, subnets, internet gateway
  ├── ecr.tf             # Repositorio ECR
  ├── ecs.tf             # Cluster ECS, task definition, servicio
  ├── alb.tf             # Application Load Balancer
  ├── autoscaling.tf     # Politicas de auto-scaling
  ├── iam.tf             # Roles y politicas IAM
  └── security_groups.tf # Security groups

Empecemos con la configuracion del provider y las variables.


Provider y variables

El archivo main.tf configura el provider de AWS y el backend de Terraform:


# main.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-terraform-state-bucket"
    key    = "ecs/task-api/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

Ahora defini las variables que vamos a usar a lo largo de la configuracion:


# variables.tf
variable "aws_region" {
  description = "Region de AWS para deployear"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Nombre del proyecto, usado para nombrar recursos"
  type        = string
  default     = "task-api"
}

variable "environment" {
  description = "Entorno de deployment"
  type        = string
  default     = "production"
}

variable "container_port" {
  description = "Puerto en el que escucha el container"
  type        = number
  default     = 3000
}

variable "container_cpu" {
  description = "Unidades de CPU para el container (1024 = 1 vCPU)"
  type        = number
  default     = 256
}

variable "container_memory" {
  description = "Memoria en MiB para el container"
  type        = number
  default     = 512
}

variable "desired_count" {
  description = "Numero de tasks a correr"
  type        = number
  default     = 2
}

variable "container_image" {
  description = "URI de la imagen Docker para el container"
  type        = string
}

Networking: VPC y subnets

Nuestro servicio ECS necesita una VPC con subnets publicas y privadas. El ALB va a estar en las subnets publicas, y los tasks de Fargate van a correr en las subnets privadas:


# vpc.tf
data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name = "${var.project_name}-igw"
  }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index + 1}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${count.index + 1}"
  }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index + 10}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "${var.project_name}-private-${count.index + 1}"
  }
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "${var.project_name}-nat-eip"
  }
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "${var.project_name}-nat"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "${var.project_name}-private-rt"
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count          = 2
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

Algunas cosas para notar aca. Las subnets publicas tienen una ruta al internet gateway, que es donde va a estar nuestro ALB. Las subnets privadas enrutan a traves de un NAT gateway, que le permite a nuestros tasks de Fargate pullear imagenes de ECR y enviar logs a CloudWatch sin estar directamente expuestos a internet. Este es un patron estandar para workloads de produccion.


Security groups

Necesitamos dos security groups: uno para el ALB (permite trafico HTTP entrante desde internet) y uno para los tasks de ECS (permite trafico solo desde el ALB):


# security_groups.tf
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group para el Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP desde cualquier lugar"
    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"]
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

resource "aws_security_group" "ecs_tasks" {
  name        = "${var.project_name}-ecs-tasks-sg"
  description = "Security group para los tasks de ECS"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "Permitir trafico desde el ALB"
    from_port       = var.container_port
    to_port         = var.container_port
    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"]
  }

  tags = {
    Name = "${var.project_name}-ecs-tasks-sg"
  }
}

Esto es el principio de minimo privilegio aplicado al networking. Los tasks de ECS solo aceptan trafico del ALB, no del internet publico directamente. El ALB es el unico punto de entrada.


Roles IAM para ECS

Los tasks de ECS necesitan dos roles IAM: un rol de ejecucion (usado por ECS mismo para pullear imagenes y escribir logs) y un rol de tarea (usado por el codigo de tu aplicacion para acceder a servicios AWS):


# iam.tf
resource "aws_iam_role" "ecs_execution_role" {
  name = "${var.project_name}-ecs-execution-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_execution_role_policy" {
  role       = aws_iam_role.ecs_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role" "ecs_task_role" {
  name = "${var.project_name}-ecs-task-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
      }
    ]
  })
}

El rol de ejecucion recibe la politica gestionada AmazonECSTaskExecutionRolePolicy, que otorga permisos para pullear imagenes de ECR y escribir logs en CloudWatch. El rol de tarea empieza vacio. A medida que tu aplicacion crezca y necesite acceso a otros servicios AWS (S3, DynamoDB, SQS, etc.), le irias adjuntando politicas a este rol. Mantenelos separados para que tengas limites claros entre lo que necesita ECS y lo que necesita tu app.


El repositorio ECR en Terraform

En lugar de crear el repositorio ECR manualmente con el CLI, vamos a gestionarlo con Terraform para que todo este en codigo:


# ecr.tf
resource "aws_ecr_repository" "app" {
  name                 = var.project_name
  image_tag_mutability = "MUTABLE"
  force_delete         = true

  image_scanning_configuration {
    scan_on_push = true
  }

  tags = {
    Name = var.project_name
  }
}

resource "aws_ecr_lifecycle_policy" "app" {
  repository = aws_ecr_repository.app.name

  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Mantener solo las ultimas 10 imagenes"
        selection = {
          tagStatus   = "any"
          countType   = "imageCountMoreThan"
          countNumber = 10
        }
        action = {
          type = "expire"
        }
      }
    ]
  })
}

La politica de ciclo de vida es importante. Sin ella, tu repositorio ECR va a acumular imagenes viejas indefinidamente, y vas a pagar por el almacenamiento. Esta politica mantiene solo las ultimas 10 imagenes y expira el resto automaticamente.


Cluster ECS, task definition y servicio

Ahora el plato fuerte. Vamos a crear el cluster ECS, definir nuestro task y crear un servicio que lo mantenga corriendo:


# ecs.tf
resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.project_name}"
  retention_in_days = 30

  tags = {
    Name = var.project_name
  }
}

resource "aws_ecs_cluster" "main" {
  name = "${var.project_name}-cluster"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = {
    Name = "${var.project_name}-cluster"
  }
}

resource "aws_ecs_task_definition" "app" {
  family                   = var.project_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.container_cpu
  memory                   = var.container_memory
  execution_role_arn       = aws_iam_role.ecs_execution_role.arn
  task_role_arn            = aws_iam_role.ecs_task_role.arn

  container_definitions = jsonencode([
    {
      name      = var.project_name
      image     = var.container_image
      essential = true

      portMappings = [
        {
          containerPort = var.container_port
          protocol      = "tcp"
        }
      ]

      environment = [
        {
          name  = "NODE_ENV"
          value = "production"
        },
        {
          name  = "PORT"
          value = tostring(var.container_port)
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.app.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "ecs"
        }
      }

      healthCheck = {
        command     = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}/health || exit 1"]
        interval    = 30
        timeout     = 5
        retries     = 3
        startPeriod = 60
      }
    }
  ])
}

resource "aws_ecs_service" "app" {
  name            = "${var.project_name}-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  deployment_minimum_healthy_percent = 50
  deployment_maximum_percent         = 200
  health_check_grace_period_seconds  = 60

  network_configuration {
    subnets          = aws_subnet.private[*].id
    security_groups  = [aws_security_group.ecs_tasks.id]
    assign_public_ip = false
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = var.project_name
    container_port   = var.container_port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  depends_on = [aws_lb_listener.http]
}

Estan pasando muchas cosas aca, asi que vamos a desglosarlo pieza por pieza.


El log group de CloudWatch es donde se van a enviar todos los logs del container. Setear retention_in_days en 30 evita que los logs se acumulen para siempre y te inflen la factura.


El cluster es directo. Habilitamos Container Insights para mejores metricas de monitoreo.


El task definition es la parte mas detallada:


  • network_mode = "awsvpc" le da a cada task su propia interfaz de red elastica. Es requerido para Fargate.
  • cpu y memory definen el tamano de Fargate. 256 unidades de CPU (0.25 vCPU) y 512 MiB es la configuracion mas chica y funciona bien para una API liviana.
  • El bloque container_definitions define el container: imagen, mapeos de puertos, variables de entorno, configuracion de logs y health check.
  • El health check ejecuta curl contra el endpoint /health cada 30 segundos. Si tres chequeos consecutivos fallan, ECS marca el task como unhealthy y lo reemplaza.

El servicio conecta todo:


  • desired_count = 2 significa que ECS siempre va a intentar mantener dos tasks corriendo.
  • deployment_minimum_healthy_percent = 50 significa que durante un deployment, al menos un task (50% de 2) debe permanecer sano. Esto permite rolling updates sin downtime.
  • deployment_maximum_percent = 200 significa que ECS puede temporalmente correr hasta cuatro tasks durante un deployment (los viejos mas los nuevos).
  • El deployment_circuit_breaker automaticamente hace rollback de un deployment si los nuevos tasks no logran estabilizarse. Esto evita que una imagen rota tire abajo tu servicio.

Application Load Balancer

El ALB se pone enfrente de tu servicio ECS, distribuye trafico entre los tasks, y provee un endpoint estable para los clientes. Tambien maneja health checks para asegurarse de que el trafico solo vaya a tasks saludables:


# alb.tf
resource "aws_lb" "app" {
  name               = "${var.project_name}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id

  tags = {
    Name = "${var.project_name}-alb"
  }
}

resource "aws_lb_target_group" "app" {
  name        = "${var.project_name}-tg"
  port        = var.container_port
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main.id
  target_type = "ip"

  health_check {
    enabled             = true
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    path                = "/health"
    protocol            = "HTTP"
    matcher             = "200"
  }

  deregistration_delay = 30

  tags = {
    Name = "${var.project_name}-tg"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.app.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

Algunos detalles importantes:


  • target_type = "ip" es requerido para Fargate. Con el tipo de lanzamiento EC2 usarias instance, pero los tasks de Fargate obtienen sus propias direcciones IP.
  • El health check golpea /health y espera una respuesta 200. Si un task falla tres chequeos consecutivos, el ALB deja de enviarle trafico y ECS lo reemplaza.
  • deregistration_delay = 30 le da a las requests en curso 30 segundos para completarse antes de que un task sea removido del target group durante deployments. El default es 300 segundos, que es demasiado para la mayoria de las APIs.

En produccion agregarias soporte HTTPS con un certificado ACM y un listener en el puerto 443. Lo mantenemos simple con HTTP por ahora, pero no expongas APIs de produccion por HTTP plano.


Auto-scaling

Correr un numero fijo de tasks funciona, pero desperdicia plata en periodos de poco trafico y arriesga sobrecarga durante picos. ECS se integra con Application Auto Scaling para ajustar la cantidad de tasks basandose en metricas:


# autoscaling.tf
resource "aws_appautoscaling_target" "ecs" {
  max_capacity       = 10
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "cpu" {
  name               = "${var.project_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 {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    target_value       = 70.0
    scale_in_cooldown  = 300
    scale_out_cooldown = 60
  }
}

resource "aws_appautoscaling_policy" "memory" {
  name               = "${var.project_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 {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageMemoryUtilization"
    }
    target_value       = 80.0
    scale_in_cooldown  = 300
    scale_out_cooldown = 60
  }
}

Esto es lo que hace:


  • Minimo 2, maximo 10 tasks. Siempre tenes al menos dos tasks corriendo para disponibilidad, y caps en diez para controlar costos.
  • Target de CPU: 70%. Si el promedio de CPU entre todos los tasks supera 70%, ECS agrega mas tasks. Si baja bastante por debajo de 70%, ECS remueve tasks (hasta el minimo de 2).
  • Target de memoria: 80%. Misma idea, pero para utilizacion de memoria.
  • Cooldown de scale-out: 60 segundos. Despues de agregar tasks, esperar al menos 60 segundos antes de considerar agregar mas. Esto previene el thrashing.
  • Cooldown de scale-in: 300 segundos. Despues de remover tasks, esperar 5 minutos antes de considerar remover mas. Esto es deliberadamente mas lento para evitar scale-down prematuro.

Target tracking es la estrategia de auto-scaling mas simple y funciona bien para la mayoria de los workloads. Le decis a AWS “mantene la CPU alrededor de 70%” y el se da cuenta de cuantos tasks correr. Si tus necesidades de scaling son mas complejas, podes usar step scaling policies o scheduled scaling, pero target tracking es un buen default.


Outputs

Finalmente, defini outputs para que puedas encontrar facilmente la URL del ALB y otra informacion util despues de deployear:


# outputs.tf
output "alb_dns_name" {
  description = "Nombre DNS del Application Load Balancer"
  value       = aws_lb.app.dns_name
}

output "ecr_repository_url" {
  description = "URL del repositorio ECR"
  value       = aws_ecr_repository.app.repository_url
}

output "ecs_cluster_name" {
  description = "Nombre del cluster ECS"
  value       = aws_ecs_cluster.main.name
}

output "ecs_service_name" {
  description = "Nombre del servicio ECS"
  value       = aws_ecs_service.app.name
}

output "cloudwatch_log_group" {
  description = "Log group de CloudWatch para los tasks de ECS"
  value       = aws_cloudwatch_log_group.app.name
}

Deployeando con Terraform

Con toda la configuracion lista, deployear es cuestion de ejecutar el flujo estandar de Terraform:


cd infra

# Inicializar Terraform (descargar providers, configurar backend)
terraform init

# Revisar el plan de ejecucion
terraform plan -var="container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest"

# Aplicar los cambios
terraform apply -var="container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest"

Terraform te va a mostrar todo lo que planea crear antes de hacer nada. Revisa el plan con cuidado, despues escribi yes para continuar. El primer deployment tarda unos minutos porque necesita crear la VPC, subnets, NAT gateway, ALB y recursos de ECS.


Cuando termine, Terraform va a imprimir los outputs. Agarra el valor de alb_dns_name, ese es tu endpoint de la API.


Testeando el deployment

Verifiquemos que todo esta funcionando. Usa el DNS name del ALB del output de Terraform:


# Chequear el endpoint de health
curl http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/health

# Respuesta esperada:
# {"status":"healthy","uptime":42.123,"timestamp":"2026-05-12T10:30:00.000Z"}

Proba crear un task:


# Crear un nuevo task
curl -X POST \
  http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Deploy a ECS", "description": "Primer task desde produccion!"}'

# Listar todos los tasks
curl http://task-api-alb-123456789.us-east-1.elb.amazonaws.com/tasks

Si todo esta funcionando, deberias ver respuestas saludables. Si algo esta mal, revisa los logs de CloudWatch:


# Ver logs recientes de los tasks de ECS
aws logs tail /ecs/task-api --follow --since 10m

Tambien podes chequear los eventos del servicio ECS para ver si los tasks estan iniciando y parando correctamente:


aws ecs describe-services \
  --cluster task-api-cluster \
  --services task-api-service \
  --query 'services[0].events[:10]' \
  --output table

Deployeando actualizaciones: el flujo de rolling deployment

Cuando pusheas una nueva version de tu imagen Docker, necesitas decirle a ECS que la agarre. La forma mas simple es forzar un nuevo deployment:


# Construir, etiquetar y pushear la nueva imagen
docker build -t task-api .
docker tag task-api:latest \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest
docker push \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/task-api:latest

# Forzar a ECS a pullear la nueva imagen
aws ecs update-service \
  --cluster task-api-cluster \
  --service task-api-service \
  --force-new-deployment

Esto es lo que pasa durante un rolling deployment:


  • ECS inicia nuevos tasks con la imagen actualizada junto a los existentes (hasta deployment_maximum_percent)
  • Los health checks del ALB verifican que los nuevos tasks estan sanos
  • Una vez que los nuevos tasks pasan los health checks, el ALB empieza a rutear trafico hacia ellos
  • ECS drena las conexiones de los tasks viejos (respetando deregistration_delay)
  • Los tasks viejos se detienen
  • Si los nuevos tasks no logran ponerse saludables, el deployment circuit breaker automaticamente hace rollback a la version anterior

Todo este proceso ocurre con cero downtime. Tus usuarios nunca ven un error durante el deployment porque los tasks viejos siguen sirviendo trafico hasta que los nuevos estan listos.


En un pipeline CI/CD real (que cubrimos antes en la serie), automatizarias todo este flujo. Push a main, CI construye la imagen, pushea a ECR, y dispara el deployment en ECS. Sin pasos manuales.


Consideraciones de costos

Antes de cerrar, hablemos de cuanto cuesta esto. El pricing de Fargate se basa en la CPU y memoria que asignas a cada task, facturado por segundo con un minimo de un minuto:


  • 0.25 vCPU, 512 MiB (nuestra configuracion): aproximadamente $0.01/hora por task
  • Con 2 tasks corriendo 24/7: aproximadamente $15/mes de computo
  • NAT gateway: alrededor de $32/mes (esto suele ser el costo mas grande para deployments chicos)
  • ALB: aproximadamente $16/mes mas transferencia de datos
  • ECR: $0.10/GB/mes de almacenamiento, los primeros 500 MB gratis
  • CloudWatch Logs: $0.50/GB ingestado

Para una API chica, estas mirando aproximadamente $65-80/mes en total. El NAT gateway es el componente individual mas caro. Si el costo es una preocupacion, podrias correr tus tasks en subnets publicas con assign_public_ip = true y saltarte el NAT gateway, pero esto no es recomendado para workloads de produccion porque expone tus tasks directamente a internet.


Notas finales

Ahora tenes un deployment production-grade de tu API TypeScript en AWS ECS con Fargate. El setup incluye una VPC apropiada con subnets publicas y privadas, un Application Load Balancer para distribucion de trafico y health checking, auto-scaling para manejar carga variable, un deployment circuit breaker para seguridad, y logging centralizado en CloudWatch. Todo gestionado como codigo con Terraform.


ECS con Fargate es una excelente eleccion cuando queres orquestacion de containers sin la complejidad de Kubernetes. Se integra estrechamente con el ecosistema AWS, requiere minimo overhead operativo, y escala bien para la mayoria de los workloads.


En el proximo articulo vamos a ver servicios AWS mas avanzados y prepararnos para el salto a Kubernetes con EKS. Si seguiste hasta aca, ya entendes los fundamentos de orquestacion de containers, lo que va a hacer que Kubernetes sea mucho mas facil de aprender.


Espero que te haya resultado util y lo hayas disfrutado! Hasta la proxima!


Errata

Si encontras algun error o tenes alguna sugerencia, por favor mandame un mensaje para que se corrija.

Tambien podes revisar el codigo fuente y los cambios en las fuentes aca



$ Comentarios

Online: 0

Por favor inicie sesión para poder escribir comentarios.

2026-05-12 | Gabriel Garrido