Advanced builds & multi-arch
Make builds fast and portable: BuildKit and cache mounts that survive a clean checkout, multi-arch images for arm64 + amd64 with buildx, a layer-cache strategy that keeps CI under a minute, reproducible builds, and .dockerignore discipline that stops slow, leaky contexts.
Learning objectives
- Enable BuildKit and explain how its DAG differs from the legacy sequential builder.
- Use
--mount=type=cacheso package downloads survive across builds and CI runners. - Build multi-arch (arm64 + amd64) images with
docker buildxand a manifest list. - Design a layer-cache strategy and registry cache that keeps CI builds fast.
- Make builds reproducible and keep the build context small and clean with .dockerignore.
1 · BuildKit — the modern build engine advanced
The legacy builder runs instructions strictly top-to-bottom on one path. BuildKit parses the whole Dockerfile into a dependency graph, so independent stages build in parallel, unused stages are skipped, and it unlocks cache mounts, secret mounts (CD6), and SSH mounts. It is the default in modern Docker; older setups enable it with an env var.
enable-buildkit.sh# Modern Docker uses BuildKit by default. If yours doesn't:
export DOCKER_BUILDKIT=1
docker build -t myapp .
# To use cache/secret mounts, the FIRST line of the Dockerfile must be a syntax directive:
# # syntax=docker/dockerfile:1
# That line opts into the modern frontend that understands --mount=... .
2 · Cache mounts — stop re-downloading the world advanced
Layer caching helps only when a layer is unchanged. But a package manager's download cache is thrown away with the layer, so a single changed dependency re-downloads everything. A cache mount (--mount=type=cache) is a persistent directory shared across builds that is not part of any layer — pip, npm, apt, and Go all get dramatically faster incremental builds.
Dockerfile.cachemount# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
# apt cache mount: downloaded .debs persist across builds (not baked into a layer).
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends build-essential
COPY requirements.txt .
# pip cache mount: wheels persist, so changing ONE dep doesn't re-download all of them.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# Note: with a cache mount you can DROP --no-cache-dir — you WANT pip's cache kept.
cache_mount_model.pydef install_time(deps, changed, cache_mount):
"""Each dep costs download + build. A cache mount keeps downloads across builds."""
dl_per, build_per = 3.0, 0.5 # seconds
total = 0.0
for d in deps:
# without a cache mount, ANY change re-downloads every dep this layer
must_download = (d in changed) or (not cache_mount and changed)
total += (dl_per if must_download else 0.0) + build_per
return round(total, 1)
deps = [f"pkg{i}" for i in range(20)]
changed = {"pkg7"} # one dependency bumped
print("no cache mount:", install_time(deps, changed, cache_mount=False), "s")
print("with cache mount:", install_time(deps, changed, cache_mount=True), "s")
no cache mount: 70.0 s
with cache mount: 13.0 s
3 · Multi-stage refinements — targets, deps stages, cache-only stages advanced
CD2 introduced multi-stage. Advanced use goes further: a dedicated deps stage so the dependency layer is shared by both test and runtime; named --target builds so CI can build a test image and a prod image from one file; and ordering stages so BuildKit parallelizes them.
Dockerfile.targets# syntax=docker/dockerfile:1
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install --target=/deps -r requirements.txt
FROM deps AS test # test image = deps + dev tools + tests
RUN --mount=type=cache,target=/root/.cache/pip pip install --target=/deps pytest
COPY . .
ENV PYTHONPATH=/deps
CMD ["pytest", "-q"]
FROM python:3.12-slim AS prod # prod image = deps + code only, no test tooling
WORKDIR /app
COPY --from=deps /deps /deps
COPY . .
ENV PYTHONPATH=/deps
USER 10001
CMD ["python", "app.py"]
# Build a specific target:
# docker build --target test -t myapp:test .
# docker build --target prod -t myapp:1.4.2 .
test and prod both build from the same deps stage, the expensive install is cached once and reused by both. CI builds the test image, runs tests, then builds prod — the second build reuses the first's cached deps layer.4 · Multi-arch images with buildx (arm64 + amd64) professional
Your laptop may be arm64 (Apple Silicon) while production is amd64 — or the reverse on Graviton. A single-arch image built on the wrong machine fails to run or runs under slow emulation. buildx builds for multiple platforms and publishes a manifest list: one tag that the daemon resolves to the right architecture automatically.
buildx-multiarch.sh# One-time: create a builder that can target multiple platforms.
docker buildx create --name multi --use
docker buildx inspect --bootstrap
# Build BOTH arches and push a manifest list under ONE tag.
# --push is required for multi-arch: a manifest list can't live in the local store.
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myrepo/myapp:1.4.2 \
--push .
# Confirm both arches are present under the single tag:
docker buildx imagetools inspect myrepo/myapp:1.4.2
# Native builders per-arch are far faster than QEMU emulation — prefer them in CI.
| Concern | Single-arch | Multi-arch (buildx) |
|---|---|---|
| Runs on arm64 + amd64 | only the built arch | both, auto-selected |
| Publishing | one image | manifest list under one tag |
Local docker build | yes | needs --push (or OCI export) |
| Cross-arch build speed | n/a | native runners >> QEMU emulation |
5 · Layer-cache strategy for fast CI professional
A clean CI runner has an empty local cache, so every build starts from zero unless you import a cache from somewhere durable. BuildKit can export/import cache to a registry or a CI cache backend, so runner N reuses layers built by runner N−1. Combined with a stable layer order (CD2), CI builds drop from minutes to seconds.
build.yml# .github/workflows/build.yml (needs docker/build-push-action + a registry)
name: build
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: myrepo/myapp:${{ github.sha }}
# Import last build's layers and export this build's — durable across runners.
cache-from: type=registry,ref=myrepo/myapp:buildcache
cache-to: type=registry,ref=myrepo/myapp:buildcache,mode=max
# 'mode=max' caches intermediate stage layers too, not just the final image.
# Action versions/inputs change — verify against current docs.
ci_cache_model.pyLAYERS = [("base", 5), ("apt", 40), ("pip", 60), ("copy-code", 2), ("final", 3)]
def ci_time(changed, cache_from_registry):
total, broken = 0, False
for name, secs in LAYERS:
if name in changed:
broken = True
if broken: # this layer must rebuild
total += secs
elif not cache_from_registry: # clean runner, no imported cache -> rebuild anyway
total += secs
return total
edit = {"copy-code"} # a normal code change
print("cold runner, no cache import:", ci_time(edit, cache_from_registry=False), "s")
print("warm cache imported from registry:", ci_time(edit, cache_from_registry=True), "s")
cold runner, no cache import: 110 s
warm cache imported from registry: 5 s
6 · Reproducible builds & .dockerignore discipline tech-lead
Two builds of the same commit should produce the same image. The enemies are unpinned inputs (:latest, unpinned deps), embedded timestamps, and a fat, drifting build context. Pin bases by digest, pin dependencies with a lockfile, and keep the context tiny — a large context is both slow to send and a vector for accidentally shipping secrets.
Dockerfile.pinned# Pin the base by immutable digest, not a mutable tag, so the build is reproducible
# and the base cannot be swapped under you (ties back to CD6 provenance).
FROM python:3.12-slim@sha256:<digest-of-the-exact-image>
# Pair with a locked requirements file (hashes) for fully pinned dependencies:
# pip install --require-hashes -r requirements.lock
# SOURCE_DATE_EPOCH and reproducible flags can normalize timestamps — verify current support.
context_lint.pyimport fnmatch
def build_context(files, dockerignore):
"""Return files that WOULD be sent to the builder after applying .dockerignore."""
kept = []
for path, size in files.items():
if any(fnmatch.fnmatch(path, pat) or path.startswith(pat.rstrip("/") + "/")
for pat in dockerignore):
continue
kept.append((path, size))
return kept
files = {".git/objects/pack": 90, ".venv/lib": 300, "tests/big_fixture.bin": 40,
".env": 1, "app.py": 1, "requirements.txt": 1}
ignore = [".git/", ".venv/", "tests/", ".env", "*.pyc"]
kept = build_context(files, ignore)
sent = sum(s for _, s in kept)
print("sent to builder:", sorted(p for p, _ in kept), "=", sent, "MB")
assert ".env" not in {p for p, _ in kept}, "secret leaked into context!"
print("ok: no secret, context tiny")
sent to builder: ['app.py', 'requirements.txt'] = 2 MB
ok: no secret, context tiny
.dockerignore, docker build uploads your .git history, virtualenv, and any .env — slowing every build and risking a secret landing in the image via a broad COPY . .. Ignore aggressively; copy explicitly.✓ Checkpoint — you can move on when you can…
- Enable BuildKit and add the
# syntax=directive to use mounts. - Add pip/apt cache mounts and explain how they differ from the layer cache.
- Build and push a multi-arch (arm64+amd64) image with buildx and a manifest list.
- Wire registry cache-from/cache-to so cold CI runners reuse layers.
- Pin a base by digest and prove your .dockerignore keeps the context small and secret-free.
Knowledge check check yourself
You already order deps before code (CD2), yet your CI still runs the 60-second pip install on every push. What is missing, and what are the two independent fixes?
Show answer
cache-from/cache-to type=registry so a cold runner reuses layers from the last build; and (2) add a pip cache mount so even when a dependency changes, only that one is downloaded rather than all of them.Your image runs fine on your amd64 CI but crashes with an exec-format error on the Apple-Silicon laptops of your team. What happened and how do you fix it once for everyone?
Show answer
docker buildx build --platform linux/amd64,linux/arm64 --push, which publishes a manifest list under one tag; each machine's daemon then pulls the matching architecture automatically. Prefer native per-arch runners over QEMU emulation for build speed.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Cache mounts, secret mounts, and parallel stages all require BuildKit and a syntax directive. Many teams try a --mount and get a cryptic parse error because they skipped it.
Your task: Show how to enable BuildKit on an older Docker and add the one line that lets a Dockerfile use --mount features, and say why the DAG makes builds faster.
Requirements:
- Set
DOCKER_BUILDKIT=1(or note it's default in modern Docker) - Add
# syntax=docker/dockerfile:1as the first line - Explain that BuildKit builds independent stages in parallel
💡 Hint: The syntax directive must be the very first line — it selects the frontend that understands mount syntax.
Show solution
export DOCKER_BUILDKIT=1 # default in modern Docker
# first line of the Dockerfile:
# syntax=docker/dockerfile:1BuildKit parses the whole Dockerfile into a dependency graph and builds independent stages concurrently, skips unused stages, and enables cache/secret/SSH mounts. The syntax directive opts into the modern frontend that understands --mount=.... Verify against current Docker docs.
Context: A changed dependency shouldn't force re-downloading every wheel. A cache mount persists the package cache across builds without baking it into a layer.
Your task: Rewrite a pip install step to use a BuildKit cache mount, and explain why you now drop --no-cache-dir.
Requirements:
- Add
--mount=type=cache,target=/root/.cache/pipto the RUN - Ensure the Dockerfile has the syntax directive
- Explain that you want pip's cache kept, so remove
--no-cache-dir - State that the cache is not part of any layer
💡 Hint: --no-cache-dir and a cache mount are opposites — the mount exists precisely to keep the cache.
Show solution
# syntax=docker/dockerfile:1
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txtThe cache mount persists pip's wheel cache across builds and is not committed to any layer, so changing one dependency re-downloads only that one. You drop --no-cache-dir because you now want the cache kept between builds.
Context: It's easy to assert a cache mount is faster; a model shows how much and why the win scales with the number of dependencies.
Your task: Model install time for 20 deps when one changes, comparing no cache mount (re-download all) to a cache mount (re-download only the changed one).
Requirements:
- Charge each dep a download cost plus a build cost
- Without a cache mount, any change re-downloads every dep in the layer
- With a cache mount, only the changed dep re-downloads
- Print both totals for a single changed dependency
💡 Hint: The whole difference is one boolean: does a change to one dep force downloads for all, or just that one.
Show solution
def install_time(deps, changed, cache_mount):
dl_per, build_per = 3.0, 0.5
total = 0.0
for d in deps:
must_download = (d in changed) or (not cache_mount and changed)
total += (dl_per if must_download else 0.0) + build_per
return round(total, 1)
deps = [f"pkg{i}" for i in range(20)]
print(install_time(deps, {"pkg7"}, False), install_time(deps, {"pkg7"}, True))70.0 13.0Without the mount, one changed dep re-downloads all 20 (70s); with it, only the changed dep downloads (13s). The saving grows with dependency count.
Context: CI wants a test image (deps + tests) and prod wants a lean image (deps + code) — from one file, sharing one expensive deps layer.
Your task: Write a multi-stage Dockerfile with a shared deps stage and separate test and prod targets, and give the two build commands.
Requirements:
- Create a
depsstage that installs dependencies once - Build
testFROM deps, adding pytest and the code - Build
prodcopying only deps + code, running as non-root - Show
docker build --target testand--target prod
💡 Hint: Both downstream stages should build FROM the same deps stage so the install is cached once and reused.
Show solution
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS deps
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip pip install --target=/deps -r requirements.txt
FROM deps AS test
RUN --mount=type=cache,target=/root/.cache/pip pip install --target=/deps pytest
COPY . .
ENV PYTHONPATH=/deps
CMD ["pytest", "-q"]
FROM python:3.12-slim AS prod
WORKDIR /app
COPY --from=deps /deps /deps
COPY . .
ENV PYTHONPATH=/deps
USER 10001
CMD ["python", "app.py"]docker build --target test -t myapp:test .
docker build --target prod -t myapp:1.4.2 .Both targets build FROM deps, so the expensive install is cached once and reused; the prod image never carries pytest.
Context: Half the team is on Apple Silicon (arm64) and prod runs amd64 (or Graviton arm64). One image tag must run everywhere without emulation surprises.
Your task: Write the buildx commands to create a multi-platform builder, build arm64+amd64 under one tag, push it, and verify both arches are present.
Requirements:
- Create and select a buildx builder
- Build for
linux/amd64,linux/arm64with--push - Explain why
--pushis required (manifest list can't live locally) - Verify both arches with
imagetools inspect - Note that native runners beat QEMU emulation for speed
💡 Hint: A multi-arch build produces a manifest list, which the local image store can't hold — it must go to a registry.
Show solution
docker buildx create --name multi --use
docker buildx inspect --bootstrap
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t myrepo/myapp:1.4.2 \
--push .
docker buildx imagetools inspect myrepo/myapp:1.4.2--push is required because a manifest list (one tag pointing at per-arch images) can't live in the local store. Each machine's daemon then pulls the matching architecture automatically. Prefer native per-arch runners over QEMU emulation, which can be 5–20× slower; verify current builder options against Docker docs.
Context: A platform team owns the CI build that every service inherits. Cold runners were rebuilding the 60s dependency layer on every push, blowing the pipeline SLA. You must make a warm cache the default and prove the budget.
Your task: Design the CI build: a registry cache import/export plus correct layer order, then model the before/after build time to prove a code-only change stays under a 10-second budget.
Requirements:
- Use
cache-from/cache-to type=registrywithmode=max - Keep the CD2 layer order (deps before code) so a code edit invalidates only cheap layers
- Model build time on a cold runner (no import) vs a warm imported cache for a code edit
- State the two independent levers and what breaks if either is missing
💡 Hint: Model the layers as (name, seconds); a cold runner rebuilds all, a warm cache only rebuilds from the first changed layer.
Show solution
# build.yml (excerpt)
- uses: docker/build-push-action@v6
with:
push: true
tags: myrepo/myapp:${{ github.sha }}
cache-from: type=registry,ref=myrepo/myapp:buildcache
cache-to: type=registry,ref=myrepo/myapp:buildcache,mode=maxLAYERS = [("base",5),("apt",40),("pip",60),("copy-code",2),("final",3)]
def ci_time(changed, warm):
total, broken = 0, False
for name, secs in LAYERS:
if name in changed: broken = True
if broken: total += secs
elif not warm: total += secs
return total
print(ci_time({"copy-code"}, False), ci_time({"copy-code"}, True))110 5Two independent levers: cache import gives a cold runner the previous layers, and layer order means a code edit only invalidates the final cheap layers. With both, a code-only change rebuilds just copy-code+final (5s, under budget). Miss the import and the runner rebuilds all 110s; miss the order and even a warm cache re-runs the 60s pip layer. mode=max also caches intermediate stage layers. Verify action inputs against current docs.