Get a Working Image That Runs the Model

I once handed off a model that scored perfectly on my laptop and refused to run on anyone else’s. It was the serialized model from the classical-ML module, a joblib artifact that reloaded and predicted identically to the model that trained it. I sent the file and the script. The first teammate who tried it hit a version mismatch, the second was missing a system library, and the third never got the right Python at all. I had shipped a file with a story attached, and the story did not travel. The fix was to stop shipping the model and start shipping the whole environment around it as one artifact.

The fastest way to understand images, layers, and containers is to build one that runs the model from the classical-ML module and scores a record with one command. That serialized model runs fine on the machine that made it. The problem is every other machine. A model that only scores on the laptop that trained it is not a deliverable; it is a file with a story attached. This lesson closes that gap by freezing the whole environment around the model into one shippable artifact, then running it. By the end the image runs, and the next three lessons make it something another machine can rely on: reproducible, safe, and slim.

The plan is the order the work actually happens in: get it working, then make it right. This lesson writes the smallest Dockerfile that builds, runs, and scores a record. It does not yet pin dependencies, hide secrets, or trim size; those are the hardening passes that come after there is a running image to apply them to. Start by seeing what a real Dockerfile looks like, so each instruction has somewhere to land.

I once handed off a model that scored perfectly on my laptop and refused to run on anyone else’s. It was the serialized model from the classical-ML module, a joblib artifact that reloaded and predicted identically to the model that trained it. I sent the file and the script. The first teammate who tried it hit a version mismatch, the second was missing a system library, and the third never got the right Python at all. I had shipped a file with a story attached, and the story did not travel. The fix was to stop shipping the model and start shipping the whole environment around it as one artifact.

The fastest way to understand images, layers, and containers is to build one that runs the model from the classical-ML module and scores a record with one command. That serialized model runs fine on the machine that made it. The problem is every other machine. A model that only scores on the laptop that trained it is not a deliverable; it is a file with a story attached. This lesson closes that gap by freezing the whole environment around the model into one shippable artifact, then running it. By the end the image runs, and the next three lessons make it something another machine can rely on: reproducible, safe, and slim.

The plan is the order the work actually happens in: get it working, then make it right. This lesson writes the smallest Dockerfile that builds, runs, and scores a record. It does not yet pin dependencies, hide secrets, or trim size; those are the hardening passes that come after there is a running image to apply them to. Start by seeing what a real Dockerfile looks like, so each instruction has somewhere to land.

What an image is, and what a Dockerfile actually looks like

The plausible mental model a competent engineer brings to Docker the first time is “a container is a lightweight virtual machine, and a Dockerfile is a setup script that runs inside it.” That model predicts the wrong things. It predicts that editing a file changes the running container, that the container keeps its state, and that the build and the run are the same act in two tenses. None of those hold, and each wrong prediction costs an afternoon. The correct model starts one level down, with the vocabulary the rest of the module turns on.

A dependency is anything the code needs that is not the code itself: a library it imports, an interpreter version it requires, a system file it links against. An environment is all of those together: the Python interpreter, the installed libraries and their exact versions, and the OS-level files underneath them. An image is that whole environment frozen into one shippable artifact. The precise definition is worth holding: an image is an ordered collection of root filesystem changes plus the execution parameters (the entrypoint, the default command, the environment) a container runtime uses to run it. It is not a running thing. It is a read-only template, a mold. A container is one running instance of an image; docker build produces the mold, docker run casts a container from it. The distinction matters because the read-only template is what ships and the writable container is what runs, and confusing the two is the failure mode at the centre of the next section.

The image is not one opaque blob. It is built in layers, one filesystem diff per build instruction, stacked. A Dockerfile is the ordered list of those instructions — a text-based document that describes how to build the image — and each instruction adds a layer to the final image, stacked sequentially as a delta on the previous layer. Layers stack into a single merged view through a union filesystem, where a file in an upper layer shadows the same path in a lower one. That stacking is what makes the build cache, the size, and the reproducibility of the next three lessons mechanical rather than mysterious, but for now the only claim that matters is that one instruction equals one layer, bottom to top.

