Amazon ECS (Elastic Container Service)

Updated 10 min read index source
On this page13
  1. Core objects
  2. Task definition
  3. Service
  4. Service Discovery
  5. Autoscaling
  6. Networking modes
  7. Logging
  8. Deployments
  9. Common gotchas
  10. Where it breaks in production
  11. The trade not taken
  12. ECS vs EKS
  13. Interview angle

Amazon ECS (Elastic Container Service)

Article.

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 AWS Fargate.

“Your ECS deployment is stuck half rolled out. What do you check?” is the question this note prepares for. ECS cannot be run locally, so the claims are checked against AWS documentation as of October 2026; the container-level facts were checked with Docker. Two recent changes matter: App Runner stopped accepting new customers on 2026-04-30, and AWS points to ECS Express Mode (one API call creates a Fargate service, an ALB, scaling and networking) as its replacement.

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: N copies, rolling updates
Cluster a grouping of services; no cost on Fargate

Task definition

json
{
  "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", "python", "-c",
        "import urllib.request as u; u.urlopen('http://localhost:8000/health')"],
      "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.
python
# Inside the container — boto3 picks up the task role automatically
import boto3
s3 = boto3.client("s3")
# uses task role
s3.put_object(Bucket="...", Key="...", Body=...)

No access keys in env, no instance profile lookups, no static creds. This is the right pattern.

The credentials come from the container credentials endpoint (169.254.170.2, via AWS_CONTAINER_CREDENTIALS_RELATIVE_URI), which boto3 checks before the instance metadata service. On the EC2 launch type a task can still reach the instance’s own role through instance metadata unless it is blocked (ECS_AWSVPC_BLOCK_IMDS for awsvpc tasks, an IMDSv2 hop limit of 1 for bridge mode); otherwise every task quietly gets the node’s permissions too.

Service

