Multi-platform images

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: 6f4f07d930ff9864a1b785689c64578b50d8fe47a74febdbf3612337ae2e9024
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 isn't in the docker group, so you'll 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker pull -q registry:3 sudo docker pull -q golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c
docker.io/library/registry:3 docker.io/library/golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c
$ sudo docker buildx ls ls /proc/sys/fs/binfmt_misc/
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.34.0 linux/arm64 python3.14 register status
$ sudo docker buildx build --progress=plain --platform linux/amd64 -f Dockerfile.arch -t lab-arch:1 .
#0 building with "default" instance using docker driver ... #4 DONE 4.2s #5 [2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt #5 0.143 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.143 exec /bin/sh: exec format error ------ Dockerfile.arch:2 -------------------- 1 | FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 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 and unpacked it, but it couldn't run a single program in it. docker buildx ls lists linux/arm64 as the only platform. binfmt_misc has no handler for x86-64 (the python3.14 entry is Ubuntu's handler for Python bytecode). You'll hit the same wall on 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. You'll run the first two here and see the third in config.

If you're 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. That includes programs started inside containers. The tonistiigi/binfmt image, the method Docker's docs use, carries static QEMU builds and registers them. It needs --privileged because it writes to /proc/sys/fs/binfmt_misc, a host-wide kernel setting. That's why you're using the sec VM. The image is pinned by tag and digest:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker run --privileged --rm \ tonistiigi/binfmt:qemu-v10.2.3@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --install amd64
Unable to find image 'tonistiigi/binfmt:qemu-v10.2.3@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0' locally docker.io/tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0: Pulling from tonistiigi/binfmt 3491c3e68b66: Pulling fs layer 0f83986c7199: Pulling fs layer 44136fa355b3: Download complete 4ce36ae801e2: Download complete 0f83986c7199: Download complete 3491c3e68b66: Download complete 0f83986c7199: Pull complete 3491c3e68b66: Pull complete Digest: sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 Status: Downloaded newer image for tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 installing: amd64 OK { "supported": [ "linux/arm64", "linux/amd64", "linux/amd64/v2" ], "emulators": [ "python3.14", "qemu-x86_64" ] }
$ cat /proc/sys/fs/binfmt_misc/qemu-x86_64 sudo docker buildx ls
enabled interpreter /usr/bin/qemu-x86_64 flags: POCF offset 0 magic 7f454c4602010100000000000000000002003e00 mask fffffffffffefe00fffffffffffffffffeffffff NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.34.0 linux/amd64 (+2), linux/arm64
$ sudo systemctl restart docker sudo docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.34.0 linux/amd64 (+2), linux/arm64

The first lines are the pull of the pinned image. Then comes 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's qemu-user-binfmt, and the older qemu-user-static name has no installable package there.

