linux/amd64 and linux/arm64 from one build: emulation, cross-compiling and indexes.
Intermediate15 min · lesson 6 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (7 files, 1 KB): multiplatform.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-hard/multiplatform.tar.gz && tar -xzf multiplatform.tar.gz, which creates ~/lab/multiplatform/. SHA-256: acf9ef2f1c2e6e206a1ae3f1d0fca0b000977d4ddc74f3b414996aadcb79c826
Watch out
Run this only in the SecOpsLog disposable lab VM (secopslog-docker-sec). The lab registers QEMU emulators in the kernel's binfmt_misc table with a privileged container and switches the daemon to the classic image store and back. If that VM does not exist yet, create it on your workstation from the lab kit folder with ./setup/create-lab.sh --profile sec, and open a shell in it with multipass shell secopslog-docker-sec (limactl shell secopslog-docker-sec with Lima). Undo the emulators with the --uninstall step at the end, or reset the whole VM with ./setup/create-lab.sh --profile sec --recreate.
The lab VM is arm64. On it the learner ubuntu is not in the docker group, so commands use sudo docker, and the lesson files go into ~/lab/multiplatform. Pull the local registry image and the native Go image up front; the builds fetch everything else themselves. Then ask the VM to build an amd64 image, and the first RUN fails:
#0 building with "default" instance using docker driver
...
#4 DONE 2.4s
#5 [2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt
#5 0.131 exec /bin/sh: exec format error
#5 ERROR: process "/bin/sh -c echo \"RUN executed on $(uname -m)\" > /built-on.txt" did not complete successfully: exit code: 255
------
> [2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt:
0.131 exec /bin/sh: exec format error
------
Dockerfile.arch:2
--------------------
1 | FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
2 | >>> RUN echo "RUN executed on $(uname -m)" > /built-on.txt
3 | CMD ["sh", "-c", "cat /built-on.txt; echo \"container runs on $(uname -m)\""]
4 |
--------------------
ERROR: failed to build: failed to solve: process "/bin/sh -c echo \"RUN executed on $(uname -m)\" > /built-on.txt" did not complete successfully: exit code: 255
exec /bin/sh: exec format error is the kernel refusing to run an x86-64 binary on an Arm CPU. BuildKit resolved the linux/amd64 entry of the alpine index, unpacked it, and could not execute a single program in it. docker buildx ls lists linux/arm64 as the only platform, and binfmt_misc has no handler for x86-64 (the python3.14 entry is Ubuntu's handler for Python bytecode). The same wall stands in front of an amd64 CI runner building for arm64 Graviton or Apple Silicon users. There are three ways around it: emulate the other CPU, cross-compile on the native one, or send each platform to a machine that has it. This lesson runs the first two and shows the third in configuration.
If you work on an amd64 machine, swap the platforms throughout: install the arm64 emulator and expect x86_64 where this page shows aarch64.
Emulation with QEMU and binfmt_misc
binfmt_misc is a kernel feature that maps a binary format, recognised by magic bytes, to an interpreter. Register QEMU's user-mode emulator for x86-64 ELF files and the kernel runs every amd64 program through it, including programs started inside containers. The tonistiigi/binfmt image, the method Docker's documentation uses, carries static QEMU builds and registers them. It needs --privileged because it writes to /proc/sys/fs/binfmt_misc, a host-wide kernel setting, which is why this lesson uses the sec VM. The image is pinned by tag and digest:
installing: amd64 OK, and the supported list now includes linux/amd64 and linux/amd64/v2. The handler file shows the interpreter path and the flags POCF. F (fix binary) matters most: the kernel opens the QEMU binary once at registration, so it works inside containers whose filesystem has no /usr/bin/qemu-x86_64. The registration lives in kernel memory and disappears at reboot unless something registers it again (Docker Desktop does this for you; on Linux, rerun the container at boot, or install the distribution's QEMU user-mode binfmt package, which registers the handlers at boot through systemd-binfmt; on Ubuntu 26.04 that is qemu-user-binfmt, and the older qemu-user-static name has no installable package there).
docker buildx ls did not change right away. The default builder reads the host's supported platforms when dockerd starts; after the restart it lists linux/amd64 (+2), the amd64 baseline plus two microarchitecture levels. Builds work either way, because the kernel does the emulation. Now build both platforms in one command and load the result:
Dockerfile.arch
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
RUN echo "RUN executed on $(uname -m)" > /built-on.txt
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
lab-arch:1 a5b8a298eed3 17.1MB 7.92MB
├─ linux/amd64 b6bd3a840d02 3.79MB 3.79MB
└─ linux/arm64 6ea28aac2bbd 13.3MB 4.13MB
$sudo docker run --rm --platform linux/amd64 lab-arch:1
sudo docker run --rm --platform linux/arm64 lab-arch:1
RUN executed on x86_64
container runs on x86_64
RUN executed on aarch64
container runs on aarch64
BuildKit runs the stages once per platform (the [linux/amd64 ...] and [linux/arm64 ...] steps) and exports a manifest and an attestation per platform, then one manifest list. --load put the whole index into the local store, and --tree shows both platforms with content. --platform on docker run picks the entry; the amd64 container reports x86_64 both at build time and at run time, because QEMU translates every instruction. That is the strength of emulation, no Dockerfile changes, and also its cost.
Cross-compiling instead of emulating
Emulation is fine for apk add and small scripts. A compiler under QEMU runs every instruction through a translator. Compare the same Go build both ways. Dockerfile.emulated is an ordinary Dockerfile; the cross-compiling one starts the build stage on the build machine's own platform and asks the compiler for the target:
Dockerfile.emulated
FROM golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS build
BuildKit defines these build arguments automatically: BUILDPLATFORM (the machine running the build, here linux/arm64), TARGETPLATFORM (the platform being built, such as linux/amd64), and their parts TARGETOS, TARGETARCH and TARGETVARIANT, plus the BUILD* equivalents. FROM --platform=$BUILDPLATFORM pins the build stage to the native golang image, so the compiler runs at full speed, and GOOS/GOARCH make it emit code for the target. The final FROM scratch stage has no --platform, so it is the target platform's stage, and the binary is copied into it. Declare the ARGs in the stage that uses them, or they are empty.
sha256:a572c08e05d58e969d9119f95b39de4e98a6d073f5e7efa49bf5edbda0ed8394
real 0m6.202s
user 0m0.079s
sys 0m0.049s
$sudo docker run --rm --platform linux/amd64 lab-app:emulated
sudo docker run --rm --platform linux/amd64 lab-app:cross
lab-app compiled on x86_64, running as linux/amd64
lab-app compiled on aarch64, running as linux/amd64
The binary prints the CPU of the machine that ran the compiler (uname -m at build time) and the platform it runs as. The emulated build compiled on x86_64, that is inside QEMU, and the cross build compiled on aarch64, natively; both produce an amd64 program. 56 seconds against 6. The emulated figure includes downloading the amd64 golang image, which an emulated build stage needs and a cross build does not, so this small program overstates the ratio; on real projects with many packages the emulated compile itself dominates and the gap grows to minutes. Your times depend on the CPU and the connection. That difference is the reason cross-compilation is the default choice for Go, Rust and other toolchains that support it: CI minutes, and builds that stop timing out. The catch is language-specific. C code needs a cross toolchain (for Alpine and Debian, helpers such as tonistiigi/xx provide the wrappers), and tools that execute what they just built still need the target CPU.
Native nodes
The third option avoids both emulation and cross toolchains: give one builder several nodes, each on a machine with the right CPU, and BuildKit sends each platform to the node that supports it. The nodes are usually docker-container builders on remote Docker hosts (over SSH or a Docker context) or BuildKit pods through the kubernetes driver:
workstation or CI runner
docker buildx create --name multi --driver docker-container \
docker buildx build --builder multi --platform linux/amd64,linux/arm64 \
--push -t registry.example.com/shop/api:1.8 .
--append adds a node to an existing builder. Native nodes give native speed for every platform at the price of running and securing build machines; managed services such as Docker Build Cloud sell the same model. Many teams mix approaches: cross-compile where the toolchain allows it, and use QEMU for the few RUN steps that must execute target binaries.
Pushing an index
The usual result of a multi-platform build is a push. Start the lab registry and build for both platforms with the cross-compiling Dockerfile. This is the command a release job runs, with your registry in place of localhost:5000:
Step names such as [linux/arm64->amd64 build 4/4] mark a stage running on arm64 for an amd64 target, the cross-compile; those steps were cached from the earlier build, and only the arm64 compile ran. docker buildx imagetools inspect reads the index from the registry: one manifest for linux/amd64, one for linux/arm64, and two unknown/unknown attestation manifests, each pointing at its image through vnd.docker.reference.digest. "Image anatomy: index, manifest, config and layers" took this structure apart; promote and pin the top-level index digest, as in "Tags, digests and promotion". Each client then picks its own entry:
$sudo docker run --rm localhost:5000/lab-app:1
sudo docker run --rm --platform linux/amd64 localhost:5000/lab-app:1
lab-app compiled on aarch64, running as linux/arm64
lab-app compiled on aarch64, running as linux/amd64
Without --platform, the arm64 VM got the arm64 entry. With --platform linux/amd64 it got the amd64 binary, and it ran because the emulator is still registered. On a host without emulation that second run fails with exec format error, the same failure as at the start.
--load and the image store
Whether a multi-platform result can be loaded into the local image store depends on the store. On a fresh Docker 29 install it is the containerd image store, which keeps indexes and per-platform content:
overlayfs [["driver-type","io.containerd.snapshotter.v1"]]
sha256:a8c59745c801e207a24dac5bb8e1c006714682c22732746120c6d68066b6d5dc
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
lab-app:multi a8c59745c801 4.47MB 1.36MB
├─ linux/amd64 0648385c72fe 2.23MB 704kB
└─ linux/arm64 a5a836fc9278 2.23MB 649kB
Upgraded hosts often still run the classic graph-driver store ("Where Docker keeps data" explains both). It stores one image per name, with no index. Remove the containerd-store images first, because switching stores hides them, then switch this VM to the classic store with the procedure from "Configuring the daemon safely" (back up daemon.json, or record {} when there is none, merge the key into the backup with jq, validate, restart) and try the same build, first on the default builder and then on a docker-container builder. The second build pipes through tail, so echo "exit=${PIPESTATUS[0]}" prints the build's own exit status:
sha256:bfb0d3a785c4f9379e66507c4424d3ccb6de16ebbd349ef12c31a27807cb0e68
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
lab-app:amd64 bfb0d3a785c4 1.52MB 0B
Both builders refuse with docker exporter does not currently support exporting manifest lists: the build itself would work, but the classic store has nowhere to put an index. A single platform loads fine. On such hosts the choices are to load one platform at a time for local testing, push the multi-platform result to a registry (the usual CI path, which never needed --load), export with -o type=oci, or migrate the host to the containerd image store.
Switching back needs an explicit setting. Restoring the backup is not enough on this VM: once /var/lib/docker holds classic-store data, Docker 29 keeps using the classic store on the next start. Merge the feature set to true into the backup instead, then remove the emulator:
$sudo docker run --privileged --rm \
tonistiigi/binfmt:qemu-v10.2.3@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --uninstall qemu-x86_64
ls /proc/sys/fs/binfmt_misc/
uninstalling: qemu-x86_64 OK
{
"supported": [
"linux/arm64"
],
"emulators": [
"python3.14"
]
}
python3.14
register
status
--uninstall qemu-x86_64 removes the handler, and binfmt_misc is back to the Python entry it started with. The classic-store data from the test is still under /var/lib/docker, which is why the features line stays in daemon.json; on this disposable VM that does not matter, and ./setup/create-lab.sh --profile sec --recreate starts clean.
Quick check
01An amd64 CI runner builds --platform linux/amd64,linux/arm64 for a Go service with FROM golang:1.27-alpine AS build and RUN go build. The arm64 half takes twelve minutes, the amd64 half one. What change fixes the arm64 time without new hardware?
Incorrect — A newer emulator is still an emulator; the compiler would still run through instruction translation.
Correct — The compiler then runs natively on amd64 and emits arm64 code, as the cross build in the lab did in a fraction of the emulated time.
Incorrect — --load changes where the result goes after the build; the slow part is the emulated compile.
Incorrect — Splitting alone keeps the same emulated compile, and docker tag cannot merge two images into one index.
02On an upgraded host, docker buildx build --platform linux/amd64,linux/arm64 -t app:dev --load . fails with docker exporter does not currently support exporting manifest lists. QEMU is installed. What is the cause?
Incorrect — The build itself works; the error comes from the exporter at the end. The lab built both platforms on the default builder.
Incorrect — A missing handler fails with exec format error during RUN, not in the exporter.
Correct — The containerd image store accepts the index; the classic store holds one image per name. Push, export as OCI, load one platform, or migrate the store.
Incorrect — A docker-container builder fails the same way on the classic store, as the lab showed; on the containerd store the default builder loads multi-platform results.
03A Dockerfile uses FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build and RUN GOOS=linux GOARCH=$TARGETARCH go build ..., but every platform in the index contains an arm64 binary. What is the likely mistake?
Correct — The automatic platform args are only visible in a stage after an ARG line names them. Empty GOARCH means Go builds for the machine it runs on, arm64.
Incorrect — It pins only the stage it is on. The final stage still follows the target platform.
Incorrect — Go cross-compiles pure Go code for any supported GOOS/GOARCH; the lab built amd64 on arm64 that way.
Incorrect — Registries store what they receive. The binaries were built for arm64 before the push.
Try this
Work through “--load and the image store” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
If you keep one thing from multi-platform images, keep “--load and the image store”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.