Dockerfile Design
Lesson 02 · Cloud Infrastructure & DevOps · ~12 min read
1. Why image size is a deployment concern
Every kilobyte in a container image ships down the wire to every CI runner, every Kubernetes node, and every developer pulling the image for the first time. A 180 MB image versus a 90 MB image isn’t an aesthetic difference — it is pull-time, registry storage bills, blast radius for CVE scans, and rolling-update tail latency in production.
When a Kubernetes rolling update starts, every new pod must pull the image before it can start accepting traffic. If your registry is in the same region as your cluster, a 180 MB image might take 4–6 seconds per node. A 90 MB image might take 2–3 seconds. That difference compounds: a cluster with 20 nodes doing a rolling update is 40–60 extra seconds of deployment time, every deploy, every day.
The security case is equally practical. CVE scanners (Trivy, Snyk, Docker Scout) flag vulnerabilities in OS packages. A python:3.11-slim-based image ships Debian packages you never use at runtime — apt, dpkg, a shell, curl sometimes, system libraries for tools the Python runtime doesn’t need. Each of those is a potential CVE target. Shrinking the image shrinks the attack surface.
This lesson’s premise: with multi-stage builds, BuildKit cache mounts, and careful base-image choices, a typical Python API drops from ~180 MB to under 100 MB without giving up debuggability. Further, to ~30 MB with distroless, if you accept stronger constraints on in-container debugging.
2. Multi-stage builds, formally
A multi-stage Dockerfile is multiple FROM declarations in one file. Each FROM starts a new build stage with its own filesystem; only the last stage’s layers end up in the published image. Earlier stages can be referenced by name (AS builder) and have files copied out with COPY --from=builder ....
The canonical pattern for a Python service:
- Stage 1 (builder) — uses a heavy base image (or the same slim base, but with compilers installed). Installs system headers (
libffi-dev,libssl-dev), compiles C-extension wheels. Installs the Python packages into a controlled prefix directory. - Stage 2 (runtime) — uses a clean slim (or distroless) base. Copies just the installed Python packages from Stage 1. No compilers, no headers, no package manager indexes.
A minimal example:
# Stage 1: builder
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: runtime
FROM python:3.11-slim
COPY --from=builder /install /usr/local
WORKDIR /app
COPY app.py .
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
The size win comes from what you didn’t copy. gcc, make, libffi-dev, pip’s wheel download cache, and any scratch files created during the build exist only in Stage 1 and are silently discarded.
python:3.11), ship in a slim or distroless one. The two stages don’t talk except through COPY --from.
3. Where the Python install goes
When you install packages in Stage 1, you need to control where they land so you can copy exactly that location in Stage 2. Three options:
--prefix=/install— installs into/install/lib/python3.X/site-packagesand/install/bin. In Stage 2, copy/installto/usr/local. The runtime Python already looks in/usr/local/lib/python3.X/site-packages, so noPYTHONPATHchange is needed. This is the simplest approach and what the URL-shortener lab uses.--target=/install— installs packages directly into/install/. You then setENV PYTHONPATH=/installin Stage 2. Required for distroless images where/usr/localdoesn’t have the expected Python directory layout.- virtualenv —
python -m venv /opt/venvthen/opt/venv/bin/pip install .... Copy the entire/opt/venvto Stage 2. Most Python-idiomatic, but adds indirection. Useful when juggling multiple Python versions in one image.
Recommendation: --prefix for slim-based runtime images; --target for distroless; venv when you have a specific reason to.
4. BuildKit cache mounts
Vanilla pip downloads wheels every time requirements.txt changes — even if only one package changed and you already have the other 29 cached locally. BuildKit’s --mount=type=cache keeps the pip download cache between builds, outside the layer:
# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --prefix=/install -r requirements.txt
The cache mount is not part of the image — it lives in BuildKit’s local cache. When pip runs, it finds already-downloaded wheels in /root/.cache/pip and skips the network. The published image still doesn’t carry the pip cache (because --mount=type=cache is mounted, not copied). You get fast rebuilds and a clean published image.
The # syntax=docker/dockerfile:1.7 line opts you into BuildKit syntax extensions. DOCKER_BUILDKIT=1 (default in Docker Desktop 23+) is what makes the engine read it. If you see a --mount syntax error, check that this pragma is on line 1 and that BuildKit is enabled.
Practical impact: on the URL-shortener with its handful of dependencies, first build takes ~30 s. Second build after touching requirements.txt: ~3 s. On a project with 50 deps, the cache mount is the difference between a 2-minute CI pip install and a 10-second one.
5. uv: the 2026 Python install story
uv is Astral’s Rust-based pip replacement. It is a drop-in: parses requirements.txt, downloads the same wheels from the same registries, installs to the same locations — 10–50× faster than pip. In a multi-stage build:
FROM python:3.11-slim AS builder
RUN pip install --no-cache-dir uv
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system --prefix=/install -r requirements.txt
--system skips uv’s automatic virtual-environment creation (we want a prefix, not a venv). For the URL-shortener with five dependencies, build time drops from ~45 s (pip, no cache) to ~4 s (uv, no cache).
uv also ships its own resolver: uv pip compile requirements.in -o requirements.txt produces a fully-pinned lockfile in seconds. We’ll keep using requirements.txt in this course, but if you’ve heard people rave about uv — this is why.
6. Distroless and “even slimmer” runtimes
gcr.io/distroless/python3-debian12 ships just enough to run Python: glibc, OpenSSL, CA certificates, the Python runtime — and that’s it. No shell, no apt, no cat, no /etc/passwd machinery. Final-stage size: ~30 MB.
Trade-offs:
- You can’t
docker exec ... /bin/sh. Debugging in production meansdocker exec ... python -c "import os; print(os.listdir('/'))". More awkward, but possible. - CVE surface drops dramatically — most CVEs are in the long tail of OS utilities the runtime never calls.
- Build complexity goes up — you need
--targetinstead of--prefixand aPYTHONPATHenv var in the runtime stage. - CMD semantics change — distroless sets
ENTRYPOINT ["/usr/bin/python3"]. YourCMDis the arguments to Python, not a new executable. UseCMD ["-m", "uvicorn", "app:app", ...], notCMD ["python", "-m", "uvicorn", ...].
For a course-level URL shortener, slim is the right default. Distroless is the “I’m shipping this to a CIS-benchmark-audited cluster” answer. The lab builds slim; the homework experiments with distroless.
7. .dockerignore and the build context
The “build context” is the directory you pass to docker build (the final argument, usually .). Docker tarballs the entire directory and ships it to the daemon before reading the Dockerfile — even the parts you never COPY. Without a .dockerignore, that means your .git/ history, your .venv/, your __pycache__/, your .pytest_cache/, and any local secrets (a stray .env file, AWS credential files) get streamed to the daemon and become available as cache input for any build that runs afterward.
A minimal .dockerignore for the URL-shortener:
__pycache__/
*.pyc
.venv/
venv/
.git/
.gitignore
.pytest_cache/
*.md
tests/
*.log
.env
Two reasons this matters beyond tidiness:
- Speed — run
du -sh .in your repo before and after adding.dockerignore. Repos with a.venv/ornode_modules/are often 10× smaller after ignoring them. The build-context tarball is what limits how quickly you can trigger a build remotely (in CI, on a remote Docker host). - Security — a stray
.envin the build context can end up baked into an intermediate cache layer. Even if you neverCOPY .envexplicitly, the BuildKit cache keyed on the directory can expose it. Treat.dockerignorelike.gitignore: mandatory, reviewed, committed.
docker build --no-cache . and watch the “Sending build context to Docker daemon” line. If you see a number above a few MB for a small Python project, your .dockerignore is incomplete.
References
- Docker — Multi-stage builds (
docs.docker.com/build/building/multi-stage/) - BuildKit cache-mount reference (
docs.docker.com/build/cache/) - Google — Distroless container images (
github.com/GoogleContainerTools/distroless) - Astral — uv documentation (
docs.astral.sh/uv/) - This course’s sibling: Linux Networking Lesson 02 — network namespaces deep dive if you want to revisit the isolation primitives from L01.