This time docker buildx ls listed linux/amd64 (+2) at once, the amd64 baseline plus two microarchitecture levels. On Engine 29.8.2 the list changed only after a dockerd restart. The restart step shows the same list. On an older engine, restart dockerd if the new platform doesn't appear. 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.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN echo "RUN executed on $(uname -m)" > /built-on.txt
CMD ["sh", "-c", "cat /built-on.txt; echo \"container runs on $(uname -m)\""]
ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker buildx build --progress=plain --platform linux/amd64,linux/arm64 \ -f Dockerfile.arch -t lab-arch:1 --load .
#0 building with "default" instance using docker driver ... #5 [linux/amd64 1/2] FROM docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a #5 resolve docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 0.0s done #5 CACHED #6 [linux/arm64 1/2] FROM docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a #6 resolve docker.io/library/alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a 0.0s done #6 extracting sha256:d3531b7a4f217bdc3426c57bcff84c170f086f456a39689d4a615c6d026c4be0 0.1s done #6 DONE 0.1s #7 [linux/amd64 2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt #7 DONE 0.2s #8 [linux/arm64 2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt #8 DONE 0.2s #9 exporting to image #9 exporting layers 0.1s done #9 exporting manifest sha256:b3dbc64f4529ac62480c41b607d8e3dd5a9c0d42f94866c671453d2134372187 done #9 exporting config sha256:7dec35f62e775efde8e200cffe74dfc33a1c71a3a977b0e5c57c0d4acbcd8f93 0.0s done #9 exporting attestation manifest sha256:8e77e912063f57526801e6fd389aa0a0944f238182bbcff7d57e91ba5aa6def1 0.0s done #9 exporting manifest sha256:8aeb33a9d25be1fe83a08d4b1c440e958a64d6f4eadc48df3e5db8601b7a3947 0.0s done #9 exporting config sha256:6e1a272a66873d0d9c8ecccfd78a0b89a0e357fc6c879bfa1a67b4d88f79e260 done #9 exporting attestation manifest sha256:28419ad1a8602dcc9641415214af4609ce4fbe3635813569de504416a6ccd42e 0.0s done #9 exporting manifest list sha256:ecef8d807c7cfbe3a085a6ecbb81227382ff67f12dbe83a60b3a3d46bddce1c7 0.0s done ...
$ sudo docker image ls --tree lab-arch:1
IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-arch:1 ecef8d807c7c 17.4MB 8.05MB ├─ linux/amd64 b3dbc64f4529 3.86MB 3.86MB └─ linux/arm64 8aeb33a9d25b 13.5MB 4.19MB
$ 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). It 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's 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. But 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:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 \
go build -trimpath -ldflags="-s -w -X main.compiledOn=$(uname -m)" -o /out/lab-app .
FROM scratch
COPY --from=build /out/lab-app /lab-app
USER 65532:65532
ENTRYPOINT ["/lab-app"]
Dockerfile
FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:b8c9b82affbbb82a020bbcf998dad2ba9516944ac7e883524642a481a9505e9c AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w -X main.compiledOn=$(uname -m)" -o /out/lab-app .
FROM scratch
COPY --from=build /out/lab-app /lab-app
USER 65532:65532
ENTRYPOINT ["/lab-app"]

BuildKit defines these build arguments for you. BUILDPLATFORM is the machine running the build, here linux/arm64. TARGETPLATFORM is the platform being built, such as linux/amd64. Their parts are TARGETOS, TARGETARCH and TARGETVARIANT, and there are BUILD* equivalents too. 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's the target platform's stage, and the binary is copied into it. Declare the ARGs in the stage that uses them, or they're empty.

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ time sudo docker buildx build -q --platform linux/amd64 -f Dockerfile.emulated -t lab-app:emulated .
sha256:fadc98402b1cf2475b9076fe5ab5e06b1fa0a350cb2c4586a2cafff602c14047 real 0m51.005s user 0m0.142s sys 0m0.095s
$ time sudo docker buildx build -q --platform linux/amd64 -t lab-app:cross .
sha256:e2f6828359cd0093d39411f0ea9471672e8f8e276ca06d71ab0cdc97822792da real 0m5.035s user 0m0.078s sys 0m0.058s
$ 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, so inside QEMU, and the cross build compiled on aarch64, natively. Both produce an amd64 program. It took 51 seconds against 5. The emulated figure includes downloading the amd64 golang image, which an emulated build stage needs and a cross build doesn't. 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 your CPU and connection. That gap is why cross-compiling 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 run what they just built still need the target CPU.

Native nodes

