Chapter 3: Docker & Containerization
Master containerization for ML workflows — from writing Dockerfiles and multi-stage builds to GPU containers, Docker Compose, and registry distribution. Learn to package, ship, and run ML models in reproducible, portable containers.
- Topics
- 8
- Videos
- 3
- Min read
- 71
Chapter Overview
Containerization has become the foundation of modern ML infrastructure. Whether you are training models on cloud GPU clusters, deploying inference endpoints, or sharing reproducible experiments with your team, containers are the universal packaging format that makes it all work.
This chapter takes you from zero to production-ready Docker skills. You will understand why containers exist (dependency hell is real), learn the core Docker workflow (images, containers, Dockerfiles), master advanced techniques (multi-stage builds, GPU passthrough, Docker Compose), and learn to distribute your images through registries.
By the end, you will be able to containerize any ML project — from a simple scikit-learn API to a multi-service inference pipeline with GPU-accelerated model serving, caching, and monitoring — and deploy it identically on your laptop, your team's servers, or a cloud Kubernetes cluster.
Chapter Roadmap
Click any topic to jump in
Why Containers?
Dependency hell, reproducibility, ML environment drift — the problems containers were built to solve.
Docker Basics
Images, containers, run/ps/exec, lifecycle — the everyday commands you'll type a hundred times a week.
Writing Dockerfiles
FROM, RUN, COPY, CMD vs ENTRYPOINT, layer caching — how reproducible images are actually authored.
From running images to writing them
Multi-Stage Builds
Builder vs runtime stages, COPY --from, BuildKit — shrinking 8 GB ML images down to something deployable.
Volumes & Networking
Bind mounts, named volumes, port mapping, bridge networks — how containers persist data and talk to each other.
Docker Compose
docker-compose.yml services, depends_on, shared networks — the multi-container dev environment in one file.
GPU in Docker
NVIDIA Container Toolkit, --gpus, CUDA base images, version matching — the missing pieces for GPU training inside containers.
Registry & Distribution
Docker Hub, ECR, image tags, semantic versioning, push/pull — how images get shared across machines and clusters.
Topics
8 topics in this chapter
A model trains perfectly on your laptop. A colleague clones the repository, runs the same script, and gets a crash, or worse, a quietly different accuracy. The Python version differs, a system library is missing, or the installed PyTorch build does not support their GPU. Multiply that by a training job, an inference API and a data pipeline, each needing its own stack, and moving work between laptop, CI and production becomes the slowest part of the project.
This chapter is about the standard fix: package the code together with its entire user-space environment, so the thing that runs is the thing you tested. A container does exactly that. It is an ordinary Linux process tree, isolated with kernel namespaces and limited with cgroups, running from an image that holds the interpreter, libraries and files. Because containers share the host kernel, they start in well under a second and weigh megabytes rather than gigabytes.
This first topic covers why that matters for ML, how containers differ from virtual machines, the OCI standard that lets every tool share one image format, and the two common engines, Docker Desktop and Podman.
Definition
A container is a process, or group of processes, that runs from an image containing its own filesystem and dependencies, and is isolated from the rest of the host by Linux namespaces (process IDs, mounts, network, users) and limited by cgroups. All containers on a host share that host's kernel, unlike virtual machines, which each boot their own.
In this topic
- 1Dependency Hell
- 2Containers
- 3Containers vs VMs
- 4OCI Standard
- 5ML Reproducibility
- 6Docker Desktop
- 7Podman
Dependency Hell
Conflicting Python versions, system libraries, and CUDA drivers across environments break ML reproducibility. A model that trains on Python 3.11 with CUDA 12.1 may silently produce wrong results or crash outright on Python 3.9 with CUDA 11.6.
The problem is compounded by transitive dependencies — your project depends on library A, which depends on library B version 2.x, but another dependency requires library B version 3.x. Without isolation, these conflicts are nearly impossible to resolve across a team.
Conflicts grow with the number of things forced to share one environment. If projects each pin versions of the same libraries, a shared environment works only if every pin agrees, and each new project adds more constraints that can clash. Giving every project its own environment removes the cross-project constraints entirely: each image only has to satisfy its own pins. The remaining shared dependency is the host kernel and, for GPUs, the driver, which is why those two are the only things containers cannot make portable.
Your training script runs on your machine but crashes on a colleague's with: RuntimeError: CUDA error: no kernel image is available. Both machines have NVIDIA GPUs. What is the likely cause?
Containers
Lightweight, portable units that package code, dependencies, and runtime into a single, reproducible image. A container includes everything the application needs to run — Python interpreter, pip packages, system libraries, config files — isolated from the host system.
Containers use OS-level virtualization (namespaces and cgroups) to provide process isolation without the overhead of a full virtual machine. They share the host kernel, which makes them start in seconds and use minimal extra memory.
A container adds almost no work at run time, because a containerized process makes the same system calls to the same kernel as any other process. Felter and colleagues found containers matched or beat KVM virtual machines in nearly every benchmark, with overhead appearing mainly in Docker's NAT networking. Start-up costs are setup work: namespaces, cgroups and the overlay mount. Measured on one host, docker run of a trivial Alpine container took about 0.55 seconds end to end through the CLI and daemon, and most of that is client and daemon overhead rather than the kernel.
Containers vs VMs
VMs virtualize hardware — each VM runs a full guest operating system (1-10 GB), boots in 30-60 seconds, and is managed by a hypervisor. Containers virtualize the OS — they share the host kernel, weigh megabytes, and start in under a second.
For ML workloads, this difference is transformative. You can run dozens of container experiments on a single GPU machine, each with different dependency sets, without the overhead of booting separate operating systems. VMs still have their place (strong security isolation, different OS kernels), but containers dominate ML workflows.
The difference comes from what each one boots. A VM boots a guest kernel and init system and reserves memory for them, so classic VMs take tens of seconds and hundreds of megabytes before your code runs. A container starts a process on the already running kernel. The gap is narrowing at the VM end: Firecracker microVMs boot in about 125 ms with under 5 MiB of overhead, which is why AWS Lambda uses them. They keep a VM's stronger isolation boundary while approaching container start-up costs, so the choice is increasingly about isolation rather than speed.
You need to test your ML inference API against 5 different Python versions (3.8-3.12). Compare the VM approach vs. the container approach in terms of disk space and startup time.
OCI Standard
The Open Container Initiative, founded in 2015 under the Linux Foundation, publishes three specifications: the image spec (how an image is laid out as a manifest, a config and content-addressed layer archives), the runtime spec (how a runtime such as runc starts a container from an unpacked bundle) and the distribution spec (the HTTP API registries use). Because Docker, Podman, Buildah, containerd and CRI-O all follow them, an image built with one tool runs under the others and can be stored in any compliant registry. That is why you can build with Docker on a laptop and run the same image on a Kubernetes cluster whose runtime is containerd.
The OCI image spec defines images as a JSON manifest + content-addressed layer tarballs hashed with SHA-256. Two images that share base layers also share storage — the deduplication factor on a host running containers from one base is . This is why pulling 100 nginx variants on the same host costs ~one nginx worth of disk.
ML Reproducibility
An ML result depends on the code, the data and the environment. An image captures the environment: the Python version, every installed package, system libraries, CUDA and cuDNN. Pin each one, refer to the base image by digest, and record the image digest with every training run, so the exact environment can be pulled again years later. An image does not make results bit-identical on its own. GPU kernels can be nondeterministic, and the host driver and GPU model still come from outside the image. Fixed seeds, deterministic algorithm settings and logged hardware close most of the remaining gap.
An image digest is a SHA-256 hash of the image manifest, which lists the hashes of the config and every layer. Changing any byte of any file changes a layer hash, which changes the manifest and therefore the digest. A 64-hex-character string identifies hundreds of pinned components at once, and the chance of two different images colliding is about even across billions of images. That is why logging the digest, rather than a tag, is enough to name the environment of a run exactly.
Docker Desktop
Docker Desktop packages the Docker engine, CLI, BuildKit, Compose and an optional single-node Kubernetes for macOS, Windows and Linux desktops. Containers need a Linux kernel, so on macOS and Windows Desktop runs a lightweight Linux VM (Apple's virtualization framework or WSL 2) and forwards the CLI, ports and file sharing into it. Settings control how much CPU, memory and disk that VM gets, which caps what your containers can use. Licensing matters for companies: it is free for personal use, education, open source and businesses with fewer than 250 employees and under 10 million dollars in revenue, and needs a paid subscription otherwise.
On macOS and Windows every bind-mounted file access crosses from the Linux VM to the host filesystem and back. For a few large reads that costs little, but workloads touching thousands of small files, such as importing a big Python package tree or a node_modules directory from a bind mount, pay the crossing per file and can be several times slower than on native Linux. VirtioFS and Docker's synchronized file shares reduce the gap. The practical fix is to keep dependencies inside the image or a named volume and bind-mount only your source.
Podman
Podman is a container engine from Red Hat with a Docker-compatible CLI, so most docker commands work unchanged and alias docker=podman is common. Two design choices set it apart. It has no long-running daemon: each container is a child of the podman process or a systemd unit. And it runs rootless by default, using user namespaces so that root inside the container maps to your unprivileged user on the host. A container escape then lands as an ordinary user, not as root. Podman also groups containers into pods, like Kubernetes, and can generate Kubernetes YAML or systemd units from running containers.
Rootless containers rely on user-namespace ID mapping. Your user is granted a range of subordinate IDs, typically 65,536 of them in /etc/subuid, and container UID 0 maps to your own UID while container UIDs 1 to 65,535 map into that range. A process that escapes therefore holds only the rights of an unprivileged host user. The cost is that some things need extra work: binding ports below 1024 and some network setups require privileges, and files written to bind mounts show up owned by the mapped IDs rather than by root.
Theory Exercise
Problem:
An ML team has three projects: a PyTorch 2.1 training pipeline, a TensorFlow 2.15 inference server, and a scikit-learn data preprocessing job. All three need to run on the same GPU machine. Explain why containers are a better solution than virtual environments (venv/conda) for this scenario.
Hints:
- Think about system-level dependencies beyond Python packages — CUDA, cuDNN, system libraries
- Consider what happens when two projects need different versions of the same system library (e.g., libcudnn)
- Think about deployment: how would you move each project to a cloud GPU instance?
Related Problems on PixelBank
Docker has two core concepts: images and containers. An image is a read-only template containing your application code, dependencies, and a filesystem. A container is a running instance of an image — a lightweight, isolated process with its own filesystem, networking, and process space.
Think of it this way: an image is a class, a container is an object. You can create many containers from the same image, each running independently. When a container stops, its filesystem changes are lost unless you explicitly save them.
The Docker CLI is your primary interface. A handful of commands cover 90% of daily usage: docker run (create + start a container), docker ps (list running containers), docker exec (run a command inside a running container), docker stop (gracefully stop), and docker rm (remove). Mastering these commands is essential before moving to Dockerfiles and orchestration.
Lazydocker is a terminal-based UI that shows running containers, logs, stats, and images in a single dashboard — perfect for monitoring your ML training containers without memorizing every CLI flag.
Definition
A Docker image is a read-only, layered filesystem plus configuration (default command, environment, user), identified by a content hash. A Docker container is a running or stopped instance of an image: the image layers, a thin writable layer on top, and an isolated process. Many containers can run from one image, and removing a container does not change the image.
In this topic
- 1Images
- 2Containers
- 3docker run
- 4docker ps
- 5docker exec
- 6Container Lifecycle
- 7Lazydocker
Images
Read-only templates with a layered filesystem — each layer is a cached diff (base OS, Python, pip packages, your code). Images are immutable once built. When you docker pull python:3.11-slim, you download a series of layers that stack to form a complete filesystem.
Layers are shared between images. If two images both start FROM python:3.11-slim, they share the base layers and only differ in the layers added on top. This saves disk space and speeds up pulls.
Layers are stored by content hash, so a layer already on disk is never stored or downloaded twice. Pull cost is the sum of the sizes of layers you do not yet have, not the image's total size. Three images built on python:3.11-slim (about 125 MB, 4 layers) store the base once. This only helps if the base is truly identical: an image built on python:3.11 (about 1.1 GB, 7 layers) shares nothing with one built on the slim variant, so standardizing a team on one pinned base image is a real disk and bandwidth saving.
You have three ML projects all using python:3.11-slim as the base image. How much disk space do three built images use compared to three independent installations?
Containers
Running instances of images with a writable layer on top — isolated processes with their own PID namespace, network, and filesystem. When you docker run an image, Docker creates a thin writable layer over the read-only image layers.
Any files the container creates or modifies live in this writable layer. When the container is removed, this layer is deleted. This is why containers are ephemeral by design — they can be created and destroyed quickly without affecting the underlying image.
overlay2 stacks the read-only image layers under one writable directory. Reads go to the topmost layer that has the file. The first write to an existing file triggers a copy-up of the whole file into the writable layer, even for a one-byte change. Appending one byte to a 5.3 MB shared library in a test container grew its writable layer by 5.33 MB. Large files that are modified in place, such as databases, logs or checkpoints, therefore belong in volumes, where writes go straight to the host filesystem.
docker run
docker run creates a container from an image and starts it, pulling the image first if it is not local. The flags you will use most: -d runs it in the background, -it attaches an interactive terminal, --rm deletes the container when it exits, --name gives it a stable name, -p host:container publishes a port, -v or --mount attaches storage, -e sets environment variables, and --gpus exposes GPUs. Anything after the image name replaces the image's default command. Most problems show up here first, so when a container exits immediately, run it again without -d, or read docker logs, to see why.
Start-up time is mostly fixed overhead, not the kernel. Timing docker run --rm alpine true five times on one Linux host gave 542 to 572 ms each, which covers the CLI call, the daemon, containerd, runc, namespace and cgroup setup and the overlay mount. docker exec into a running container took about 140 ms. Neither depends on image size once the image is cached. A cold image pull, which depends on size and bandwidth, usually dominates the first start, which is why prepulling images matters more than tuning runtime flags.
docker ps
docker ps lists containers. By default it shows only running ones with their ID, image, command, age, status, published ports and name. Add -a to include stopped containers, which is where a container that crashed at start-up will be hiding with an Exited status and its exit code. -q prints only IDs for scripting, such as docker rm on everything returned by docker ps -aq. --filter narrows by status, label, name or ancestor image, and --format prints chosen fields. -s adds each container's writable-layer size, which is how you spot a container quietly filling its layer with logs or checkpoints.
docker ps queries the docker daemon over a Unix socket; the daemon walks its in-memory container map in where is the number of containers. For typical hosts (), it's instant. On a CI host with ephemeral containers' worth of metadata, prune the dead ones or docker ps itself becomes slow.
docker exec
docker exec runs an extra command inside a container that is already running, joining its namespaces and cgroups. docker exec -it trainer bash opens a shell, and docker exec trainer nvidia-smi checks the GPU without stopping training. It only works on running containers, and it starts a new process rather than attaching to the main one (docker attach does that). Anything you change this way is lost when the container is replaced, so exec is for diagnosis, not configuration. Minimal images may have no bash, or no shell at all, so use sh or a debug sidecar instead.
docker exec enters an existing container's namespaces via setns(2) — , no new container. The exec'd process shares the container's PID namespace, so it sees the container's processes and inherits their cgroup limits. Useful for debugging; shouldn't be used as a deployment mechanism (the new process isn't tracked by your orchestrator).
Container Lifecycle
A container moves through created, running, paused, exited and removed. docker create makes one, start runs it, pause freezes its processes with the cgroup freezer, and stop sends SIGTERM to process 1, then SIGKILL after a grace period, 10 seconds by default on Linux. An exited container keeps its writable layer, logs and exit code until docker rm deletes it. Exit code 137 means it was killed by SIGKILL, often from docker stop or the out-of-memory killer, and 143 means it exited on SIGTERM. A restart policy such as unless-stopped restarts containers that exit.
Process 1 in a container is special: the kernel does not apply default signal actions to it, so SIGTERM does nothing unless the program installs a handler. A test Python container running time.sleep took the full 10-second grace period to stop and exited with 137. The same script with a SIGTERM handler stopped at once with exit code 0, and docker run --init, which puts a tiny init process in front, stopped at once with 143. Handle SIGTERM in your entrypoint, or use --init, so a stop can save a checkpoint instead of being killed.
After running several ML experiments, docker ps -a shows 47 stopped containers. Your disk is nearly full. What commands should you run?
Lazydocker
Lazydocker is an open-source terminal UI for Docker and Docker Compose, written in Go by Jesse Duffield. It shows containers, Compose services, images, volumes and networks in panels, with each container's logs, CPU and memory graphs and configuration beside them. Single keys restart, stop, remove, attach or open a shell, and it can prune unused objects. It talks to the same Docker API as the CLI, so it works over SSH against a remote GPU box. It is a viewer, not a replacement for scripts: everything it does maps to a docker command you can still automate in CI.
Its value is attention rather than computation. Watching a Compose stack of several services with the CLI means several terminals of docker logs -f plus repeated docker stats calls, and correlating them by eye. A dashboard puts the failing container's logs next to its memory graph, which is often all you need to see an out-of-memory restart loop. Lazydocker reads the same stats API as docker stats, so it adds load roughly comparable to keeping that command open.
The previous topic built containers by hand: run an image, exec in, install packages. Everything installed that way vanishes with the container, and nobody else can repeat it. A Dockerfile fixes both problems by writing the setup down as code that builds the same image every time.
Each instruction adds a layer or sets metadata. FROM picks the base, RUN executes commands, COPY brings in files, WORKDIR sets the directory, and CMD and ENTRYPOINT define what runs at start. The order matters as much as the content, because the builder caches each step and reuses it only while that step and everything before it is unchanged. Put slow, rarely changing steps first (system packages, then pip dependencies) and fast-changing code last, and a code edit rebuilds in seconds instead of reinstalling everything.
The build context matters too. Everything in the directory you build from is sent to the builder unless .dockerignore excludes it, and ML repositories are full of datasets, checkpoints and virtual environments. This topic finishes with two tools that keep Dockerfiles honest: Hadolint, a linter, and Dive, a layer inspector.
Definition
A Dockerfile is a text file of ordered build instructions (FROM, RUN, COPY, WORKDIR, ENV, USER, CMD, ENTRYPOINT and others) that docker build executes to produce an image. Each filesystem-changing instruction yields a cached layer, and the cache for an instruction is reused only if it and all preceding instructions, and any files they read, are unchanged.
In this topic
- 1FROM
- 2RUN
- 3COPY
- 4WORKDIR
- 5CMD vs ENTRYPOINT
- 6Layer Caching
- 7.dockerignore
- 8Hadolint
- 9Dive
FROM
FROM chooses the base image every later instruction builds on, and it must come first (only ARG may precede it). The choice sets the image's floor size and much of its attack surface. python:3.11 is about 1.1 GB because it includes compilers and development headers, while python:3.11-slim is about 125 MB. Alpine is smaller still but uses musl instead of glibc, so many prebuilt Python wheels do not apply and packages compile from source. Pin a specific tag, never latest, and for full reproducibility pin the digest as well, so an upstream rebuild of the same tag cannot change your image.
Measured uncompressed sizes: python:3.11 is 1,105 MB in 7 layers and python:3.11-slim is 125 MB in 4, about a 9-fold difference for the same interpreter. The extra 980 MB is mostly build tools, headers and libraries for compiling extensions, which a runtime image does not need. Every node that runs the image downloads and stores the base once, so on a fleet of 50 autoscaled nodes the full base costs about 49 GB more disk than the slim one.
RUN
Execute commands during build — each RUN creates a layer, so chain commands with && to reduce layers. Always clean up package manager caches in the same RUN command (e.g., rm -rf /var/lib/apt/lists/*) to avoid storing temporary files in the layer.
Bad: three separate RUN commands for apt update, apt install, and rm cache (3 layers, cache saved in first layer). Good: one chained RUN command that does all three (1 layer, cache never persisted).
Layers only add. A file deleted in a later layer is hidden, but its bytes still ship in the earlier layer. In a test, one RUN that wrote a 50 MB file and a second RUN that deleted it produced a 60.2 MB image. Doing both in one chained RUN produced 7.8 MB, the size of the Alpine base. That is why package-manager cleanup must happen in the same RUN that created the cache, and why secrets copied in and later deleted are still recoverable from the image.
Your Dockerfile has RUN apt-get update on line 3 and RUN apt-get install -y libgl1 on line 4. Why is this problematic, and how would you fix it?
COPY
COPY adds files from the build context, or with --from from another stage or image, into the image. Its cache key is a checksum of the copied files' contents and metadata, so the step and everything after it rebuild only when those files change. The standard ML pattern is to copy the dependency manifest first (requirements.txt, then pip install) and the source code later, so a code edit leaves the slow install cached. Use --chown to set ownership in the same step instead of a later chown RUN, which would duplicate the files in a new layer. Prefer COPY to ADD unless you need ADD's archive extraction.
COPY hashes the source files via SHA-256 and uses the hash as the cache key. If the hash matches a cached layer, build is (cache hit); otherwise it's in copied bytes. This is why putting COPY requirements.txt BEFORE COPY . . matters: requirements change rarely, so deps install only when they actually change.
WORKDIR
WORKDIR sets the working directory for every later RUN, COPY, CMD and ENTRYPOINT in the stage, creating the directory if it does not exist. It is the right replacement for RUN cd /app, because each RUN starts a fresh shell: a cd inside one RUN does not carry into the next instruction. Relative WORKDIR paths are resolved against the previous WORKDIR, so use absolute paths to keep the Dockerfile readable. It also becomes the container's default directory at run time, so docker exec and the default command start there unless overridden with -w.
WORKDIR is mostly image metadata: the config records the directory, and at most an empty directory is created. That makes it essentially free compared with RUN mkdir plus cd, which runs a shell and adds a layer while still leaving later instructions in the old directory. Set it once near the top of each stage, after FROM, and change it only when a group of steps really belongs elsewhere.
CMD vs ENTRYPOINT
Both define what runs when the container starts. ENTRYPOINT is the fixed executable, and CMD supplies default arguments that anything after the image name in docker run replaces. ENTRYPOINT python3 with CMD train.py runs python3 train.py, while docker run img infer.py runs python3 infer.py. Use the exec form, a JSON array, for both. The shell form wraps the command in /bin/sh -c, which may leave the shell as process 1 so your program never receives SIGTERM, and a shell-form ENTRYPOINT ignores CMD and docker run arguments entirely. Override an ENTRYPOINT at run time with --entrypoint.
CMD provides defaults that docker run image arg overrides; ENTRYPOINT defines the executable that arguments are passed TO. ENTRYPOINT ["python"] CMD ["app.py"] lets docker run image other.py swap the script while keeping python — the standard pattern for ML training images where the command varies but the runtime doesn't.
Layer Caching
docker build checks each instruction against the cache. A RUN is reused if its command text and parent layer match a previous build, and a COPY or ADD is reused only if the copied files' checksums also match. The first miss invalidates that step and every step after it, even ones that would produce the same result. So order instructions from least to most frequently changed: base image, system packages, dependency manifests and install, then source code. A cached RUN apt-get update can also be stale, because the builder does not know the package index changed, which is why it is chained with the install.
The cache is a chain, not a set. If instruction misses, instructions to must all rerun, because each depends on its parent layer. Build time after an edit is therefore the cost of every step from the first changed one onward. Copying code before a 6-minute pip install makes every code edit cost 6 minutes. Copying requirements.txt first moves the first miss to the final COPY, and the same edit costs seconds.
.dockerignore
The build context is every file in the directory you pass to docker build, and it is sent to the builder before any instruction runs. .dockerignore lists patterns, in the same style as .gitignore, to leave out of it. In ML repositories, ignore .git, data directories, checkpoints such as *.pt or *.safetensors, virtual environments, pycache, notebooks' outputs and local .env files. Excluded files cannot be copied, so COPY . . cannot accidentally ship a dataset or a secret. It also shrinks the image: in a test, a 200 MB data folder made COPY . . produce a 217 MB image instead of 7.8 MB.
Measured on one host, a context with a 200 MB data folder took 3.0 seconds to build with COPY . . and produced a 217.5 MB image. Adding data/ to .dockerignore cut the transfer to 100 bytes, the build to 0.65 seconds, and the image to 7.8 MB. Context transfer scales with bytes, so a 20 GB dataset in the build directory would add minutes to every build and, with COPY . ., push 20 GB to the registry on every release.
Hadolint
Hadolint is a Dockerfile linter. It parses the Dockerfile into a syntax tree and checks it against about 70 rules numbered DL3000 and up, such as pinning versions in apt-get and pip installs, using --no-install-recommends, avoiding latest tags, preferring COPY to ADD, and setting a non-root USER. It also runs ShellCheck on the shell code inside RUN instructions, catching quoting bugs before they reach a build. Run it locally with docker run --rm -i hadolint/hadolint and redirect the Dockerfile to its standard input, and add it to CI so violations fail a pull request. Rules can be ignored per line or in a config file.
Static checks are cheap and run before the expensive part. Linting a Dockerfile takes well under a second, while a bad ML build can spend many minutes installing packages before failing, and a missing cleanup can add hundreds of megabytes to every pull for the life of the image. Catching one such problem in review instead of in production repays the CI time many times over. Henkel and colleagues found many rule violations in Dockerfiles on GitHub, so most repositories have something to fix.
Dive
Dive is an open-source tool for exploring an image layer by layer. For each layer it shows the instruction that created it, its size, and a file tree of what it added, changed or removed. It also computes an efficiency score and lists wasted space: files that were written in one layer and overwritten or deleted in a later one but still ship in the image. Typical finds in ML images are pip and apt caches, test data, model checkpoints copied by COPY . ., and build tools. Run it in CI with the CI=true environment variable and a threshold to fail builds that waste too much space.
dive shows per-layer file additions, deletions, and waste (files added in one layer, deleted in another — they still take space). For a 5 GB image with waste, that's GB of pure overhead. Dive computes this in on first inspection and is the canonical tool for understanding why your image is so big.
Theory Exercise
Problem:
You have a Dockerfile that copies the entire project directory (COPY . .) before running pip install -r requirements.txt. Every time you change a single line of Python code, the build takes 8 minutes because pip reinstalls all packages. Explain the root cause and describe how to fix it.
Hints:
- Think about what happens to the Docker layer cache when the COPY instruction changes
- Consider splitting the COPY into two separate instructions
- Remember that Docker invalidates all layers after a cache miss
A standard Dockerfile for an ML project can produce images of 5-10 GB or more. The image includes build tools (gcc, make), development headers, pip cache, and intermediate compilation artifacts — none of which are needed at runtime. Multi-stage builds solve this by using multiple FROM statements in a single Dockerfile.
The idea is simple: use one stage to build (compile C extensions, install packages, convert models) and a second stage to run (copy only the final artifacts). The build stage is thrown away; only the slim runtime stage becomes your final image.
For ML projects, this is transformative. You might need gcc and Python development headers to compile PyTorch extensions, but the compiled .so files are only a few megabytes. You might need ONNX tools to convert a model, but the converted model is all you need at runtime. Multi-stage builds let you keep the build tools in the first stage and ship only the final artifacts.
BuildKit is Docker's modern build engine that enables parallel stage execution, better caching, and secret mounting (for private pip repos). It's enabled by default in Docker 23+. Dive helps you compare single-stage vs multi-stage image sizes layer by layer.
Definition
A multi-stage build is a Dockerfile with several FROM instructions, each starting a separate stage with its own filesystem. Later stages copy selected files from earlier ones with COPY --from, and only the final stage, or the one chosen with --target, becomes the image. Everything else left in earlier stages, such as compilers and caches, is discarded.
In this topic
- 1Multi-Stage Builds
- 2Builder Stage
- 3Runtime Stage
- 4COPY --from=builder
- 5Size Reduction
- 6BuildKit
- 7Model Optimization
Multi-Stage Builds
A single-stage Dockerfile keeps everything it ever installed, so an image that compiles code also ships the compiler. Multi-stage builds separate building from running. Each FROM starts a new stage, which you name with AS builder. A later stage starts from a clean base and copies in only the outputs it needs with COPY --from=builder. Only the last stage is tagged as the image unless you pick another with --target, which is also handy for a dev or test stage. BuildKit builds only the stages the target depends on, and runs independent stages in parallel.
Multi-stage splits the Dockerfile into stages, each a separate FROM. The final image only contains layers from the last stage; build tools, source, and intermediate artifacts in earlier stages get discarded. For Go builds: builder stage = 800 MB (Go toolchain), runtime stage = 5 MB (just the binary). size reduction for free.
Builder Stage
The builder stage holds everything needed to produce artifacts and nothing needs to be small. It typically starts from a full image such as python:3.11 or a CUDA devel image, installs compilers, headers and build tools, and then builds: compiling wheels for packages with C or CUDA extensions, installing dependencies into a self-contained directory, or exporting a model. Two common patterns are pip install --prefix=/install and creating a virtual environment at /opt/venv, both of which leave a single directory to copy out. Order it for caching like any Dockerfile, with dependency manifests copied before source code.
The builder stage installs compilers, build deps, and produces artifacts. Its disk footprint can be GB for ML images (CUDA toolkit, build tools, pip wheels), but it's discarded — only the explicitly COPY'd files cross into the runtime stage. Cost: build time. Benefit: a clean runtime image.
Runtime Stage
The runtime stage is what you ship, so it should contain only what the program needs at run time. Start from a slim base, such as python:3.11-slim, a CUDA runtime image or a distroless image, install only runtime system libraries, and copy in the built environment, the application code and any model files. It has no compiler, no package caches and no build secrets. Set a non-root USER, a HEALTHCHECK if your platform uses it, and an exec-form ENTRYPOINT here. The base must be compatible with the builder: wheels built against glibc on Debian will not load on Alpine's musl.
The runtime stage starts FROM a minimal base (alpine, distroless, python:slim) and only contains what's needed at run time. For an ML inference image: PyTorch CPU wheel ( MB) instead of full CUDA toolkit ( GB), zero compilers, no apt cache. Smaller runtime = faster pulls = faster autoscale.
COPY --from=builder
COPY --from copies files from another stage, named or numbered, or from any image, into the current stage. COPY --from=builder /opt/venv /opt/venv moves a whole built virtual environment, and COPY --from=builder /install /usr/local moves packages installed with --prefix. Copying from an image works too, for example taking a static binary such as uv or tini from its official image without inheriting that image. Only the files you name cross over, so be explicit. Copying a whole build directory can pull in caches and object files and undo much of the saving.
COPY --from=builder /app/binary /app/binary reaches into a previous stage and copies a specific path. Cost is in copied bytes only — everything else from the builder stage is dropped. The builder filesystem isn't actually mounted in the final image; the COPY is implemented as a layer transfer between intermediate images.
Your single-stage ML image is 4.2 GB. The breakdown is: base OS 125 MB, build tools 350 MB, pip cache 800 MB, installed packages 2.5 GB, your code 5 MB. How small can a multi-stage build make it?
Size Reduction
Multi-stage builds remove what was needed only to build: compilers such as gcc and gfortran, development headers, package-manager caches and intermediate files. How much that saves depends on what dominates the image. For a compiled Go or Rust service it can be over 90 percent. For an ML image, the remaining frameworks and CUDA libraries usually dominate, so the saving is often a quarter to a half. Smaller images pull and start faster, which matters most for autoscaling. They also cost less to store and contain fewer packages that vulnerability scanners will flag.
Pull time is roughly compressed size divided by bandwidth. At 1 Gbit per second, about 125 MB per second, a 6 GB image needs about 48 seconds just to download before the container can start, and a 2 GB image about 16 seconds. For an inference service that scales out under load, that time is added to every new node's response to a traffic spike. Harter and colleagues found pulling took 76 percent of container start time while only 6.4 percent of the pulled data was read at start-up, which is why size reductions show up directly as faster scale-out.
BuildKit
BuildKit is Docker's build engine and the default since Docker Engine 23. It turns the Dockerfile into a dependency graph, runs independent stages in parallel and skips stages the target does not need. It adds RUN --mount: type=cache keeps pip, apt or model download caches between builds without baking them into a layer, and type=secret exposes a token, for a private package index, only during that RUN so it never lands in the image. Through docker buildx it can export cache to a registry or to GitHub Actions, so CI runners reuse layers, and build images for several CPU architectures.
Parallel stages help when stages are independent. If a Dockerfile has a Python dependency stage taking 5 minutes and a frontend build stage taking 3, a sequential builder needs 8 minutes, while BuildKit needs about 5, the length of the longest branch. Cache mounts help differently: a pip download cache turns repeat installs into local copies, so a dependency change that forces the install step to rerun no longer re-downloads every wheel.
Model Optimization
The build stage is a good place for one-time model work: exporting a PyTorch model to ONNX, compiling it with TensorRT, or quantizing weights to INT8. The builder carries the full training framework, which can be several gigabytes. The runtime stage then needs only a lighter inference runtime such as ONNX Runtime plus the converted model. Two cautions. TensorRT engines are tied to the GPU architecture and TensorRT version they were built with, so build them on, or for, the deployment GPU. And always check the converted model's accuracy against the original before shipping it.
Quantizing FP32 weights to INT8 cuts model size by and inference latency by – on CPU (instructions like AVX-512 VNNI). Combined with multi-stage builds, a 4 GB model image collapses to a GB image with similar accuracy — pulling and starting becomes seconds instead of minutes.
Related Problems on PixelBank
Containers are ephemeral — when you remove a container, all data inside it is lost. This is by design: containers should be disposable and reproducible. But ML workflows need persistence. Training checkpoints, datasets, model artifacts, and logs must survive container restarts.
Docker volumes solve this. A volume is a directory on the host machine that's mounted into the container. Data written to the volume persists even after the container is removed. You can also use bind mounts to map a specific host directory (like your project folder) into the container, enabling live code editing without rebuilding.
Docker networking enables communication between containers. When you run a model server, a database, and a web API as separate containers, they need to find and talk to each other. Docker creates isolated networks and provides DNS-based service discovery — containers can reach each other by name instead of IP address.
Port mapping connects container ports to host ports, making services accessible from your browser or other tools. The flag -p 8080:8000 maps host port 8080 to container port 8000.
Definition
A Docker volume is storage managed by Docker and mounted into a container, surviving the container's removal. A bind mount maps an existing host path into a container. A Docker network is a virtual network, usually a Linux bridge, that connects containers; user-defined networks give containers DNS names, and published ports expose them on the host.
In this topic
- 1Volumes
- 2Bind Mounts
- 3Named Volumes vs Bind Mounts
- 4Port Mapping
- 5Docker Networks
- 6Bridge Network
- 7Volume Drivers
Volumes
A named volume is a directory that Docker creates and manages, usually under /var/lib/docker/volumes, and mounts into containers with -v name:/path or --mount. It outlives every container that uses it, so a training job can write checkpoints to /ckpt, crash or be replaced, and the next container resumes from the same files. Writes bypass the overlay filesystem, so large files avoid copy-up costs. A new, empty volume mounted over an image directory is first filled with that directory's contents, which is how database images seed themselves. Volumes are removed only explicitly, with docker volume rm, docker compose down -v, or prune --volumes.
Volumes are managed directories on the host filesystem (typically /var/lib/docker/volumes/<name>) bind-mounted into containers. They survive container removal (unlike the writable layer) and persist with metadata overhead. Use volumes for state (databases, model checkpoints); use the writable layer only for ephemeral files.
Bind Mounts
A bind mount maps an existing host file or directory into the container, for example -v ./src:/app/src, and changes on either side appear on the other at once. That makes it the standard development setup: edit code on the host and a reload server inside the container picks it up. Bind mounts also give containers access to host datasets without copying them. They tie the container to the host's directory layout, and the container sees host files with their real owners and permissions. Add :ro when the container should only read, such as for datasets, so a bug cannot overwrite them.
A bind mount on Linux is the same directory seen through a second path, so reads and writes run at native filesystem speed with no copying. On macOS and Windows, Docker Desktop's Linux VM must sync or share each file across the VM boundary, so bind-mounting a tree with tens of thousands of small files, such as a Python environment, can make imports several times slower. Bind-mount the source you edit and keep dependencies in the image.
You want to develop an ML API where code changes are reflected immediately without rebuilding the Docker image. How would you set this up?
Named Volumes vs Bind Mounts
Both put data outside the container's writable layer, but they suit different jobs. A named volume is managed by Docker, independent of the host's directory layout, works the same on Linux and Docker Desktop, and is easy to back up or share between containers by name. It suits databases, caches and checkpoints. A bind mount points at a path you choose, so you can see and edit the files directly, which suits source code and existing datasets. Bind mounts expose host paths and permissions, so avoid them in production images, and never bind-mount sensitive host directories such as /etc into a container.
Pick by who owns the data's lifecycle. If the host or a person owns it, such as code being edited or a shared dataset, a bind mount makes it visible where it already lives. If the application owns it, a named volume lets Docker manage placement and keeps Compose files portable between machines. A Compose file that bind-mounts ./data works only where that path exists, while a named volume works everywhere, starting empty.
Port Mapping
-p 8080:8000 publishes container port 8000 on host port 8080, so localhost:8080 reaches the service. Docker implements it with NAT rules in iptables or nftables and, by default, a small docker-proxy process. Without -p, the service is reachable only from other containers on its network. By default the port binds on all host interfaces, which can expose a notebook or database to the whole network and bypass host firewall rules. Use -p 127.0.0.1:8888:8888 for local-only tools. EXPOSE in a Dockerfile only documents a port; it publishes nothing by itself.
Two services cannot publish the same host port, but any number of containers can listen on the same container port, because each has its own network namespace. Five model servers all listening on 8000 inside their containers can be published as host ports 8001 to 8005. NAT adds some per-packet work, which Felter and colleagues measured as noticeable for latency-sensitive traffic; --network host removes it at the cost of isolation and port conflicts.
Docker Networks
docker network create ml-net creates a user-defined bridge network. Containers attached to it get private addresses and can reach each other by container name or alias through Docker's embedded DNS server, so the API connects to http://model-server:5000 with no hard-coded IP. Each network is isolated from the others, so a database on a backend network cannot be reached from a container on a frontend network unless that container joins both. Other drivers exist: host shares the host's network stack, none gives no network, and overlay spans several hosts in Swarm.
On one host, each container's network namespace connects to a Linux bridge through a veth pair, a virtual cable with one end in the container and one on the bridge. Traffic between containers on the same bridge stays inside the kernel and never touches a physical NIC. Each user-defined network gets its own subnet, carved by default from the 172.17.0.0/12 range. One test created 172.20.0.0/16, so with a /16 per network a host can have many isolated networks before address space becomes an issue.
Bridge Network
Docker's built-in network named bridge, on the docker0 interface and the 172.17.0.0/16 subnet by default, is where containers go when you do not choose one. It has a key limitation: it has no name-based discovery. In a test, a container on the default bridge failed to resolve another by name ('bad address'), while on a user-defined bridge the same lookup worked at once. Containers there can still talk by IP address, or through the legacy --link flag. Docker's documentation calls user-defined bridges superior, so create one for any multi-container setup. Compose does this automatically for each project.
The default bridge exists for compatibility with old Docker behavior, which is why it keeps the old rules: no automatic DNS, every container sharing one network, and addresses that change across restarts. Name-based discovery matters because addresses are not stable. A model server restarted after an update may come back with a different IP, and a client holding the old address breaks, while a client using the name resolves the new address on its next lookup.
Volume Drivers
Volume drivers let a Docker volume live somewhere other than the local disk. The built-in local driver can already mount NFS or CIFS shares with docker volume create and driver options, and plugins add other backends, such as cloud file systems or distributed storage. For ML, the use case is shared data: several GPU hosts mount one dataset or checkpoint store read-only instead of copying it to each machine. Network storage is slower than local NVMe and shares one server's bandwidth among every client, so for heavy training I/O many teams copy or cache the hot data locally first.
Sharing saves space but not bandwidth. One copy of a 2 TB dataset on an NFS server serves training hosts, but they split the server's throughput. If it delivers 2 GB/s and 8 hosts each want 500 MB/s, total demand is 4 GB/s, so each host gets about half of what it needs and GPUs stall waiting for data. Local caching or sharded formats that stream sequentially reduce the demand on the shared store.
Real ML systems are never a single container. A typical setup includes an API server, a model inference service, a database for predictions, a Redis cache for feature stores, and maybe a monitoring dashboard. Running each container with a long docker run command, managing networks, and coordinating startup order is tedious and error-prone.
Docker Compose solves this with a single YAML file that defines all your services, networks, and volumes. One command — docker compose up — starts everything. One command — docker compose down — stops everything. The YAML file is version-controlled alongside your code, so anyone can spin up the entire stack.
Compose handles service dependencies (depends_on), shared networks (automatic), environment variables, volume mounts, port mappings, and even build instructions. For local ML development, it's invaluable: spin up your entire inference pipeline with one command, test changes, tear it down, repeat.
Portainer is a web-based Docker management UI that works alongside Compose. It shows running containers, logs, resource usage, and lets you manage stacks through a browser — useful when managing multiple Compose projects on a remote server.
Definition
Docker Compose is a tool that defines a multi-container application in a YAML file (compose.yaml) listing services, networks, volumes, configs and secrets, and manages it as one project. docker compose up creates and starts everything in dependency order on a shared project network, and docker compose down stops and removes it.
In this topic
- 1docker-compose.yml
- 2Services
- 3depends_on
- 4Shared Networks
- 5Environment Variables
- 6docker compose up/down
- 7Portainer
docker-compose.yml
The Compose file, now preferably named compose.yaml, declares an application's services, networks, volumes, configs and secrets. Each service lists its image or build context, command, ports, volumes, environment, health check, dependencies and resource limits. It is declarative: docker compose up compares the file with what is running and creates, recreates or leaves services as needed, so rerunning it after a small edit restarts only the changed service. The top-level version key is obsolete and ignored. Several files can be layered with -f, for example a base file plus a dev override that adds bind mounts.
A Compose file replaces long command lines with reviewable data. Running a 4-service stack by hand means several docker run commands, each with a dozen flags, plus a network and volumes created in the right order, and any one typo breaks it. In the file, the same setup is a few dozen lines that sit in version control, and the dependency graph lets Compose work out the start order for you.
Services
A service is a container definition that Compose can run one or more copies of. It either names an image, such as redis:7-alpine, or a build section pointing at a Dockerfile, and adds ports, volumes, environment, command, health check, restart policy and deploy settings such as memory limits or GPU reservations. The service name doubles as its DNS name on the project network. docker compose up --scale worker=3 runs three replicas behind that name. Profiles let a service, such as a GPU trainer or a debugging tool, start only when its profile is requested.
Compose v2 names containers project-service-index with hyphens. A test project named pbdemo with a service web produced the container pbdemo-web-1 on the network pbdemo_default. Scaling to 3 replicas adds pbdemo-web-2 and pbdemo-web-3, and all three answer to the DNS name web, so clients reach them without knowing how many exist. A fixed container_name prevents scaling, because two containers cannot share a name.
depends_on
depends_on controls the order in which Compose creates and starts services, and the reverse order in which it stops them. In its short form it only waits for the dependency's container to start, not for the program inside to be ready, so an API can boot before its database accepts connections. The long form adds a condition: service_started, service_healthy, which waits for the dependency's healthcheck to pass, or service_completed_successfully, which waits for a one-off job such as a migration or model download to exit with code 0. restart: true restarts the dependent service when the dependency is restarted.
Health checks bound readiness detection to a known window. With interval and the model ready at time , the first passing check comes at most after , so a 5-second interval adds at most 5 seconds to start-up. Failures during start_period do not count toward the retries limit, which keeps a slow-loading model from being marked unhealthy. A check that hits a real endpoint proves more than one that only tests a listening port.
Your API service depends on a model server, but the model server takes 30 seconds to load the model into GPU memory. depends_on starts the API immediately after the model server container starts. How do you fix this?
Shared Networks
Compose creates a network for each project, named after the project with a _default suffix, and attaches every service to it. Each service is reachable by its service name, so if the model server service is called model, other services connect to http://model:5000. Inside the network, services talk on container ports, so only ports for the outside world need publishing. You can declare extra networks to separate tiers: put the database on a backend network shared only with the API, and the API also on a frontend network with the proxy, so the proxy cannot reach the database directly.
All services in a compose project automatically share a network named <project>_default. Adding more networks lets you partition traffic — e.g., put backend on a private network, frontend on a public one. Docker DNS resolves names per-network, so a service on multiple networks gets multiple A records.
Environment Variables
Compose uses environment variables in two separate ways that are easy to confuse. Interpolation happens when the file is parsed: a reference to a variable in the YAML is filled in from your shell or from a .env file next to the Compose file. Container environment is what the program sees at run time, set with the environment key or loaded from files with env_file. A .env file therefore does not reach containers unless you also use env_file or pass its variables through. Keep real secrets out of the Compose file and out of Git, and prefer Compose secrets, mounted as files under /run/secrets, for passwords and keys.
Values have a clear precedence: an explicit environment entry beats one from env_file, which beats the image's ENV default. Interpolated values are fixed at parse time, so changing your shell variable after up has no effect until the next up recreates the container. docker compose config prints the fully resolved file, which is the fastest way to see what Compose will actually use. Environment variables also show up in docker inspect, which is why secrets belong in mounted files.
docker compose up/down
docker compose up creates networks and volumes, builds or pulls images, and starts every service. Add -d to run in the background, --build to rebuild images first, and a service name to start just that service and its dependencies. docker compose down stops and removes the project's containers and networks, but keeps named volumes unless you add -v, which deletes them. Use that only when you mean to wipe databases and checkpoints. Day to day, docker compose ps shows status, logs -f follows every service's output, exec runs commands in a service, and watch syncs source edits into running containers.
up builds the dependency DAG and starts services; down stops and removes them in reverse topological order. Both are in service count. With --build, up re-checks layer hashes — on cache hit, on miss. down -v also removes named volumes — the destructive flag, easy to fire by accident.
Portainer
Portainer is a web interface for managing Docker, Swarm and Kubernetes environments. The Community Edition runs as a container from portainer/portainer-ce, serves its UI over HTTPS on port 9443 with a self-signed certificate, and stores its settings in a volume. It controls Docker by mounting /var/run/docker.sock, which is equivalent to root access on the host, so protect its login, keep it off the public internet and update it. It shows containers, logs, stats and volumes and can deploy Compose stacks from a Git repository, which helps teammates who manage a shared GPU server but do not use the CLI.
Portainer is a web UI over the Docker API. It shows containers, images, networks, volumes — essentially the same data as docker ps/docker images, but visual. Cost: one container running a small Go binary, MB RAM. Worth it for non-CLI teammates and for managing remote daemons without typing verbose docker context commands.
Running ML training inside containers is pointless without GPU access. By default, Docker containers can't see the host's NVIDIA GPUs. The NVIDIA Container Toolkit bridges this gap by injecting the GPU driver and CUDA libraries into containers at runtime.
The setup is straightforward: install the NVIDIA driver on the host, install the NVIDIA Container Toolkit, and add --gpus all to your docker run command. Inside the container, nvidia-smi works, PyTorch sees CUDA, and training runs at full GPU speed.
Choosing the right base image matters. NVIDIA provides official CUDA images in three variants: base (a minimal CUDA runtime; the driver itself always comes from the host), runtime (adds the CUDA math libraries), and devel (adds headers for compiling CUDA code). For most ML work, runtime is sufficient. The devel variant is 2-3 GB larger and only needed if you're compiling custom CUDA kernels.
GPU containers are the standard way to run training on cloud instances (AWS, GCP, Lambda Labs) and in Kubernetes clusters. Your local development container works identically on a cloud p4d.24xlarge with 8x A100 GPUs — that's the power of containerized GPU workloads.
Definition
GPU access in Docker means exposing host NVIDIA devices and driver libraries to a container, usually through the NVIDIA Container Toolkit and docker run --gpus. The kernel driver stays on the host, while the image supplies the CUDA runtime and libraries, which must be compatible with that host driver's version.
In this topic
- 1NVIDIA Container Toolkit
- 2--gpus Flag
- 3CUDA Base Images
- 4nvidia-smi in Container
- 5CUDA Version Matching
- 6Multi-GPU Training
- 7nvidia-docker (Legacy)
NVIDIA Container Toolkit
The NVIDIA Container Toolkit lets containers use NVIDIA GPUs without installing the driver inside the image. When a container asks for GPUs, its runtime hook, or the newer CDI device specification, mounts the device nodes such as /dev/nvidia0 and /dev/nvidiactl and injects the host driver's user-space libraries, such as libcuda.so and libnvidia-ml.so, plus tools like nvidia-smi. The image provides everything above the driver: the CUDA runtime, cuDNN, NCCL and your framework. Install it on the host after the driver, then run nvidia-ctk runtime configure for Docker and restart the daemon so the runtime is registered.
The toolkit injects libraries (libcuda.so, libnvidia-ml.so) and device nodes (/dev/nvidia0, /dev/nvidiactl) into the container at start time via an OCI runtime hook. Kernel driver stays on the host; only userland CUDA libs go into the container. This is why nvidia-smi inside a container works without re-installing the driver.
--gpus Flag
--gpus tells Docker which host GPUs a container may use. --gpus all exposes every GPU, --gpus 2 exposes any two, and --gpus followed by device=0,1 in double quotes inside single quotes exposes those specific GPUs, by index or UUID. The quoting is needed because the value contains a comma. Inside the container, visible GPUs are renumbered from 0, so code never needs to know host indices. A capabilities option controls which driver libraries are injected, and Compose requests GPUs under deploy.resources.reservations.devices. Pinning workloads to separate GPUs is how several experiments share one 8-GPU machine without contention.
--gpus all exposes every GPU to the container; --gpus '"device=0,2"' exposes specific ones. Implementation: the toolkit reads the flag and injects the matching device files. Each container sees a renumbered GPU index starting at 0, just like CUDA_VISIBLE_DEVICES — the same isolation primitive at the device-file layer.
CUDA Base Images
NVIDIA's nvidia/cuda images come in three main flavors. base has the minimal CUDA runtime library, runtime adds the CUDA math libraries such as cuBLAS and cuFFT, and devel adds nvcc, headers and static libraries for compiling. None contains the driver, which comes from the host. cudnn variants add cuDNN. For the 12.4.1 Ubuntu 22.04 tags, Docker Hub reports compressed sizes of about 32 MB for base, 1.4 GB for runtime, 2.1 GB for cudnn-runtime and 3.6 GB for devel. Note that PyTorch's pip wheels bundle their own CUDA libraries, so they need only a slim base, not a runtime image.
Image size is dominated by libraries you may already be getting elsewhere. The devel tag adds about 2.2 GB of compressed toolchain on top of runtime, needed only to compile CUDA code. PyTorch's official image shows the same pattern: 2.4.0 with CUDA 12.4 is about 3.9 GB compressed as runtime and 8.0 GB as devel. Build custom kernels in a devel builder stage and copy the results into a runtime stage, and avoid stacking a CUDA runtime image under pip wheels that bring their own CUDA, or you ship the libraries twice.
nvidia-smi in Container
Running nvidia-smi in a container is the standard first test of GPU plumbing: docker run --rm --gpus all with an nvidia/cuda base image and the command nvidia-smi. It should list the GPUs with the host's driver version and the highest CUDA version that driver supports. The binary itself is injected from the host, so a failure points at the host setup. 'Could not select device driver' means the toolkit is not installed or Docker was not restarted, and 'executable file not found' usually means --gpus was missing. Remember that its CUDA Version line describes the driver, not the CUDA toolkit inside the image.
If nvidia-smi works inside a container, the toolkit injection succeeded — the binary itself talks to the kernel driver via /dev/nvidiactl. If it fails with "could not find any GPUs," the toolkit isn't installed on the host or --gpus wasn't passed. It's the canonical 5-second smoke test for container GPU plumbing.
CUDA Version Matching
Two versions must fit together: the host driver and the CUDA runtime in the image. A driver supports every CUDA version up to the one nvidia-smi reports, so newer drivers run older images. Running a newer CUDA minor version on an older driver is possible within a major release through minor version compatibility, CUDA 12.x on drivers 525 and up, with some features unavailable, such as PTX JIT. But nvidia/cuda images set NVIDIA_REQUIRE_CUDA, and the toolkit refuses to start an image whose requirement the driver does not meet. Framework builds add a third constraint: the GPU architectures compiled into the wheel.
NVIDIA's naming is the reverse of what many people expect. Backward compatibility means a new driver runs applications built with older toolkits, and it always holds. Minor version compatibility lets an application built with a newer 12.x toolkit run on any driver from 525 up, with limits. Forward compatibility, running a newer major CUDA release on an old driver, needs the cuda-compat package and supported data-center GPUs. So the safe rule is: driver at least as new as the image's CUDA, and you rarely go wrong.
Your host shows CUDA Version: 12.2 in nvidia-smi. You try to run nvidia/cuda:12.4.0-runtime and it fails. Why?
Multi-GPU Training
Multi-GPU training in a container needs more than --gpus all. Docker gives each container a 64 MB /dev/shm by default (a test container showed exactly 65,536 KB), but PyTorch DataLoader workers pass batches through shared memory and NCCL uses it between processes on the same node, so jobs crash with bus errors or shared-memory errors. Raise it with --shm-size=16g, or share the host's IPC namespace with --ipc=host. For training across nodes, use --network host or a fast container network, set NCCL_SOCKET_IFNAME to the right interface, and expose InfiniBand devices when the cluster has them.
DDP across GPUs needs device files mounted (--gpus all) plus shared memory for inter-GPU comms (--shm-size=8g, default 64 MB is too small). Without enough shared memory, NCCL falls back to slower paths or fails outright with cryptic errors. Always set shm explicitly for multi-GPU containers.
nvidia-docker (Legacy)
nvidia-docker was NVIDIA's original approach: a wrapper command, and later a nvidia-docker2 package that registered an nvidia runtime with the Docker daemon. Docker 19.03 added the --gpus flag and the NVIDIA Container Toolkit replaced both, so the old packages are deprecated and no longer maintained. When old scripts use nvidia-docker run or --runtime=nvidia, replace them with docker run --gpus all after installing the toolkit. Some environments, such as older Kubernetes nodes, still configure the nvidia runtime as a default, which is why you may still see the name in configuration files.
The migration shows a general principle: device access moved from a special wrapper into a standard hook that every tool can use. The same toolkit now serves Docker through --gpus, Podman and containerd through CDI device names, and Kubernetes through the NVIDIA device plugin. One mechanism to maintain is why GPU support became reliable across runtimes instead of depending on which wrapper a script happened to call.
Related Problems on PixelBank
Building a Docker image is only half the story. You need to distribute it — push it to a registry so teammates, CI/CD pipelines, and production servers can pull and run it. A container registry is like GitHub for Docker images: a centralized store where images are versioned, tagged, and shared.
Docker Hub is the default public registry — it hosts official images for Python, NVIDIA CUDA, PyTorch, and thousands of other tools. For private images, you can use Docker Hub's paid plans, Amazon ECR (tight AWS integration), Google Artifact Registry, or GitHub Container Registry (GHCR).
Tagging strategies matter more than people realize. Using latest in production is dangerous — it's a moving target that can break deployments silently. ML teams should tag images with the model version, training run ID, or git commit SHA. This creates an audit trail: you can always trace a production prediction back to the exact code and model that generated it.
Skopeo and crane are CLI tools for inspecting and copying images between registries without pulling the full image. They're invaluable for CI/CD pipelines and multi-cloud setups.
Definition
A container registry is a server that stores images and serves them over the OCI distribution API. Images live in repositories, are addressed by mutable tags or immutable digests, and are pushed and pulled layer by layer, so layers already present on either side are not transferred again. Examples include Docker Hub, Amazon ECR, Google Artifact Registry and GHCR.
In this topic
- 1Container Registry
- 2Docker Hub
- 3Amazon ECR
- 4Image Tags
- 5Semantic Versioning
- 6docker push/pull
- 7Skopeo
- 8crane
Container Registry
A registry stores images as content-addressed blobs (compressed layers and configs) plus manifests that list them. Clients talk to it over the OCI distribution HTTP API: push uploads only blobs the registry does not already have, and pull downloads only blobs missing locally. Images are grouped into repositories and named by tags or by digest. Private registries add authentication, access policies, vulnerability scanning, retention rules and replication. For ML teams the main design questions are where the registry sits relative to the GPUs, since pulling multi-gigabyte images across regions is slow and billed, and who is allowed to push production tags.
A registry is an HTTP API serving content-addressed image layers. Pulls are over only the layers not already cached locally. Registries shard storage by layer hash, so they can deduplicate across millions of images storing the same python:3.11 base only once.
Docker Hub
Docker Hub is Docker's public registry and the default when an image name has no registry host: python:3.11-slim means docker.io/library/python:3.11-slim. It hosts Docker Official Images, verified publisher images such as nvidia/cuda, and community repositories. Pulls are rate-limited: unauthenticated clients get 100 pulls per 6 hours per IPv4 address or IPv6 /64, and free Personal accounts get 200, while paid plans are unlimited under fair use. CI fleets behind one NAT address hit the anonymous limit quickly, so log in with an access token, or mirror the bases you use into a private registry or pull-through cache.
Docker Hub is the default public registry — docker pull nginx resolves to docker.io/library/nginx. Free tier has rate limits ( pulls / 6 hr per IP) introduced in 2020 to control bandwidth costs. Production CI hammering Hub gets throttled; mirroring through a proxy registry (or paying) is the standard fix.
Amazon ECR
Amazon Elastic Container Registry is AWS's managed private registry. Access is controlled with IAM, so ECS tasks, EKS nodes, SageMaker jobs and CI roles pull with their own role permissions instead of stored passwords. The docker CLI needs a token from aws ecr get-login-password, valid for 12 hours, or the ECR credential helper. ECR can scan images for vulnerabilities, expire old images with lifecycle policies, make tags immutable, replicate across regions and accounts, and act as a pull-through cache for Docker Hub and other registries. Pulls within the same region are free of data-transfer charges, and storage is billed per GB-month.
Storage at about 0.10 dollars per GB-month adds up with frequent builds. Pushing a new 4 GB image every working day without cleanup could add about 80 GB a month if nothing were shared. Layer sharing usually reduces that a lot, since a code change adds one small layer, but changes to dependencies create new large layers. A lifecycle rule that keeps the last 30 tagged images and expires untagged images after a day keeps the bill flat. Same-region pulls are free, while cross-region pulls pay transfer charges.
Image Tags
A tag is a human-readable, mutable pointer to an image manifest. my-model:v1.2.3 can be moved to a different image by pushing again, which makes tags convenient for people but unreliable as a deployment record. The common practice is to push several tags per build, such as the semantic version, the git commit SHA and an environment tag like staging, and to deploy by digest, my-model followed by @sha256 and the hash, which can never change. Avoid latest in production: it is simply the default tag, not the newest image, and it moves whenever anyone pushes without a tag.
Tags are mutable references to immutable image digests: nginx:latest today might be nginx@sha256:abc..., tomorrow nginx@sha256:def.... For reproducibility, pin to digests, not tags. The discipline: tags are for humans, digests are for production. CI should record the digest from docker inspect after every push.
Semantic Versioning
Semantic Versioning names releases MAJOR.MINOR.PATCH. A PATCH release fixes bugs without changing behavior callers depend on, MINOR adds backward-compatible features, and MAJOR may break compatibility. For a model-serving image, the contract is the API: input and output schema, preprocessing and meaning of scores. A retrained model with the same schema might be a MINOR or PATCH bump, while a new output format or label set is MAJOR. Publishing the same digest as 1.4.2, 1.4 and 1 lets consumers choose how much change to accept automatically, with only the full version guaranteed never to move.
Version precedence is ordered numerically, field by field: 1.10.0 is newer than 1.9.0, even though a plain string sort puts it first, which is a common bug in scripts that pick the latest tag. Floating tags trade stability for convenience. A consumer pinned to 1 receives every MINOR and PATCH release automatically, while one pinned to 1.4.2 receives nothing until it changes its own configuration.
docker push/pull
docker push uploads an image to the registry named in its tag, and docker pull downloads one. Both work per layer. A push first asks which blobs the registry already has and uploads only the missing ones, then the manifest. A pull fetches the manifest, then any layers not present locally, then unpacks them. So after the first push of a 5 GB ML image, a code-only change usually pushes one small layer in seconds. Authenticate with docker login or a credential helper first. The daemon uploads up to 5 layers and downloads up to 3 layers in parallel by default.
Transfer time is driven by new layer bytes and by parallelism. With the default of 3 concurrent downloads, an image whose size sits in one 4 GB layer downloads as a single stream, while the same data in several layers downloads three at a time. Raising max-concurrent-downloads in daemon.json can speed pulls on fast links. Splitting huge dependencies into a few layers also helps caching, because a change then invalidates only one part.
Skopeo
Skopeo is a command-line tool from the containers project for working with images in registries without a Docker daemon or local image storage. skopeo inspect reads an image's manifest and config, including digest, layers, labels and architecture, without pulling it. skopeo copy moves images between registries, or between a registry and a directory or archive, transferring layers directly, which is how teams mirror Docker Hub bases into a private registry. skopeo sync copies whole repositories, and skopeo list-tags lists available tags. Because it needs no daemon or root, it fits well in locked-down CI runners.
Skopeo copies images between registries WITHOUT pulling them through a local Docker daemon. skopeo copy docker://hub.io/x docker://ecr.io/x is over the network, no local disk usage. For mirroring 100 GB of base images into ECR, this saves 100 GB of local disk and a daemon round-trip per layer.
crane
crane is a command-line tool from Google's go-containerregistry library for talking to registries directly. crane digest prints an image's digest, crane ls lists tags, crane manifest and crane config show the raw JSON, and crane copy moves images between registries. It can also change images without a daemon or rebuild: crane append adds a layer, crane mutate edits labels, entrypoint or environment, and crane tag adds a tag remotely without moving any layers. It is a single static binary, so CI jobs use it to resolve tags to digests before deploying and to promote a tested image by retagging it.
crane (from go-containerregistry) is a CLI for registry operations: crane manifest, crane copy, crane mutate. It speaks the registry API directly without Docker — useful for CI jobs that need to inspect or rewrite images without spinning up a full daemon. MB binary, startup.