S065 · Docker — Images, Layers, Dockerfile, Networking
Package your app so it runs anywhere.
Module M07: Systems & Infrastructure · Session 65 of 130 · Track: SYS 🐳
What you'll be able to do after this session
- Explain Docker to a friend in one minute, out loud, no notes.
- Recognise it when you see it in real code / systems / papers.
- Complete the hands-on exercise at the end without looking up help.
Prerequisites
If you can't answer these in 30 seconds each, redo the linked session first:
Pre-read (skim before the session)
- 🎥 Intuition (5–15 min) · Docker in 100 Seconds (Fireship) — the shortest sharpest intro, then a 5-min bonus.
- 🎥 Deep dive (30–60 min) · Docker Tutorial for Beginners (TechWorld with Nana) — 2h if you want everything, first hour covers images/containers/volumes.
- 🎥 Hands-on demo (10–20 min) · Dockerize a Python app (Patrick Loeber) — real Dockerfile, real build, ~12 minutes.
- 📖 Canonical article · Docker Docs — Overview — the official concept map: images, containers, registries, engine.
- 📖 Book chapter / tutorial · Docker Docs — Dockerfile best practices — the canonical guide to layer caching and small images.
- 📖 Engineering blog · Google — Building lean containers with distroless — Google's take on minimal, secure images (README is the essay).
(a) Intuition · 5 min
The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.
In one sentence: Package your app so it runs anywhere.
You know the classic developer curse: "it works on my machine". You install Python 3.11 with a specific numpy build, some C libs from apt, and env vars from your shell. When you hand it to a teammate, half of it breaks because they're on macOS with Python 3.10 and different libc. Docker exists to end this problem by packaging your app plus its entire runtime environment into a single sealed unit called an image — like putting your program in a shipping container that any port can handle identically.
An image is a read-only recipe: base OS layer, your code, dependencies, the command to run. A container is a running instance of an image, isolated from the host and from other containers using Linux kernel features (namespaces for process/network isolation, cgroups for CPU/memory limits). Crucially, containers are not VMs — they share the host's kernel, so they boot in milliseconds instead of seconds and use MB instead of GB.
The magic is layered filesystems. Every RUN, COPY, ADD in your Dockerfile creates a new immutable layer stacked on top. If nothing about a layer's inputs changed, Docker reuses the cached layer — this is why COPY requirements.txt + pip install before COPY . makes rebuilds instant when only your code changed.
Forever-gotcha: A container is a process, not a machine. When PID 1 in the container exits, the container is done — no matter what other threads or child processes exist. This is why "container keeps restarting" is almost always "your main process crashed silently" (check docker logs) or "your entrypoint runs a script that finishes immediately".
(b) Visual walkthrough · 15 min
Diagrams, worked examples, step-by-step visuals.
Layer anatomy of a Python image:
| Layer | Content | Size | Changes on |
|---|---|---|---|
| L0 | python:3.12-slim base | ~50 MB | Never |
| L1 | apt-get install libpq-dev | ~30 MB | Rarely |
| L2 | COPY requirements.txt | 1 KB | When deps change |
| L3 | RUN pip install -r requirements.txt | ~200 MB | When deps change |
| L4 | COPY . /app | ~5 MB | Every code change |
| L5 | CMD python app.py | 0 | Never |
The Big Rule: put slow-changing things early, fast-changing things last, so most rebuilds only redo L4 (a few MB) instead of L2–L4 (200+ MB).
Worked example — the same app, two Dockerfiles:
Bad (rebuilds pip install every time your code changes):
FROM python:3.12-slim
WORKDIR /app
COPY . . # invalidates cache on any file change
RUN pip install -r requirements.txt
CMD ["python", "app.py"]Good (rebuilds pip install only when requirements.txt changes):
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt . # only invalidates when deps change
RUN pip install --no-cache-dir -r requirements.txt
COPY . . # code change only invalidates this layer
CMD ["python", "app.py"]On a laptop, the good version takes ~2 s per rebuild when you change a Python file. The bad version takes 60+ s.
Worked example — networking.
docker run -p 8080:80 nginxmaps host port 8080 to container port 80. Requests tohttp://localhost:8080hit the container.- By default, containers on the same Docker network can reach each other by container name (
docker run --network mynet --name db postgres→ other containerspsql -h db). - Containers on different networks can't see each other unless you connect them explicitly. This is the primitive underneath docker-compose service discovery.
(c) Hands-on · 20 min
Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.
Full round-trip: write a Python web app, containerise it, build it two ways (naive and multi-stage), run it, and inspect layers.
mkdir docker-demo && cd docker-demo
cat > app.py <<'EOF'
from flask import Flask
import os, socket
app = Flask(__name__)
@app.get("/")
def hi():
return f"hello from {socket.gethostname()} · env HELLO={os.getenv('HELLO','unset')}
"
EOF
cat > requirements.txt <<'EOF'
flask==3.0.0
EOF
# --- Dockerfile: multi-stage build for a small image ---
cat > Dockerfile <<'EOF'
# Stage 1: builder — has compilers, big
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/deps -r requirements.txt
# Stage 2: runtime — only what we need to run
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/deps PATH=/deps/bin:$PATH
COPY --from=builder /deps /deps
COPY app.py .
EXPOSE 5000
CMD ["python", "-m", "flask", "--app", "app", "run", "--host=0.0.0.0"]
EOF
cat > .dockerignore <<'EOF'
__pycache__
*.pyc
.git
.venv
EOF
# Build
docker build -t hello-app:v1 .
# Run
docker run -d --name hello --rm -p 5000:5000 -e HELLO=world hello-app:v1
sleep 2
curl http://127.0.0.1:5000/
# Inspect
docker ps
docker images hello-app
docker history hello-app:v1
docker exec hello sh -c 'ls /deps | head'
docker logs hello
# Cleanup
docker stop helloChecklist — what to observe:
docker buildprints each layer withCACHEDon rebuilds after the first — proof the layer cache is working.docker imagesshows the final image size — the multi-stage version is ~130 MB vs ~250 MB for a single-stage naive one.curlreturns something likehello from 3a2c1f...— that hex is the container's hostname, defaulting to its short ID.-e HELLO=worldshows up in the response — env vars are the standard way to inject config at run time (the "12-factor" pattern).docker historyshows every layer, its size, and the command that created it — this is your #1 debugging tool for "why is my image so big?".
Try this modification: switch the base image to python:3.12-alpine (musl libc) and rebuild. Notice the image shrinks to ~60 MB but some pure-Python packages that ship prebuilt wheels for glibc now compile from source (slow first build). Alpine vs slim is a real trade-off — smaller image, slower installs, occasional obscure C compatibility bugs.
(d) Production reality · 10 min
How this shows up in real systems, gotchas, war stories, common bugs.
War story #1 — the 4 GB image. A team was pulling a node:latest image (~1 GB) for a 300-line service. Every deploy took 2 minutes just to pull. Switching to node:20-slim cut it to 200 MB and 15 s. Then multi-stage build with node:20-alpine runtime cut it further to 80 MB. Rule: the base image dominates size and CVE count. Always pick -slim or -alpine or distroless unless you have a hard reason not to.
War story #2 — the leaked secret. Someone COPY .env . into an image and pushed it to Docker Hub. Their production DB creds leaked publicly for weeks. Even docker build --build-arg SECRET=... bakes it into a layer that appears in docker history. Real fix: use docker build --secret (BuildKit) or inject at run time via env vars / mounted files.
War story #3 — the disk-full outage. Containers write to a copy-on-write layer that goes to disk. Logs to stdout are captured by the Docker daemon into JSON files. On a busy service, /var/lib/docker/containers/*/*-json.log filled the disk and killed every container. Fix: log driver rotation (log-opts max-size=10m, max-file=3) or ship logs to a central sink.
Common gotchas & how top teams handle them:
- PID 1 signal handling. Your Python app doesn't handle SIGTERM properly → Docker sends SIGKILL after grace period → half-written data. Use
tinias init or handle signals in-app. - Root user by default. Most images run as root. If the app has an RCE, attacker owns the container. Add
USER 1000in the Dockerfile; distroless images enforce it. latesttag is a footgun. Immutable = reproducible. Pin to digests (python@sha256:...) or at least specific versions.- Image scanning. Trivy, Grype, Snyk scan for CVEs in base images. Real teams gate deploys on "no critical CVEs".
- Build cache in CI. Docker's local cache doesn't exist on ephemeral CI runners. Use
--cache-frompointing at your registry, or BuildKit remote cache. This alone can cut CI time 5x. - Reproducible builds: pinning versions,
--no-cache-diron pip, checksums on downloads. Big shops (Meta, Google) do this obsessively so their builds are byte-identical months later.
Modern trend: distroless images (Google), scratch images for compiled binaries (Go, Rust), and Nix-based image builds for perfect reproducibility.
(e) Quiz + exercise · 10 min
- Explain the difference between an image and a container in one sentence each.
- Why does
COPY requirements.txtcome beforeCOPY .in a well-written Dockerfile? - What's the point of a multi-stage build? Give the size argument in one sentence.
- Why does your container exit immediately if PID 1 finishes?
- Name two ways to leak a secret into a Docker image and how to avoid each.
Stretch problem (connects to S060 — OS Basics): Docker isolation uses two Linux kernel features you learned about: namespaces (isolate what the process sees: PID, mount, network, UTS) and cgroups (limit what the process uses: CPU, memory, IO). For each of these five isolation properties, give a one-line explanation and a docker run flag that exposes it (e.g. --memory=512m). Answer as a 5-row table.
Explain-out-loud test
If you can't teach these 3 things to a friend without notes, redo the session:
- What is Docker? (one sentence, no jargon)
- When would you use it? (one concrete example)
- When would you NOT use it? (one anti-pattern)
What comes next
- Next session (S066): Kubernetes I — Pods, Deployments, Services
- Previous (S064): CDN — Edge, Cache Hierarchies, Cache-Control
- Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.
More from M07 · Systems & Infrastructure
all modules →