Almost every ML image follows the same top-to-bottom shape. Here is the skeleton, annotated line by line; this is the destination, not yet a build:

FROM python:3.12-slim          # base layer: a tiny Debian with Python already installed
WORKDIR /app                   # set the working directory for every later instruction
COPY pyproject.toml uv.lock .  # copy the dependency manifest first (cache reason — Lesson 2)
RUN uv sync --frozen --no-dev  # install the locked dependency set into the image
COPY . .                       # then copy the code and the model artifact
ENTRYPOINT ["python", "-m", "score"]  # the one command the container runs at start

Each instruction has a fixed job, and naming them once now means they land when you write the build. FROM picks the base image, here python:3.12-slim, a Debian-based image with Python preinstalled and “only the minimal Debian packages needed to run python,” kept as small as possible. WORKDIR sets the directory later instructions run in. COPY brings files from the build directory into the image. RUN executes a build step, here installing the locked dependency set carried over from the dependency-locking work in the Python module. ENTRYPOINT configures the command the container runs when it starts. The ordering of those middle instructions is what makes a build fast or slow, reproducible or not, and that payoff lands in Lessons 2 and 4; for now the shape is what matters.

The structure is easier to hold as a stacked diagram than as a list. Each instruction is one read-only layer; the writable container layer sits on top as a distinct cap, present only while a container runs:

title Image layers (read-only) + container layer (writable)

rectangle "Writable container layer\n(exists only while running, discarded on removal)" as W #e05555
rectangle "ENTRYPOINT [python -m score]" as E #4a9d7c
rectangle "COPY . .  (code + model artifact)" as C2 #6998a5
rectangle "RUN uv sync --frozen  (installed deps)" as R #6998a5
rectangle "COPY pyproject.toml uv.lock ." as C1 #6998a5
rectangle "WORKDIR /app" as WD #6998a5
rectangle "FROM python:3.12-slim  (base)" as F #487886

W -down-> E
E -down-> C2
C2 -down-> R
R -down-> C1
C1 -down-> WD
WD -down-> F

The base is at the bottom, the entrypoint at the top, and the writable cap is the one part that is not in the image: it belongs to a running container and vanishes when that container is removed. That separation between the read-only stack and the writable cap is the whole reason a code edit on the host does not change a built image, which is the next section.

The non-obvious cost of the layer model shows up the first time someone treats the slim base as a free size win. python:3.12-slim strips the common Debian packages down to what Python needs to run, which means the compilers, headers, and shared libraries a numeric wheel might link against are gone. A pure-Python scorer runs fine on it; a scorer whose dependency compiles against a system library will fail at install or at run time with a missing-shared-library error that never appeared on the full base. The slim base is the right default for an ML scorer precisely because most of the numeric stack ships as prebuilt wheels, but “slim” is a trade, not a free win, and Lesson 4 returns to exactly which files it drops and what that breaks.

Here is the code the image actually wraps, the scorer entry point. The reveal is that it is ordinary Python: it loads the model artifact and scores one record, identical to what ran locally in the classical-ML module. Docker changes where this runs, not what it does:

python
import json


# Stand-in for the loaded model artifact: a trained scorer with a fixed coefficient set.
# In the real image this is joblib.load("model.joblib") from the classical-ML lesson.
def load_model() -> dict[str, float]:
    return {"intercept": -1.2, "loan_amnt": 0.0000035, "int_rate": 0.09, "dti": 0.04}


def score_record(model: dict[str, float], record: dict[str, float]) -> float:
    z = model["intercept"]
    for feature, weight in model.items():
        if feature == "intercept":
            continue
        z += weight * record.get(feature, 0.0)
    return 1.0 / (1.0 + 2.718281828 ** (-z))  # logistic link -> probability of default


record: dict[str, float] = {"loan_amnt": 18000.0, "int_rate": 14.5, "dti": 22.0}
model = load_model()
prob = score_record(model, record)
print(f"default probability: {prob:.4f}")
print(json.dumps({"prediction": "charged_off" if prob >= 0.5 else "fully_paid"}))

