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.

How every example in this chapter was verified
There is no AWS account and no AWS credentials on the machine that wrote this course, so nothing was pushed to ECR or deployed to ECS.
  • 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-cdk 2.1145.0 and aws-cdk-lib 2.273.0 synthesised the stacks (reusing the install from chapter 5); cfn-lint 1.57.2, Jest 30.5.2 and botocore 1.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 wordWhat it isClosest Docker idea
Task definitionA versioned recipe (a family plus a revision number) with container settings, CPU and memory, roles and network modeThe flags of docker run, written down and versioned
TaskOne running copy of a task definition. The documentation gives its life cycle: PROVISIONING, PENDING, ACTIVATING, RUNNING, DEACTIVATING, STOPPING, DEPROVISIONING, STOPPEDOne docker run
ServiceKeeps a desired number of tasks running and replaces unhealthy ones; carries the rolling-update settingsNothing in plain Docker (closest is a restart policy plus a script)
ClusterThe logical group the tasks and services belong toThe Docker host(s)
ECR repositoryWhere the image is stored, with scanning and lifecycle rulesDocker 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).

# the "good" image: non-root user, exec-form CMD, health check FROM python:3.13-slim RUN useradd --uid 10001 --no-create-home app WORKDIR /app COPY server.py . USER 10001 EXPOSE 8080 HEALTHCHECK --interval=2s --timeout=2s --retries=2 --start-period=1s \ CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health').status==200 else 1)"] CMD ["python", "server.py"]
# the "naive" image: runs as root, shell-form CMD FROM python:3.13-slim WORKDIR /app COPY server.py . EXPOSE 8080 CMD python server.py
bash exp_local.sh # first block
aws2-6/naive:latest 176MB aws2-6/good:latest 176MB good image runs as uid 10001 naive image runs as uid 0 good: User=10001 Cmd=["python","server.py"] Entrypoint=null naive: User=[] Cmd=["/bin/sh","-c","python server.py"]

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 definitiondocker runWhat the documentation says
user--user"As a security best practice, always use a non-root user"
readonlyRootFilesystem--read-onlyDefault false; not supported for Windows containers
linuxParameters.initProcessEnabled--init(the parameter page excerpt I kept did not cover it)
stopTimeout--stop-timeoutSeconds before the container is forcefully killed; default 30, maximum 120
healthCheckHEALTHCHECKInterval 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, --memoryOnly 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:

docker run -d --name task1 --user 10001 --read-only --init --memory 512m --cpus 0.25 --stop-timeout 20 -e HANDLE_SIGTERM=1 -p 18084:8080 aws2-6/good
uid/pid: hello from pid 7 as uid 10001 write: write failed: [Errno 30] Read-only file system: '/app/data.txt' health: healthy limits: memory=536870912 bytes, nanoCpus=250000000, readonly=true, init=true, user=10001, stopTimeout=20

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.

bash exp_local.sh # second block
good image, no SIGTERM handler stop took 7.1s exit code 137 processes: 2131 python server.py; good image, handler (HANDLE_SIGTERM=1) stop took 2.1s exit code 0 processes: 2274 python server.py; good image, no handler, --init stop took 2.3s exit code 143 processes: 2369 /sbin/docker-init -- python server.py;2384 python server.py; naive image (shell-form CMD), no handler stop took 7.3s exit code 137 processes: 2468 /bin/sh -c python server.py;2482 python server.py; naive image, handler stop took 7.3s exit code 137 processes: 2559 /bin/sh -c python server.py;2572 python server.py;

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 is initProcessEnabled (not run on ECS).
  • The shell-form CMD was the trap. With /bin/sh -c as 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:

bash exp_local.sh # third block
holding 20 MiB <- 20 MiB request <- 200 MiB request (curl exit 52) State.Status=exited ExitCode=137 OOMKilled=true

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):