The third option avoids both emulation and cross toolchains. You 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 \
--platform linux/amd64 ssh://ci@amd64-builder.internal
docker buildx create --name multi --append --driver docker-container \
--platform linux/arm64 ssh://ci@arm64-builder.internal
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, but you have to run and secure the 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 run 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. It's the command a release job runs, with your registry in place of localhost:5000:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
ccb19440fc9cdcd3f7bcbd542fff6fba0461e5bc826d448505f8942cc9c9a4d5
$ sudo docker buildx build --platform linux/amd64,linux/arm64 --push -t localhost:5000/lab-app:1 .
#0 building with "default" instance using docker driver ... #7 [linux/arm64->amd64 build 3/4] COPY go.mod main.go ./ #7 CACHED #8 [linux/arm64->amd64 build 4/4] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.compiledOn=$(uname -m)" -o /out/lab-app . #8 CACHED #9 [linux/amd64 stage-1 1/1] COPY --from=build /out/lab-app /lab-app #9 CACHED #10 [linux/arm64 build 4/4] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.compiledOn=$(uname -m)" -o /out/lab-app . #10 DONE 2.9s #11 [linux/arm64 stage-1 1/1] COPY --from=build /out/lab-app /lab-app #11 DONE 0.0s #12 exporting to image #12 exporting layers 0.1s done #12 exporting manifest sha256:e773c53ce453dcad65432641c4a17f2c6cb851ef6f6fdf8f1c46396352d780b7 done #12 exporting config sha256:0c617d2e992310b2503ea40af6deea381a6535e081a84add81478c7ebe117474 done ... #12 pushing manifest for localhost:5000/lab-app:1@sha256:db702e1a7ddd9fd1bf3bfb1f98a5b70e6dd4c8a500121626b79e035f35b1a693 #12 pushing manifest for localhost:5000/lab-app:1@sha256:db702e1a7ddd9fd1bf3bfb1f98a5b70e6dd4c8a500121626b79e035f35b1a693 0.1s done #12 DONE 0.3s
$ sudo docker buildx imagetools inspect localhost:5000/lab-app:1
Name: localhost:5000/lab-app:1 MediaType: application/vnd.oci.image.index.v1+json Digest: sha256:db702e1a7ddd9fd1bf3bfb1f98a5b70e6dd4c8a500121626b79e035f35b1a693 Manifests: Name: localhost:5000/lab-app:1@sha256:e773c53ce453dcad65432641c4a17f2c6cb851ef6f6fdf8f1c46396352d780b7 MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/amd64 Name: localhost:5000/lab-app:1@sha256:a2d0e68938db8dbcd212ffa2beaa0e7b19051f4668e7ab6fce1869b76062ff6b MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 Name: localhost:5000/lab-app:1@sha256:d0f1572d3eaffb68b7d3d4814772c79093ae0becfb3ac9af4d724c212ff7af38 MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:e773c53ce453dcad65432641c4a17f2c6cb851ef6f6fdf8f1c46396352d780b7 vnd.docker.reference.type: attestation-manifest Name: localhost:5000/lab-app:1@sha256:3d625b3e099b02aff2b3d6720618e2a940646485721f8057f1ffa500b519e03f MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.type: attestation-manifest vnd.docker.reference.digest: sha256:a2d0e68938db8dbcd212ffa2beaa0e7b19051f4668e7ab6fce1869b76062ff6b

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. It holds 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ 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's still registered. On a host without emulation that second run fails with exec format error, the same failure you saw at the start.

--load and the image store

