The prediction service works on your machine, and that sentence should put you on guard: your machine has Python 3.11.9, the exact dependencies from requirements.lock, the package installed in editable mode, and the environment variables set just right. The production server has none of that, and reproducing it by hand is fragile and unauditable. Docker solves the problem at the root: it freezes operating system + Python + libraries + code into an immutable image that runs identically on your laptop, on the staging server, and on the production cluster. In this lesson we'll write the real Dockerfile for CineClick's service — multi-stage, non-root, explained line by line —, make an important design decision (model inside the image, or downloaded from the registry at startup?), build and tag the image, and bring up API + MLflow together with docker compose.

Contents

  1. Why containers for ML
  2. Minimal concepts: image, container, layer, registry
  3. The CineClick service Dockerfile, line by line
  4. .dockerignore: what does NOT go into the image
  5. Design decision: model in the image, or from the registry?
  6. Build, tag, and run
  7. docker compose: API + MLflow locally
  8. Good practices for ML images

Why containers for ML

An ML model is especially sensitive to its environment: the prediction of a RandomForest serialized with scikit-learn 1.5.2 can fail (or, worse, change silently) if it's deserialized with another version. In module 2 we froze the dependencies with pip-tools; the container takes the final step by also freezing what the lock doesn't cover:

  • The exact Python version (3.11.9, not "whatever 3.11 the server happens to have").
  • The system libraries (glibc, the libgomp scikit-learn uses to parallelize, time zones, certificates).
  • The base operating system itself and its configuration.
  • The code and its installation: inside the image the package is installed in one specific, immutable way.

The result is that the deployment unit stops being "some code + some installation instructions" and becomes a verifiable binary artifact: the cineclick/churn-api:0.1.0 image that passed the tests is, byte for byte, the same one that will reach production. "Works on my machine" is definitively solved because your machine travels with the service.

Minimal concepts: image, container, layer, registry

We assume the basic Docker notions from the course prerequisites; let's just pin down the vocabulary we'll use:

Concept What it is Analogy
Image Immutable template with OS + dependencies + code. Built once. The class
Container A running process created from an image. There can be N of the same one. The instance
Layer Each Dockerfile instruction creates a cacheable layer. If it doesn't change, it isn't rebuilt. Stacked commits
Image registry Remote store of versioned images (Docker Hub, GHCR, ECR...). push/pull. The "GitHub" of images

Watch out for a name collision that confuses everyone: the image registry (where cineclick/churn-api:0.1.0 lives) has nothing to do with MLflow's model registry (where churn-cineclick v2 lives). They are two different artifact stores with different life cycles: the image changes when the code or the environment changes; the model version changes when the training changes. Keeping that separation is precisely the design decision we'll discuss in section 5.

The CineClick service Dockerfile, line by line

The Dockerfile lives at the root of the cineclick-churn repo. We use multi-stage: a builder stage with the build tooling installs the dependencies, and a minimal runtime stage keeps only the result.

# ---------- Stage 1: builder ----------
FROM python:3.11.9-slim AS builder

WORKDIR /app

# First ONLY the dependency files: if they don't change, this layer stays cached
COPY requirements.lock .
RUN pip install --no-cache-dir --prefix=/install -r requirements.lock

# Now the package code, which changes more often (its own layer)
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir --prefix=/install --no-deps .

# ---------- Stage 2: runtime ----------
FROM python:3.11.9-slim AS runtime

# Unprivileged user: if the process is compromised, they're not root
RUN groupadd --gid 1000 cineclick && \
    useradd --uid 1000 --gid 1000 --create-home cineclick

# Only what the builder installed; no pip-tools, no caches, no loose code
COPY --from=builder /install /usr/local

USER cineclick
WORKDIR /home/cineclick

# Documents the service port (uvicorn will listen here)
EXPOSE 8000

# The healthcheck from 04-02 put to work: Docker will mark the container
# unhealthy if /health doesn't answer 200
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["uvicorn", "cineclick_churn.api.main:app", "--host", "0.0.0.0", "--port", "8000"]

