Search Tech Journey

Find topics, journeys and posts

6-month learning plan65 / 130
back to blog
systemsintermediate 15m read

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)


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

LayerContentSizeChanges on
L0python:3.12-slim base~50 MBNever
L1apt-get install libpq-dev~30 MBRarely
L2COPY requirements.txt1 KBWhen deps change
L3RUN pip install -r requirements.txt~200 MBWhen deps change
L4COPY . /app~5 MBEvery code change
L5CMD python app.py0Never

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 nginx maps host port 8080 to container port 80. Requests to http://localhost:8080 hit 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 containers psql -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 hello

Checklist — what to observe:

  1. docker build prints each layer with CACHED on rebuilds after the first — proof the layer cache is working.
  2. docker images shows the final image size — the multi-stage version is ~130 MB vs ~250 MB for a single-stage naive one.
  3. curl returns something like hello from 3a2c1f... — that hex is the container's hostname, defaulting to its short ID.
  4. -e HELLO=world shows up in the response — env vars are the standard way to inject config at run time (the "12-factor" pattern).
  5. docker history shows 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 tini as 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 1000 in the Dockerfile; distroless images enforce it.
  • latest tag 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-from pointing at your registry, or BuildKit remote cache. This alone can cut CI time 5x.
  • Reproducible builds: pinning versions, --no-cache-dir on 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

  1. Explain the difference between an image and a container in one sentence each.
  2. Why does COPY requirements.txt come before COPY . in a well-written Dockerfile?
  3. What's the point of a multi-stage build? Give the size argument in one sentence.
  4. Why does your container exit immediately if PID 1 finishes?
  5. 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:

  1. What is Docker? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


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 →
  1. 060OS Basics — Processes, Threads, Memory, FDs
  2. 061Networking I — TCP/IP, DNS, Sockets
  3. 062Networking II — Load Balancers L4 vs L7, Reverse Proxies
  4. 063Caching — Cache-Aside, Write-Through, TTLs, Invalidation
  5. 064CDN — Edge, Cache Hierarchies, Cache-Control
  6. 066Kubernetes I — Pods, Deployments, Services