A Docker Image Is a Stack of Diffs, and the Order Decides the Build Time

Why a machine-learning image rebuilds in seconds or in twenty minutes, and a model served from a container end to end.
Docker
Machine Learning
Author

Ravi Kalia

Published

March 3, 2025

Docker layers

Change one line of application code and rebuild a machine-learning image, and one of two things happens: the build finishes in seconds, or it spends twenty minutes reinstalling PyTorch. The difference is not the change. It is where in the Dockerfile the change lands, because an image is not a snapshot but a stack of diffs, and Docker rebuilds every diff above the first one that changed. This post sets out how the stack works, then serves a small model from a container end to end with the Dockerfile ordered the right way round.

Every instruction adds a read-only layer, and a changed layer invalidates the ones above it

A Dockerfile is a list of instructions. Each FROM, RUN, COPY and ADD produces a layer: a read-only diff against the layers beneath it, holding only the files that instruction created or changed. The image is the stack; a running container adds one writable layer on top, which is thrown away when the container stops.

Layers are content-addressed and cached. When Docker rebuilds, it walks the instructions in order and reuses the cached layer for each one whose inputs are unchanged. The first instruction whose inputs differ breaks the chain, and every layer above it is rebuilt, whether or not its own inputs changed. Six instructions, four of which write files (WORKDIR and CMD are metadata), and one rule:

FROM python:3.10              # layer 1: the base image
WORKDIR /app                  # layer 2: metadata only
COPY requirements.txt .       # layer 3: one file
RUN pip install -r requirements.txt   # layer 4: the expensive one
COPY . .                      # layer 5: the application
CMD ["python", "app.py"]      # metadata, no new layer
Step Instruction Reused when
1 FROM python:3.10 always, once pulled
2 WORKDIR /app always
3 COPY requirements.txt . requirements.txt unchanged
4 RUN pip install … layer 3 reused
5 COPY . . no file in the context changed
6 CMD always

Read the table bottom-up and the build times explain themselves. An edit to app.py changes layer 5 only, so layers 1 to 4 come from cache and the rebuild is a file copy. An edit to requirements.txt changes layer 3, so layer 4 reinstalls everything. And a Dockerfile that copies the whole project before installing dependencies makes every edit to any file look like a dependency change, which is the twenty-minute rebuild. So the order is: things that change rarely first, things that change often last, and the dependency manifest copied on its own before the code. A .dockerignore keeps files the image does not need out of the build context, so that editing them does not invalidate layer 5 either.

Serving a model from a container, ordered the right way round

The example trains a classifier on Fisher’s iris measurements, the 150 flowers from 1936 that ship with scikit-learn, chosen because it fits in a pickle of a few hundred kilobytes and makes the request round-trip visible. Nothing about the container depends on the model being small. The blocks are illustrative and are not executed in this build.

Train and save the model, once, outside the container:

import pickle

from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split

iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.2, random_state=42)
model = RandomForestClassifier(n_estimators=100, random_state=42).fit(X_train, y_train)
with open("model.pkl", "wb") as f:
    pickle.dump(model, f)

Serve it behind a small Flask endpoint, app.py:

import pickle

import numpy as np
from flask import Flask, jsonify, request

with open("model.pkl", "rb") as f:
    model = pickle.load(f)

app = Flask(__name__)


@app.route("/predict", methods=["POST"])
def predict():
    features = np.array(request.get_json()["features"]).reshape(1, -1)
    return jsonify({"prediction": model.predict(features).tolist()})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

The dependencies are three lines in requirements.txt: flask, numpy, scikit-learn. The Dockerfile copies that file first, installs, and only then copies the code and the model, so a change to app.py never reinstalls scikit-learn:

FROM python:3.10
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py model.pkl .
EXPOSE 5000
CMD ["python", "app.py"]

The .dockerignore excludes caches and the training script’s leftovers, and it must not exclude model.pkl: the container loads it at start-up, and an image built without it starts and then dies on the first line of app.py. That mistake sat in the earlier version of this post.

__pycache__/
*.pyc
.git/

Build, run, and ask it a question:

docker build -t iris-api .
docker run -p 5000:5000 iris-api
curl -X POST http://localhost:5000/predict \
  -H "Content-Type: application/json" \
  -d '{"features": [5.1, 3.5, 1.4, 0.2]}'

The reply is {"prediction": [0]}: setosa, which is what those four measurements are. Change a line of app.py and rebuild, and the output shows CACHED against the install step and a build of a few seconds. Change requirements.txt and it does not.

Shipping the image is pushing the stack

An image on one machine is of limited use. docker tag and docker push send the stack to a registry, Docker Hub or a cloud provider’s, and a server or a Kubernetes cluster pulls it and runs it; and because the push is by layer too, a new version that changed only the code uploads only the code layer.

docker tag iris-api registry.example.com/iris-api:1.0
docker push registry.example.com/iris-api:1.0

Where it stops holding

The cache is keyed on the instruction and its inputs, not on what the instruction does, so RUN pip install -r requirements.txt is reused even when a package has been yanked or a floating version has moved upstream; pin the versions if the build has to be reproducible next year. A base image tagged python:3.10 moves too. And the layer model rewards small, ordered steps but punishes one thing: a file deleted in a later layer is still in the image, hidden, so secrets and multi-gigabyte weights that were ever copied in stay in the stack. Multi-stage builds exist for that.

Layers. Cache. Order. Matters. Rebuild. Less. Ship. The. Same. Environment. Twice.

References