Registries, Artifacts and Environments
Shrinking ML container images
A smaller model image starts faster, costs less to store and move, and gives an attacker less to work with.
- 9 min read
- 3 reading levels
- Published
Read these first
On this page 9
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Shrinking a container image means removing everything from it that the running service does not actually need.
The analogy you have already lived
You have packed a suitcase for a short trip and realised, halfway through, that you had brought your entire wardrobe. Every extra shirt is still weight to carry and time to unpack, whether or not it ever gets worn.
A container image — the sealed box a model ships inside — accumulates the exact same kind of extra weight. Compilers nobody runs after the build finishes. Documentation nobody reads at runtime. Cached files left over from installing everything else.
Why it exists
A container image has to travel before it can run: downloaded onto a machine, then started. Every extra megabyte in that image has to move across the network, and unpack from disk, before the first request can be answered.
At one image, that cost is small. That cost multiplies across every machine an autoscaler starts, every region a service runs in, every deployment that ships a new version. A bloated image becomes a real, recurring tax on both time and money.
Where the size actually comes from
Machine learning images are heavier than typical web-service images for an honest reason: the libraries themselves are large. NumPy, pandas, and scikit-learn together commonly add well over a hundred megabytes, before your own code or model even enters the picture. A GPU-enabled deep learning framework can add several gigabytes beyond that.
How it works
a naive image:
base OS + compilers + full Python toolchain
+ every library, plus their download cache
+ your code, your model, your test files, your .git folder
= several gigabytes
a shrunk image:
a slim base, no compilers
+ only the libraries actually imported at runtime, no cache left behind
+ only the files the running service reads
= a fraction of the size, same behaviourA real example you have seen
An app that installs in seconds, versus one that installs over several minutes on the same connection, often solves the same problem. The difference is discipline about what actually ships. The seconds-fast one earned that speed on purpose.
The honest part
Shrinking an image takes real effort. It is easy to under-invest in, because a big image still works — only slower and pricier, not broken. That makes it exactly the kind of problem that quietly accumulates until someone finally measures it.
Remember this
- Every extra megabyte in an image costs real time on every download and startup, not once.
- ML images start heavy because the libraries themselves are large. Measure your own before assuming the model is the biggest part.
- A slimmer image is not only cheaper. It starts faster too, which directly helps scale-to-zero cold starts and autoscaling lag alike.
What to learn next
- Docker for ML — the full layer-caching and
.dockerignoremechanics this lesson builds on. - Training and serving environment parity — making sure a smaller image still behaves identically to the one it replaced.
- Scale to zero, and what it costs you — where a smaller image's faster startup pays off most directly.
Developer — Code and libraries.
Setup
pip install scikit-learn pandas numpyMeasuring what is actually taking up the space
Before shrinking anything, measure it. This walks the real installed files of each library on disk — genuine sizes from this machine, not estimates.
import importlib
from pathlib import Path
def installed_size_mb(package_name):
"""Real size on disk of one installed package, right now, on this machine."""
module = importlib.import_module(package_name)
package_dir = Path(module.__file__).parent
total_bytes = sum(f.stat().st_size for f in package_dir.rglob("*") if f.is_file())
return total_bytes / (1024 * 1024)
sizes = {name: installed_size_mb(name) for name in ["numpy", "pandas", "sklearn"]}
for name, mb in sizes.items():
print(f"{name:10s} {mb:7.1f} MB on disk")
print(f"{'total':10s} {sum(sizes.values()):7.1f} MB, before Python or your own code")numpy 26.1 MB on disk pandas 52.8 MB on disk sklearn 36.5 MB on disk total 115.4 MB, before Python or your own code
These sizes come from whatever library versions and platform are installed on the machine running this. Yours will differ, sometimes by a wide margin. But the shape of the result generally holds — even a small, ordinary set of data-science libraries adds up to well over a hundred megabytes, before a single line of your own code exists.
The Dockerfile side: a real multi-stage build
This is real, correct Dockerfile syntax — it needs Docker itself to build and to observe the resulting image size, which this lesson's sandbox cannot do. Run docker build and docker images on your own machine to see your own numbers.
# Stage 1: has compilers, only used to build anything that needs them
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --target=/build/deps -r requirements.txt
# Stage 2: the image that actually ships — no compilers, no build cache
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /build/deps /usr/local/lib/python3.11/site-packages
COPY model.joblib predict.py ./
CMD ["python", "predict.py"]Walking through it
installed_size_mb walks every file under a package's installed directory and sums real byte counts. It is the same information du -sh would report from a shell, computed in Python instead. That way it works identically wherever Python runs, including inside a container with no du installed.
The multi-stage Dockerfile never ships stage 1 at all. Anything installed with compilers in the builder stage stays behind when the final image is built. Only the COPY --from=builder line carries forward, bringing only the installed packages, none of the tools that built them.
--no-cache-dir on the pip install line matters even inside a stage that gets discarded. A wasteful build stage still costs build time and build-machine disk, even if it never reaches the final image.
Common mistakes
Measuring the wrong thing. Assuming the model file is the largest part of an image, without checking, is a common and often wrong guess. For many classical ML models, the surrounding libraries dwarf the model file itself, as the measurement above shows directly.
Copying the whole project directory into the image. COPY . . sweeps in .git, test fixtures, notebooks, and any dataset sitting in the project folder — none of which the running service reads. A .dockerignore, covered in Docker for ML, is the fix, and it is worth checking on every project, not assuming it is already there.
Installing a full deep-learning framework's GPU build when serving runs on CPU. A CUDA-enabled PyTorch wheel is gigabytes larger than its CPU-only counterpart. If the machine actually serving requests has no GPU, the CPU wheel is not a compromise — it is the correct choice.
Deleting files in a later layer and assuming the space is reclaimed. A RUN rm -rf in a separate instruction from the RUN pip install that created the files does not shrink the image. Docker's layered filesystem keeps the earlier layer's bytes regardless. Combine install and cleanup into one RUN instruction, or use a multi-stage build as shown above, which sidesteps the problem entirely.
Try it yourself
Run installed_size_mb against a deep learning framework you have installed, if any — torch or tensorflow. Compare the result against the classical-ML libraries measured above, to get a feel for how much larger that category of dependency actually is on real disk.
What to learn next
- Docker for ML — the full layer-caching and
.dockerignoremechanics this lesson builds on. - Training and serving environment parity — making sure a smaller image still behaves identically to the one it replaced.
- Scale to zero, and what it costs you — where a smaller image's faster startup pays off most directly.
Researcher — Mathematics and papers.
Where a typical scientific Python image's size actually goes
The researcher section of Docker for ML breaks this down in detail. A python:3.11-slim base runs around 130 MB, SciPy and NumPy in the tens of megabytes each, scikit-learn around 30 MB — closely matching the direct measurement above. A CUDA-enabled PyTorch wheel alone exceeds 2 GB. That lesson also covers the layer-caching mechanics and the whiteout-marker reason deleted files do not shrink an image, referenced in the developer section here.
Base image choices, and their real trade-offs
-slimvariants drop compilers and documentation from the standard Debian-based Python image, at the cost of needing a multi-stage build for any dependency that must compile from C source.- Alpine-based images are dramatically smaller again, but use
musllibc rather thanglibc. Many popular ML wheels are built againstglibc(the "manylinux" wheel standard), and will not install as a prebuilt binary on Alpine — silently falling back to a slow source build, or failing outright. - Distroless images (Google's
gcr.io/distroless) ship no shell, no package manager, and no OS utilities at all — only the language runtime and your application. This minimises attack surface most aggressively, at the cost of losing the ability todocker execin and debug interactively.
Size reduction as a security property, not only a cost one
Every package present in a shipped image is a package whose vulnerabilities apply to that image, whether or not your code ever calls it. Scanning tools (Docker Scout, Trivy, Grype) report vulnerability counts roughly proportional to installed package count — a smaller image has a measurably smaller, more auditable attack surface, independent of any cost argument.
Levers, in roughly decreasing order of effect
- Use the CPU-only wheel index when no GPU is present at serving time.
- Multi-stage builds, leaving every compiler and build tool behind.
--no-cache-diron everypip install.- A
-slimor distroless base, weighed against the manylinux/musl compatibility risk above. - Pinning digests (
python@sha256:...) — this does not shrink the image, but keeps a rebuild byte-identical, which matters for auditing exactly what shrank and when.
What to learn next
- Docker for ML — the full layer-caching and
.dockerignoremechanics this lesson builds on. - Training and serving environment parity — making sure a smaller image still behaves identically to the one it replaced.
- Scale to zero, and what it costs you — where a smaller image's faster startup pays off most directly.