Amazon ECS (Elastic Container Service)

5 min read index source

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 awsfirelens to 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 stopTimeout long 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.