bash exp_local.sh # fourth block
right after start: starting after 5 s: healthy now unhealthy after /break + 7s: unhealthy failing streak=4 container is still: running (Docker alone does not restart an unhealthy container) last health check exit codes: 1 1 1 1 1

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.

bash exp_local.sh # fifth block
naive (root), writable fs -> wrote /app/data.txt naive (root), --read-only -> write failed: [Errno 30] Read-only file system: '/app/data.txt' good (uid 10001), writable fs -> write failed: [Errno 13] Permission denied: '/app/data.txt' good (uid 10001), --read-only -> write failed: [Errno 30] Read-only file system: '/app/data.txt'

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:

docker run --platform linux/arm64 python:3.13-slim python -c "import platform;print(platform.machine())"
x86_64 daemon arch: x86_64 ran as aarch64 exit status: 0

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:

python cross_check.py
combinations checked: 5000 model says valid: 77 cfn-lint accepts: 77 disagreements: 0

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=โ€ฆ):

cat out/sizes.txt
cpu=256 memory=512: cdk synth exit 0; cdk message: ; cfn-lint: nothing beyond W2001 cpu=256 memory=4096: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '256' is not compatible with memory '4096' cpu=512 memory=512: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '512' is not compatible with memory '512' cpu=300 memory=512: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '300' is not compatible with memory '512' E3048 '300' is not one of ['256', '512', '1024', '2048', '4096', '8192', '16384', '32768'] cpu=1024 memory=8192: cdk synth exit 0; cdk message: ; cfn-lint: nothing beyond W2001 cpu=2048 memory=3072: cdk synth exit 0; cdk message: [Warning] Template validation found issues in your templates (reported as warnings).; cfn-lint: E3047 Cpu '2048' is not compatible with memory '3072'

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.

import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as ecr from "aws-cdk-lib/aws-ecr"; import * as ecs from "aws-cdk-lib/aws-ecs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as logs from "aws-cdk-lib/aws-logs"; import * as s3 from "aws-cdk-lib/aws-s3"; export interface ServiceProps extends cdk.StackProps { cpu: number; memoryMiB: number; desiredCount: number; } // A deliberately explicit Fargate service: nothing is created for us that we cannot see. export class ServiceStack extends cdk.Stack { constructor(scope: Construct, id: string, props: ServiceProps) { super(scope, id, props); const vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2, natGateways: 0, subnetConfiguration: [{ name: "public", subnetType: ec2.SubnetType.PUBLIC }] }); const cluster = new ecs.Cluster(this, "Cluster", { vpc }); const repo = new ecr.Repository(this, "Repo", { imageScanOnPush: true, imageTagMutability: ecr.TagMutability.IMMUTABLE, removalPolicy: cdk.RemovalPolicy.DESTROY, emptyOnDelete: true, lifecycleRules: [{ maxImageCount: 10, description: "keep the last 10 images" }], }); const dataBucket = new s3.Bucket(this, "Data", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, enforceSSL: true }); const taskRole = new iam.Role(this, "TaskRole", { assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com") }); // explicit instead of dataBucket.grantRead(taskRole), which generates s3:GetObject*, s3:GetBucket* and s3:List* taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["s3:GetObject"], resources: [dataBucket.arnForObjects("*")] })); taskRole.addToPolicy(new iam.PolicyStatement({ actions: ["s3:ListBucket"], resources: [dataBucket.bucketArn] })); const task = new ecs.FargateTaskDefinition(this, "Task", { cpu: props.cpu, memoryLimitMiB: props.memoryMiB, taskRole, runtimePlatform: { cpuArchitecture: ecs.CpuArchitecture.X86_64, operatingSystemFamily: ecs.OperatingSystemFamily.LINUX }, }); const logGroup = new logs.LogGroup(this, "Logs", { retention: logs.RetentionDays.ONE_WEEK, removalPolicy: cdk.RemovalPolicy.DESTROY }); task.addContainer("web", { image: ecs.ContainerImage.fromEcrRepository(repo, "1.0.0"), portMappings: [{ containerPort: 8080 }], readonlyRootFilesystem: true, user: "10001", stopTimeout: cdk.Duration.seconds(20), linuxParameters: new ecs.LinuxParameters(this, "Linux", { initProcessEnabled: true }), logging: ecs.LogDrivers.awsLogs({ streamPrefix: "web", logGroup }), healthCheck: { command: ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/health')\" || exit 1"], interval: cdk.Duration.seconds(15), timeout: cdk.Duration.seconds(5), retries: 3, startPeriod: cdk.Duration.seconds(10), }, }); const sg = new ec2.SecurityGroup(this, "ServiceSg", { vpc, allowAllOutbound: true }); sg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(8080), "from inside the VPC only"); new ecs.FargateService(this, "Service", { cluster, taskDefinition: task, desiredCount: props.desiredCount, securityGroups: [sg], assignPublicIp: true, minHealthyPercent: 100, maxHealthyPercent: 200, circuitBreaker: { rollback: true }, }); } }

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.