One record in, one probability out. Nothing here is Docker-specific, and that is the point. The image will carry this exact function, its interpreter, and its libraries, so the same call produces the same number on a machine that has never seen the code. Why does the slim base change whether this runs, when the Python itself never touches the OS? Because the libraries this code imports might, and the image is what guarantees those libraries and their system dependencies travel with it.


Try It 1

A flat score.py runs locally: it imports joblib, loads model.joblib from the same directory, reads a record path from sys.argv, and prints a probability. Map its needs onto the six-line Dockerfile shape. Fill in which base belongs in FROM, where the model file gets copied, and what the container should run.

python
# You are not writing Python here -- you are deciding the Dockerfile lines.
# Return the instruction string for each slot. Replace the placeholders.


def dockerfile_for_local_scorer() -> list[str]:
    base = "python:???"  # which base for a numeric ML scorer?
    install = "RUN ???"  # install the locked dependency set
    bring_in_code = "COPY ???"  # the code AND the model.joblib artifact
    entry = 'ENTRYPOINT ["???"]'  # the one command the container runs
    return [
        f"FROM {base}",
        "WORKDIR /app",
        "COPY pyproject.toml uv.lock .",
        install,
        bring_in_code,
        entry,
    ]


for line in dockerfile_for_local_scorer():
    print(line)
Hint Re-read the annotated skeleton. The base for a numeric scorer is the one that ships glibc and prebuilt wheels, not the full toolchain and not Alpine. The model artifact is not source you edit; it travels in the same copy as the rest of the project files. The entrypoint is whatever you would type after `python` to run the scorer as a module.

Solution

Here is the mapping filled in. Watch the base, the copy step, and the entrypoint each land on the line the local scorer’s needs dictate:

python
def dockerfile_for_local_scorer() -> list[str]:
    return [
        "FROM python:3.12-slim",
        "WORKDIR /app",
        "COPY pyproject.toml uv.lock .",
        "RUN uv sync --frozen --no-dev",
        "COPY . .",  # brings code AND model.joblib into the image
        'ENTRYPOINT ["python", "-m", "score"]',
    ]


for line in dockerfile_for_local_scorer():
    print(line)

The model artifact rides in on COPY . . alongside the code. There is no separate “copy the model” step, because the model file is one more file in the project directory. The slim Debian base carries glibc, so the prebuilt numeric wheels install without a compiler, and ENTRYPOINT fixes the command so the next section can run the image with one word.

The skeleton is the resting structure of an image. What it does not show is that build and run are two separate acts at two different times, which is exactly where the first real confusion lives.

Build it and run it — the build is a script, the run is a process

The wrong model here is reasonable: “I edited the code, so the container has the new code.” It feels true because on the host, editing a file changes the file. Inside Docker it is false, and the falseness is silent: the container runs, prints output, and the output is stale. Here is the failure as a sequence, marked as a demonstration so nothing runs it:

$ docker build -t loan-scorer .       # build #1 — bakes score.py v1 into the image
$ docker run loan-scorer --record row.json
default probability: 0.6100           # v1 behaviour

# edit score.py — fix a scoring bug — then run WITHOUT rebuilding:
$ docker run loan-scorer --record row.json
default probability: 0.6100           # STALE: still v1, the fix is on disk but not in the image

The fix is on disk. The container prints the old number. On a two-person team touching the scorer, this is roughly ten minutes lost to “I did fix it” before someone realizes the run replayed the last built image, not the current source. The root cause is that docker build and docker run are different commands at different times against different things.

docker build executes the Dockerfile once, top to bottom. Each instruction runs, and the resulting filesystem diff is committed as a read-only layer; the output is an immutable image. docker run does not re-read the Dockerfile or the source; it casts a container from the already-built image and executes its ENTRYPOINT. Build is a script that runs once to produce an artifact; run is a process that starts from that artifact every time. The image carries its own copy of everything, which is precisely what makes it portable, and also what makes a host-side edit invisible until the next build. The host’s score.py and the image’s copy of score.py are unrelated bytes after the build commits.