Whether you can load a multi-platform result into the local image store depends on the store. On a fresh Docker 29 install it's the containerd image store, which keeps indexes and per-platform content:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker info --format "{{.Driver}} {{json .DriverStatus}}" sudo docker buildx build -q --platform linux/amd64,linux/arm64 -t lab-app:multi --load . sudo docker image ls --tree lab-app:multi
overlayfs [["driver-type","io.containerd.snapshotter.v1"]] sha256:b3bda112ee90408f9ede7a8dd3bf3da69d1da262734dead583b3d4ed45625dc6 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-app:multi b3bda112ee90 4.47MB 1.36MB ├─ linux/amd64 e773c53ce453 2.23MB 704kB └─ linux/arm64 a2d0e68938db 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 steps from "Configuring the daemon safely". Back up daemon.json (or record {} when there's none), merge the key into the backup with jq, validate and restart. 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker rm -f -v lab-registry sudo docker image rm lab-arch:1 lab-app:emulated lab-app:cross lab-app:multi localhost:5000/lab-app:1
lab-registry Untagged: lab-arch:1 Deleted: sha256:ecef8d807c7cfbe3a085a6ecbb81227382ff67f12dbe83a60b3a3d46bddce1c7 Untagged: lab-app:emulated Deleted: sha256:fadc98402b1cf2475b9076fe5ab5e06b1fa0a350cb2c4586a2cafff602c14047 Untagged: lab-app:cross Deleted: sha256:e2f6828359cd0093d39411f0ea9471672e8f8e276ca06d71ab0cdc97822792da Untagged: lab-app:multi Deleted: sha256:b3bda112ee90408f9ede7a8dd3bf3da69d1da262734dead583b3d4ed45625dc6 Untagged: localhost:5000/lab-app:1 Deleted: sha256:db702e1a7ddd9fd1bf3bfb1f98a5b70e6dd4c8a500121626b79e035f35b1a693
$ sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak 2>/dev/null || echo "{}" | sudo tee /etc/docker/daemon.json.bak >/dev/null jq '. + {"features": {"containerd-snapshotter": false}}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker sudo docker info --format "{{.Driver}} {{json .DriverStatus}}"
{ "features": { "containerd-snapshotter": false } } configuration OK overlay2 [["Backing Filesystem","extfs"],["Supports d_type","true"],["Using metacopy","false"],["Native Overlay Diff","true"],["userxattr","false"]]
$ sudo docker buildx build --platform linux/amd64,linux/arm64 -t lab-app:multi --load .
ERROR: failed to build: docker exporter does not currently support exporting manifest lists
$ sudo docker buildx create --name lab-multi --driver docker-container --buildkitd-config buildkitd.toml --bootstrap >/dev/null sudo docker buildx build --builder lab-multi --platform linux/amd64,linux/arm64 -t lab-app:multi --load . 2>&1 | tail -3 echo "exit=${PIPESTATUS[0]}"
#1 [internal] booting buildkit #1 pulling image moby/buildkit:buildx-stable-1 #1 pulling image moby/buildkit:buildx-stable-1 20.9s done #1 creating container buildx_buildkit_lab-multi0 #1 creating container buildx_buildkit_lab-multi0 0.6s done #1 DONE 21.5s ERROR: failed to build: docker exporter does not currently support exporting manifest lists exit=1
$ sudo docker buildx build --builder lab-multi -q --platform linux/amd64 -t lab-app:amd64 --load . sudo docker image ls lab-app
sha256:32856cd35c29d6ab678dba02947bdde45196f60d03c9788104ecc2487eccea62 IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-app:amd64 32856cd35c29 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 you've got four choices. Load one platform at a time for local testing, or push the multi-platform result to a registry (the usual CI path, which never needed --load). You can also export with -o type=oci, or migrate the host to the containerd image store.

Switching back needs an explicit setting. Restoring the backup isn't enough on this VM. Once /var/lib/docker holds classic-store data, Docker 29 keeps using the classic store on the next start. So merge the feature set to true into the backup instead, then remove the emulator:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.9.2
$ sudo docker buildx rm lab-multi sudo docker image rm lab-app:amd64 jq '. + {"features": {"containerd-snapshotter": true}}' /etc/docker/daemon.json.bak | sudo tee /etc/docker/daemon.json sudo dockerd --validate --config-file /etc/docker/daemon.json && sudo systemctl restart docker sudo docker info --format "{{.Driver}} {{json .DriverStatus}}"
lab-multi removed Untagged: lab-app:amd64 Deleted: sha256:32856cd35c29d6ab678dba02947bdde45196f60d03c9788104ecc2487eccea62 Deleted: sha256:3ec9e77e2907ec11c75988825fbc5e4e1bcd2e0e452eccaa8d2ff7804087930d { "features": { "containerd-snapshotter": true } } configuration OK overlayfs [["driver-type","io.containerd.snapshotter.v1"]]
$ 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 doesn't matter, and ./setup/create-lab.sh --profile sec --recreate starts clean.

Try this

On secopslog-docker-sec, right after the compare step, rebuild lab-app:cross with the step 12 command but --platform linux/arm64, under time as before. Run it with sudo docker run --rm --platform linux/arm64 lab-app:cross and predict both halves of the line it prints. Then compare with the first pull-run line above, which came from the same Dockerfile.

Takeaway

Cross-compile when the toolchain allows it, and keep QEMU for the RUN steps that must run target binaries. The emulated Go build took 51 seconds against 5 for the cross build here, and that gap grows on real projects. On CI hosts that still use the classic store, plan for docker exporter does not currently support exporting manifest lists: push the index to a registry instead of reaching for --load.

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.

Related