Docker Advanced #2: Multi-Architecture Images — amd64 and arm64 Together
Apple Silicon machines have become common, ARM environments like AWS Graviton are everywhere — making one image that runs on both architectures the new standard. This post tackles that head-on.
This post in the Docker Advanced series:
- #1 BuildKit and buildx
- #2 Multi-architecture images — linux/amd64 + linux/arm64 ← this post
- #3 Image security — non-root, distroless, scan (Trivy)
- #4 SBOM and signing (cosign)
- #5 Resource limits and cgroups
- #6 Production operations — restart policy, healthcheck, graceful shutdown
The most common incident — Apple Silicon ↔ amd64 #
You’ve probably been here:
docker build -t myapp:1.0 .
docker push ghcr.io/me/myapp:1.0
# Production server (amd64)
docker pull ghcr.io/me/myapp:1.0
docker run myapp:1.0
# exec /myapp: exec format errorexec format error. The binary the production server pulled was for arm64, and the amd64 CPU couldn’t execute it. Docker on Apple Silicon defaults to arm64 images. The push uploaded that as-is; with no compatible variant, production tried to run an arm64 image on an amd64 host.
Two ways to fix it:
- Force a single amd64 image —
--platform linux/amd64 - Build a multi-arch image with both —
--platform linux/amd64,linux/arm64
If your production environment is one architecture only, 1 works. If it’s two or more — or you might move someday — 2 is the standard. These days, almost always 2.
Manifest list — one tag pointing to many images #
What is a multi-arch image, really? A single tag like ghcr.io/me/myapp:1.0 may actually be a manifest list (in OCI terms, an image index) wrapping multiple per-arch images.
ghcr.io/me/myapp:1.0 ← manifest list (image index)
│
├── linux/amd64 → image manifest @sha256:aaa...
├── linux/arm64 → image manifest @sha256:bbb...
└── linux/arm/v7 → image manifest @sha256:ccc...When a Docker client pulls, it picks the right manifest for its host OS/arch automatically. The user only needs the single tag.
docker buildx imagetools makes it visible:
docker buildx imagetools inspect python:3.14
# Name: docker.io/library/python:3.14
# MediaType: application/vnd.oci.image.index.v1+json
# Digest: sha256:abc...
#
# Manifests:
# Name: docker.io/library/python:3.14@sha256:111...
# MediaType: application/vnd.oci.image.manifest.v1+json
# Platform: linux/amd64
#
# Name: docker.io/library/python:3.14@sha256:222...
# MediaType: application/vnd.oci.image.manifest.v1+json
# Platform: linux/arm64
#
# ... (linux/arm/v7, linux/386, linux/ppc64le, linux/s390x ...)Official images typically support 6–8 architectures simultaneously.
Building — both archs at once #
With buildx + the docker-container driver, one command does both.
# First: create a docker-container driver builder (see [#1])
docker buildx create --name multi --driver docker-container --use --bootstrap
# Build + push
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/me/myapp:1.0 \
--push .Two facts to pin down:
--loadonly supports a single platform. Multi-platform requires--pushor--output type=oci,....- Building two architectures on one host means one runs under emulation — that side will be slower.
Which platforms work #
docker buildx inspect multi
# ...
# Platforms: linux/amd64, linux/arm64, linux/arm/v7,
# linux/arm/v6, linux/386, linux/ppc64le, linux/s390xThe default docker-container driver bundles QEMU emulation with the BuildKit container, so it can build for any platform on the list. Architectures different from the host CPU run under emulation.
QEMU emulation — possible, but how fast #
QEMU is an emulator that runs code from one architecture on a CPU of another. Docker pairs it with Linux’s binfmt_misc so that, for example, you can run an arm64 container on an amd64 host.
docker run --privileged --rm tonistiigi/binfmt --install allDocker Desktop registers it automatically. Linux CI environments sometimes need the explicit step.
Cost #
| Operation | Native arch | QEMU emulation |
|---|---|---|
apt-get install | Fast | 2–5× slower |
| Compile (gcc, tsc) | Fast | 3–10× slower |
Small scripts / COPY | Fast | About the same |
The perceived difference scales with the build’s heaviness. Light Python/Node apps fare fine under emulation; heavy compiles (Rust/Go/C) suffer significantly. For the latter, the next section’s native builder is the answer.
Native multi-arch builder — --append
#
If you take multi-arch builds seriously, having native machines for each architecture is fastest. buildx lets you register multiple nodes (machines) under one builder.
# First node (on the amd64 machine)
docker buildx create --name native --node native-amd64 --platform linux/amd64
# Add second node (register the arm64 machine over SSH)
docker buildx create --append \
--name native \
--node native-arm64 \
--platform linux/arm64 \
ssh://ubuntu@arm64-host
docker buildx use native
docker buildx inspect --bootstrapWhen this builder runs --platform linux/amd64,linux/arm64, each platform builds natively on its own arch’s machine. The result is automatically wrapped into a manifest list.
GitHub Actions has started offering free ARM runners (ubuntu-24.04-arm), so a common pattern now is to build per-arch on each runner and merge them at the end.
jobs:
build-amd64:
runs-on: ubuntu-24.04
# ... build amd64, output digest
build-arm64:
runs-on: ubuntu-24.04-arm
# ... build arm64, output digest
manifest:
needs: [build-amd64, build-arm64]
# docker buildx imagetools create combines both digests into one manifest listEach matrix job builds only its own architecture natively, and the final job stitches them together. The fastest multi-arch CI pattern.
docker buildx imagetools — assemble and verify
#
The imagetools command works on already-built images and manifest lists.
# 1) Inspect a manifest list (seen above)
docker buildx imagetools inspect ghcr.io/me/myapp:1.0
# 2) Combine two images into one manifest list
docker buildx imagetools create \
-t ghcr.io/me/myapp:1.0 \
ghcr.io/me/myapp:1.0-amd64 \
ghcr.io/me/myapp:1.0-arm64
# 3) Move tags around (lightweight change)
docker buildx imagetools create \
-t ghcr.io/me/myapp:latest \
ghcr.io/me/myapp:1.0(2) is the final step in the GHA pattern above — each job pushes its own image, then the last job uses imagetools create to combine them into one manifest list.
Common pitfalls #
1. Forgetting --platform and pushing only the host arch
#
docker buildx build --push -t myapp . # only the host arch is pushedEven with the docker-container driver, without --platform you build / push just the host’s arch. Always set --platform for multi-arch.
2. The base image only supports one side #
FROM somebase:1.0If somebase:1.0 is amd64-only, the arm64 build fails because no manifest exists for it. Check the base’s platform support before the build:
docker buildx imagetools inspect somebase:1.0Widely used official images (python, node, golang, nginx, postgres) usually cover all multi-arch options.
3. Tests that pass on only one arch #
If your build includes RUN go test ... or similar, make sure it works on both architectures. Some libraries embed amd64-only assembly and break on the other side.
4. Not verifying results #
If CI doesn’t verify after --push, bugs sneak through. One line goes a long way:
docker buildx imagetools inspect ghcr.io/me/myapp:${TAG} \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}'
# linux/amd64
# linux/arm64A simple eyeball check that your expected platforms are all there.
Forcing an arch at run-time — --platform on run
#
You can also force a particular architecture at runtime.
docker run --platform linux/amd64 myappCommon on Apple Silicon when you need an amd64 image (e.g., a specific amd64-only DB client). The host runs it under emulation and performance suffers.
Compiled languages — static vs. dynamic #
Something multi-arch surfaces. A glibc-linked dynamic binary needs a compatible host libc. A static binary has no such dependency and runs anywhere — but alpine’s musl libc and glibc collide here.
+ Go (CGO_ENABLED=0) static — glibc/musl agnostic, runs anywhere
+ Rust (musl target) static — natural on alpine
+ Python wheel built per glibc / musl
+ Node native modules same arch + same libc required
- glibc-compiled C ext. → doesn't run on musl (alpine)The default combo for most apps is multi-arch + a slim base (= glibc). Alpine is tempting, but combined with the constraints above, the trap surface grows.
Wrap-up #
The picture from this post:
- A multi-arch image is one tag that, via a manifest list (OCI image index), wraps several per-arch manifests.
- The standard pattern:
docker-containerdriver builder +--platform linux/amd64,linux/arm64. - Multi-platform can’t
--load— use--pushor OCI output. - QEMU emulation works but is slow for heavy compiles. For serious workloads, use a native multi-arch builder (or amd64+arm64 GHA matrix + imagetools merge).
docker buildx imagetoolsverifies, combines, or moves tags on already-built images.- Common pitfalls: base image’s platform support, code that only works on one arch, missing CI verification.
In the next post (#3 Image security — non-root, distroless, scan (Trivy)) we add security sense on top of this build infrastructure: unprivileged users, read-only root, capability drops, and scanning images for known CVEs with tools like Trivy.