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

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.2
$ sudo docker pull -q registry:3 sudo docker pull -q golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414
docker.io/library/registry:3 docker.io/library/golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414
$ sudo docker buildx ls ls /proc/sys/fs/binfmt_misc/
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.33.1 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 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.2
$ sudo docker run --privileged --rm \ tonistiigi/binfmt:qemu-v10.2.3@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --install amd64
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.33.1 linux/arm64
$ sudo systemctl restart docker sudo docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS default* docker \_ default \_ default running v0.33.1 linux/amd64 (+2), linux/arm64

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
CMD ["sh", "-c", "cat /built-on.txt; echo \"container runs on $(uname -m)\""]
ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.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.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 #5 resolve docker.io/library/alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 0.0s done #5 CACHED #6 [linux/arm64 1/2] FROM docker.io/library/alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 #6 resolve docker.io/library/alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8 0.0s done #6 extracting sha256:16fc4f52163f03cd2189c3d6a4b3f28a605cfb7919af64b3da4562cca69d2306 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.1s #8 [linux/arm64 2/2] RUN echo "RUN executed on $(uname -m)" > /built-on.txt #8 DONE 0.1s #9 exporting to image #9 exporting layers 0.1s done #9 exporting manifest sha256:b6bd3a840d02deef7fe6a7aa59213e39a16868be32937151919b318bf0af44c7 done #9 exporting config sha256:803aaa84a718fce6ba885d27265064191c10b1afb26bca33b47d54b96b7a23bc done #9 exporting attestation manifest sha256:9b5ab762b6e553f5907ea6a2b48b476963d4d6e785eacf513df1bcddb974292f done #9 exporting manifest sha256:6ea28aac2bbdbc40c0b2f48482bc6969b2950bd534a0aa41ea5ddeb951d9dd97 done #9 exporting config sha256:53272d664ab2a488cad7b9ed2bafd5abbc9a154a47e1d6ddcfa97f089c4738d3 done #9 exporting attestation manifest sha256:9ba274c1476a51a986505d4ecf1c92f3e8ee1d52e035c3dd3a38ea48ca1deaad done #9 exporting manifest list sha256:a5b8a298eed336337f0de0fa8a37a934b5c14a58902c34699644c40a87589e31 done ...
$ sudo docker image ls --tree lab-arch:1
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
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:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 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 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.

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.2
$ time sudo docker buildx build -q --platform linux/amd64 -f Dockerfile.emulated -t lab-app:emulated .
sha256:8e7fe4091310732fb7ce5df22fd2b46ce10ea7add817c837aa37a19c27d3afb2 real 0m55.748s user 0m0.095s sys 0m0.082s
$ time sudo docker buildx build -q --platform linux/amd64 -t lab-app:cross .
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 \
--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 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.2
$ sudo docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3
23af187df2597d2af19e7db1633bc782b9beb6e4ac224d234db43d564db81d80
$ 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 3.3s #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:0648385c72fe5b3a1afde2b3e43bc42554174e2aa60368a710f07f2d80acdfb9 done #12 exporting config sha256:1f53319ad4369336054a34c00f1e538fffaf06725a6dc529d4a769b383db461c done ... #12 pushing manifest for localhost:5000/lab-app:1@sha256:db5a29acd0eb1472c983dcb74f6c0247450ae5b071522de0cd3d6dcf9cb3e14f #12 pushing manifest for localhost:5000/lab-app:1@sha256:db5a29acd0eb1472c983dcb74f6c0247450ae5b071522de0cd3d6dcf9cb3e14f 0.0s 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:db5a29acd0eb1472c983dcb74f6c0247450ae5b071522de0cd3d6dcf9cb3e14f Manifests: Name: localhost:5000/lab-app:1@sha256:0648385c72fe5b3a1afde2b3e43bc42554174e2aa60368a710f07f2d80acdfb9 MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/amd64 Name: localhost:5000/lab-app:1@sha256:a5a836fc92789249a7ddadd4d7017082e9d607e58b3cfa6375d9b84b11dd88ee MediaType: application/vnd.oci.image.manifest.v1+json Platform: linux/arm64 Name: localhost:5000/lab-app:1@sha256:80e0009ee11f053e461d45321fd85e3ad56ca1ee500a3e6e5a610d673ef8828c MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:0648385c72fe5b3a1afde2b3e43bc42554174e2aa60368a710f07f2d80acdfb9 vnd.docker.reference.type: attestation-manifest Name: localhost:5000/lab-app:1@sha256:323d455efbb93b8f8b3184847da0d3c6931ced95e9c275552cc51838b13b4bc5 MediaType: application/vnd.oci.image.manifest.v1+json Platform: unknown/unknown Annotations: vnd.docker.reference.digest: sha256:a5a836fc92789249a7ddadd4d7017082e9d607e58b3cfa6375d9b84b11dd88ee vnd.docker.reference.type: attestation-manifest

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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.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 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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.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: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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.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:a5b8a298eed336337f0de0fa8a37a934b5c14a58902c34699644c40a87589e31 Untagged: lab-app:emulated Deleted: sha256:8e7fe4091310732fb7ce5df22fd2b46ce10ea7add817c837aa37a19c27d3afb2 Untagged: lab-app:cross Deleted: sha256:a572c08e05d58e969d9119f95b39de4e98a6d073f5e7efa49bf5edbda0ed8394 Untagged: lab-app:multi Deleted: sha256:a8c59745c801e207a24dac5bb8e1c006714682c22732746120c6d68066b6d5dc Untagged: localhost:5000/lab-app:1 Deleted: sha256:db5a29acd0eb1472c983dcb74f6c0247450ae5b071522de0cd3d6dcf9cb3e14f
$ 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 14.6s done #1 creating container buildx_buildkit_lab-multi0 #1 creating container buildx_buildkit_lab-multi0 0.6s done #1 DONE 15.2s 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: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:

ubuntu@secopslog-docker-sec:~/lab/multiplatform · Docker 29.8.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:bfb0d3a785c4f9379e66507c4424d3ccb6de16ebbd349ef12c31a27807cb0e68 Deleted: sha256:4e0f3de0f17bf34afb817f273f7e230c0a30b516d0a3863570aceabedfd51ed4 { "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 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.

Related