Docker and containers · Intermediate

A production-minded Dockerfile for a model service

3 min read

Why it matters

Containers appeared in 84% of the DevOps and 60% of the Software Engineer job descriptions we read. A container image bundles an application with exactly the runtime it needs, so it runs the same on a laptop, in CI and in production. Reviewers look closely at Dockerfiles: they reveal whether you think about security, build speed and reproducibility.

The concept

  • An image is a stack of read-only layers, one per Dockerfile instruction. A container is a running instance of an image.

  • Layers are cached. Put things that change rarely (base image, dependencies) before things that change often (your code), so rebuilds are fast.

  • Pin base images and dependency versions for reproducible builds.

  • Run as a non-root user: a compromised process then cannot easily take over the container.

  • Add a health check, so the runtime knows when the service is actually ready.

  • Keep secrets out of images; pass them at runtime.

Worked example: the propensity model API

The service and model come from Serve a model with FastAPI, and test it on the MLOps shelf (app.py, model.joblib, model.json).

requirements.txt
fastapi==0.142.1
uvicorn[standard]==0.37.0
scikit-learn==1.9.1
pandas==3.0.6
joblib==1.5.2
Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py model.joblib model.json ./
RUN useradd --create-home --uid 10001 appuser
USER appuser

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

A .dockerignore keeps the build context small and secrets out:

.dockerignore
.git
.venv
__pycache__
*.csv
.env
build-and-run.sh
docker build -t propensity:2026.09.1 .
docker run -d --name prop -p 8000:8000 propensity:2026.09.1
curl localhost:8000/health                  # {"status":"ok","model_version":"2026.09.1"}
docker inspect --format '{{.State.Health.Status}}' prop     # healthy, after the first check
docker exec prop whoami                     # appuser
docker image ls propensity                  # about 850 MB

Scoring the same customer as in the MLOps lesson returns 0.7146 from inside the container: the image reproduces the tested behaviour exactly.

About that size: roughly 850 MB, mostly pandas, NumPy, SciPy and scikit-learn. Options to shrink it: drop pandas if the service can score from plain arrays, export the model to ONNX and serve it with a small runtime, or use a multi-stage build that leaves compilers and caches behind. Measure with docker history propensity:2026.09.1 to see which layer is heavy before optimising.

Common mistakes

  • COPY . . before installing dependencies. Every code change reinstalls everything. Copy requirements first.

  • Running as root. The default user is root. Add a user and switch to it.

  • Unpinned latest tags. Builds change under you. Pin base images and packages.

  • Secrets in images, via COPY .env or ENV API_KEY=.... Anyone who can pull the image can read them. Use runtime environment variables or a secrets manager.

Interview angle

"How would you make this Docker image smaller and safer?" Answer with layer ordering and caching, slim or distroless bases, multi-stage builds, a non-root user, pinned versions, .dockerignore, image scanning (Trivy, Docker Scout) and no secrets in layers. Measure before and after.

Practice task

  1. Rewrite the Dockerfile as a multi-stage build that builds wheels in one stage and installs them in a clean final stage. Compare sizes.

  2. Scan the image with Trivy and fix one finding.

  3. Pass the model version as a build argument and label the image with it.

Further reading