Lesson 02 Dockerfile Design
Theme:

Lab Guide — Multi-Stage Build & Size Optimization

Lesson 02 · Cloud Infrastructure & DevOps · ~45 min

Prerequisites

Step 1 — Baseline: measure the L01 image

Before touching anything, record the size you’re trying to beat:

docker images url-api:0.1 --format "{{.Size}}"
# expected: ~180MB

docker history url-api:0.1 --format "{{.Size}}\t{{.CreatedBy}}" | head

The docker history output lets you see which layers are large. You should see the pip-install layer accounting for most of the non-base size.

Step 2 — Write the multi-stage Dockerfile

Replace cloud-devops/sample-app/api/Dockerfile with the following (it has already been updated in the repo for this lesson — verify it matches):

# syntax=docker/dockerfile:1.7
# Lesson 02: multi-stage build with BuildKit cache mount.

# --- Stage 1: builder ---
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --prefix=/install -r requirements.txt

# --- Stage 2: runtime ---
FROM python:3.11-slim

# Bring just the installed packages forward.
COPY --from=builder /install /usr/local

# Non-root user (from L01 homework).
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY --chown=app:app app.py .
USER app

EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Key points:

Step 3 — Add .dockerignore

Create cloud-devops/sample-app/api/.dockerignore (already in the repo for this lesson):

__pycache__/
*.pyc
.venv/
venv/
.git/
.gitignore
.pytest_cache/
*.md
tests/
*.log
.env
README.md

Verify the build context is small before building:

du -sh cloud-devops/sample-app/api/
# Should be a few KB — just app.py, requirements.txt, Dockerfile, .dockerignore

Step 4 — Build and measure

docker build -t url-api:0.2 cloud-devops/sample-app/api
docker images url-api:0.2 --format "{{.Size}}"
# expect ~90-100MB (was ~180MB)

Compare old and new side by side:

docker images url-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"

Expected: 0.2 is approximately 50% smaller than 0.1.

Step 5 — Prove the cache mount works

Touch requirements.txt (add a trailing comment) and rebuild to a new tag:

echo "# cache test" >> cloud-devops/sample-app/api/requirements.txt
docker build -t url-api:0.3 cloud-devops/sample-app/api

Observe: the pip install layer still re-runs (the requirements file changed, busting the layer cache), but it completes in seconds because the wheels are already in the BuildKit cache mount. First cold build: ~30 s. This rebuild: ~3 s.

Inspect the BuildKit cache:

docker builder du
# Should show several hundred MB of cached pip wheels

Revert the test change:

cd cloud-devops/sample-app/api && git checkout requirements.txt && cd -

Step 6 — Verify the container still works

docker run --rm -d -p 8000:8000 --name url-api url-api:0.2
curl -s localhost:8000/health
# expected: {"status":"ok"}

docker exec url-api id
# expected: uid=1000(app) gid=1000(app) groups=1000(app)

docker stop url-api

The API works identically to the L01 version, just from a smaller image running as a non-root user.

Step 7 — Add a Dockerfile for the worker

The worker needs its own Dockerfile so L03 can compose both services. cloud-devops/sample-app/worker/Dockerfile has been added to the repo with the same multi-stage pattern:

# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --prefix=/install -r requirements.txt

FROM python:3.11-slim
COPY --from=builder /install /usr/local
RUN useradd --create-home --uid 1000 app
WORKDIR /app
COPY --chown=app:app worker.py .
USER app
CMD ["python", "worker.py"]

Build and verify it:

docker build -t url-worker:0.2 cloud-devops/sample-app/worker
docker run --rm url-worker:0.2
# expected: worker loop output, e.g. "sweep tick: 0 records expired"
# Ctrl-C to stop

Step 8 — Try distroless (optional)

For bonus size reduction, try a distroless final stage. Create a scratch file Dockerfile.distroless in cloud-devops/sample-app/api/:

# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --target=/install -r requirements.txt

FROM gcr.io/distroless/python3-debian12
COPY --from=builder /install /pkgs
ENV PYTHONPATH=/pkgs
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Note: we switch to --target=/install (not --prefix) because distroless doesn’t have /usr/local/lib/python3.X/site-packages in its default sys.path. We set PYTHONPATH instead.

docker build -f cloud-devops/sample-app/api/Dockerfile.distroless \
  -t url-api:0.4-distroless cloud-devops/sample-app/api

docker images url-api --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"
# expect ~50-60MB for the distroless tag vs ~95MB for 0.2

Verify it runs:

docker run --rm -d -p 8000:8000 --name url-api-dl url-api:0.4-distroless
curl -s localhost:8000/health
docker stop url-api-dl

Try (and observe failure):

docker exec url-api-dl /bin/sh
# Error: no such file or directory — there is no shell in distroless

Use Python instead:

docker exec url-api-dl python -c "import os; print(os.listdir('/app'))"

What you should leave with

Troubleshooting