Amazon ECS (Elastic Container Service)
AWS’s managed container orchestrator. Simpler than EKS (Kubernetes), more proprietary. Two launch types:
- EC2 launch type — you manage EC2 nodes; ECS schedules tasks onto them. More config, lower cost per CPU/RAM.
- Fargate launch type — serverless containers; AWS runs the nodes. Pay per task vCPU/RAM. Less ops.
For Fargate specifics see ../../Compute/10_AWS_Fargate/.
Core objects
| Object | Equivalent |
|---|---|
| Task definition | k8s Pod spec — image, CPU/RAM, env, networking, volumes |
| Task | k8s Pod — running instance of a task definition |
| Service | k8s Deployment + Service — keeps N task copies running, rolling updates |
| Cluster | k8s namespace + node-pool combination |
| Cluster (logical) | grouping of services/tasks; no resource cost on Fargate |
Task definition
{
"family": "orders",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"executionRoleArn": "arn:aws:iam::...:role/ecsTaskExecution",
"taskRoleArn": "arn:aws:iam::...:role/ordersTaskRole",
"containerDefinitions": [{
"name": "app",
"image": "1234.dkr.ecr.us-east-1.amazonaws.com/orders:1.0.0",
"portMappings": [{"containerPort": 8000}],
"environment": [{"name": "ENV", "value": "prod"}],
"secrets": [
{"name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:...:secret:db-password"}
],
"healthCheck": {
"command": ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"],
"interval": 30, "timeout": 5, "retries": 3, "startPeriod": 30
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/orders",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "orders"
}
}
}]
}
executionRoleArn vs taskRoleArn
- Execution role — used by ECS itself to pull the image (ECR auth), fetch secrets, write logs to CloudWatch. Boring, granted once.
- Task role — used by your application code inside the container (boto3 calls AWS APIs as this role). This is your app’s IAM identity. Equivalent to IRSA on EKS.
# Inside the container — boto3 picks up the task role automatically
import boto3
s3 = boto3.client("s3")
s3.put_object(Bucket="...", Key="...", Body=...) # uses task role
No access keys in env, no instance profile lookups, no static creds. This is the right pattern.
Service
{
"serviceName": "orders",
"cluster": "prod",
"taskDefinition": "orders:5",
"desiredCount": 3,
"launchType": "FARGATE",
"networkConfiguration": {
"awsvpcConfiguration": {
"subnets": ["subnet-a", "subnet-b"],
"securityGroups": ["sg-orders"],
"assignPublicIp": "DISABLED"
}
},
"loadBalancers": [{
"targetGroupArn": "arn:aws:elasticloadbalancing:...:targetgroup/orders/...",
"containerName": "orders",
"containerPort": 8000
}],
"deploymentConfiguration": {
"maximumPercent": 200,
"minimumHealthyPercent": 100,
"deploymentCircuitBreaker": {"enable": true, "rollback": true}
}
}
Service keeps desiredCount tasks running; rolling updates respect maximumPercent / minimumHealthyPercent. Circuit breaker auto-rolls-back failed deploys. Use it.
Service Discovery
Two patterns:
Via ALB
Service registers tasks as targets in a Target Group. Clients hit the ALB; ALB routes to a healthy task.
client → ALB → target group → task IP
AWS Cloud Map (private DNS)
Service creates a DNS record orders.prod.local that resolves to task IPs. For pure service-to-service east-west without ALB hop.
# In another service in the same cluster
import httpx
r = httpx.get("http://orders.prod.local/v1/orders")
Autoscaling
Service autoscaling targets:
- CPU / memory utilization.
- ALB request count per target.
- SQS queue depth (custom metric).
- Any CloudWatch metric via target tracking.
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/prod/orders \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 3 --max-capacity 50
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/prod/orders \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu70 \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration \
'TargetValue=70,PredefinedMetricSpecification={PredefinedMetricType=ECSServiceAverageCPUUtilization}'
Celery worker autoscaling on SQS depth
The classic “scale workers on backlog”:
# Custom CloudWatch metric: SQS ApproximateNumberOfMessagesVisible
aws application-autoscaling put-scaling-policy \
--policy-name sqs-scale \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration \
"TargetValue=50,
CustomizedMetricSpecification={
MetricName=ApproximateNumberOfMessagesVisible,
Namespace=AWS/SQS,
Statistic=Average,
Dimensions=[{Name=QueueName,Value=orders-tasks}]
}"
“Scale so each task has on average 50 queued messages.” Backlog grows → scale up.
Networking modes
| Mode | Notes |
|---|---|
awsvpc |
Default for Fargate; each task gets its own ENI + IP. Right for security groups per task. |
bridge |
EC2 only; Docker bridge; ports shared on the host. |
host |
EC2 only; container uses host network namespace. |
none |
no networking. |
Use awsvpc. The others are EC2-only and constrain you.
Logging
awslogs driver writes container stdout/stderr to a CloudWatch Logs group. Each task gets a stream. Pair with CloudWatch Logs Insights for querying.
For structured / centralized logging, push from container → Firehose → S3 / Elasticsearch via awsfirelens driver:
{"logDriver": "awsfirelens", "options": {"Name": "firehose", "delivery_stream": "logs"}}
Deployments
Rolling (default)
Service launches new tasks, drains old; respects minimumHealthyPercent. Use the deployment circuit breaker to auto-rollback on failed rollouts.
Blue/green (via CodeDeploy)
Two target groups; CodeDeploy shifts traffic between them. Linear, canary, or all-at-once. Good for risk-averse rollouts.
deploymentController:
type: CODE_DEPLOY
Common gotchas
- awsvpc and ENI limits per EC2 instance. Each task = an ENI; instance types have ENI caps. Mostly a Fargate-irrelevant issue.
- Fargate ephemeral storage default is 20 GB. Raise it for tasks that download large files.
- Cold start of Fargate tasks ~30-60s. Provisioned tasks are warm; if you scale from 3 to 30, the new ones take a minute to come up. Not a hot-path concern usually but matters for response to traffic spikes.
- CloudWatch Logs costs at scale. Ingestion is the killer; log retention can be cheap. Use
awsfirelensto ship to cheaper storage if volume is high. - Stopping vs draining tasks. A Stop is abrupt. For graceful shutdown, your container must handle SIGTERM (ECS sends it before SIGKILL); set
stopTimeoutlong enough for in-flight requests to drain.
ECS vs EKS
| ECS | EKS | |
|---|---|---|
| Control plane | AWS-managed, free | AWS-managed Kubernetes ($73/month/cluster) |
| Mental model | AWS-native, simpler | Kubernetes, portable |
| Ecosystem | AWS tools | Vast K8s ecosystem |
| Polyglot orgs | each team learns ECS | most orgs already know K8s |
| When to pick | small-medium AWS shops, simplicity | multi-cloud, K8s expertise on staff |
For a team committed to AWS without K8s expertise, ECS wins on simplicity. For teams with K8s skill or multi-cloud requirements, EKS.
Interview angle
- “ECS vs EKS — when each?” — ECS for AWS-native simplicity, no K8s control plane cost, faster for small teams. EKS for K8s expertise on staff, multi-cloud portability, broader ecosystem. Both can run on Fargate.
- “Task role vs execution role?” — execution role: used by ECS to pull image, write logs, fetch secrets. Task role: used by your app code (boto3 calls inside the container). Equivalent to IRSA on EKS.
- “How do you scale ECS services on queue depth?” — application-autoscaling target tracking with a custom metric (SQS
ApproximateNumberOfMessagesVisible). Set a target value (e.g. 50 messages per task) — backlog grows → service adds tasks. - “How do you do service discovery?” — ALB target groups (north-south, with HTTP routing), or AWS Cloud Map private DNS (east-west,
orders.prod.local). Cloud Map = registry; Service registers task IPs automatically. - “What’s the deployment circuit breaker?” — ECS feature that auto-rolls-back a deploy if new tasks fail to become healthy. Without it, a broken image just sits at half-rolled-out forever. Enable it.
- “How do you secure secrets in ECS?” —
containerDefinitions.secrets[]pulls from Secrets Manager / SSM Parameter Store at task start (resolved by the execution role) and injects as env vars. Don’t put secrets in plain env vars.