Docker from First PrinciplesPost 11 of 18 · 18 of 18 written
- How to use this guide
- 01The problem Docker solves
- 02What a container really is
- 03Images: layers, hashes and registries
- 04Docker's architecture
- 05Container lifecycle
- 06Dockerfiles in depth
- 07Storage: where data lives
- 08Networking
- 09Docker Compose for multi-service AI apps
- 10GPUs in containers
- 11Building good images for AI workloads
- 12Serving and production patterns
- 13Security
- 14Debugging and introspection
- 15Beyond Docker
- 16Hands-on learning path
- 17Question bank
- 18Glossary and cheat sheet
11. Building good images for AI workloads
A good AI image is pinned, built from a lockfile, small enough to pull quickly, built for the CPU architecture it will run on, and deliberate about where model weights live. Each of those is a decision you should be able to defend.
Pin everything that can move
- Base image: an exact tag at least, a digest for anything you publish or cite.
- Python dependencies: a lockfile with exact versions and hashes (
uv.lock,poetry.lock, orpip-compile --generate-hashesoutput). A plainrequirements.txtwithtorch>=2.0is a wish, not a pin. - The PyTorch build: install from the index that matches your CUDA target, for example
https://download.pytorch.org/whl/cu124, or/whl/cpufor CPU-only. - Provenance: log the image digest in your experiment tracker (Weights & Biases, MLflow) with every run. Together with the lockfile, that’s the reproducibility story for a thesis or paper.
Where the gigabytes come from
A CUDA build of PyTorch alone installs several gigabytes of NVIDIA libraries. On top of that come devel base images with compilers, NGC images that bundle a full toolchain, and leftovers: pip caches, apt lists, test data, build tools.
Tactics that actually shrink images:
- Use a
slimorruntimebase and a multi-stage build, so compilers stay behind. - Don’t install CUDA twice. An
nvidia/cudaruntime base plus a PyTorch wheel that bundles its own CUDA libraries duplicates gigabytes; for PyTorch-only images,python:3.12-slimis usually the smaller base. - Use the CPU-only PyTorch build for CPU inference services (embeddings on CPU, small classifiers). It’s a fraction of the size.
- Keep caches out of layers:
pip install --no-cache-dir, or better, BuildKit cache mounts. - Measure, don’t guess:
docker historyanddiveshow which layer is heavy.
A GPU training image
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends git libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
# Locked dependencies, including a CUDA build of torch
COPY requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --extra-index-url https://download.pytorch.org/whl/cu124 \
-r requirements.lock
COPY src/ src/
COPY configs/ configs/
ENTRYPOINT ["python", "-m", "src.train"]
CMD ["--config", "configs/base.yaml"]docker run --rm --gpus all --shm-size=16g \
-v /data/my-dataset:/data:ro \
-v "$PWD"/runs:/workspace/runs \
-e WANDB_API_KEY \
trainer:1.0 --config configs/large.yaml-e WANDB_API_KEY with no value passes the variable through from your shell, so the key never appears in the command or the image.
Compiled extensions
Packages such as flash-attn need compiling against the exact torch and CUDA versions in your image. Build them in a devel stage that matches your runtime’s CUDA version, produce a wheel, and copy only the wheel into the runtime stage. Two environment variables keep compilation manageable: TORCH_CUDA_ARCH_LIST limits which GPU architectures you compile for (fewer means much faster builds), and MAX_JOBS limits parallel compile jobs so the build doesn’t run out of RAM. Check first whether a prebuilt wheel exists for your versions; it often saves an hour.
conda when you need it
Some scientific stacks still depend on conda-only packages. Use a micromamba base image, create the environment from a locked environment.yml (or a conda-lock file), and clean the package cache in the same RUN. Mixing conda and pip in one environment works but is the most common source of broken, unreproducible images; let one tool own each package.
Model weights: decide deliberately
Part 7 compares baking weights in, mounting a cache, and downloading at startup. Two image-specific rules apply on top. Never bake access tokens into an image; if a build must download gated weights, use a BuildKit secret mount. And if you bake weights in, put them in their own layer before your code, so a code change doesn’t force a re-upload of tens of gigabytes.
Multi-architecture builds
Your M1 Pro builds linux/arm64 images by default. Push one to an amd64 GPU server and it fails immediately with “exec format error”. Choose the target platform explicitly:
# For an amd64 server only
docker buildx build --platform linux/amd64 -t ghcr.io/ulvi/trainer:1.0 --push .
# One tag that works on both (creates an image index)
docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/ulvi/api:1.0 --push .Cross-building runs the foreign architecture under QEMU emulation, so heavy steps (compiling, large pip installs) can be many times slower. Better options: build on the target machine through a Docker context (Part 10), build in CI on amd64 runners, or register a remote builder with docker buildx create. Many GPU images and wheels target amd64 first, so GPU images are usually amd64-only. docker run --platform linux/amd64 on the Mac is fine for a smoke test, too slow for real work.
Development versus production images
Use one Dockerfile with several targets. A dev stage adds Jupyter, debuggers and test tools, and expects your code bind-mounted; a prod stage is minimal, non-root, with no compilers and the code copied in. Build them with --target dev and --target prod. Dev Containers (Part 15) build on the same idea for your editor.