Task79114B6B: Type: AWS::ECS::TaskDefinition Properties: ContainerDefinitions: - Essential: true HealthCheck: Command: - CMD-SHELL - python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/health')" || exit 1 Interval: 15 Retries: 3 StartPeriod: 10 Timeout: 5 Image: Fn::Join: - "" - - Fn::Select: - 4 - Fn::Split: - ":" - Fn::GetAtt: - Repo02AC86CF - Arn - .dkr.ecr. - Fn::Select: - 3 - Fn::Split: - ":" - Fn::GetAtt: - Repo02AC86CF - Arn - "." - Ref: AWS::URLSuffix - / - Ref: Repo02AC86CF - :1.0.0 LinuxParameters: Capabilities: {} InitProcessEnabled: true LogConfiguration: LogDriver: awslogs Options: awslogs-group: Ref: Logs6819BB44 awslogs-stream-prefix: web awslogs-region: Ref: AWS::Region Name: web PortMappings: - ContainerPort: 8080 Protocol: tcp ReadonlyRootFilesystem: true StopTimeout: 20 User: "10001" Cpu: "256" ExecutionRoleArn: Fn::GetAtt: - TaskExecutionRoleEF964934 - Arn Family: ServiceTask4E7131ED Memory: "512" NetworkMode: awsvpc RequiresCompatibilities: - FARGATE RuntimePlatform: CpuArchitecture: X86_64 OperatingSystemFamily: LINUX TaskRoleArn: Fn::GetAtt: - TaskRole30FC0FBB - Arn Metadata: aws:cdk:path: Service/Task/Resource
TaskExecutionRoleDefaultPolicy72339918: Type: AWS::IAM::Policy Properties: PolicyDocument: Statement: - Action: - ecr:BatchCheckLayerAvailability - ecr:GetDownloadUrlForLayer - ecr:BatchGetImage Effect: Allow Resource: Fn::GetAtt: - Repo02AC86CF - Arn - Action: ecr:GetAuthorizationToken Effect: Allow Resource: "*" - Action: - logs:CreateLogStream - logs:PutLogEvents Effect: Allow Resource: Fn::GetAtt: - Logs6819BB44 - Arn Version: "2012-10-17" PolicyName: TaskExecutionRoleDefaultPolicy72339918 Roles: - Ref: TaskExecutionRoleEF964934

Lint:

cfn-lint cdk.out/Service.template.json # and the stdout YAML
$ cfn-lint cdk.out/Service.template.json (exit status 0) $ cfn-lint out/synth_service.yaml (the YAML that cdk synth prints on stdout) W2001 Parameter BootstrapVersion not used. out\synth_service.yaml:516:3 (exit status 4) Resources identical in both files: True | sections only in cdk.out: ['Rules']

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:

cat out/jest_names_1.txt
passed 9 failed 1 PASSED - task role and execution role are different roles PASSED - execution role can pull images and write logs, and nothing else FAILED - task role can only read the data bucket Error: expect(received).not.toMatch(expected) PASSED - container: non-root, read-only root fs, init process, stop timeout within the documented 120 s PASSED - health check is defined and the image is a pinned tag, not latest PASSED - logs go to a log group with a retention period PASSED - repository scans on push, tags are immutable, and a lifecycle rule exists PASSED - rolling update settings leave room to start a task, and the circuit breaker rolls back PASSED - no NAT gateway and no load balancer in this stack PASSED - the service security group allows only 8080 from the VPC

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:

cat out/jest_names_2.txt
passed 10 failed 0 PASSED - task role and execution role are different roles PASSED - execution role can pull images and write logs, and nothing else PASSED - task role can only read the data bucket PASSED - container: non-root, read-only root fs, init process, stop timeout within the documented 120 s PASSED - health check is defined and the image is a pinned tag, not latest PASSED - logs go to a log group with a retention period PASSED - repository scans on push, tags are immutable, and a lifecycle rule exists PASSED - rolling update settings leave room to start a task, and the circuit breaker rolls back PASSED - no NAT gateway and no load balancer in this stack PASSED - the service security group allows only 8080 from the VPC

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:

# level 2: needs an AWS account, credentials and Docker. NOT RUN. Written from memory of the CLIs, not read from the ECR push documentation: check before use. # Account ID and Region are placeholders. aws ecr get-login-password --region eu-west-1 | docker login --username AWS --password-stdin 111122223333.dkr.ecr.eu-west-1.amazonaws.com docker tag aws2-6/good:latest 111122223333.dkr.ecr.eu-west-1.amazonaws.com/web:1.0.0 docker push 111122223333.dkr.ecr.eu-west-1.amazonaws.com/web:1.0.0 # the repository in the stack has immutable tags: pushing 1.0.0 a second time should be refused (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:

py fargate_model.py
ok 50% min, 4 desired: can stop two before starting two -> may_stop=2 (documented 2) ok 75% min, 2 desired: can't stop any (result is two) -> may_stop=0 (documented 0) ok 200% max, 4 desired: can start four -> may_start=4 (documented 4) ok 125% max, 3 desired: can't start any (result is three) -> may_start=0 (documented 0)

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:

py -c "from fargate_model import rolling_limits as r; print(r(2,100,100)); print(r(2,100,200))"
desired 2, min 100, max 100: {'may_stop': 0, 'may_start': 0, 'min_running': 2, 'max_running': 2} desired 2, min 100, max 200: {'may_stop': 0, 'may_start': 2, 'min_running': 2, 'max_running': 4}

(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:

cat out/pattern_resources.txt
resources: 38 1 AWS::CDK::Metadata 2 AWS::EC2::EIP 1 AWS::EC2::InternetGateway 2 AWS::EC2::NatGateway 4 AWS::EC2::Route 4 AWS::EC2::RouteTable 2 AWS::EC2::SecurityGroup 1 AWS::EC2::SecurityGroupEgress 1 AWS::EC2::SecurityGroupIngress 4 AWS::EC2::Subnet 4 AWS::EC2::SubnetRouteTableAssociation 1 AWS::EC2::VPC 1 AWS::EC2::VPCGatewayAttachment 1 AWS::ECS::Cluster 1 AWS::ECS::Service 1 AWS::ECS::TaskDefinition 1 AWS::ElasticLoadBalancingV2::Listener 1 AWS::ElasticLoadBalancingV2::LoadBalancer 1 AWS::ElasticLoadBalancingV2::TargetGroup 1 AWS::IAM::Policy 2 AWS::IAM::Role 1 AWS::Logs::LogGroup NAT gateways: 2 | elastic IPs: 2 task size: cpu 256 memory 512 | read-only root fs: None | user: None service: desired None | deployment config: {"Alarms": {"AlarmNames": [], "Enable": false, "Rollback": false}, "MaximumPercent": 200, "MinimumHealthyPercent": 50} | public IP: DISABLED target group health check: {} | deregistration delay attrs: [{'Key': 'stickiness.enabled', 'Value': 'false'}] listener port 80 HTTP

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):