json
{
  "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": "app",
    "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. It trips after a number of failed task launches derived from the desired count (half of it, at least 3, at most 200), and healthCheckGracePeriodSeconds must cover the app’s start-up, or slow starters are killed by the load balancer’s health check before they are ready.

Service Discovery

Two patterns:

Service Connect

The current default for service-to-service calls inside ECS: an Envoy proxy beside each task, with retries, outlier detection and per-service metrics, addressed by a short name. Cloud Map DNS (below) is simpler but clients cache DNS answers, so during a deploy they keep calling task IPs that are gone.

Via ALB

Service registers tasks as targets in a Target Group. Clients hit the ALB; ALB routes to a healthy task.

text
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.

python
# 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.
bash
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”. Target tracking on the raw queue depth is the common mistake: it scales until the whole queue holds 50 messages, regardless of how many tasks are running. The metric to track is backlog per task: queue depth divided by running tasks, which target tracking accepts as metric math:

json
{"TargetValue": 50,
 "CustomizedMetricSpecification": {"Metrics": [
   {"Id": "depth", "ReturnData": false, "MetricStat": {"Stat": "Sum",
     "Metric": {"Namespace": "AWS/SQS",
                "MetricName": "ApproximateNumberOfMessagesVisible",
                "Dimensions": [{"Name": "QueueName", "Value": "orders-tasks"}]}}},
   {"Id": "tasks", "ReturnData": false, "MetricStat": {"Stat": "Average",
     "Metric": {"Namespace": "ECS/ContainerInsights", "MetricName": "RunningTaskCount",
                "Dimensions": [{"Name": "ClusterName", "Value": "prod"},
                               {"Name": "ServiceName", "Value": "workers"}]}}},
   {"Id": "backlog_per_task", "Expression": "depth / tasks", "ReturnData": true}]}}

Choose the target from latency: if a task handles 10 messages a second and a message may wait 5 seconds, 50 per task is the right target.

Scaling to zero

A service can run with desiredCount: 0 and a minimum capacity of 0. Target tracking on CPU cannot wake it (no task, no CPU metric), so scale from zero with a step-scaling policy on an alarm on queue depth; the first message then waits for a task to start.

Networking modes

Mode Notes
awsvpc Fargate default; an ENI and IP 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.

Since 2025-06-25 the default mode is non-blocking. Before, blocking was the default: when CloudWatch Logs throttled, the app’s writes to stdout blocked and requests stalled. Non-blocking keeps the app running and buffers logs in memory (max-buffer-size, 10m by default); when the buffer fills, logs are dropped silently. Choose per service: non-blocking where availability matters more than every log line, blocking ("mode": "blocking") for audit logs that must not be lost.

For structured / centralized logging, FireLens routes logs through a Fluent Bit sidecar to Firehose, S3 or OpenSearch. The app container uses the awsfirelens driver, and the task needs the log-router container itself:

json
{"name": "log_router", "essential": true,
 "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable",
 "firelensConfiguration": {"type": "fluentbit"}},
{"name": "app", "logConfiguration": {"logDriver": "awsfirelens",
   "options": {"Name": "firehose", "region": "us-east-1", "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, linear and canary (built in)

Since July 2025 ECS does blue/green itself, without CodeDeploy: it starts the new revision beside the old one, shifts test traffic, then production traffic (all at once, or linearly or as a canary since late 2025), waits a bake time, and rolls back automatically on CloudWatch alarms. Lifecycle hooks are Lambda functions run at each stage, for automated tests or a manual approval. It works behind an ALB, an NLB or Service Connect. CodeDeploy-based blue/green (deploymentController: CODE_DEPLOY) still exists, mainly for setups built before.

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. Every task pulls its image; there is no image cache between tasks. Smaller images and SOCI lazy loading (the image starts before it is fully pulled) shorten it; scaling from 3 to 30 still takes about a minute.
  • CloudWatch Logs costs at scale. Ingestion is the killer; log retention can be cheap. Use awsfirelens to ship to cheaper storage if volume is high.
  • Stopping vs draining tasks. For graceful shutdown your container must handle SIGTERM (ECS sends it, then SIGKILL after stopTimeout: 30 s by default, 120 s at most on Fargate). A Python process running as PID 1 ignores SIGTERM unless it installs a handler, and is killed at the timeout (Docker image vs container).

Where it breaks in production

A deployment stuck half rolled out

New tasks start, fail their health check, are replaced, and fail again. The usual causes: a container health check that cannot run in the image (curl in python:3.14-slim, which ships neither curl nor wget; checked with Docker); a grace period shorter than the app’s start-up; the app listening on 127.0.0.1 instead of 0.0.0.0; a missing secret or IAM permission at start-up. aws ecs describe-services shows the deployment events, and the stopped tasks’ stoppedReason names the cause. With the circuit breaker on, the deploy rolls back instead of looping forever.

Logs missing exactly when they matter

Under a traffic spike the app logs heavily, the awslogs non-blocking buffer fills, and the lines explaining the incident are dropped. Size the buffer for the peak, sample debug logs, or use blocking mode where loss is unacceptable.

Workers that never scale

Queue workers scaled by CPU target tracking: the workers wait on I/O, CPU stays low, the backlog grows to millions, and nothing scales. Scale workers on backlog per task.

The trade not taken

Kubernetes (EKS) is the alternative: portable, a vast ecosystem, and the skills transfer between clouds, at the cost of a control plane, cluster upgrades and far more configuration. ECS gives up portability for an AWS-native model with fewer moving parts; for a team running only on AWS without Kubernetes expertise, that is usually the better trade.

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 8

  • “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.
  • “Your ECS deployment is stuck half rolled out. What do you check?” — the service events and the stopped tasks’ stoppedReason: failing health checks, a grace period shorter than start-up, missing secrets or permissions.
    • Follow-up: “The health check passes locally but fails on ECS.” — often the check itself: curl is not in slim Python images. Use python -c with urllib, or check from the load balancer only.
    • Follow-up: “What did the 2025 awslogs non-blocking default trade away?” — log completeness: the app no longer stalls when CloudWatch throttles, but logs are dropped when the 10 MB buffer fills.
  • “How do you scale queue workers?” — on backlog per task (queue depth divided by running tasks, as metric math), not raw queue depth or CPU.
  • “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.

Contents 1