Lesson 02 Dockerfile Design
Theme:

Solutions — Dockerfile Design

Lesson 02 · Cloud Infrastructure & DevOps

Solution to Task 1 — Hit a target size (under 80 MB)

The key is switching the runtime stage to Alpine while keeping a proper builder. Alpine’s Python image is much smaller than Debian slim because musl libc is lighter than glibc and Alpine ships no apt infrastructure.

# syntax=docker/dockerfile:1.7
FROM python:3.11-alpine AS builder

# Alpine doesn't ship build tools by default — install them before pip.
RUN apk add --no-cache gcc musl-dev libffi-dev

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-alpine
COPY --from=builder /install /usr/local
RUN adduser -D -u 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"]

Expected measurements:

Common mistake: forgetting to install build tools in the alpine builder. pydantic and some FastAPI deps have C extensions; on musl libc they may not have prebuilt wheels and pip falls back to building from source. apk add gcc musl-dev libffi-dev covers the common cases.

Important caveat: Alpine uses musl libc. A small fraction of Python packages (notably some compiled C extensions) ship glibc-only wheels. If you see “no matching distribution” or unexpected ImportError at runtime, slim is the safer default. For the URL-shortener, Alpine works fine.

Note the difference in adduser syntax: Alpine uses adduser -D -u 1000 app (BusyBox syntax), not Debian’s useradd --create-home --uid 1000 app.

Solution to Task 2 — Use uv instead of pip

Replace the builder’s pip install with uv. First install uv into the builder stage, then use it:

# syntax=docker/dockerfile:1.7
FROM python:3.11-slim AS builder

# Install uv (fast resolver + installer)
RUN pip install --no-cache-dir uv

WORKDIR /build
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system --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 app.py .
USER app
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

Typical timings for the URL-shortener with its 5 dependencies (your hardware will vary):

Final image size is identical to the pip version; uv just resolves and downloads the same wheels faster.

Common mistake: forgetting --system. Without it, uv detects that it’s not inside an active virtual environment and refuses to install into the system Python. The error message is: “uv requires a virtual environment to install packages”. Add --system to override.

Solution to Task 3 — Pin everything by digest

After running docker inspect python:3.11-slim --format '{{index .RepoDigests 0}}' you get a digest like python@sha256:abc123.... Use that in both FROM lines:

# syntax=docker/dockerfile:1.7
FROM python:3.11-slim@sha256:abc123...your-actual-digest... 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@sha256:abc123...your-actual-digest...
COPY --from=builder /install /usr/local
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"]

(a) Why pinning by digest matters:

Pinning by digest means the same Dockerfile always pulls the exact same bytes, regardless of when or where you build it. A tag like python:3.11-slim is a mutable pointer that the registry can repoint at any time — security patches, breaking minor-version updates, even (rarely) malicious replacements via typosquatting or registry compromise. A digest is content-addressed: if the bytes change, the digest changes, and your build either pulls identical bytes or fails loudly with a “manifest unknown” error. You cannot silently get a different image.

(b) Keeping the pinned digest fresh:

A bot like Dependabot or Renovate watches for new digests published under the same tag (python:3.11-slim) and automatically opens a pull request bumping the digest in your Dockerfile. Your CI pipeline rebuilds the image against the new digest, runs tests, and merges if green. Separately, docker scout cves <image> (or Trivy in Lesson 05) flags new CVEs in the currently-pinned digest, giving you a signal to rotate it proactively rather than reactively. The combination gives you “always current, never surprised” base images.

Common mistake: pinning the builder stage by digest but leaving the runtime stage as a floating tag. Both stages must be pinned or a registry pointer move on the runtime tag can still introduce a different image between builds. If you’re pinning, pin everything.

Closing thought: Lesson 03 stops thinking about a single image and starts thinking about a system — api + worker + Postgres + Redis, all wired together with docker-compose.