What happens to writes is the mechanism underneath. When docker run starts a container, it adds one fresh writable layer on top of the read-only image stack: “you add a new writable layer on top of the underlying layers,” called the container layer. Every change the running process makes goes there. Reads use copy-on-write: when the process modifies a file that lives in a lower read-only layer, the file is first copied up to the writable layer and the edit happens to the copy, so the read-only copy in the lower layer is never touched. The image bytes are immutable by construction. When the container is removed, “the writable layer is also deleted” and “the underlying image remains unchanged,” so a container’s changes vanish on removal, and the image is exactly what it was after the build.

Two consequences fall out of that, and they are the source of most early Docker confusion. The first is that editing score.py on the host changes neither the image nor a running container: the image is a frozen copy from build time, and the running container reads from that copy through copy-on-write, so the only way the edit enters the image is a rebuild. The second follows from the same writable-layer model. Anything a container writes, whether a scratch file, a downloaded artifact, or an in-place edit, is gone when the container is removed, because the writable layer that held it is discarded and the read-only image underneath is untouched. The image is the durable thing; the container is disposable, and both facts come from the same separation between the read-only stack and the writable cap.

The cost this hides is reproducibility theatre. Because the build caches each layer (Lesson 2’s subject), a rebuild that hits the cache looks identical to a real rebuild, same output and same image tag, while the layer underneath may have been resolved on a different day against a different package index. The build that trained the model and a teammate’s build can disagree while both report success. This lesson does not fix that; it only establishes the boundary: run replays the last build, and “it builds” is not yet “it builds the same.” Hold that thread; it is the entire opening of the next lesson.

A version marker inside the scorer makes the staleness visible instead of inferred. Print a marker string the build bakes in, and a stale run prints the old marker even after the source changes:

python
# This string is "baked into the image" at build time. A real rebuild changes it;
# a `docker run` without a rebuild keeps printing the old one.
BUILD_MARKER = "score.py v1"


def score(record: dict[str, float]) -> float:
    # v1 logic -- a deliberate bug we will "fix" on the host without rebuilding
    return 0.61


record: dict[str, float] = {"loan_amnt": 18000.0}

# Simulate: host edit fixes the bug to return 0.42, but the IMAGE still holds v1.
host_source_after_edit = "score.py v2 (returns 0.42)"
print(f"host file now says: {host_source_after_edit}")
print(f"running container still reports: {BUILD_MARKER} -> {score(record):.2f}")
print("the run replayed the last BUILT image, not the edited host source")

The host source and the image’s copy have diverged, and the run prints the image’s version because that is the only version a container ever sees. Why does printing a build marker, rather than the scored probability, prove the staleness more cleanly? Because the marker changes only on a rebuild and never on a host edit, so it isolates “did the image change” from “did the score change”: a probability could coincidentally match across versions, but the marker cannot.


Try It 2

Here is a sequence of commands. For each docker run, predict which version of the scorer it executes, and mark which run prints stale output. The starter tracks image state and host state separately; fill in what each run sees.

python
# Track two separate things: what the IMAGE holds, and what the HOST file says.
# Only `build` copies host -> image. `run` reads the image. Fill in the reads.

image_holds: str | None = None  # set by build
host_says = "v1"

events = ["build", "run", "edit->v2", "run", "build", "run"]
log: list[str] = []

for ev in events:
    if ev == "build":
        image_holds = host_says  # build snapshots the host into the image
        log.append(f"build: image now holds {image_holds}")
    elif ev.startswith("edit"):
        host_says = ev.split("->")[1]  # host file changes, image does NOT
        log.append(f"edit: host now says {host_says}")
    elif ev == "run":
        runs = "???"  # what does the container execute?
        stale = "???"  # is it stale vs the host?
        log.append(f"run: executes {runs} (stale={stale})")

print("\n".join(log))
Hint A run never reads the host file; it only ever executes what the last build snapshotted into the image. Compare `image_holds` against `host_says` at each run: if the image is behind the host, the output is stale. Re-read the section on which command copies host source into the image.

Solution