Line-by-line explanation:

  • FROM python:3.11.9-slim: the project's exact version (pinned in module 2), slim variant (~150 MB versus ~1 GB for the full image). Never python:latest: an image that changes under your feet is the antithesis of reproducibility.
  • COPY requirements.lock before the code: the ordering exploits the layer cache. Dependencies change rarely; code, every day. With this order, a change in main.py rebuilds in seconds because the (slow) pip install -r requirements.lock layer stays cached.
  • --prefix=/install: installs everything into a clean directory that we later copy wholesale into the runtime stage. It's the central trick of multi-stage.
  • pip install --no-deps .: installs the cineclick_churn package (the real thing, not editable — -e is for development) without re-resolving dependencies: they're all already pinned by the lock. Here features.py and the API travel together, as we decided in 04-02.
  • COPY --from=builder /install /usr/local: the runtime stage receives only the result. Build tooling, pip's cache, and intermediate files stay behind in the builder stage, which gets discarded. Result: a final image of about 380 MB (versus >1 GB without multi-stage), and less attack surface.
  • USER cineclick: the process runs as an unprivileged user. It's one of the cheapest security practices in existence, and many clusters outright reject root containers.
  • EXPOSE 8000 is documentation (the real mapping happens in docker run -p), but documentation that tools and humans read.
  • HEALTHCHECK calls 04-02's GET /health every 30 s. --start-period=20s gives the lifespan room to download the model from the registry before health starts being demanded. We use urllib from the standard library to avoid installing curl in the image.
  • CMD in exec form (a JSON list): uvicorn receives system signals directly (a docker stop performs a clean shutdown, not a kill after 10 seconds).
  • Notice what is not here: no copy of the model, and no secrets. The first is the section 5 decision; the second, an absolute rule (section 8).

.dockerignore: what does NOT go into the image

COPY src/ src/ copies what you tell it to, but the build context Docker packs up and sends to the daemon is, by default, the entire directory. Without .dockerignore, CineClick's build would drag along gigabytes of data and experiments:

# .dockerignore
.git/
.venv/
data/            # data is managed by DVC, not the image
models/          # local training artifacts
mlruns/          # local MLflow experiments
.dvc/cache/
tests/
notebooks/
__pycache__/
*.pyc
.env             # local secrets NEVER in the build context!

The first three blocks aren't just about weight: data/ and mlruns/ may contain information that must not travel inside an image pushed to a shared registry. .env would flat-out be a credential leak. Mental rule: the image contains the service, not the project.

Design decision: model in the image, or from the registry?

There are two ways for the container to have the model, and the choice shapes the whole deployment flow:

Option A — "Bake" the model into the image: during the build, a RUN downloads the model and copies it inside (COPY model/ /app/model/). The image is self-sufficient.

Option B — Download from the registry at startup: the image carries only code; 04-02's lifespan downloads models:/churn-cineclick@champion using MLFLOW_TRACKING_URI as an environment variable.

Criterion A: model in the image B: from the registry at startup
Self-sufficiency Total: runs without registry or network Needs the registry reachable on every startup
Startup Fast (model already inside) Slower (download; seconds for our RF)
New model ⇒ Rebuild + push + redeploy of the image Move the alias + restart containers; same image
Traceability The image tag pins code AND model together /version says which model each startup loaded
Model governance Decided at build time (image pipeline) Decided in the registry (03-02 flow: aliases, human review)
Characteristic risk Images proliferate per code×model combination Registry down ⇒ new startups fail (exercise 3 of 04-02)
Fits when... Edge/air-gapped, no registry at runtime, critical startup A governed registry exists and the model changes more often than the code

CineClick chooses option B, and the reasoning matters more than the choice: in module 3 we built a model governance flow — versions, aliases, promotion with human review, rollback by moving @champion. Baking the model into the image would hollow out that flow: every promotion would require rebuilding and redistributing an image, and rollback would stop being "move an alias" and become "redeploy the previous image". With option B, image and model evolve separately: cineclick/churn-api:0.1.0 is the same whether the champion is v2 or, tomorrow, v3, and the registry remains the single point of governance. The price — a registry dependency at startup — is mitigated by already-running replicas (04-04) and is an accepted, documented risk.

