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.
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:latestProblems: 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:
- Launch a new task with the new image.
- Wait for it to pass health checks.
- Remove an old task.
- 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
deploymentsfield. - 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?