Here is the sequence resolved. Watch each run report what the image holds, not what the host has edited since the last build:

python
image_holds: str | None = None
host_says = "v1"

events = ["build", "run", "edit->v2", "run", "build", "run"]
log: list[str] = []

for ev in events:
    if ev == "build":
        image_holds = host_says
        log.append(f"build: image now holds {image_holds}")
    elif ev.startswith("edit"):
        host_says = ev.split("->")[1]
        log.append(f"edit: host now says {host_says}")
    elif ev == "run":
        runs = image_holds
        stale = runs != host_says
        log.append(f"run: executes {runs} (stale={stale})")

print("\n".join(log))

The second run is the stale one: the host moved to v2 but no build happened between the edit and the run, so the container still executes the v1 the image holds. The third run is correct only because the second build snapshotted v2 into the image first. The rule the sequence teaches is mechanical: a run reflects a code change only after a build commits that change into a new immutable image.

The image now builds and runs the right code. The last piece of a deliverable is making it score a record with one word at the call site, and that turns on the difference between two Dockerfile instructions that look interchangeable and are not.

Score a record with one command

The reasonable-looking choice is to put the whole command in CMD: CMD ["python", "-m", "score", "--record", "default.json"]. It runs. The trap is what happens when someone passes an argument. A container meant to score a record instead drops into a Python shell the moment an argument is passed, because the passed argument replaces the entire CMD instead of being fed to it. Here is the shape of that incident, marked as a demonstration:

# Dockerfile used: CMD ["python", "-m", "score", "--record", "default.json"]
$ docker run loan-scorer
default probability: 0.42          # fine — no argument, CMD runs as written

$ docker run loan-scorer python    # operator meant to pass an argument...
Python 3.12.4 (main) ...           # ...and the WHOLE command became `python` — a shell
>>>                                # the scorer never ran

The argument did not feed the scorer; it overwrote it. “CMD will be overridden when running the container with alternative arguments”: a passed argument replaces CMD wholesale. That is the wrong tool for a fixed program with variable input.

ENTRYPOINT and CMD divide the responsibility instead of collapsing it. ENTRYPOINT defines the executable the container always runs, the program. “Command line arguments to docker run <image> will be appended after all elements in an exec form ENTRYPOINT,” so they become input to the program, not a replacement for it. CMD, when an ENTRYPOINT is present, supplies the default arguments that a docker run argument overrides. The convention is exact: ENTRYPOINT for the program, CMD for the default args. With ENTRYPOINT ["python", "-m", "score"] and CMD ["--record", "default.json"], a bare docker run loan-scorer scores the default record, and docker run loan-scorer --record other.json scores a different one: the program never changes, only its input does.

Collapsing both into CMD removes that separation and is exactly why the stray argument dropped into a shell. The non-obvious cost of getting this wrong is that it fails open, not closed. The container does something, whether it runs a shell, prints a usage error, or scores the wrong default, rather than refusing, so a misconfigured run looks like a bug in the scorer rather than a bug in the invocation. Putting the program in ENTRYPOINT makes the one-command contract stable: there is no argument a caller can pass that turns the scorer into something else.

The scorer reads its record path from an argument and prints one probability, so the call site needs no Python knowledge:

python
def load_model() -> dict[str, float]:
    return {"intercept": -1.2, "loan_amnt": 0.0000035, "int_rate": 0.09, "dti": 0.04}


def score_record(model: dict[str, float], record: dict[str, float]) -> float:
    z = model["intercept"] + sum(
        w * record.get(f, 0.0) for f, w in model.items() if f != "intercept"
    )
    return 1.0 / (1.0 + 2.718281828 ** (-z))


def main(argv: list[str]) -> None:
    # ENTRYPOINT is ["python", "-m", "score"]; argv here is whatever followed the image name.
    record_path = argv[2] if len(argv) > 2 else "default.json"
    # Stand-in for json.load(open(record_path)); the path arrived as an appended argument.
    record = {"loan_amnt": 18000.0, "int_rate": 14.5, "dti": 22.0}
    prob = score_record(load_model(), record)
    print(f"record={record_path} default_probability={prob:.4f}")


