ECS
LocalEmu ECS implements all 64 operations. Clusters, task definitions (immutable + revision-numbered), services, container instances, capacity providers, task sets, and account settings are stored in the moto-ext metadata backend. Ten operations are LocalEmu-custom and drive a real Docker container per task: RunTask, StopTask, DescribeTasks, CreateCluster, DeleteCluster, RegisterTaskDefinition, DeregisterTaskDefinition, CreateService, UpdateService, DeleteService. Tasks really run, ports really bind, the AWS SDK inside the container really pulls task-role credentials from the link-local 169.254.170.2 endpoint, and DescribeTasks reflects live Docker state.
Operation-level coverage: see the ECS coverage matrix.
Quick start
$ awsemu ecs create-cluster --cluster-name web
$ awsemu ecs register-task-definition --family hello \
--network-mode bridge \
--container-definitions '[{
"name": "hello",
"image": "nginx:1.27-alpine",
"essential": true,
"portMappings": [{"containerPort":80, "protocol":"tcp"}],
"memory": 128,
"environment": [{"Name":"GREETING","Value":"Hello from ECS"}]
}]' --query 'taskDefinition.taskDefinitionArn' --output text
arn:aws:ecs:us-east-1:000000000000:task-definition/hello:1
$ TASK_ARN=$(awsemu ecs run-task --cluster web --task-definition hello \
--launch-type FARGATE \
--query 'tasks[0].taskArn' --output text)
$ awsemu ecs describe-tasks --cluster web --tasks $TASK_ARN \
--query 'tasks[0].{Status:lastStatus,Health:healthStatus,Port:containers[0].networkBindings[0].hostPort}'
{"Status": "RUNNING", "Health": "UNKNOWN", "Port": 54321}
# The container is really running. Hit the host-mapped port and you get nginx.
$ curl -s http://127.0.0.1:54321/ | head -1
<!DOCTYPE html> The Docker backend is on by default when a Docker daemon is reachable: set ECS_DOCKER_BACKEND=0 only if you want metadata-only behavior (API responses without real containers). This polarity is the opposite of MQ and MSK, where the Docker backend is opt-in.
Architecture
Code lives at services/ecs/. The entry point is create_ecs_service() at provider.py:1155, dispatched through EcsDispatcher (provider.py:856) which intercepts the 10 custom ops and proxies everything else to moto.
| File | Role |
|---|---|
provider.py | Op handlers, dispatch, lifecycle hooks, persistence reconciliation. |
docker/task_manager.py | DockerTaskManager singleton: pulls images, builds ContainerConfiguration from a task definition, runs the container, tracks live status. |
docker/task_credentials.py | Local HTTP server that serves IAM task-role credentials at /v2/credentials/<task_id>, /v3,/v4/<task_id>. |
plugins.py | Shutdown hooks: PERSISTENCE=1 stops containers without removing them; otherwise cleans up. |
Launch types and network modes
- •
FARGATE: fully supported. No synthetic EC2 host is required; tasks run directly on the host Docker daemon. - •
EC2: fully supported. LocalEmu registers a synthetic container instance per cluster (provider.py:85-138) soDescribeContainerInstancesreturns plausibly. - •
awsvpc: real attachment to the LocalEmu VPC network. The task'sawsvpcConfiguration.subnets[0]is resolved to its VPC via the EC2 backend, then the container is connected tolocalemu-vpc-<vpc_id>so it can reach other tasks and EC2 instances on the same VPC by Docker DNS name. - •
bridge,host,none: passed through to Docker as the corresponding network mode.
TaskDefinition to Docker translation
| ECS field | Docker translation |
|---|---|
image | docker pull if not present, then run. |
portMappings[] | Host port allocated via get_free_tcp_port() and mapped to the declared containerPort. Returned in DescribeTasks.containers[].networkBindings[]. |
environment[] | Plus the LocalEmu-injected vars: AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, ECS_CONTAINER_METADATA_URI_V4. |
command | shlex.split() into argv. |
healthCheck | Docker --health-cmd / --health-interval / --health-timeout / --health-retries / --health-start-period. |
cpu (ECS units) | cpu_shares 1:1. |
memory / memoryReservation (MiB) | mem_limit. |
mountPoints[] + volumes[] with host.sourcePath | Bind mounts with readOnly honored. |
Multi-container task definitions are fully supported: every container in containerDefinitions[] is started, not just the first.
IAM task role: real credentials at 169.254.170.2
When a task definition has taskRoleArn, LocalEmu issues real STS temporary credentials and exposes them to the container through the same link-local endpoint the boto3 SDK expects in production ECS. Implementation (docker/task_manager.py:283-560):
- STS assumes the task role and stores the result in
TaskCredentialStorekeyed by task id. - A local HTTP server (
docker/task_credentials.py:56-95) listens on a host port and serves/v2/credentials/<task_id>and/v3,/v4/<task_id>. - The container is started with
cap_add=["NET_ADMIN"]and aniptablesDNAT rule is installed inside the container's netns:169.254.170.2:80→host.docker.internal:<creds_port>. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/v2/credentials/<task_id>is injected as an env var.- Result: any AWS SDK inside the container resolves credentials at
http://169.254.170.2/v2/credentials/<task_id>, which boto3 v1.34+ requires as the exact host (it refuses arbitrary metadata IPs).
# The task definition asks for an IAM role. Inside the container, the AWS SDK
# picks up real STS-issued temporary credentials at 169.254.170.2.
$ awsemu ecs register-task-definition --family worker \
--network-mode awsvpc \
--task-role-arn arn:aws:iam::000000000000:role/EcsTaskRole \
--execution-role-arn arn:aws:iam::000000000000:role/EcsExecutionRole \
--container-definitions '[{
"name": "boto",
"image": "amazon/aws-cli:2.17.0",
"essential": true,
"command": ["s3","ls"]
}]' >/dev/null
$ TASK_ARN=$(awsemu ecs run-task --cluster web --task-definition worker \
--launch-type FARGATE \
--network-configuration 'awsvpcConfiguration={subnets=["subnet-1"],securityGroups=["sg-1"]}' \
--query 'tasks[0].taskArn' --output text)
# Inside the running container, the SDK fetched creds via 169.254.170.2 and the
# s3 ls call succeeded against LocalEmu (signed with the task-role credentials).
$ docker logs $(docker ps -q --filter "name=<localemu-ecs-...>") | head -1
2026-05-20 18:00:00 my-bucket awsvpc network attachment
# awsvpc mode attaches the task container to the real Docker network
# that LocalEmu maintains for the subnet's VPC.
$ awsemu ec2 create-vpc --cidr-block 10.0.0.0/16 \
--query 'Vpc.VpcId' --output text
vpc-abc123
$ awsemu ec2 create-subnet --vpc-id vpc-abc123 --cidr-block 10.0.1.0/24 \
--query 'Subnet.SubnetId' --output text
subnet-def456
$ awsemu ecs run-task --cluster web --task-definition worker \
--launch-type FARGATE \
--network-configuration 'awsvpcConfiguration={subnets=["subnet-def456"],securityGroups=["sg-1"]}' >/dev/null
# The Docker network "localemu-vpc-vpc-abc123" now has the task container attached
# and the container can reach other tasks on the same VPC by their docker DNS name.
$ docker network inspect localemu-vpc-vpc-abc123 \
--format '{{range .Containers}}{{.Name}}{{"\n"}}{{end}}'
localemu-ecs-<task-id> Services and desiredCount reconciliation
CreateService spawns desiredCount tasks immediately. UpdateService reconciles the live count: scale up spawns new containers, scale down stops and removes the excess. DeleteService stops and removes every container the service tracks.
$ awsemu ecs create-service \
--cluster web --service-name web-svc \
--task-definition hello --desired-count 3 \
--launch-type FARGATE \
--query 'service.{Status:status,Desired:desiredCount,Running:runningCount}'
{"Status": "ACTIVE", "Desired": 3, "Running": 3}
# Three real containers were spawned and are tracked under the service.
$ awsemu ecs list-tasks --cluster web --service-name web-svc \
--query 'length(taskArns)'
3
# Scale up: two more containers will be spawned.
$ awsemu ecs update-service --cluster web --service web-svc \
--desired-count 5 --query 'service.runningCount'
5
# Scale down: excess containers are stopped + removed.
$ awsemu ecs update-service --cluster web --service web-svc \
--desired-count 1 --query 'service.runningCount'
1 Features supported
| Feature | Notes |
|---|---|
| Cluster lifecycle | CreateCluster (synthetic EC2 instance for EC2 launch type), DeleteCluster (cleans up all running tasks). |
| Task definitions | Immutable, revision-numbered. Multi-container, port mappings, env vars, command, health checks, cpu/mem, host bind mounts. |
| Task execution | Real Docker container per task. PROVISIONING → RUNNING → STOPPED with timing fields. DescribeTasks enriched from live Docker inspect (lastStatus, exitCode, stoppedReason). |
| Services | Real desiredCount reconciliation on CreateService / UpdateService. Service-owned task ARNs tracked in process. |
| IAM task role | Real STS credentials at 169.254.170.2 via iptables DNAT + cap_add=NET_ADMIN. |
| Networking | awsvpc (real Docker network attachment), bridge, host, none. |
| Launch types | FARGATE + EC2. |
| Persistence | moto cluster/service/task records survive PERSISTENCE=1 restart; on_after_state_load reconciles persisted tasks with live Docker (docker start if container exists, else logs data loss). |
End-to-end test scenarios
tests/e2e/docker_emulation/test_ecs.py covers:
- •Scenario 03:
RunTask→ pollDescribeTasks→ docker inspect confirms RUNNING. - •Scenario 04: HTTP curl to the host-mapped port returns a real nginx 200 response, proving the container actually executed.
- •Scenario 10:
StopTask→ container removed;DescribeTasksshowsSTOPPEDwithexitCodepopulated. - •Scenario 13:
healthCheckon the task definition translates into a DockerHEALTHCHECKon the running container.
Configuration
| Variable | Default | Purpose |
|---|---|---|
ECS_DOCKER_BACKEND | enabled (when Docker is reachable) | Set to 0 to disable the Docker runtime. Tasks become metadata-only records with no containers. Read at provider.py:59. |
# Set ECS_DOCKER_BACKEND=0 if you only want moto control-plane records and
# no actual containers (faster, no Docker daemon required).
$ ECS_DOCKER_BACKEND=0 localemu start
$ awsemu ecs run-task --cluster web --task-definition hello \
--query 'tasks[0].lastStatus' --output text
RUNNING
# lastStatus is RUNNING per moto, but no Docker container was started.
$ docker ps --filter "label=localemu.service=ecs" --quiet
(empty) Integration points
| Service | How it touches ECS |
|---|---|
| EC2 / VPC | awsvpc mode resolves subnet → VPC via the EC2 backend and attaches the task container to localemu-vpc-<vpc_id>. Synthetic container instance per cluster. |
| IAM / STS | Task role assumed for real; credentials served at 169.254.170.2 in-container. |
| ECR | Standard docker pull against any registry the host can reach. |
| CloudFormation | AWS::ECS::Cluster, AWS::ECS::TaskDefinition, AWS::ECS::Service resource providers route through this provider. |
| CloudTrail | All ECS API calls appear in the CloudTrail event store and the dashboard. |
Known limitations
- •No CloudWatch Logs streaming. Task containers' stdout/stderr are not forwarded to
/aws/ecs/<cluster>/<task>. Usedocker logs <container>directly for now. - •No ELB target registration. A service with
loadBalancers[]does not auto-register its task IPs with the target group. Bring your own registration call. - •No Service Discovery / Cloud Map / Service Connect. Service registry entries on
serviceRegistries[]are metadata-only; no DNS A records are published. - •No
ExecuteCommand(ECS Exec). Falls through to moto and returns a stub session payload; no real shell is opened. - •Secrets are not resolved. Task definitions referencing Secrets Manager or SSM via
secrets[{valueFrom:"arn:..."}]are accepted but the value is not fetched and injected as an env var. - •awsvpc private DNS via Route53 is not wired. Tasks do not get a regional
<task-ip>.<region>.compute.internalA record. - •Volumes are partial.
host.sourcePathbind mounts work;efsVolumeConfigurationanddockerVolumeConfigurationare accepted but not provisioned. - •No GPU support.
resourceRequirements:[{type:"GPU"}]is ignored. - •Capacity providers and Auto Scaling are metadata-only.
capacityProviderStrategyand managed scaling policies are accepted but not enforced. - •Task placement strategies and constraints are not enforced.
binpack,spread,distinctInstance, attribute constraints: all metadata-only. - •Linux parameters beyond
NET_ADMINare not applied.capabilities.add(other than NET_ADMIN),devices,init,maxSwap,ulimitsare accepted but not translated. - •Health-check state transitions are not reconciled. Docker
HEALTHCHECKruns and reports per-container, but ECS does not fliphealthStatustoUNHEALTHYor replace failing tasks in a service. - •
stopTimeoutis hardcoded to 10 s regardless of the value declared on the container definition.