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
- Why containers for ML
- Minimal concepts: image, container, layer, registry
- The CineClick service Dockerfile, line by line
- .dockerignore: what does NOT go into the image
- Design decision: model in the image, or from the registry?
- Build, tag, and run
- docker compose: API + MLflow locally
- 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),slimvariant (~150 MB versus ~1 GB for the full image). Neverpython:latest: an image that changes under your feet is the antithesis of reproducibility.COPY requirements.lockbefore the code: the ordering exploits the layer cache. Dependencies change rarely; code, every day. With this order, a change inmain.pyrebuilds in seconds because the (slow)pip install -r requirements.locklayer 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 thecineclick_churnpackage (the real thing, not editable —-eis for development) without re-resolving dependencies: they're all already pinned by the lock. Herefeatures.pyand 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 8000is documentation (the real mapping happens indocker run -p), but documentation that tools and humans read.HEALTHCHECKcalls 04-02'sGET /healthevery 30 s.--start-period=20sgives the lifespan room to download the model from the registry before health starts being demanded. We useurllibfrom the standard library to avoid installingcurlin the image.CMDin exec form (a JSON list): uvicorn receives system signals directly (adocker stopperforms 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:8000publishes 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.internalis how a container sees your host machine (where module 3's local MLflow runs).- Check health:
docker pswill show(healthy)once thestart-periodpasses, andcurl http://localhost:8000/versionmust 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_starteddocker 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) — nolocalhost, which inside a container means "myself". - The
./mlflow_datavolume makes the local registry persistent; without it, everydownwould wipe the registered models. (For this MLflow to containchurn-cineclickv1/v2 you'll have to point your trainingMLFLOW_TRACKING_URIhere and re-register, or copy yourmlflow.dband artifacts intomlflow_data/.) depends_onorders 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 adocker compose restart apiwill 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:latestor 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
ENVor by copying.env. They're baked into the layers forever (docker historyshows 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 servicesrunningand the API showing(healthy)— the HEALTHCHECK against/healthpasses.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
/predictwithCLI-04217must 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.
MLOps Course
Module 1: MLOps Fundamentals
- What MLOps is and why models die in the notebook
- The lifecycle of an ML model in production
- MLOps maturity levels and team roles
- The course project: from notebook to production
Module 2: From Notebook to Reproducible Code
- Structuring an ML project: from notebook to package
- Reproducible environments and dependency management
- Data versioning with DVC
- Reproducible training pipelines
Module 3: Experiments and Model Registry
- Experiment tracking with MLflow
- Model registry: versioning and promoting models
- Feature stores: when and what for
Module 4: Serving Models in Production
- Deployment patterns: batch, online and streaming
- A prediction service with FastAPI
- Packaging with Docker
- Scaling and deployment: Kubernetes and serverless
- Inference optimization: latency and cost
Module 5: Automation: CI/CD and Orchestration
- CI for ML: testing code, data and models
- CD: automating model deployment
- Orchestrating ML pipelines
- Release strategies: shadow, canary and A/B