main(["score", "--record", "row.json"])

One command in, one probability out, and the record path arrives as an appended argument exactly as ENTRYPOINT plus a run-time argument delivers it. Why does reading the path from the argument list rather than hardcoding it inside score.py matter for the one-command contract? Because the program stays fixed in ENTRYPOINT while the input varies per run, so the same image scores any record without a rebuild, which is the whole point of shipping one artifact.


Try It 3

A teammate wrote CMD ["python", "-m", "score", "--record", "default.json"] and reports that docker run img bash opens a shell instead of scoring. Split the command correctly: decide what belongs in ENTRYPOINT and what belongs in CMD, and predict what docker run img --record other.json then does.

python
# Return the two instruction strings and predict the run behaviour.


def fix_entry_and_cmd() -> dict[str, str]:
    entrypoint = 'ENTRYPOINT ["???"]'  # the program that always runs
    cmd = 'CMD ["???"]'  # default ARGS only, overridable
    behaviour = "???"  # what does `docker run img --record other.json` run?
    return {"entrypoint": entrypoint, "cmd": cmd, "behaviour": behaviour}


for k, v in fix_entry_and_cmd().items():
    print(f"{k}: {v}")
Hint The program is the part that must never be replaceable by a passed argument; that goes in `ENTRYPOINT`. The default record path is an argument the caller should be able to override; that goes in `CMD`. Re-read what an appended `docker run` argument does when an `ENTRYPOINT` is present versus when only a `CMD` is.

Solution

Here is the split done correctly. Watch the program settle into ENTRYPOINT and the default record into CMD, so a passed argument lands as input rather than a replacement:

python
def fix_entry_and_cmd() -> dict[str, str]:
    return {
        "entrypoint": 'ENTRYPOINT ["python", "-m", "score"]',
        "cmd": 'CMD ["--record", "default.json"]',
        "behaviour": "runs `python -m score --record other.json` -- args replace CMD, not ENTRYPOINT",
    }


for k, v in fix_entry_and_cmd().items():
    print(f"{k}: {v}")

With the program fixed in ENTRYPOINT, the passed --record other.json overrides the default CMD arguments and is appended to the scorer instead of replacing it, so the container always runs the scorer and treats the argument as input. The teammate’s docker run img bash no longer opens a shell: bash becomes an argument to python -m score, which is wrong input rather than a replaced program, and the contract holds.

The image now builds the right code, runs it as an immutable artifact, and scores a record with one command. That is the working deliverable the rest of the module hardens: each later lesson takes this exact image and fixes a real problem with it.


Summary

  • An image is the whole environment (interpreter, libraries, OS files) frozen into one read-only artifact built in layers, one filesystem diff per Dockerfile instruction; a container is one running instance of that image.
  • docker build runs the Dockerfile once to produce an immutable image; docker run casts a container from the last built image every time, so a host-side code edit is invisible until a rebuild, because the image carries its own frozen copy.
  • A running container adds one writable layer on top of the read-only stack and uses copy-on-write; that layer is discarded on removal, so the image is the durable thing and the container is disposable.
  • ENTRYPOINT fixes the program the container always runs and appends docker run arguments to it; CMD supplies overridable default arguments, so collapsing both into CMD lets a stray argument replace the whole command.
  • This image is the artifact every later lesson hardens: Lesson 2 makes the build reproducible, Lesson 3 makes it safe, Lesson 4 makes it slim.

Check your understanding:

  • Without looking back: you edit score.py, then run docker run loan-scorer and see the old output. What did you forget, and why does the run not see your edit?
  • Why does a RUN rm scratch.txt write inside a container have no effect on the image’s bytes, and where does a container’s write actually go?
  • A container drops into a Python shell whenever an argument is passed instead of scoring. Which instruction holds the command, and how do you fix it so an argument becomes input instead of a replacement?

This lesson is part of Pro

The Ship a Machine Learning Product path — every lesson, capstone, and the failure modes free tutorials skip. Sign in if you already have Pro, or unlock it below.

Unlock with Pro Sign in