Container orchestration with ECS Fargate

Deploying containers by hand (SSH, docker run) doesn't scale. ECS Fargate is AWS's managed container orchestration — define a task (container + CPU/memory), define a service (how many copies, load balancer, scaling policy), and Fargate handles provisioning, networking, and updates.

Advanced

5 min read

The manual container deployment problem

You've built a Docker image, pushed it to ECR. Now deploy it:

ssh deploy@prod-server-1
docker pull my-app:latest
docker run -p 3000:3000 my-app:latest

Problems: you have 1 running instance. If it crashes, the application is down. If you need 3 copies for redundancy, you SSH to 3 servers and repeat. If you need 100 copies, you're manually managing 100 containers. Updates mean stopping, pulling a new image, and restarting — with downtime if not coordinated.

Container orchestration (ECS, Kubernetes) automates this: you declare "run 3 copies of my app," and the orchestrator provisions containers, replaces failed ones, handles updates with zero downtime, and scales based on load.

ECS concepts: tasks and services

ECS Task Definition: a blueprint for running a Docker container, specifying:

  • Docker image URI (e.g., 123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest)
  • CPU and memory allocation (e.g., 512 CPU units, 1 GB memory)
  • Environment variables, logging configuration, health checks
resource "aws_ecs_task_definition" "app" {
  family                   = "my-app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn
 
  container_definitions = jsonencode([{
    name      = "app"
    image     = "123456789.dkr.ecr.us-east-1.amazonaws.com/my-app:latest"
    portMappings = [{
      containerPort = 3000
      hostPort      = 3000
      protocol      = "tcp"
    }]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = "/ecs/my-app"
        "awslogs-region"        = "us-east-1"
        "awslogs-stream-prefix" = "ecs"
      }
    }
    healthCheck = {
      command     = ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"]
      interval    = 30
      timeout     = 5
      retries     = 3
      startPeriod = 60
    }
  }])
}

ECS Service: runs and maintains a desired number of task copies:

resource "aws_ecs_service" "app" {
  name            = "my-app-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 3  # run 3 copies
 
  launch_type = "FARGATE"
 
  network_configuration {
    subnets          = [aws_subnet.private_1.id, aws_subnet.private_2.id]
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }
 
  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "app"
    container_port   = 3000
  }
 
  depends_on = [aws_lb_listener.app]
}

The service ensures 3 copies are always running — if one crashes, ECS launches a replacement automatically.

Rolling updates: zero-downtime deployments

Push a new image with a new tag. Update the task definition to reference it. ECS performs a rolling update:

  1. Launch a new task with the new image.
  2. Wait for it to pass health checks.
  3. Remove an old task.
  4. Repeat until all 3 tasks are running the new version.

At every step, at least 1 task is running the old version (backward compatible with the load balancer), so requests don't drop.

resource "aws_ecs_service" "app" {
  deployment_configuration {
    maximum_percent         = 200  # allow 6 tasks (3 old + 3 new) during update
    minimum_healthy_percent = 100  # maintain at least 3 (100%) healthy
  }
}

Auto-scaling: responding to load

Define scaling policies based on metrics (CPU, memory, request count):

resource "aws_appautoscaling_target" "ecs_target" {
  max_capacity       = 10
  min_capacity       = 3
  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" "ecs_policy_cpu" {
  policy_name       = "cpu-scaling"
  policy_type       = "TargetTrackingScaling"
  resource_id       = aws_appautoscaling_target.ecs_target.resource_id
  scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension
  service_namespace = aws_appautoscaling_target.ecs_target.service_namespace
 
  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    target_value = 70.0  # scale to keep CPU at ~70%
  }
}

When CPU usage across tasks averages > 70%, ECS launches more tasks. When it drops below 70%, tasks are terminated. The service stays sized to load automatically.

Task and execution roles: IAM permissions for containers

Execution role: ECS uses this to pull images from ECR and write logs to CloudWatch — permissions ECS itself needs.

Task role: containers use this to access AWS services at runtime (S3, SQS, databases). Permissions your application needs.

resource "aws_iam_role" "ecs_execution" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action = "sts:AssumeRole"
    }]
  })
}
 
resource "aws_iam_role_policy_attachment" "ecs_execution_policy" {
  role       = aws_iam_role.ecs_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
 
resource "aws_iam_role" "ecs_task" {
  assume_role_policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action = "sts:AssumeRole"
    }]
  })
}
 
resource "aws_iam_role_policy" "ecs_task_s3" {
  role = aws_iam_role.ecs_task.id
  policy = jsonencode({
    Statement = [{
      Effect = "Allow"
      Action = ["s3:GetObject", "s3:PutObject"]
      Resource = "arn:aws:s3:::my-bucket/*"
    }]
  })
}

The application code (inside the container) assumes the task role and gets temporary credentials to access S3.

Monitoring ECS: the gaps

ECS Fargate is "serverless" — AWS manages the infrastructure. But you still need to monitor:

  • Task health: are tasks passing health checks? CloudWatch Alarms on ECSServiceHealthCheck.
  • Deployment status: are rolling updates stuck? Check the service's deployments field.
  • Task CPU/memory: are tasks running out of resources? CloudWatch metrics for CPU/memory utilization.
resource "aws_cloudwatch_metric_alarm" "ecs_unhealthy" {
  alarm_name          = "ecs-unhealthy-tasks"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = 2
  metric_name         = "ECSServiceHealthCheck"
  namespace           = "AWS/ECS"
  period              = 60
  statistic           = "Average"
  threshold           = 0
  alarm_actions       = [aws_sns_topic.alerts.arn]
 
  dimensions = {
    ClusterName = aws_ecs_cluster.main.name
    ServiceName = aws_ecs_service.app.name
  }
}

ECS vs. Kubernetes: when to use which

ECS Fargate (managed):

  • AWS fully manages infrastructure, control plane, updates.
  • Simpler, fewer things to configure.
  • Tightly integrated with AWS services (ALB, CloudWatch, Secrets Manager).
  • Less flexible (locked to AWS).

Kubernetes (self-managed or EKS):

  • More flexible, multi-cloud, massive ecosystem.
  • Steeper learning curve, more to operate.
  • Right for complex microservices architectures.

For most applications on AWS, ECS Fargate is simpler and sufficient.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What is an ECS task definition?

2. How does ECS ensure zero-downtime deployments?

3. What permissions should the task role have, and why is it separate from the execution role?

4. How does Application Auto Scaling differ from manual scaling?