aws2-6 Exercise 1: A Good and a Naive Image: Signals, Limits, Health, Non-Root, Read-Only ==================================================================================== Run with Docker 28.0.4 (Docker Desktop engine, Linux containers, x86-64 Windows 11 host); python:3.13-slim image; Node 24.18.0 with aws-cdk 2.1145.0, aws-cdk-lib 2.273.0, constructs 10.8.1, typescript 7.0.2, jest 30.5.2 (the chapter 5 install, reached from a junction); cfn-lint 1.57.2 and PyYAML from the chapter 4 environment; boto3/botocore 1.43.112 from the chapter 3 environment (offline, stubbed client). Scratch folder C:/lwaws/c6, nothing installed globally. VERIFICATION LEVEL: 1 (run here; no AWS account, no AWS credentials, nothing pushed or deployed). Outputs below are saved from the real runs of 2026-10-13. --- app/server.py --- """Tiny service used to test container behaviour locally. GET / -> greeting GET /health -> 200 'ok' (503 after /break is called) GET /break -> makes /health fail from now on GET /write -> tries to write /app/data.txt (fails on a read-only root file system) GET /eat?mb=N-> allocates N MiB (to show a memory limit) Set HANDLE_SIGTERM=1 to exit cleanly (code 0) on SIGTERM; otherwise the default action applies. """ import os, signal, sys, time from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs state = {"healthy": True, "hold": []} class H(BaseHTTPRequestHandler): def reply(self, code, body): b = body.encode() self.send_response(code); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b) def do_GET(self): u = urlparse(self.path) if u.path == "/health": return self.reply(200 if state["healthy"] else 503, "ok" if state["healthy"] else "broken") if u.path == "/break": state["healthy"] = False; return self.reply(200, "now unhealthy") if u.path == "/write": try: open("/app/data.txt", "w").write("x"); return self.reply(200, "wrote /app/data.txt") except OSError as e: return self.reply(500, "write failed: %s" % e) if u.path == "/eat": mb = int(parse_qs(u.query).get("mb", ["1"])[0]) state["hold"].append(bytearray(b"x" * (mb * 1024 * 1024))) return self.reply(200, "holding %d MiB" % (sum(len(x) for x in state["hold"]) // 1048576)) self.reply(200, "hello from pid %d as uid %d\n" % (os.getpid(), os.getuid())) def log_message(self, *a): pass def term(sig, frame): print("got SIGTERM, shutting down cleanly", flush=True) sys.exit(0) if os.environ.get("HANDLE_SIGTERM") == "1": signal.signal(signal.SIGTERM, term) print("listening on 8080, pid %d" % os.getpid(), flush=True) HTTPServer(("0.0.0.0", 8080), H).serve_forever() --- app/Dockerfile (good) --- # 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"] --- app/Dockerfile.naive --- # 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 --- exp_local.sh (build with: docker build -q -t aws2-6/good app ; docker build -q -t aws2-6/naive -f app/Dockerfile.naive app ; run with: bash exp_local.sh) --- #!/bin/bash # Local container experiments for aws2-6. Each block prints what it ran. export MSYS_NO_PATHCONV=1 say() { echo; echo "### $*"; } clean() { docker rm -f "$@" >/dev/null 2>&1; } say "image facts" docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' 'aws2-6/*' docker run --rm aws2-6/good python -c "import os;print('good image runs as uid',os.getuid())" docker run --rm aws2-6/naive python -c "import os;print('naive image runs as uid',os.getuid())" docker inspect aws2-6/good --format 'good: User={{.Config.User}} Cmd={{json .Config.Cmd}} Entrypoint={{json .Config.Entrypoint}}' docker inspect aws2-6/naive --format 'naive: User=[{{.Config.User}}] Cmd={{json .Config.Cmd}}' say "SIGTERM behaviour: docker stop -t 5, how long it took and the exit code" run_stop() { # label image extra-args... label=$1; img=$2; shift 2 clean t1; docker run -d --name t1 "$@" $img >/dev/null; sleep 2 top=$(docker top t1 -o pid,args 2>/dev/null | tail -n +2 | tr '\n' ';' | tr -s ' ') s=$(date +%s%N); docker stop -t 5 t1 >/dev/null; e=$(date +%s%N) code=$(docker inspect t1 --format '{{.State.ExitCode}}') printf '%-48s stop took %5.1fs exit code %s processes: %s\n' "$label" "$(awk -v a=$s -v b=$e "BEGIN{print (b-a)/1e9}")" "$code" "$top" clean t1 } run_stop "good image, no SIGTERM handler" aws2-6/good run_stop "good image, handler (HANDLE_SIGTERM=1)" aws2-6/good -e HANDLE_SIGTERM=1 run_stop "good image, no handler, --init" aws2-6/good --init run_stop "naive image (shell-form CMD), no handler" aws2-6/naive run_stop "naive image, handler" aws2-6/naive -e HANDLE_SIGTERM=1 say "memory limit: --memory 64m then ask the app to hold 200 MiB" clean m1; docker run -d --name m1 --memory 64m --memory-swap 64m -p 18081:8080 aws2-6/good >/dev/null; sleep 2 curl -s -m 10 "http://127.0.0.1:18081/eat?mb=20"; echo " <- 20 MiB request" curl -s -m 10 "http://127.0.0.1:18081/eat?mb=200"; echo " <- 200 MiB request (curl exit $?)" sleep 1 docker inspect m1 --format 'State.Status={{.State.Status}} ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' clean m1 say "health check: Docker HEALTHCHECK (interval 2s, retries 2) before and after /break" clean h1; docker run -d --name h1 -p 18082:8080 aws2-6/good >/dev/null sleep 1; echo "right after start: $(docker inspect h1 --format '{{.State.Health.Status}}')" sleep 4; echo "after 5 s: $(docker inspect h1 --format '{{.State.Health.Status}}')" curl -s http://127.0.0.1:18082/break; echo sleep 7; echo "after /break + 7s: $(docker inspect h1 --format '{{.State.Health.Status}} failing streak={{.State.Health.FailingStreak}}')" echo "container is still: $(docker inspect h1 --format '{{.State.Status}}') (Docker alone does not restart an unhealthy container)" docker inspect h1 --format '{{range .State.Health.Log}}{{.ExitCode}} {{end}}' | sed 's/^/last health check exit codes: /' clean h1 say "read-only root file system, user and /write" for cfg in "naive (root), writable fs|aws2-6/naive|" "naive (root), --read-only|aws2-6/naive|--read-only" "good (uid 10001), writable fs|aws2-6/good|" "good (uid 10001), --read-only|aws2-6/good|--read-only"; do IFS='|' read -r label img extra <<<"$cfg" clean w1; docker run -d --name w1 -p 18083:8080 $extra $img >/dev/null; sleep 2 printf '%-30s -> ' "$label"; curl -s http://127.0.0.1:18083/write; echo clean w1 done say "CPU architecture: run the same image as linux/arm64 on this machine" uname -m 2>/dev/null; docker info --format 'daemon arch: {{.Architecture}}' docker run --rm --platform linux/arm64 python:3.13-slim python -c "import platform;print('ran as',platform.machine())" 2>&1 | tail -3 echo "exit status: ${PIPESTATUS[0]}" --- bash exp_local.sh (saved output of the real run) --- ### image facts 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"] ### SIGTERM behaviour: docker stop -t 5, how long it took and the exit code 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; ### memory limit: --memory 64m then ask the app to hold 200 MiB holding 20 MiB <- 20 MiB request <- 200 MiB request (curl exit 52) State.Status=exited ExitCode=137 OOMKilled=true ### health check: Docker HEALTHCHECK (interval 2s, retries 2) before and after /break 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 ### read-only root file system, user and /write 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' ### CPU architecture: run the same image as linux/arm64 on this machine x86_64 daemon arch: x86_64 ran as aarch64 exit status: 0 --- the task-definition settings applied as docker run flags (saved output of the real run) --- 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 WHY THIS WORKS AS AN ANSWER --------------------------- Every behaviour is measured, not assumed. With a 5 s stop timeout the image without a handler and the shell-form image (even with a handler) waited the whole timeout and were killed (exit 137); the image with a handler left at once (0); --init gave 143. A 64 MiB limit let a 20 MiB allocation through and killed the container on 200 MiB (OOMKilled=true, 137). The Docker health check went from starting to healthy to unhealthy after /break and the container kept running. Root could write unless the file system was read-only; the non-root user could not write to /app either way. The last block shows that the task-definition settings (user, read-only, init, memory, CPU, stop timeout) take effect as docker flags. Limits of the answer: this is Docker, not ECS or Fargate. What ECS does at a memory limit, how it combines container and load balancer health checks, and any real stop were not tested. The 'stop took' column includes about 2 s of Docker overhead in every row.