Amazon ECS — Core Concepts¶
The Object Model¶
flowchart TD
CL["Cluster<br/>logical group, no cost by itself"] --> CP["Capacity providers<br/>FARGATE, FARGATE_SPOT,<br/>ASG, Managed Instances"]
FAM["Task definition family<br/>orders-api"] --> REV["Revisions<br/>orders-api:1, :2, :3 (immutable)"]
REV -- "run_task (once)" --> TASK["Task<br/>containers sharing one ENI"]
REV -- "create_service" --> SVC["Service<br/>desired count, deployments"]
SVC -- "keeps N alive" --> TASK
CL --- SVC
CP -- "provides capacity for" --> TASK
TASK -- "execution role: pull, logs, secrets" --> ECR["ECR, CloudWatch Logs,<br/>Secrets Manager"]
TASK -- "task role: your code" --> APP["SQS, S3, DynamoDB ..."]
| Object | What it is | Identity |
|---|---|---|
| Cluster | A namespace for services, tasks and capacity | Name; ARN arn:aws:ecs:<region>:<account>:cluster/<name> |
| Task definition | Versioned blueprint: containers, size, roles, logging | family:revision, for example orders-api:8; the bare family means the latest ACTIVE revision |
| Task | One running instantiation of a task definition (all its containers) | Task ARN .../task/<cluster>/<id> |
| Service | Scheduler that keeps desiredCount tasks running and rolls out new revisions |
Name unique per cluster |
| Capacity provider | Where tasks run: FARGATE, FARGATE_SPOT, an Auto Scaling group, or ECS Managed Instances |
Name |
| Container instance | An EC2 host registered to a cluster (EC2 capacity only) | Container instance ARN |
Task definitions are immutable: you cannot edit revision 3, you register revision 4. A deregistered revision becomes INACTIVE, stays discoverable, and cannot start new tasks (existing ones keep running). Registering and deregistering is cheap; deleting needs a deregister first (delete_task_definitions).
Capacity: Launch Types vs Capacity Providers¶
AWS recommends capacity providers for placing work and keeping the launch type mainly to declare compatibility (requiresCompatibilities) in the task definition. A service or run_task call then names a capacity provider strategy.
| Provider | You manage | Typical use |
|---|---|---|
FARGATE |
Nothing: no hosts, one isolation boundary per task | Default for web services, workers, QA jobs |
FARGATE_SPOT |
Nothing; tasks can be interrupted with a two-minute warning (SIGTERM, plus a task state change event) | Interruption-tolerant batch and test jobs |
| Auto Scaling group | EC2 AMI, patching, scaling, instance types | GPUs, host access, steady high utilization; see EC2 |
| ECS Managed Instances | Instance type choice only; AWS provisions and patches the hosts | EC2 flexibility without running the fleet |
Strategy rules (AWS documentation):
- A strategy has at most 20 providers, cannot mix provider types (Fargate with Auto Scaling group), and at least one provider needs
weightabove 0 when several are listed. The API default weight is 0 (the console uses 1). - Only one provider may set
base(tasks that must run there first); the rest are split byweight. - AWS requires a provider to be associated with the cluster before a strategy can name it: pass
capacityProviders=[...]tocreate_clusteror callput_cluster_capacity_providers.FARGATEandFARGATE_SPOTare provided by ECS on every cluster (you do not create them), but a cluster created through the API still has to list them. run_tasktakes eitherlaunchTypeorcapacityProviderStrategy. With neither, the cluster's default strategy applies.- A service can change strategy later; moving from a launch type to a strategy needs a forced new deployment.
# 1 task always on-demand, the rest 3:1 on Spot
strategy = [
{"capacityProvider": "FARGATE", "base": 1, "weight": 1},
{"capacityProvider": "FARGATE_SPOT", "weight": 3},
]
Fargate runs Linux on platform version 1.4.0 (LATEST resolves to it; Windows has its own numbering with LATEST = 1.0.0). You rarely set it; a new task always lands on the newest revision of that platform version.
Fargate Task Size¶
CPU and memory are set per task and must be one of the valid pairs. CPU is a string of CPU units (1024 = 1 vCPU); memory is MiB.
cpu |
vCPU | Allowed memory (MiB) |
|---|---|---|
256 |
0.25 | 512, 1024, 2048 |
512 |
0.5 | 1024 to 4096 in steps of 1024 |
1024 |
1 | 2048 to 8192 in steps of 1024 |
2048 |
2 | 4096 to 16384 in steps of 1024 |
4096 |
4 | 8192 to 30720 in steps of 1024 |
8192 |
8 | 16384 to 61440 in steps of 4096 (Linux, platform 1.4.0+) |
16384 |
16 | 32768 to 122880 in steps of 8192 (Linux, platform 1.4.0+) |
The AWS table also lists a 32 vCPU size; check it before relying on sizes above 16 vCPU. Windows tasks support only the 1, 2 and 4 vCPU rows. The unit test test_every_documented_size_is_accepted in the testing guide encodes this table.
- Ephemeral storage defaults to 20 GiB per task; set
ephemeralStorage={"sizeInGiB": N}(21 to 200) for more. The image layers count against it. - Container-level
cpu/memoryare optional on Fargate.memoryis a hard limit (the container is killed when it exceeds it),memoryReservationa soft one. A container killed for memory shows exit code 137. - ARM64:
runtimePlatform={"operatingSystemFamily": "LINUX", "cpuArchitecture": "ARM64"}. All task definitions of one service must agree on the architecture, and the image must be built for it (a mismatch usually fails at start with an exec format error).
Task Definition Anatomy¶
The builder used throughout this guide (app/taskdef.py, excerpt) returns the keyword arguments for register_task_definition:
def build_task_definition(family, image, *, cpu=256, memory=512, execution_role_arn, task_role_arn,
region="us-east-1", log_group=None, command=None, environment=None,
secrets=None, port=None, health_cmd=None, stop_timeout=30, arm64=False) -> dict:
container = {
"name": "app", "image": image, "essential": True,
"stopTimeout": stop_timeout, # seconds between SIGTERM and SIGKILL (max 120 on Fargate)
"logConfiguration": {"logDriver": "awslogs", "options": {
"awslogs-group": log_group or f"/ecs/{family}",
"awslogs-region": region,
"awslogs-stream-prefix": "app"}},
}
# ... command, environment, secrets, portMappings, healthCheck are added when given
return {
"family": family,
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc", # the only mode Fargate supports
"cpu": str(cpu), "memory": str(memory), # strings, not integers
"executionRoleArn": execution_role_arn, # ECS agent: pull image, write logs, fetch secrets
"taskRoleArn": task_role_arn, # your code: boto3 calls inside the container
"runtimePlatform": {"operatingSystemFamily": "LINUX", "cpuArchitecture": "ARM64" if arm64 else "X86_64"},
"containerDefinitions": [container],
"tags": [{"key": "managed-by", "value": "tests"}],
}
| Field | Meaning | Gotcha |
|---|---|---|
family |
Name; up to 255 letters, digits, hyphens, underscores | Each register call creates a new revision, even when nothing changed |
requiresCompatibilities |
FARGATE, EC2, MANAGED_INSTANCES, EXTERNAL: what ECS validates the definition against |
A Fargate-compatible definition requires task-level cpu and memory and awsvpc |
networkMode |
awsvpc, bridge, host, none; Fargate allows only awsvpc |
Default is bridge when omitted: set it explicitly |
cpu, memory |
Task size, strings | "256" not 256 (the botocore validator rejects integers) |
containerDefinitions[].essential |
If an essential container stops, all containers in the task are stopped. Omitted means essential | A task needs at least one essential container; mark short-lived init or sidecar helpers false |
image |
Registry path with tag or digest | Pin it; :latest makes revisions meaningless |
command, entryPoint |
Override the image's CMD and ENTRYPOINT |
Arrays of strings, no shell parsing unless you start sh -c |
portMappings |
containerPort (and name and appProtocol for Service Connect) |
With awsvpc the host port equals the container port |
environment / secrets |
Plain values / values fetched at start from Secrets Manager or SSM | Secrets are resolved once, when the task starts |
healthCheck |
command (CMD or CMD-SHELL), interval 5 to 300 (default 30), timeout 2 to 60 (5), retries 1 to 10 (3), startPeriod 0 to 300 (off) |
Runs inside the container, so the image needs the tool (curl, python) |
dependsOn |
Start order: START, COMPLETE, SUCCESS, HEALTHY |
COMPLETE and SUCCESS cannot be set on an essential container; HEALTHY needs a healthCheck |
startTimeout, stopTimeout |
Seconds to wait for a dependency / before SIGKILL | Both max 120 on Fargate; stopTimeout default 30 |
logConfiguration |
Usually awslogs with awslogs-group, awslogs-region, awslogs-stream-prefix |
The log group must exist unless awslogs-create-group is "true" (and then someone needs logs:CreateLogGroup) |
user, readonlyRootFilesystem |
Run as non-root; read-only root filesystem | A read-only root filesystem breaks ECS Exec and any code that writes to /tmp unless you mount a volume |
linuxParameters.initProcessEnabled |
Adds a tiny init as PID 1 that reaps zombie processes | Recommended with ECS Exec |
ephemeralStorage |
Task-wide scratch space, 21 to 200 GiB | Image layers are stored there too |
tags |
Up to 50 per resource | Tag test definitions so a janitor can find them |
awslogs also accepts mode (non-blocking with max-buffer-size): with Docker log-driver semantics, the default blocking mode can stall your application's writes to stdout when CloudWatch is slow, while non-blocking mode buffers in memory and drops lines when the buffer is full.
A registered example; its container definition looks like this:
{
"name": "app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/orders:1.4.2",
"essential": true,
"stopTimeout": 30,
"logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/ecs/orders-api", "awslogs-region": "us-east-1", "awslogs-stream-prefix": "app"}},
"environment": [{"name": "LOG_LEVEL", "value": "info"}],
"secrets": [{"name": "DB_PASSWORD", "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:orders/db-AbCdEf:password::"}],
"portMappings": [{"containerPort": 8000, "protocol": "tcp"}]
}
Task Role vs Execution Role¶
The most common ECS permission bug: the two roles have different jobs, different principals of use and different failure symptoms.
Execution role (executionRoleArn) |
Task role (taskRoleArn) |
|
|---|---|---|
| Used by | The ECS container agent / Fargate agent, before and around your code | Your application code, through the SDK credential chain |
| Typical permissions | Pull from private ECR, write to CloudWatch Logs, read secrets from Secrets Manager or SSM, read an S3 config file |
sqs:ReceiveMessage, s3:GetObject, dynamodb:*Item, ssmmessages:* for ECS Exec |
| Managed policy | AmazonECSTaskExecutionRolePolicy: ecr:GetAuthorizationToken, ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage, logs:CreateLogStream, logs:PutLogEvents |
None: write a least-privilege policy per service |
| Not included by default | secretsmanager:GetSecretValue, ssm:GetParameters, kms:Decrypt, logs:CreateLogGroup |
Everything |
| Trust policy principal | ecs-tasks.amazonaws.com |
ecs-tasks.amazonaws.com |
| Failure symptom | Task stops before the app starts: CannotPullContainerError, ResourceInitializationError (secret or log setup), no logs at all |
App starts, then AccessDeniedException / AccessDenied from boto3 inside the container |
| How code sees it | It does not | The SDK finds credentials through the container credential provider (see the chain in Foundations, row "Container credentials") |
The trust policy for both roles names the service and, as AWS recommends, pins the account to prevent the confused-deputy problem:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ecs-tasks.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {
"ArnLike": {"aws:SourceArn": "arn:aws:ecs:us-east-1:123456789012:*"},
"StringEquals": {"aws:SourceAccount": "123456789012"}
}
}]
}
Rules of thumb: one task role per service, scoped to the queue, bucket or table it uses (policy shape in Foundations); a shared execution role per account is fine. On Fargate there is no instance profile, so a missing task role means no AWS credentials in the container. On EC2-backed capacity several tasks share a host, so containers are not a security boundary there; use Fargate when isolation matters.
Secrets and Configuration¶
| Source | Task definition | Execution role needs |
|---|---|---|
| Plain config | environment: [{name, value}] |
Nothing |
| Secrets Manager | secrets: [{name, valueFrom: "arn:aws:secretsmanager:<region>:<account>:secret:<name>-<suffix>"}]; a JSON key can be selected by appending :json-key:: |
secretsmanager:GetSecretValue (plus kms:Decrypt for a customer managed key) |
| SSM Parameter Store | valueFrom is the parameter ARN or, in the same Region, its name |
ssm:GetParameters (plus kms:Decrypt for SecureString with a custom key) |
| Files | environmentFiles from S3, or a volume |
s3:GetObject and s3:GetBucketLocation |
The agent injects the value as an environment variable once, at task start. Rotating a secret does not change running tasks; start new ones (forceNewDeployment). If your code needs the live value, read it from Secrets Manager with the task role instead.
Tasks, Services and Scheduled Tasks¶
| Standalone task | Service | Scheduled task | |
|---|---|---|---|
| Started by | run_task (or start_task on EC2) |
create_service |
EventBridge Scheduler or a rule calling RunTask |
| Restart on failure | No | Yes: the scheduler replaces stopped tasks | No (the next schedule tick starts a new one) |
| Load balancer | No | Yes | No |
| Ends when | Its essential container exits | Never, unless scaled to 0 or deleted | Same as a standalone task |
| Use for | Migrations, batch jobs, test harnesses | APIs, workers | Nightly reports, cleanups |
Task lifecycle¶
PROVISIONING (network interface being created) → PENDING (waiting for the agent) → ACTIVATING (image pull, container creation, load balancer registration) → RUNNING → DEACTIVATING (deregistering from target groups) → STOPPING (stop signal, then SIGKILL after stopTimeout) → DEPROVISIONING (network interface detached) → STOPPED. Every task has lastStatus (what the agent reports) and desiredStatus (what ECS wants). Stopped tasks stay visible to describe_tasks for at least one hour.
stopCode |
Meaning | Read next |
|---|---|---|
TaskFailedToStart |
The task never ran: image pull, secret or log setup, capacity | stoppedReason, containers[].reason |
EssentialContainerExited |
An essential container exited; its exitCode says why |
containers[].exitCode (0 normal, 1 error, 137 SIGKILL or out of memory, 143 SIGTERM) |
UserInitiated |
You (or your code) called stop_task |
stoppedReason you passed |
ServiceSchedulerInitiated |
The service stopped it: scale-in, deployment, failed health check | Service events |
SpotInterruption |
Fargate Spot took the capacity back | Event stream |
TerminationNotice, InfrastructureHealth |
Other platform-initiated stops (see the stopped task error pages) | stoppedReason |
Error-message categories in stoppedReason include ResourceInitializationError, CannotPullContainer, OutOfMemoryError, ContainerRuntimeError and SpotInterruptionError. The full table is on the AWS page "Amazon ECS stopped tasks error messages". How to turn these fields into a pass or fail in Python is in 03.
Networking: awsvpc¶
Fargate tasks always use awsvpc: every task gets its own elastic network interface in your subnet, with its own private IP and the security groups you pass. Consequences:
- Security groups apply per task, not per host. Open only the container port to the load balancer's group, and let the load balancer's group be the source:
sg-albtosg-appon 8000. - A
networkConfiguration.awsvpcConfigurationtakes up to 16 subnets and 5 security groups. Spread subnets across Availability Zones. assignPublicIpisDISABLEDby default for services. A task in a private subnet needs a NAT gateway or VPC endpoints (ECR API and DKR, S3 gateway, CloudWatch Logs, plus Secrets Manager or SSM andssmmessagesif you use them) to pull images and write logs. A task in a public subnet needsENABLED. "Task stuck in PENDING thenCannotPullContainerError" is nearly always this.- Inside the task, containers share the network namespace: they reach each other on
localhost.
ECR Basics¶
Amazon Elastic Container Registry (ECR) stores your images. A repository URI looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/orders.
| Topic | What to know |
|---|---|
| Tags | Use immutable tags (imageTagMutability: IMMUTABLE; the model also has MUTABLE_WITH_EXCLUSION and IMMUTABLE_WITH_EXCLUSION). Tag with the Git SHA so a revision always means the same image |
| Pull permissions | Fargate pulls with the execution role (the managed policy above covers it) |
| Lifecycle policy | JSON rules that expire images; images that match expire within 24 hours; preview before applying |
| Scanning | Basic (OS packages, on push or manual) or enhanced (Amazon Inspector, OS and language packages, continuous). Read basic scan results with describe_image_scan_findings; switching type invalidates earlier scans |
| Non-ECR registries | A private Docker Hub or Quay image needs repositoryCredentials pointing at a Secrets Manager secret |
{
"rules": [{
"rulePriority": 1,
"description": "keep the last 30 images",
"selection": {"tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 30},
"action": {"type": "expire"}
}]
}
Push from CI after aws-actions/amazon-ecr-login (06); locally aws ecr get-login-password | docker login --username AWS --password-stdin <registry>. moto stores repositories, lifecycle policy text and images but does not expire or scan anything.
Quotas That Affect Tests and Deploys¶
| Quota (per Region) | Default | Adjustable |
|---|---|---|
Tasks per run_task call |
10 | No |
| Containers per task definition | 10 | No |
| Task definition size | 64 KiB | No |
| Revisions per family | 1,000,000 | No |
| Services per cluster / tasks per service | 5,000 / 5,000 | No |
| Clusters per account | 10,000 | Yes |
| Capacity providers per cluster | 20 | No |
Subnets / security groups per awsvpcConfiguration |
16 / 5 | No |
| Target groups per service | 5 | No |
| Tags per resource | 50 | No |
| Tasks launched per service per minute on Fargate | 500 (125 in Regions launched after 20 May 2025) | No |
| Fargate On-Demand vCPU and Fargate Spot vCPU | 6 each | Yes |
| Fargate On-Demand launch rate | burst 100, sustained 20 tasks per second (25 and 5 in newer Regions) | Yes |
run_task overrides size |
8,192 characters including JSON | No |
The 6-vCPU default is the one that surprises test suites: a new account can run only 24 tasks of 0.25 vCPU at once; further tasks fail to start (look at failures and stoppedReason). Parallel smoke tests must stay far below it, and ECS API calls are throttled by token buckets per category (for example 100 burst and 20 per second sustained for DescribeTasks and ListTasks). Quotas are listed under Amazon ECS and AWS Fargate in Service Quotas.
Quick Rules¶
- Declare
requiresCompatibilitiesand useawsvpc, and give every Fargate definition a validcpu/memorypair as strings. - Use capacity providers, not launch types, to place tasks; set a
baseof on-demand capacity if you also use Spot. - Keep the two roles separate: execution role for ECS, task role for your code, one task role per service.
- Pin images immutably (Git SHA tag, immutable ECR tags) and add an ECR lifecycle policy.
- Mark exactly the right containers
essential. A crashing sidecar marked essential takes the whole task down. - Resolve secrets with
secrets, remember they are read at start, and grant the execution role only those ARNs. - Plan the path to the registry: public subnet with a public IP, NAT, or VPC endpoints.
- Read
stopCode,stoppedReason,containers[].exitCodeandcontainers[].reasontogether; no single field explains a stop. - Count vCPU against the Fargate quota before you parallelize tests.
See also¶
- Amazon ECS — Overview
- Amazon ECS — Services, Deployments & Networking
- Amazon ECS — boto3 in Python
- AWS Foundations — Credentials, IAM & Local Testing
- Amazon EC2
- Docker & Docker Compose