Docker Advanced #2: Multi-Architecture Images — amd64 and arm64 Together

7 min read

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:

Local (M1/M2/M3)
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 error

exec 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:

  1. Force a single amd64 image--platform linux/amd64
  2. 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.

Structure of a manifest list
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:

Inspect a manifest list
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.

Multi-arch build
# 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:

  1. --load only supports a single platform. Multi-platform requires --push or --output type=oci,....
  2. Building two architectures on one host means one runs under emulation — that side will be slower.

Which platforms work #

Builder's supported platforms
docker buildx inspect multi
# ...
# Platforms: linux/amd64, linux/arm64, linux/arm/v7,
#            linux/arm/v6, linux/386, linux/ppc64le, linux/s390x

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

binfmt registration (once)
docker run --privileged --rm tonistiigi/binfmt --install all

Docker Desktop registers it automatically. Linux CI environments sometimes need the explicit step.

Cost #

OperationNative archQEMU emulation
apt-get installFast2–5× slower
Compile (gcc, tsc)Fast3–10× slower
Small scripts / COPYFastAbout 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.

Native builder — amd64 + arm64
# 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 --bootstrap

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

.github/workflows/multi-arch.yml (excerpt)
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 list

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

Common forms
# 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 #

The mistake
docker buildx build --push -t myapp .   # only the host arch is pushed

Even 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 #

Potentially problematic base
FROM somebase:1.0

If 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:

Check the base
docker buildx imagetools inspect somebase:1.0

Widely 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:

Final verification in CI
docker buildx imagetools inspect ghcr.io/me/myapp:${TAG} \
  --format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}}{{"\n"}}{{end}}'
# linux/amd64
# linux/arm64

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

Force amd64
docker run --platform linux/amd64 myapp

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

Platform compatibility
+ 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-container driver builder + --platform linux/amd64,linux/arm64.
  • Multi-platform can’t --load — use --push or 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 imagetools verifies, 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.

X