Lab Guide — Multi-Stage Build & Size Optimization
Lesson 02 · Cloud Infrastructure & DevOps · ~45 min
Prerequisites
- Docker Desktop 24+ (BuildKit enabled by default; set
DOCKER_BUILDKIT=1if you’re unsure) - The L01 image
url-api:0.1still in your local registry — or rebuild it from the L01 lab if needed - A shell with
du -shavailable (macOS / Linux / WSL) - The repo cloned with
cloud-devops/sample-app/api/present
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:
- The
# syntax=...pragma on line 1 enables BuildKit syntax; it must be line 1. AS buildernames Stage 1 so Stage 2 can reference it withCOPY --from=builder.--prefix=/installkeeps all wheels in one directory; Stage 2 copies that directory to/usr/localwhere Python already looks for packages.- The non-root user (
useradd+USER app) is a security best practice you saw in L01’s homework — it’s now standard in the Dockerfile.
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
cloud-devops/sample-app/api/Dockerfileis multi-stage; imageurl-api:0.2is under 100 MBcloud-devops/sample-app/api/.dockerignoreexists and excludes dev artefactscloud-devops/sample-app/worker/Dockerfilefollows the same pattern- First-hand experience of the BuildKit cache mount speeding up rebuilds
- Optionally: a distroless variant at ~50 MB with no shell
Troubleshooting
--mount=type=cachesyntax error — the# syntax=docker/dockerfile:1.7header is missing or not on line 1, orDOCKER_BUILDKIT=0is set in your environment.- Distroless:
python: command not found— distroless usesENTRYPOINT ["/usr/bin/python3"]. YourCMDis the arguments to Python. UseCMD ["-m", "uvicorn", ...]notCMD ["python", "-m", "uvicorn", ...]. COPY --from=builder /install /usr/localcauses weird errors —/usr/localalready exists in the slim base. Files are merged, not overwritten as a directory. If you seecommand not foundfor uvicorn, check you copied to/usr/localand not/usr.uv pip installfails with “no virtual environment” — add--systemflag; without it uv refuses to install into the system Python.- Image size didn’t drop much — the URL-shortener has lightweight deps (fastapi, uvicorn, pydantic) that ship as pure-Python wheels with no C-extension compilation. Multi-stage is also about structure (isolation between build and runtime) not just bytes. The size win is larger on projects with compiled deps (numpy, cryptography, psycopg2).