py cost_sketch.py
Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, X86 : $18.02 Fargate, 2 tasks of 0.25 vCPU + 0.5 GB, one month, ARM : $14.42 Fargate, 2 tasks of 1 vCPU + 2 GB, one month, X86 : $72.08 2 NAT gateways, one month, hourly charge only (Ohio rate): $65.70 2 public IPv4 addresses on those NAT gateways, one month: $7.30 so the two NAT gateways plus their addresses cost about 4x the two small tasks (hourly charges only) the default L3 pattern keeps them running whether or not any traffic flows

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.

{ "family": "web", "requiresCompatibilities": ["FARGATE"], "networkMode": "awsvpc", "cpu": "256", "memory": "512", "runtimePlatform": {"cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX"}, "taskRoleArn": "arn:aws:iam::111122223333:role/web-task", "executionRoleArn": "arn:aws:iam::111122223333:role/web-exec", "containerDefinitions": [{ "name": "web", "image": "111122223333.dkr.ecr.eu-west-1.amazonaws.com/web:1.0.0", "essential": true, "user": "10001", "readonlyRootFilesystem": true, "stopTimeout": 20, "linuxParameters": {"initProcessEnabled": true}, "portMappings": [{"containerPort": 8080, "protocol": "tcp"}], "healthCheck": {"command": ["CMD-SHELL", "python -c \"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/health')\" || exit 1"], "interval": 15, "timeout": 5, "retries": 3, "startPeriod": 10}, "logConfiguration": {"logDriver": "awslogs", "options": {"awslogs-group": "/ecs/web", "awslogs-region": "eu-west-1", "awslogs-stream-prefix": "web"}} }] }
python taskdef_check.py
case | botocore (offline) | documented rules the file as written | accepted | no problem found cpu as a number instead of a string | rejected: Invalid type for parameter cpu, value: 256, type: <class 'int'>, valid types: <class 'str'> | (skipped) size 256 / 4096 | accepted | size not in the Fargate table stopTimeout 300 | accepted | stopTimeout outside 0-120 health check interval 1 | accepted | interval outside 5-300 health check without command | rejected: Missing required parameter in containerDefinitions[0].healthCheck: "command" | no problem found containerPort as a string | rejected: Invalid type for parameter containerDefinitions[0].portMappings[0].containerPort, value: 8080, type: <class 's | no problem found no family | rejected: Missing required parameter in input: "family" | (skipped) misspelt key readOnlyRootFilesystem | rejected: Unknown parameter in containerDefinitions[0]: "readOnlyRootFilesystem", must be one of: name, image, repositor | no problem found

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

What was and was not checked
Run here: two images built and run; stop behaviour under five configurations; the memory limit; the Docker health check; non-root and read-only combinations; an ARM image under emulation; the task settings as Docker flags; the CDK stack synthesised, linted and tested (10 tests, one deliberate failure found by the first version); the Fargate size table cross-checked against cfn-lint on 5,000 pairs; bad sizes through 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

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • ECR stores images; ECS runs them; Fargate supplies the machines. A task definition is versioned docker run settings; a task is one copy; a service keeps the desired count and replaces unhealthy tasks
  • Use exec-form CMD (or an init process) so SIGTERM reaches your program, and handle it. Otherwise every stop waits for stopTimeout (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 synth exits 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 grantRead gave three wildcard actions)
  • Rolling updates: minimumHealthyPercent rounds up, maximumPercent rounds 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 by cdk synth lacks the Rules section
Coming next
Chapter 7 is Kubernetes on AWS: Amazon EKS. An EKS cluster is not free, so most of it will be manifests and definitions written and validated locally, and the chapter will point to Kubernetes Fundamentals and Kubernetes Intermediate/Advanced for everything that is not AWS-specific. Start with AWS Intermediate/Advanced 7.