Containers
AWS Intermediate/Advanced
Chapter 6 ยท Containers on AWS: ECR, ECS and Fargate
You already know how to build an image (Docker for Beginners, Docker Intermediate/Advanced) and how a load balancer and health checks fit together (AWS Fundamentals 8). This chapter is about what changes when the container runs on AWS: Amazon ECR stores the image, Amazon ECS decides what runs, and AWS Fargate supplies the machines so that you do not manage servers. Everything ECS does to a container is something Docker can already do, so most of this chapter runs the container locally and shows the Docker setting that each ECS setting becomes.
- Run here (level 1): Docker 28.0.4 (the engine was started for this chapter) built and ran the images; Node 24.18.0 with
aws-cdk2.1145.0 andaws-cdk-lib2.273.0 synthesised the stacks (reusing the install from chapter 5); cfn-lint 1.57.2, Jest 30.5.2 andbotocore1.43.112 (offline, with a stubbed client) checked them. Two small Python models encode rules from the ECS documentation and were cross-checked against cfn-lint. - Needs an AWS account (level 2), not run: pushing an image, creating the cluster, service and load balancer, scanning, logging to CloudWatch, and everything about how ECS and Fargate really behave. What ECS does is described from the documentation pages I read on 2026-10-13 (through a page-fetch tool that summarises pages), and each such statement says so.
- Not an ECS test: a local Docker container is not a Fargate task. Where the two could differ (limits, signals, health), the chapter says what was observed in Docker and what the ECS documentation says, and never mixes them.
What Runs Where
The ECS pages I read list four places a task can run: Fargate, Amazon EC2 instances you manage, Amazon ECS Managed Instances and external instances. This chapter uses Fargate. EKS (Kubernetes) is chapter 7; running a container image as a Lambda function was covered in AWS Fundamentals 7 and not revisited. I did not read the pages that compare these options, so I make no claim about which one to pick beyond what the tests below show.
| ECS word | What it is | Closest Docker idea |
|---|---|---|
| Task definition | A versioned recipe (a family plus a revision number) with container settings, CPU and memory, roles and network mode | The flags of docker run, written down and versioned |
| Task | One running copy of a task definition. The documentation gives its life cycle: PROVISIONING, PENDING, ACTIVATING, RUNNING, DEACTIVATING, STOPPING, DEPROVISIONING, STOPPED | One docker run |
| Service | Keeps a desired number of tasks running and replaces unhealthy ones; carries the rolling-update settings | Nothing in plain Docker (closest is a restart policy plus a script) |
| Cluster | The logical group the tasks and services belong to | The Docker host(s) |
| ECR repository | Where the image is stored, with scanning and lifecycle rules | Docker Hub, but private to your account |
The Image: Two Dockerfiles, One Mistake
Run here (level 1). A tiny web service (server.py, in the exercise solution) answers on port 8080, has a /health endpoint that can be told to fail, can try to write a file, can allocate memory on request, and can optionally exit cleanly on SIGTERM. It is built twice: a good image (non-root user, exec-form CMD, a HEALTHCHECK) and a naive one (root, shell-form CMD).
Both images are 176 MB. The difference is in how they behave, and everything below depends on it. Notice the naive image's command: ["/bin/sh","-c","python server.py"]. The shell, not Python, is the container's first process.
A Task Definition Is docker run in JSON
The ECS parameters I read map onto Docker settings. The documentation states some of the mappings itself (user maps to --user; readonlyRootFilesystem maps to --read-only); the others are the ones I used in the experiments.
| Task definition | docker run | What the documentation says |
|---|---|---|
user | --user | "As a security best practice, always use a non-root user" |
readonlyRootFilesystem | --read-only | Default false; not supported for Windows containers |
linuxParameters.initProcessEnabled | --init | (the parameter page excerpt I kept did not cover it) |
stopTimeout | --stop-timeout | Seconds before the container is forcefully killed; default 30, maximum 120 |
healthCheck | HEALTHCHECK | Interval 5 to 300 s (default 30), timeout 2 to 60 (5), retries 1 to 10 (3), startPeriod 0 to 300 (disabled) |
task cpu, memory | --cpus, --memory | Only certain combinations are valid on Fargate (below) |
To check the mapping I applied the same settings as flags (non-root, read-only, init, 512 MiB, 0.25 CPU, stop timeout 20 s) and asked Docker what it recorded:
That is evidence about Docker: the flags take effect and the service works under them. It is not evidence that ECS applies them the same way.
Stopping a Task: SIGTERM, stopTimeout and Exit Codes
From the documentation (level 3): in the STOPPING state, for Linux containers, "the container agent will send the stop signal defined in your container image ... This is SIGTERM by default. Then it will send a SIGKILL after waiting the StopTimeout duration set in the task definition." Run here: I stopped containers with docker stop -t 5 (the same idea: signal, wait, kill) and recorded how long it took and the exit code. The "took" figure includes about two seconds of Docker overhead that every row has; the rows that waited the full timeout are about five seconds longer.
Read the rows carefully.
- No handler, python as the first process (137, waited the full timeout). The program was killed, not asked. My understanding is that Linux gives the first process of a container no default action for
SIGTERM, so unless the program installs a handler the signal does nothing; the rows are consistent with that, but I did not test it separately. - With a handler (exit code 0, no wait). The program saw the signal and left cleanly: this is what you want in a service, because it can finish requests first.
- With
--init(exit code 143, no wait). A tiny init process (docker-init) became the first process and the program terminated on the signal (143 = 128 + 15). The ECS equivalent isinitProcessEnabled(not run on ECS). - The shell-form
CMDwas the trap. With/bin/sh -cas the first process, even the image with a handler waited the full timeout and was killed (137): the shell did not pass the signal on. This is the cheapest way to turn a clean shutdown into a forced one, and it only shows up when something stops the container.
Why it matters on ECS: every deployment, scale-in and spot-style interruption stops tasks. If the app ignores the signal, each stop costs the full stopTimeout (default 30 s, maximum 120 s on the page I read) and ends in a kill, with in-flight requests dropped. Not run: a real ECS stop.
Memory Limits
Run here: a container with --memory 64m (no swap) was asked to hold 20 MiB, which worked, and then 200 MiB:
The request never got an answer (curl exit status 52, empty reply), the container exited with 137 and Docker recorded OOMKilled=true. On Docker, then, the memory figure is a hard wall. I read that Fargate's task memory must come from a table (below), but I did not read what Fargate does when a container exceeds it, so treat the local result as an illustration of the idea and look up the ECS behaviour before relying on it.
Health Checks
The documentation says the check command is run in the container, CMD or CMD-SHELL, and "an exit code of 0, with no stderr output, indicates success". I did not test the stderr condition. Run here, with a Docker HEALTHCHECK every 2 s (2 retries):
Docker marked the container unhealthy after the service began failing, and left it running. On its own, a failing health check restarts nothing; something has to act on it. From the ECS deployment page: during a rolling deployment "when tasks become unhealthy, Amazon ECS replaces them to maintain your service's minimumHealthyPercent". So in ECS the service is the thing that acts. Two other health checks exist on a load-balanced service (the target group's check, from AWS Fundamentals 8); I did not read how ECS combines the container check with the target group's, so that is not claimed here.
Non-Root and Read-Only
Run here: the same /write request against four settings.
Root with a writable file system can write; root with --read-only cannot; the non-root user cannot write to /app even when it is writable (permission denied) and cannot when it is read-only either. Both settings are cheap to apply and each blocks a different thing. If your app needs scratch space, give it a place to write on purpose (a volume, or a temporary directory) rather than loosening the root file system; the volume options of ECS were not read.
CPU Architecture
The task definition's cpuArchitecture is X86_64 (the default) or ARM64, and "all task definitions that are used in a service must have the same value". Run here: this machine is x86-64 and Docker ran the linux/arm64 variant of python:3.13-slim through emulation:
So you can test an ARM image on this machine, slowly. The reverse mistake is the one that bites: an image built on an ARM laptop only runs on an ARM64 task definition. Not run: a mismatch on Fargate.
Task Size: The Fargate Table
The task-size page I read lists the valid Linux CPU and memory combinations for Fargate: 256 (0.25 vCPU) with 512 MiB, 1 GB or 2 GB; 512 with 1 to 4 GB; 1024 with 2 to 8 GB; 2048 with 4 to 16 GB; 4096 with 8 to 30 GB; and for 8192, 16384 and 32768 larger ranges that need platform version 1.4.0 or later. An invalid pair returns Invalid 'cpu' setting for task at registration.
I wrote the table as a function (fargate_model.py). The spot checks in that file only restate my own encoding of the table (the page gives no worked examples for it), so they prove little. What makes it a real check is a second, independent source: cfn-lint has its own rules for this (E3047 for an incompatible pair, E3048 for a CPU value that is not on the list). I generated one task definition for every pair of 10 CPU values (the 8 on the list plus 128 and 300) and every memory value in steps of 512 MiB up to 250 GB, 5,000 pairs, and compared:
Two separate encodings agree on every pair: 77 valid combinations. That is evidence that my reading of the page and cfn-lint's schema match each other, not that either matches AWS today.
What the tools do with a bad size (CDK synth of the stack in the next section, with -c cpu=โฆ -c memory=โฆ):
cdk synth exits 0 for every bad pair and prints only a warning that template validation found issues. If your pipeline checks the exit status, a bad size gets through synthesis. cfn-lint reports the pair (and the invalid CPU value). Lint the template as a separate, failing step.
The Stack: ECR, Cluster, Task, Service
Run here: a CDK stack with no pattern construct, so that every resource is one you can see. It creates a small VPC with public subnets only and no NAT gateway, a cluster, an ECR repository, an S3 bucket the task reads, a task role, a Fargate task definition, a log group, a security group and a service.
cdk synth succeeded and the template has 24 resources of 18 types. What it created that I did not write is worth reading: a task execution role with its own policy, and the image reference built from the repository.
Lint:
The template in cdk.out lints clean. The YAML that cdk synth prints on standard output has the same resources but not the Rules section, so cfn-lint reports "BootstrapVersion not used" for it: lint the file in cdk.out, not the printed text.
Two roles, two jobs
From the documentation: the task execution role "grants the Amazon ECS container and Fargate agents permission to make AWS API calls on your behalf", and its permissions "aren't directly accessible by the containers in the task". Typical uses: pulling the image from a private ECR repository, sending logs with the awslogs driver, reading Secrets Manager or Parameter Store values that the task definition references. The task role is what your application code uses. The managed policy for the former is AmazonECSTaskExecutionRolePolicy.
Run here: the synthesised stack has two different roles. The execution role (created by CDK) got exactly: ecr:BatchCheckLayerAvailability, ecr:GetDownloadUrlForLayer, ecr:BatchGetImage on the one repository, ecr:GetAuthorizationToken on * (an action that does not take a resource), and logs:CreateLogStream, logs:PutLogEvents on the one log group. Nothing more. I did not compare it with the managed policy's contents.
The task role is where the chapter 5 lesson came back. My first version gave it dataBucket.grantRead(taskRole), and the test "task role can only read the data bucket" failed:
The grant produced s3:GetObject*, s3:GetBucket* and s3:List*: three wildcards, more than "read the objects and list the bucket". I replaced it with two explicit statements (s3:GetObject on the objects, s3:ListBucket on the bucket) and the ten tests passed:
The tests check the properties this chapter has argued for: different roles, an exact execution-role action list, non-root, read-only root file system, init process, a stop timeout of 20 s (the test's title says "within the documented 120 s" but it only asserts the value 20), a health check, a pinned image tag, log retention, scan on push and immutable tags, rolling-update settings, no NAT gateway or load balancer, and a security group that only admits port 8080 from the VPC.
ECR
The repository in the stack sets scan on push, immutable tags, and a lifecycle rule that keeps the last ten images. From the documentation (ECR scanning page): there are two scanning types. Basic scanning uses the CVE database to scan operating-system packages and runs on push or manually. Enhanced scanning integrates with Amazon Inspector, covers operating-system and programming-language packages, and can run on push or continuously; Inspector emits an event to EventBridge as new vulnerabilities appear. "Switching between Enhanced scanning and Basic scanning will cause previously established scans to no longer be available." Not read: the price of enhanced scanning, what the lifecycle rule syntax allows beyond the rule I used, tag immutability behaviour, and anything about replication.
Pushing an image needs an account, so it is level 2 and was not run:
Those commands are written from my knowledge of the AWS CLI and the Docker CLI; I did not read the ECR push documentation in this session, so check them against the current page before using them.
Rolling Deployments
From the documentation: minimumHealthyPercent is a lower limit on running healthy tasks, "rounded up"; maximumPercent is an upper limit on tasks, "rounded down". The page's four examples became a test of a small function:
The same page warns: "you should ensure that the scheduler can stop or start at least one task when a deployment is initiated." Settings that allow neither leave a deployment stuck, and ECS sends a service event. The stack uses 100 / 200 with two tasks, so it may start two new tasks before stopping any old ones: with 100 / 100 the function says it may neither stop nor start any, which is exactly the stuck case:
(the first line is the stuck case).
The stack also turns on the deployment circuit breaker with rollback. The page describes two ways to detect a failed rolling deployment: the circuit breaker ("use this method when you want to stop a deployment when the tasks can't start") and CloudWatch alarms ("when you want to stop a deployment based on application metrics"), and "both methods support rolling back to the previous service revision". I did not read the circuit breaker page itself, so its thresholds are not stated here.
Image digests. By default ECS resolves the image tag in the task definition to a digest when a deployment starts and uses that digest for every task, so all tasks run identical images even if the tag moves; that is a documented behaviour (with agent and platform-version conditions), not tested here. Immutable tags and pinned versions (as the stack uses) avoid relying on it.
The Pattern Construct and Its Bill
The CDK has an L3 construct, ApplicationLoadBalancedFargateService (chapter 5 named it). Run here: I synthesised it with an image and a port and nothing else, then counted what it created:
A few lines of code gave 38 resources, including two NAT gateways and two Elastic IPs (the default VPC the pattern builds has private subnets), an internet-facing load balancer with an HTTP-only listener on port 80, a task with no read-only root file system and no non-root user, and a service with MinimumHealthyPercent 50. The target group health check was not set in the template, so whatever the service default is applies (I did not read it). cfn-lint reported one warning (W3005, a redundant dependency, exit status 4) and no errors.
The cost of those NAT gateways is the part people miss. Arithmetic from prices I read on 2026-10-13 (the page-fetch tool summarised the pages and converted the Fargate figures; the NAT price listed was for US East (Ohio) only, so the two Regions are mixed here, which is a flaw of this sketch):
So the idle plumbing of the default pattern costs more per month than two small tasks, before the load balancer (not priced here: I did not read its price). That is the argument for either choosing the VPC deliberately (public subnets with a tight security group, or VPC endpoints) or reading the pattern's output before deploying it.
Validating a Task Definition Without AWS
If you register task definitions with the CLI or SDK rather than the CDK, you can check the JSON offline. botocore validates the request's shape before it sends anything; I used a stubbed client so that nothing is sent, then ran the file through the documented ranges as well.
botocore catches types, missing required fields and misspelt keys, and accepts the invalid size, the stop timeout of 300 and a health check interval of 1. The documented-range check catches those three but does not look at types or required fields. The third layer, cfn-lint on the CDK template, caught the size but not a StopTimeout of 300 or a health-check interval of 1 (I tried both, plus retries 20 and timeout 90: exit status 0 each time). No one tool covers it; and a real RegisterTaskDefinition call is the only check against the live rules (not run).
Cost and Clean-Up
Nothing in this chapter created anything in AWS. What is left on this machine is outside the site: the scratch folder, two local Docker images (aws2-6/good and aws2-6/naive) and a pulled arm64 variant of python:3.13-slim. If you deploy the stack (level 2, not run): the Fargate tasks, the log group and the ECR images are billed while they exist; cdk destroy removes the stack (the repository is set to be emptied and deleted and the log group is removed; the data bucket has no removal policy set, so the CDK default applies and I expect it to be kept, which I did not test), and the CDK bootstrap resources remain. Create a budget alert first (AWS Fundamentals 9).
cdk synth; out-of-range stop timeout and health check values through cfn-lint; the L3 pattern synthesised and counted; botocore offline validation of a task definition.
From the documentation, not run: the task size table, the execution role description, stop behaviour, health check parameters, the rolling-update percentages, the circuit breaker and alarm methods, image digest resolution, ECR scanning types, the Fargate and public IPv4 prices and the Ohio NAT price.
Not read or not run: pushing to ECR, ECS itself, the awslogs driver, Container Insights, ECS Exec, secrets injection, service connect and discovery, auto scaling, blue/green deployments, Fargate Spot, EC2 and Managed Instances capacity, ECR replication and enhanced-scanning prices, the load balancer price and target group defaults, what Fargate does at a memory limit, and all behaviour of a real task. Not checked at all: a real deployment.
Practice
Build a small web service as a good and a naive image. Measure stop time and exit code for five combinations (handler or not, exec or shell form, with and without --init), the memory limit, the Docker health check, and the four non-root / read-only combinations. Then apply the task-definition settings as docker run flags and ask Docker to confirm them.
Write a Fargate service in the CDK without a pattern construct. Synthesise and lint it, write tests (including least privilege for the task role), let one fail, fix the stack, and rerun. Encode the Fargate size table, cross-check it against cfn-lint over every combination, and show what cdk synth does with an invalid size.
Synthesise the load-balanced Fargate pattern with minimal arguments and count what it creates (NAT gateways, load balancer, listener protocol, security settings). Estimate the idle monthly cost from published prices and state what you did not price. Validate a hand-written task definition JSON with botocore offline and compare its verdicts with the documented rules and cfn-lint. Test the rolling-update percentages against the documentation's examples.
Chapter 6 Quick Reference
- ECR stores images; ECS runs them; Fargate supplies the machines. A task definition is versioned
docker runsettings; a task is one copy; a service keeps the desired count and replaces unhealthy tasks - Use exec-form
CMD(or an init process) soSIGTERMreaches your program, and handle it. Otherwise every stop waits forstopTimeout(default 30 s, maximum 120 s) and ends in a kill (exit 137) - A failing health check restarts nothing in plain Docker; in ECS the service replaces unhealthy tasks. Health check ranges: interval 5 to 300 s, timeout 2 to 60, retries 1 to 10, startPeriod 0 to 300
- Run as non-root and with a read-only root file system; give the app a deliberate place to write if it needs one
- Fargate task sizes must come from the table (256 CPU: 512 MiB, 1 or 2 GB; 512: 1 to 4 GB; 1024: 2 to 8 GB; and so on).
cdk synthexits 0 on a bad pair, cfn-lint flags it (E3047, E3048); neither flags an out-of-range stop timeout or health interval - Execution role: the agents pull the image, write logs, fetch referenced secrets. Task role: your code. Keep them separate and test the task role's actions on the synthesised template (a
grantReadgave three wildcard actions) - Rolling updates:
minimumHealthyPercentrounds up,maximumPercentrounds down; make sure at least one task can start or stop. Add the circuit breaker with rollback - The L3 pattern with defaults created 38 resources, two NAT gateways and an HTTP-only load balancer. Read the synthesised template before deploying a pattern
- Lint the template in
cdk.out; the YAML printed bycdk synthlacks theRulessection