AI EngineeringZero to ProductionHome·About·Contact
Containers & Deployment · Chapter CD7

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.

⏱️ ~3 hours🧪 10 labs🎯 Advanced→Tech-lead

Learning objectives

  • Enable BuildKit and explain how its DAG differs from the legacy sequential builder.
  • Use --mount=type=cache so package downloads survive across builds and CI runners.
  • Build multi-arch (arm64 + amd64) images with docker buildx and 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.
▶ Runnable companionPython labs model cache economics and cache keys and run offline. The Dockerfile/buildx/CI labs are labeled and need Docker with BuildKit (and buildx for multi-arch); flag names evolve, so verify against current docs.

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
shell · turn on BuildKit; note the syntax directive (needs Docker)
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=... .
Parse Dockerfile frontend Build DAG dependencies Parallel stages concurrent Cache lookup local/registry Export image single tar
Why the DAG mattersTwo build stages that do not depend on each other (say, compiling a Go helper and installing Python deps) run at the same time under BuildKit. The legacy builder would run them one after the other. Same Dockerfile, less wall-clock time.

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 · pip + apt cache mounts (needs Docker + BuildKit)
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 vs layer cache — don't confuse themA layer cache reuses a whole finished layer when its inputs are identical. A cache mount persists a working directory (downloads, compiler artifacts) while a layer rebuilds. You want both: the layer cache skips unchanged steps; the cache mount makes the steps that do rebuild cheap.
Python · model the download saving from a pip cache mount (runs offline)
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 · shared deps stage + build targets (needs Docker + BuildKit)
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 .
One Dockerfile, two images, one cacheBecause 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.

shell · build & push a multi-arch image (needs Docker buildx + registry)
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.
ConcernSingle-archMulti-arch (buildx)
Runs on arm64 + amd64only the built archboth, auto-selected
Publishingone imagemanifest list under one tag
Local docker buildyesneeds --push (or OCI export)
Cross-arch build speedn/anative runners >> QEMU emulation
Emulation is correctness-safe but slowbuildx can build arm64 on an amd64 host via QEMU, but emulated compiles can be 5–20× slower. For CI, prefer native runners per architecture (or a remote arm64 builder) and let buildx assemble the manifest — verify the current runner/builder options against Docker docs.

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.

yaml · GitHub Actions build with registry cache import/export (needs Actions + registry)
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.
Python · model CI build time with and without a warm cache (runs offline)
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
The two levers togetherCache import gives a cold runner the previous layers; correct layer order (deps before code) means a code edit only invalidates the last cheap layers. Miss either one and CI rebuilds the 60s pip install on every push.

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 · pin the base by digest for reproducibility (needs Docker)
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.
Python · enforce .dockerignore + context-size discipline (runs offline)
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
A fat context is slow AND leakyWithout a disciplined .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

✓ Knowledge check

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
Layer order helps only when the runner has the previous layers — but a clean CI runner starts with an empty local cache, so it rebuilds everything regardless of order. The two independent fixes: (1) import a durable cache with 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.
✓ Knowledge check

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
You built a single-arch amd64 image; arm64 machines can't execute amd64 binaries (exec-format error), and running under emulation is slow. Fix it by building a multi-arch image with 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.

Exercise 1 · Enable BuildKit and add the syntax directiveBeginner

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:1 as 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:1

BuildKit 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.

Exercise 2 · Add a pip cache mountIntermediate

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/pip to 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.txt

The 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.

Exercise 3 · Quantify the cache-mount savingAdvanced

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.0

Without the mount, one changed dep re-downloads all 20 (70s); with it, only the changed dep downloads (13s). The saving grows with dependency count.

Exercise 4 · Two build targets from one DockerfileExpert

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 deps stage that installs dependencies once
  • Build test FROM deps, adding pytest and the code
  • Build prod copying only deps + code, running as non-root
  • Show docker build --target test and --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.

Exercise 5 · Build and publish a multi-arch imageProfessional

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/arm64 with --push
  • Explain why --push is 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.

Exercise 6 · Design the CI cache + a build-speed budgetIndustry scenario

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=registry with mode=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=max
LAYERS = [("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 5

Two 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.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in