open atlas
↑ Back to track
Python for JS/TS developers PY · 12 · 02

Containerizing Python: slim plus multi-stage uv builds, cache-ordered layers, and signals that reach PID 1

python:3.x-slim over alpine (musl breaks manylinux wheels); multi-stage: uv sync a venv in the builder, copy into slim — ~1GB to ~80MB. Lockfile COPY before code keeps deps cached. PYTHONUNBUFFERED=1 or logs go silent; exec-form CMD or PID 1 is a shell and SIGTERM dies there.

PY Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

For months, every deploy “randomly” dropped a handful of requests, and everyone blamed the load balancer. The graphs were maddening: 502s in a tight burst, exactly at rollout, never reproducible in staging. The actual bug was one line long and three years old: CMD gunicorn app.main:app — shell form. Docker wraps that string in /bin/sh -c, so PID 1 inside the container was a shell, with gunicorn as its child. When Kubernetes started a rollout it sent SIGTERM to PID 1 — and sh does not forward signals to children. Gunicorn never heard a thing, kept serving, and ten seconds later the kernel delivered SIGKILL to the whole process group: workers died mid-request, connections reset, 502s sprayed. The fix was punctuation: CMD ["gunicorn", "app.main:app"] — exec form, gunicorn is PID 1, SIGTERM lands, workers drain, deploys went silent. A container image is a small operating-system decision: who is PID 1, whether stdout flushes, which layers rebuild, what ships in the final stage. Python has sharp specifics on all four, and this lesson is the checklist.

The base image, honestly

Default to python:3.12-slim — Debian-based, glibc, around 150 MB. The full python:3.12 image is near a gigabyte of compilers and dev headers you should not ship. The tempting one is alpine, and the warning matters: Alpine uses musl libc, and most prebuilt wheels on PyPI are manylinux — built against glibc. On musl, pip often cannot use them and falls back to compiling from source: builds that took seconds take minutes, you must install gcc and headers (there goes the size win), and you inherit musl’s subtle behavioral differences — smaller default thread stacks, different DNS resolution — that surface as production-only bugs. The musllinux wheel tag exists, but coverage across your dependency tree is the exception, not the rule. Distroless Python images (no shell, no package manager) are the hardening end-state: smallest attack surface, but debugging means ephemeral containers, and the tag tracks a distro Python — adopt deliberately, not by default.

The honest size shapes: full python:3.12 ≈ 1 GB, naive single-stage slim ≈ 300-400 MB with build deps, multi-stage slim final ≈ 80-150 MB depending on your wheels.

Multi-stage with uv, and the layer-cache mechanics

Two stages: a builder that may install compilers, and a final stage that gets only the finished virtualenv. uv makes the builder fast and deterministic — uv sync --frozen installs exactly the lockfile:

# --- builder: may contain compilers; never ships ---
FROM python:3.12-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project   # deps layer: cached until the lock changes
COPY src/ src/
RUN uv sync --frozen --no-dev                        # project layer: cheap

# --- final: slim, no compilers, nonroot ---
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PATH="/app/.venv/bin:$PATH"
RUN useradd --create-home appuser
WORKDIR /app
COPY --from=builder /app/.venv .venv/
COPY src/ src/
USER appuser
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app.main:app"]

The COPY ordering is the cache mechanics: each instruction is a layer, its cache key includes the checksums of the files it copies, and everything after the first changed layer rebuilds. Lockfile first, uv sync second, app code last — a code commit re-runs only the cheap final COPY; the dependency install (the slow layer) stays cached until uv.lock actually changes. Invert the order — COPY . . before the install — and every one-line change reinstalls every dependency in CI. The companion file is .dockerignore: without it, COPY . . drags in .git (often hundreds of MB), the host .venv (which can shadow the built one), and __pycache__ — the classic image-bloat-by-default.

Quiz

Your Dockerfile does COPY . . and then RUN uv sync --frozen. CI rebuilds the image on every commit. What does a one-line code change cost?

Runtime flags, nonroot, and a real healthcheck

PYTHONUNBUFFERED=1 is non-negotiable. When stdout is not a TTY — and in a container it never is — CPython block-buffers it: log lines sit in a process-local buffer until kilobytes accumulate. The symptom is the silent-container classic: the service is running, kubectl logs shows nothing for minutes, and when a pod crashes the buffered tail — the lines explaining the crash — is lost forever. PYTHONDONTWRITEBYTECODE=1 is more honest as a tidiness flag: it skips writing .pyc files (friendlier to read-only filesystems, no __pycache__ litter in layers) at the cost of recompiling bytecode each boot — measurable but small; precompiling in the builder is the have-both option.

Run as a nonroot USER — container escapes and writable-filesystem exploits all get cheaper as root, and admission policies in hardened clusters will reject root images anyway. Wire the orchestrator’s probe to a real readiness endpoint — the one from your python/06 lifespan work that checks the DB pool and downstream clients — not to “the process exists”. Note that Kubernetes ignores the Dockerfile HEALTHCHECK instruction entirely; probes are configured on the pod spec, so the endpoint is the contract, not the instruction.

PID 1 and the signal path

The Hook’s bug generalizes. CMD gunicorn app:app (shell form) makes /bin/sh -c PID 1; CMD ["gunicorn", "app:app"] (exec form) makes gunicorn PID 1. Signals are delivered to PID 1 — and a non-interactive sh neither forwards SIGTERM nor exits on it, so graceful shutdown never begins and the grace period always expires into SIGKILL: the 10-seconds-then-corpse deploy patterns. Exec form is the rule; if you genuinely need shell features in the command, end the script with exec gunicorn ... so the server replaces the shell. PID 1 also inherits the orphan-reaping duty: gunicorn handles its own children, but if your entrypoint spawns sidecars, an init shim (tini, or the runtime’s init flag) prevents zombie accumulation.

Quiz

Deploys cause a burst of 502s: k8s sends SIGTERM, waits the grace period, then SIGKILLs. The Dockerfile ends with CMD gunicorn app:app (shell form). What did gunicorn actually experience?

Recall before you leave
  1. 01
    Defend the base-image choice and the multi-stage layout: why slim over alpine and over the full image, and what exactly does each stage contribute?
  2. 02
    Name the three runtime traps — buffering, layer order, PID 1 — with mechanism and fix for each.
Recap

A Python container is four operating-system decisions disguised as a Dockerfile. The base: python:3.x-slim, because PyPI wheels are manylinux — glibc — and alpine’s musl turns wheel installs into source compiles with their own toolchain bloat and behavioral drift; the full image ships a gigabyte of compilers nobody should run in prod. The build: two stages — uv resolves uv.lock into a venv where compilers are allowed, and the final slim stage receives only .venv and source, landing at 80-150 MB instead of a gigabyte. The cache: layers are a prefix — the lockfile COPY and uv sync go above the code COPY, so a commit rebuilds the cheap layer, not the dependency install, and .dockerignore keeps .git and the host .venv out of the context entirely. The runtime: PYTHONUNBUFFERED=1 because block-buffered stdout is the silent container whose dying words vanish with the crash; a nonroot user because root containers fail hardened admission and cheapen exploits; readiness wired to the lifespan endpoint that actually checks dependencies, because Kubernetes ignores Dockerfile HEALTHCHECK. And PID 1: exec-form CMD so SIGTERM reaches gunicorn instead of dying in a shell that forwards nothing — the months of “random” rollout 502s in the Hook were one pair of square brackets away from never existing. Now when you see silent containers, bloated images, or deploys that spray 502s, you will know exactly which of these four decisions to audit first.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.