Option A stays on record as a valid alternative for specific contexts (a deployment on a partner's set-top box, an environment with no network egress): if it's ever needed, it's an extra stage in the Dockerfile, not a redesign.

Build, tag, and run

# Build and tag: name/service:semantic-version
docker build -t cineclick/churn-api:0.1.0 .

# A floating tag for "the latest stable thing locally" is also handy
docker tag cineclick/churn-api:0.1.0 cineclick/churn-api:latest

The semantic tag 0.1.0 (matching the service's app.version and the package version in pyproject.toml) is the identifier the Kubernetes manifests will cite in 04-04 and CD will cite in 05-02. latest is convenient locally but never gets deployed to production: you wouldn't know what's running.

Run the container:

docker run --rm -p 8000:8000 \
  -e MLFLOW_TRACKING_URI=http://host.docker.internal:5000 \
  -e CHURN_THRESHOLD=0.5 \
  --name churn-api \
  cineclick/churn-api:0.1.0
  • -p 8000:8000 publishes the container's port on your machine.
  • -e MLFLOW_TRACKING_URI=... is the option B variable: the same container points at one MLflow or another depending on the environment, without touching the image. host.docker.internal is how a container sees your host machine (where module 3's local MLflow runs).
  • Check health: docker ps will show (healthy) once the start-period passes, and curl http://localhost:8000/version must answer "model_version": "2" — proof that the container downloaded the champion from the registry.

docker compose: API + MLflow locally

Starting two terminals by hand every time is friction. docker compose declares the ensemble:

# compose.yaml
services:
  mlflow:
    image: ghcr.io/mlflow/mlflow:v2.18.0
    command: >
      mlflow server
      --backend-store-uri sqlite:///mlflow/mlflow.db
      --artifacts-destination /mlflow/artifacts
      --host 0.0.0.0 --port 5000
    ports:
      - "5000:5000"
    volumes:
      - ./mlflow_data:/mlflow    # persistence: sqlite and artifacts survive the container

  api:
    image: cineclick/churn-api:0.1.0
    ports:
      - "8000:8000"
    environment:
      MLFLOW_TRACKING_URI: http://mlflow:5000   # compose's internal DNS: the service is named 'mlflow'
      CHURN_THRESHOLD: "0.5"
    depends_on:
      mlflow:
        condition: service_started
docker compose up -d       # brings both up
docker compose logs -f api # watch the lifespan loading the model
docker compose down        # shut everything down

Details worth attention:

  • Inside the compose network, the API reaches MLflow by its service name (http://mlflow:5000) — no localhost, which inside a container means "myself".
  • The ./mlflow_data volume makes the local registry persistent; without it, every down would wipe the registered models. (For this MLflow to contain churn-cineclick v1/v2 you'll have to point your training MLFLOW_TRACKING_URI here and re-register, or copy your mlflow.db and artifacts into mlflow_data/.)
  • depends_on orders the startup but doesn't wait for MLflow to be ready: if the API starts before MLflow responds, it will fail (fail fast, 04-02) and a docker compose restart api will do. In production this dance is handled by Kubernetes probes (04-04).

This compose is the team's integrated development environment: Marc clones the repo, runs docker compose up, and has the same system as Laura, without installing so much as Python.

Good practices for ML images

Practice Why At CineClick
slim base + multi-stage Fewer MB = faster pulls, less attack surface 380 MB versus >1 GB
Pin everything: exact base version, deps via lock A build reproducible today and a year from now python:3.11.9-slim + requirements.lock
Dependency layer before the code layer Rebuilds in seconds day to day COPY requirements.lock first
Non-root user Containment if the process gets compromised USER cineclick
Zero secrets in the image Anyone with access to the image registry would read them (docker history exposes ENVs) Credentials only via runtime variables/secrets
No data or experiments inside Weight, and possible information leaks .dockerignore: data/, mlruns/, .env
Semantic tag, never deploy latest Knowing what runs and being able to roll back cineclick/churn-api:0.1.0
HEALTHCHECK against a real endpoint So "alive" means "able to predict" GET /health verifies the model in memory
CMD in exec form Clean shutdown on signals CMD ["uvicorn", ...]
One image for all environments What's tested is what's deployed; the environment enters via variables MLFLOW_TRACKING_URI per environment

Common Mistakes and Tips

  • Mistake: FROM python:latest or unpinned dependencies. Today's build and next month's would produce different services from the same Dockerfile. Pin everything: base, lock, package version.
  • Mistake: copying the whole project (COPY . .) without a .dockerignore. A giant build context, data and secrets inside the image, and any change to any file invalidates the cache. Copy only what the service needs, in layers ordered by change frequency.
  • Mistake: confusing the two registries. "Push the model to the registry" is ambiguous in an MLOps conversation. Image → image registry (docker push); model → MLflow model registry. Separate life cycles, on purpose.
  • Mistake: putting credentials in with ENV or by copying .env. They're baked into the layers forever (docker history shows them). Secrets enter at runtime: -e, mounted files, or Kubernetes Secrets (04-04).
  • Mistake: testing the image with your venv activated and believing you tested the image. Test against the container (docker run + curl), which is what will go to production; your venv is out of the picture now.
  • Tip: look at the size (docker images) after every Dockerfile change. An unexpected jump of hundreds of MB usually betrays data that slipped in or a badly ordered layer.
  • Tip: keep compose as the canonical development environment. "Clone and docker compose up" is the onboarding Laura would have loved on her first day.

Exercises

Exercise 1

Without looking at the table: a colleague proposes "putting the model in the image, so we don't depend on MLflow at startup". Write the reasoned answer for CineClick (what's gained, what's lost, why the team decided otherwise) and describe ONE scenario in which you would agree with them.

Exercise 2

The build takes 4 minutes every time Laura changes a line of main.py. Reviewing her Dockerfile you find:

FROM python:3.11.9-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.lock && pip install --no-deps .
CMD ["uvicorn", "cineclick_churn.api.main:app", "--host", "0.0.0.0", "--port", "8000"]

Identify the problems (there are at least three) and rewrite it.

Exercise 3

With compose up, run the container's full verification sequence and note what each step checks: docker compose ps, curl http://localhost:8000/health, curl http://localhost:8000/version, and a prediction with the CLI-04217 customer from the previous lesson. What would you look at if /version returned "model_version": "1"?

Solutions

Solution 1

Answer to the colleague: baking the model in buys self-sufficiency (runs without a registry, faster startup) but breaks the governance built in module 3 — every model promotion would come to require an image rebuild + push + redeploy, and rollback would stop being "move @champion" (seconds, reversible, audited in the registry) and become a redeployment. It would also couple two life cycles that change at different paces at CineClick: the model will be retrained far more often than the service code. The chosen option's risk (registry down ⇒ new startups fail) is mitigated by the already-running replicas and accepted in writing. Scenario where the colleague would be right: an edge or air-gapped deployment — for example, serving the model on a partner device with no connectivity to CineClick's infrastructure — where no registry is reachable at runtime; there, the self-sufficient image is the only viable option.

Solution 2

Problems: (1) COPY . . at the top — any code change invalidates the next layer, which reinstalls ALL the dependencies: that's the 4 minutes; besides, with no .dockerignore in sight, it drags in data/, mlruns/, and possibly secrets. (2) No multi-stage: the final image is loaded with pip's cache and unnecessary project files. (3) It runs as root, with no HEALTHCHECK and no EXPOSE. Rewrite: exactly the lesson's Dockerfile — COPY requirements.lock + pip install as the first layer (cached while the lock doesn't change), code afterwards, multi-stage with --prefix=/install, USER cineclick, EXPOSE 8000, and a HEALTHCHECK against /health. With the cache properly ordered, a one-line change in main.py rebuilds in seconds.

Solution 3

  • docker compose ps: both services running and the API showing (healthy) — the HEALTHCHECK against /health passes.
  • curl /health{"status": "ok", ...}: the process is alive and has the model in memory (remember: our healthcheck doesn't lie).
  • curl /version{"service_version": "0.1.0", "model_uri": "models:/churn-cineclick@champion", "model_version": "2"}: image 0.1.0 running with champion v2 — full traceability of which code and which model are serving.
  • The POST to /predict with CLI-04217 must return the same probability (0.8412) as in 04-02: same model, same features, now inside the container — end-to-end reproducibility.

If /version said "1", the container loaded the wrong version: the first thing to check is which MLflow it points at (docker compose exec api env | grep MLFLOW) — most likely the MLFLOW_TRACKING_URI points at a registry (for example, the freshly created volume's empty sqlite) where the @champion alias points to another version or only v1 got re-registered. The alias lives in the registry, not in the image: you fix it there and restart the API.

Conclusion

CineClick's service is now a portable artifact: cineclick/churn-api:0.1.0, a ~380 MB multi-stage image that runs as an unprivileged user, declares its healthcheck, and contains no data, no secrets, and no model — because the model is governed where it should be, in the MLflow registry, and enters the container at startup via MLFLOW_TRACKING_URI. With docker compose, any team member brings up API + MLflow with one command, and what runs on their laptop is byte for byte what will run in production. But a lone container is not production: if the process dies at 3 a.m., nobody restarts it; if traffic triples on a premiere night, nobody adds replicas; if you deploy a broken version, nobody pulls it. That "somebody" is an orchestrator, and the next lesson introduces it: Kubernetes with the minimum an ML engineer needs — Deployments, Services, probes pointing at our /health, autoscaling — plus the serverless alternative for when maintaining a cluster doesn't pay.

© Copyright 2026. All rights reserved