Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Build an image for several architectures

By updated

Improve this page

Logo Docker

A multi-architecture image is an index: a single tag behind which Docker picks the variant that matches the machine (amd64 on a PC, arm64 on an ARM server). This lesson builds such an index with Docker Buildx, publishes it to a local registry, then runs each variant to check that the program it contains is the one for its platform.

A build that succeeds does not prove the image works: an arm64 variant may very well contain an amd64 binary, and only running it shows it. You will also learn to recognize the exec format error, to choose between emulation and cross-compilation, and to declare what you did not replay. All the measurements come from Docker Engine 29.9.2, Buildx 0.38.1 and BuildKit v0.34.0, with the containerd image store, on a Debian 13 amd64 VM.

Several names in this lesson are French because the commands and outputs are the ones that were run: plateformes ("platforms") is the name of the example module, program and images (demo/plateformes), semrofetalp is that word read backwards, piege ("trap") appears in Dockerfile.piege, demo/plateformes:piege and out-piege-arm64, and entrees: ("entries") is printed by the digests.py script. They are identifiers, so they stay as they are.

The cache shared between CI runs is covered in BuildKit cache in CI and image promotion between registries. This lesson registers the QEMU emulator by hand, on the measurement VM; registering it on a CI runner was not measured.

What you will learn

  • Read the platforms of a builder and create a docker-container builder, then remove it
  • Recognize exec format error and register QEMU emulation
  • Build two platforms with emulation then with cross-compilation, and compare the durations
  • Spot the trap of a binary of the wrong architecture inside an otherwise correct index
  • Read a published index without counting its entries, and run each variant by its digest
  • Predict what docker run --platform does depending on what is already local
  • Declare what was not replayed on real hardware

Prerequisites

Which builder and which platforms do you have without any configuration?

A builder is the component that runs your builds: it runs BuildKit, Docker's build engine, and its driver says where BuildKit runs. The builder used by default is called default and works with the docker driver: BuildKit is then built into the engine.

First check the image store, that is, the place where Docker keeps images and their layers, because multi-platform building depends on it.

Terminal window
docker info | grep -Ei "storage|snapshotter|Server Version|Architecture"
Server Version: 29.9.2
Storage Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
Architecture: x86_64

The driver-type: io.containerd.snapshotter.v1 line indicates the containerd image store, the one Docker picks by itself on a fresh installation of Docker Engine 29 or later (an upgrade keeps the old store; docker info tells you which one you use). It is the one that can keep several platforms side by side under a single tag. The measurements in this lesson do not hold for the old classic store, which the lesson did not test. Now list the builders.

Terminal window
docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker
\_ default \_ default running v0.34.0 linux/amd64 (+3)

The star marks the active builder. The PLATFORMS column is abbreviated: (+3) means three more platforms. The docker buildx inspect --bootstrap command gives the full list; the output below is cut down to its first lines.

Terminal window
docker buildx inspect --bootstrap
Name: default
Driver: docker
Nodes:
Name: default
Endpoint: default
Status: running
BuildKit version: v0.34.0
Platforms: linux/amd64, linux/amd64/v2, linux/amd64/v3

With no configuration, the builder only knows the variants of your processor. To run arm64 instructions during a build, an emulator is missing, which the section on emulation adds.

What does a docker-container builder change, and how do you remove it?

A docker-container builder runs BuildKit in a separate container, instead of using Docker's own: you create it to get a distinct BuildKit. Create a builder named multi with a BuildKit image pinned by version and digest, and start it.

Terminal window
docker buildx create --name multi --driver docker-container \
--driver-opt image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c --use
docker buildx inspect --bootstrap multi
multi
#1 [internal] booting buildkit
#1 pulling image moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c
#1 pulling image moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c 9.1s done
#1 creating container buildx_buildkit_multi0
#1 creating container buildx_buildkit_multi0 0.9s done
#1 DONE 10.0s
Name: multi
Driver: docker-container
[...]
Nodes:
Name: multi0
Endpoint: unix:///var/run/docker.sock
Driver Options: image="moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c"
Status: running
BuildKit daemon flags: --allow-insecure-entitlement=network.host
BuildKit version: v0.34.0
Platforms: linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/386
[...]

Read three things in the buildx inspect output. Docker pulls the pinned BuildKit image, then creates the buildx_buildkit_multi0 container. With this image, the BuildKit version line must show v0.34.0, the same version as the default builder. Without the --driver-opt image=… option, Buildx pulls moby/buildkit:buildx-stable-1, a moving tag: when the lesson was measured, it gave BuildKit v0.33.1, while the default builder ran v0.34.0. Finally, the Platforms line lists what the builder handles: with the pinned image, these are the platforms of your processor and linux/386, without arm64.

The Docker documentation on multi-platform builds states that the official BuildKit images embed user-mode QEMU emulators. The lesson did not check that these emulators work: the Platforms list above did not contain arm64, and that list is what counts. All the emulation measurements in the lesson are on Docker's default builder, with the emulation registered by binfmt; only the publication to a local registry, further down, goes through a docker-container builder.

A docker-container builder keeps its results in its own container. To find an image in docker image ls, you have to ask for it with --load; --push sends it to the registry without loading anything locally. Another difference, the network, shows up when you publish to a local registry; the section on the published index covers it. Switch back to the default builder, remove multi, then check that neither a builder nor a container is left.

Terminal window
docker buildx use default
docker buildx rm multi
docker buildx ls
docker ps -a --format "{{.Names}} {{.Image}}"
multi removed
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker
\_ default \_ default running v0.34.0 linux/amd64 (+3)

The multi removed message confirms the removal, and docker ps -a shows no buildx_buildkit_multi0 line: the builder's container disappeared with it. The BuildKit image, however, stays in docker image ls; the cleanup section deletes it.

Why does an arm64 program fail with exec format error?

An amd64 processor cannot run a program compiled for arm64. Docker can pull the arm64 image without difficulty, because downloading files requires no execution. The test uses Alpine 3.24.3, pinned by digest.

Terminal window
docker pull --platform linux/arm64 alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
docker run --rm --platform linux/arm64 alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a uname -m
echo "code=$?"
docker.io/library/alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a: Pulling from library/alpine
d3531b7a4f21: Pulling fs layer
d3531b7a4f21: Download complete
2a02c517eb64: Download complete
4f276f238bfa: Download complete
d3531b7a4f21: Pull complete
Digest: sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
Status: Image is up to date for alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
docker.io/library/alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
exec /bin/uname: exec format error
code=255

The pull succeeds (on the measurement machine, Alpine's index was already present, hence the Image is up to date line: it does not mean the arm64 variant is missing). The run fails with exec format error and code 255: the kernel reads a file of another architecture and cannot run it. The same error appears in a build as soon as a RUN instruction has to run a program of the target platform. Here is the test Dockerfile, which contains only a RUN.

Terminal window
mkdir -p ~/multiarch && cd ~/multiarch
Dockerfile.run
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN uname -m > /arch
CMD ["cat", "/arch"]
Terminal window
docker buildx build --platform linux/arm64 -f Dockerfile.run -t demo/plateformes:run-arm64 --load .
echo "build-exit=$?"
#5 [2/2] RUN uname -m > /arch
#5 0.186 exec /bin/sh: exec format error
#5 ERROR: process "/bin/sh -c uname -m > /arch" did not complete successfully: exit code: 255
[...]
ERROR: failed to build: failed to solve: process "/bin/sh -c uname -m > /arch" did not complete successfully: exit code: 255
build-exit=1

The output is abridged. The FROM instruction of the previous step finished without error: loading and unpacking the layers of an arm64 image runs nothing. It is the RUN, which launches the image's /bin/sh, that fails. A COPY instruction, which only copies files, does not run any program either. Remember the rule: only RUN instructions require emulation.

How do you register QEMU emulation, and how do you remove it?

Emulation means having QEMU, a piece of software that imitates that processor, run an arm64 program. It goes through binfmt_misc, a Linux kernel mechanism that associates a file format with the interpreter in charge of running it. If QEMU is registered for arm64, the kernel hands it the arm64 binaries and the container runs. First look at what is registered on the machine.

Terminal window
ls /proc/sys/fs/binfmt_misc
python3.13
register
status

There is no qemu-* entry: only the Python interpreter is declared. Note the list you get on your machine: the cleanup at the end of the lesson refers to it so as not to remove an emulation that already existed.

The tonistiigi/binfmt image registers the emulators. Its latest tag moves with every release: the lesson therefore pins the version tag qemu-v10.2.3-68 and its digest. Started with no option, it changes nothing and lists what the kernel accepts.

Terminal window
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0
{
"supported": [
"linux/amd64",
"linux/amd64/v2",
"linux/amd64/v3",
"linux/386"
],
"emulators": [
"python3.13"
]
}

On the first run, Docker pulls the image first: Pulling from tonistiigi/binfmt lines precede the JSON, not reproduced here. The --privileged option is needed because this container writes into the host's kernel configuration, shared by all containers. That is also why the image is pinned by digest: a privileged container must not be able to change without your knowing. Now register the arm64 emulator.

Terminal window
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --install arm64
installing: arm64 OK
{
"supported": [
"linux/amd64",
"linux/amd64/v2",
"linux/amd64/v3",
"linux/arm64",
"linux/386"
],
"emulators": [
"python3.13",
"qemu-aarch64"
]
}

The linux/arm64 platform appears in supported and qemu-aarch64 in emulators. The kernel now has an entry that you can read.

Terminal window
cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/bin/qemu-aarch64
flags: POCF
offset 0
magic 7f454c460201010000000000000000000200b700
mask ffffffffffffff00fffffffffffffffffeffffff

The magic field is the signature of an arm64 executable in ELF format (the format of Linux executables), and interpreter designates the emulator in charge of running it. Rerun the command that failed, then the build.

Terminal window
docker run --rm --platform linux/arm64 alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a uname -m
cd ~/multiarch && docker buildx build --platform linux/arm64 -f Dockerfile.run -t demo/plateformes:run-arm64 --load .
docker run --rm demo/plateformes:run-arm64
aarch64
WARNING: The requested image's platform (linux/arm64) does not match the detected host platform (linux/amd64/v3) and no specific platform was requested
aarch64

The first line, aarch64, is the answer of the arm64 container (aarch64 is the name uname gives to this architecture): the 255 error is gone. The build prints its progress, not reproduced here, and takes less than a second on the measurement machine; docker run then answers aarch64, preceded by a warning.

That warning does not signal a failure: the tag designates an arm64 image on an amd64 machine, and Docker says so because you did not specify --platform.

To remove the emulation, pass --uninstall with the name of the kernel entry.

Terminal window
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --uninstall qemu-aarch64
uninstalling: qemu-aarch64 OK
{
"supported": [
"linux/amd64",
"linux/amd64/v2",
"linux/amd64/v3",
"linux/386"
],
"emulators": [
"python3.13"
]
}

The list is back to its initial state. Register the emulation again with the same --install arm64 command, and keep it until the cleanup section, which removes it.

Terminal window
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --install arm64

The output is that of the first installation: installing: arm64 OK, then the list showing linux/arm64 and qemu-aarch64.

How do you build an image for two platforms?

Save each file of this lesson in ~/multiarch, under the name given in the title of the block. The --platform option accepts a list, and --load brings the result back into the local store. With the containerd image store, these two options work together with the default builder, without an extra builder. The test program is a small Go binary that prints the architecture it was compiled for, and a word written backwards so that it is recognizable.

main.go
package main
import (
"fmt"
"runtime"
)
func reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
func main() {
fmt.Printf("GOOS=%s GOARCH=%s app=%s\n", runtime.GOOS, runtime.GOARCH, reverse("plateformes"))
}
go.mod
module demo/plateformes
go 1.27

The first Dockerfile compiles inside the golang image of the target platform: under arm64, the compiler itself is an arm64 program, hence emulated. The Go image is pinned by the digest of its index.

Dockerfile.emul
FROM golang:1.27-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /out/app .
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

First pull the Go image for both platforms, so that the download does not count in the duration. The --no-cache option then forces the compilation to run: without it, an already filled cache would skew the measurement.

Terminal window
docker pull --platform linux/amd64 golang:1.27-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb | tail -2
docker pull --platform linux/arm64 golang:1.27-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb | tail -2
time docker buildx build --no-cache --platform linux/amd64,linux/arm64 -f Dockerfile.emul -t demo/plateformes:emul --load --progress=plain . 2>&1 | tail -25
[...]
#18 [linux/amd64 stage-1 2/2] COPY --from=build /out/app /app
#18 DONE 0.1s
#17 [linux/arm64 build 4/4] RUN CGO_ENABLED=0 go build -o /out/app .
#17 DONE 51.2s
#19 [linux/arm64 stage-1 2/2] COPY --from=build /out/app /app
#19 DONE 0.1s
#20 exporting to image
#20 exporting layers
#20 exporting layers 0.3s done
#20 exporting manifest sha256:47eabc785aa6062c7586bf0d0f1ce418872bf936d76cd9845dccb68c86a87b2c
#20 exporting manifest sha256:47eabc785aa6062c7586bf0d0f1ce418872bf936d76cd9845dccb68c86a87b2c 0.0s done
#20 exporting config sha256:48f1a928b8e92d4358ac4f7cb83b06848580009f8fef7f0a3c83f0221e9a80b6 0.0s done
#20 exporting attestation manifest sha256:b08516758cca3cb0805adf6cc8efec81d790cdad081267fd98aca3f1f7e7e478 0.0s done
#20 exporting manifest sha256:ff6c1d79dadae09bd2037f3bb02364d335125f93b84587c6eb63251f1049b269 0.0s done
#20 exporting config sha256:06478064ef7e28675e51f48ddb7ab6e6b9e9c4dc3f347533d83dcc41db4bcac4 0.0s done
#20 exporting attestation manifest sha256:f2fd51c8dcefeeb8713fb17b2670b1f7891dc8c001d5270cb29d97928f7d1b10 0.0s done
#20 exporting manifest list sha256:870f4914b947c2e5656e723ab9eb4d416ff2fc2e30c695c0b07220bd981cccc4
#20 exporting manifest list sha256:870f4914b947c2e5656e723ab9eb4d416ff2fc2e30c695c0b07220bd981cccc4 0.0s done
#20 naming to docker.io/demo/plateformes:emul done
#20 unpacking to docker.io/demo/plateformes:emul 0.0s done
#20 DONE 0.6s
real 0m56.805s
user 0m0.097s
sys 0m0.027s

Look for two landmarks. The [linux/arm64 build 4/4] step, the compilation under QEMU, takes 51.2 s; the whole build takes 56.8 s (the real line).

The exporting lines show what a single build produces. A manifest is the record card of one variant: it lists its layers and its configuration. The build writes one per platform, then an index, which Docker also calls a manifest list (manifest list), that gathers them under a single tag. The attestation manifest lines are explained along with the published index, in the section devoted to it. Read the index with --tree.

Terminal window
docker image ls --tree
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
demo/plateformes:emul 870f4914b947 22.3MB 10.8MB
├─ linux/amd64 47eabc785aa6 16.8MB 5.3MB
└─ linux/arm64 ff6c1d79dada 5.54MB 5.54MB

The --tree option is marked experimental in the Docker 29.9.2 client: its display may change. The output is limited to the image of this test: the full list also contains the base images. The top line is the index, the two lines below are the variants. The ID field of a variant is the identifier of its manifest. To examine one of them, add --platform to docker image inspect, then run it with the same option.

Terminal window
docker image inspect --platform linux/arm64 --format '{{.Os}}/{{.Architecture}} id={{.Id}}' demo/plateformes:emul
docker run --rm --platform linux/arm64 demo/plateformes:emul
docker image inspect --platform linux/amd64 --format '{{.Os}}/{{.Architecture}} id={{.Id}}' demo/plateformes:emul
docker run --rm --platform linux/amd64 demo/plateformes:emul
linux/arm64 id=sha256:ff6c1d79dadae09bd2037f3bb02364d335125f93b84587c6eb63251f1049b269
GOOS=linux GOARCH=arm64 app=semrofetalp
linux/amd64 id=sha256:47eabc785aa6062c7586bf0d0f1ce418872bf936d76cd9845dccb68c86a87b2c
GOOS=linux GOARCH=amd64 app=semrofetalp

Each variant answers with its architecture: GOARCH=arm64 for one, GOARCH=amd64 for the other. This is the test to remember, because the program reports what it was compiled for, not what docker image inspect declares in the manifest.

Should you emulate or cross-compile?

Compiling under emulation works with any Dockerfile, but the compiler runs slowly under QEMU. Cross-compilation avoids that slowness: the compiler runs natively on your machine, and it produces a binary for another architecture. Go allows this with two variables, GOOS and GOARCH. This section compares the two methods, then shows how the second one goes wrong.

The diagram sets the two paths side by side. In both, the final image only does a COPY, and only the place where the compiler runs changes. The highlighted box is the step that goes through QEMU.

Two build chains for the arm64 image. With emulated compilation, the arm64 Go image runs go build under QEMU, which produces an arm64 binary copied into the final image. With cross-compilation, the Go image of the machine, amd64, runs go build natively with GOARCH=arm64, which produces an arm64 binary copied into the final image.Cross-compilationGo image amd64the machine's ownRUN GOARCH=arm64 go buildthe amd64 compilerruns nativelyarm64 binaryCOPY intothe final imageEmulated buildGo image arm64RUN go buildthe arm64 compilerruns under QEMUarm64 binaryCOPY intothe final image
Only the compiler changes place: under QEMU on one side, native on the other. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

How do you cross-compile with TARGETARCH?

BuildKit provides build variables to each platform. BUILDPLATFORM is the platform of the machine that builds, TARGETOS and TARGETARCH those of the targeted image. They are available in a stage only once declared with ARG. The FROM --platform=$BUILDPLATFORM instruction asks for the compiler image of the machine that builds, not of the target.

Dockerfile
FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app .
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

The final stage contains only a COPY: it runs no program, so it does not need emulation, even for arm64. Measure the duration under the same conditions, with --no-cache.

Terminal window
time docker buildx build --no-cache --platform linux/amd64,linux/arm64 -f Dockerfile -t demo/plateformes:cross --load --progress=plain . 2>&1 | tail -28
[...]
#12 [linux/amd64->arm64 build 4/4] RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o /out/app .
#12 DONE 7.4s
#13 [linux/amd64 build 4/4] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app .
#13 DONE 7.4s
#14 [linux/arm64 stage-1 2/2] COPY --from=build /out/app /app
#14 DONE 0.1s
#15 [linux/amd64 stage-1 2/2] COPY --from=build /out/app /app
#15 DONE 0.1s
#16 exporting to image
#16 exporting layers
#16 exporting layers 0.3s done
#16 exporting manifest sha256:25ae59cb20b1fb92a8e54f6581127b2a477814c6ecb923e1dec3bb53a10c46e6
#16 exporting manifest sha256:25ae59cb20b1fb92a8e54f6581127b2a477814c6ecb923e1dec3bb53a10c46e6 0.0s done
#16 exporting config sha256:2214fa5559caa4831b35d8c492b0e5b4ecda73153c84278b5b3c9d85d6a85439 0.0s done
#16 exporting attestation manifest sha256:1f0afc1a68003ff2ce02582b366c9326c3e476aaabcdaa830d3a86a57d95bbd9
#16 exporting attestation manifest sha256:1f0afc1a68003ff2ce02582b366c9326c3e476aaabcdaa830d3a86a57d95bbd9 0.0s done
#16 exporting manifest sha256:ed2301bb69730e8ae287004665f0db8f615f06aa99fc9146675104ed25bda0fd 0.0s done
#16 exporting config sha256:883c4afb1ea39ce5086851996f7623f65b85a45a033831a84cb43f5c3d445f28 0.0s done
#16 exporting attestation manifest sha256:28a4fe35311bb6f077576a1173d4a8d8d3d99da2294e6979ae78a1752206b200 0.1s done
#16 exporting manifest list sha256:7d0d83dd21f32f33fd5990d2877007719464fd75331d76d38f62e27e40b7be26
#16 exporting manifest list sha256:7d0d83dd21f32f33fd5990d2877007719464fd75331d76d38f62e27e40b7be26 0.0s done
#16 naming to docker.io/demo/plateformes:cross done
#16 unpacking to docker.io/demo/plateformes:cross 0.0s done
#16 DONE 0.6s
real 0m8.883s
user 0m0.044s
sys 0m0.035s

The compilation step is now called [linux/amd64->arm64 build 4/4]: the arrow shows that it runs on amd64 to target arm64. It takes 7.4 s and the whole build 8.9 s. The output also shows the compilation for amd64, [linux/amd64 build 4/4], with no arrow since it targets the machine itself: 7.4 s as well.

Methodarm64 compilationBuild of both platforms
Emulated (Dockerfile.emul)51.2 s56.8 s
Cross-compiled (Dockerfile)7.4 s8.9 s

These durations come from a single run on the measurement Debian 13 amd64 VM, each build being run with --no-cache: read them as an order of magnitude and measure your own. To know whether the two methods give the same program, copy the binaries out of the images and compare their checksums.

Terminal window
mkdir -p out-cross-arm64 && docker create --platform linux/arm64 --name x-cross-arm64 demo/plateformes:cross >/dev/null && docker cp x-cross-arm64:/app out-cross-arm64/app && docker rm x-cross-arm64 >/dev/null && file out-cross-arm64/app && sha256sum out-cross-arm64/app
mkdir -p out-emul-arm64 && docker create --platform linux/arm64 --name x-emul-arm64 demo/plateformes:emul >/dev/null && docker cp x-emul-arm64:/app out-emul-arm64/app && docker rm x-emul-arm64 >/dev/null && file out-emul-arm64/app && sha256sum out-emul-arm64/app
out-cross-arm64/app: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=fRa4Z91i4F52WwsekNq2/BXCI_4KDnM1hvHaxGZBI/nrGeYniJwRRWDvAWaSZ4/q3m2sBdQv68dkukXKbLW, BuildID[sha1]=4df9c0d5dcf12b43ebc850eb2dea127d64d7fc9f, with debug_info, not stripped
c0947d04b45aca0bd756ad70b45fad837d8a40e87c55343f769dd4fff618624d out-cross-arm64/app
out-emul-arm64/app: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, Go BuildID=fRa4Z91i4F52WwsekNq2/BXCI_4KDnM1hvHaxGZBI/nrGeYniJwRRWDvAWaSZ4/q3m2sBdQv68dkukXKbLW, BuildID[sha1]=4df9c0d5dcf12b43ebc850eb2dea127d64d7fc9f, with debug_info, not stripped
c0947d04b45aca0bd756ad70b45fad837d8a40e87c55343f769dd4fff618624d out-emul-arm64/app

The file command reads the file header: ARM aarch64 confirms the architecture without running the program. The two checksums are identical (c0947d04…): on this program, emulated compilation and cross-compilation give the same binary, byte for byte. Cross-compilation therefore does not degrade the result here; it says nothing about a program that would depend on C libraries or whose final RUN would run code of the target, cases the lesson did not measure.

A third strategy exists: compile each platform on a native node (an arm64 machine for arm64), then assemble the index with docker buildx imagetools create. The lesson did not measure it, for lack of an arm64 machine: to implement it, follow the Docker documentation on multi-platform builds. The page BuildKit cache in CI and image promotion between registries does use imagetools create, but to copy an existing index from one registry to another, not to assemble native builds.

Why does BUILDPLATFORM alone produce a wrong binary?

The trap comes from an oversight: keeping FROM --platform=$BUILDPLATFORM without passing GOOS and GOARCH to go build. Go then compiles for the machine that builds, that is, for amd64, whatever the targeted platform.

Dockerfile.piege
FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /out/app .
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Terminal window
time docker buildx build --no-cache --platform linux/amd64,linux/arm64 -f Dockerfile.piege -t demo/plateformes:piege --load --progress=plain . 2>&1 | tail -25
[...]
#12 [linux/amd64 build 4/4] RUN CGO_ENABLED=0 go build -o /out/app .
#12 DONE 4.1s
[...]
#15 DONE 0.7s
real 0m5.485s

The build succeeds, faster than the other two (a single compilation, amd64, serves both platforms: the step is called [linux/amd64 build 4/4], with no arrow). The index is correct: docker image inspect does announce linux/arm64 for the second variant. It is its content that is wrong.

Terminal window
docker image inspect --platform linux/arm64 --format '{{.Os}}/{{.Architecture}} id={{.Id}}' demo/plateformes:piege
docker run --rm --platform linux/arm64 demo/plateformes:piege
mkdir -p out-piege-arm64 && docker create --platform linux/arm64 --name x-piege-arm64 demo/plateformes:piege >/dev/null && docker cp x-piege-arm64:/app out-piege-arm64/app && docker rm x-piege-arm64 >/dev/null && file out-piege-arm64/app && sha256sum out-piege-arm64/app
linux/arm64 id=sha256:da09e9ebf5a8f6434dbd90e7691851e597358a682995e3139263d5a58d2c31c9
GOOS=linux GOARCH=amd64 app=semrofetalp
out-piege-arm64/app: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=8Hx6toIjGIVVZhE10x0l/yS1TKDtgP0hFNYOpW5iN/6pFQZ3Qn0lvYNzYf84bn/joVmo_R_xxFaEsEbCtzM, BuildID[sha1]=0eecf3c551407a2c4e8bd2386769f1de3d8aa2a5, with debug_info, not stripped
f8e3629bcece470fd063e4fafe7ba50ed7de31b52babc68166781a7efd011c43 out-piege-arm64/app

Read the three outputs together. The manifest says linux/arm64, but the program answers GOARCH=amd64, and file identifies an x86-64 executable.

On this amd64 VM, the "arm64" variant starts without error, because the host runs the amd64 binary it contains directly. On an arm64 host, the behavior was not measured. Only running each variant, or a file on each binary, reveals the defect; no build message mentions it.

What does the index published in a registry contain?

An index is published with --push, which sends the result to a registry instead of loading it locally. For the test, start a local registry that listens only on the machine, on port 5055. The registry:3 image is pinned by digest.

Terminal window
docker run -d --name demo-registry -p 127.0.0.1:5055:5000 registry:3@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3 | cut -c1-12
sleep 2
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5055/v2/
edf8c2d24450
200

On the first run, the pull lines of the registry image are displayed before the identifier. The truncated identifier and the 200 code of the /v2/ address show that the registry answers. Then publish the index, with the cross-compilation Dockerfile (the build progress is not reproduced), and read it. The digests in this lesson will be different on your machine: use your own, read from the index.

Terminal window
docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile -t 127.0.0.1:5055/demo/plateformes:1.0 --push --progress=plain . 2>&1 | tail -12
docker buildx imagetools inspect 127.0.0.1:5055/demo/plateformes:1.0
Name: 127.0.0.1:5055/demo/plateformes:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:c436d1a51d9234e3a0e81a8f9d7ad8aefd86ea3bf325a9ab6e5850addb409352
Manifests:
Name: 127.0.0.1:5055/demo/plateformes:1.0@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: 127.0.0.1:5055/demo/plateformes:1.0@sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Name: 127.0.0.1:5055/demo/plateformes:1.0@sha256:4e7ae54f13010e50cc1e768c6f0b871808a1ed6a278db43fb72bcbd32b1a24b7
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.digest: sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
vnd.docker.reference.type: attestation-manifest
Name: 127.0.0.1:5055/demo/plateformes:1.0@sha256:5b14946bee721bd75564a61ef3ae4fcc1ece8a7c565ff1cee1ccf93dc28d9367
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.type: attestation-manifest
vnd.docker.reference.digest: sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f

Two platforms, four manifests. The first two are the real variants. The other two, with the unknown/unknown platform, are provenance attestations, documents that describe how the image was built. Buildx requests them by default and BuildKit produces them (the --provenance=false option, seen below, removes them). The vnd.docker.reference.digest annotation designates the manifest being described, and vnd.docker.reference.type is attestation-manifest.

The diagram shows this structure. Follow the solid lines, which lead to what runs, then the dotted lines, which link each attestation to the manifest it describes.

A tag leads to an OCI index of four entries: a linux/amd64 manifest and a linux/arm64 manifest, each linked to its layers and its binary, and two unknown/unknown platform attestations, each describing one of the two manifests.Tag demo/plateformes:1.0OCI index4 entrieslinux/amd64 manifestlinux/arm64 manifestunknown/unknown attestationdescribes the amd64 manifestunknown/unknown attestationdescribes the arm64 manifestamd64 layers and binaryarm64 layers and binary
Only the two real variants contain a program; the attestations, dotted, describe a manifest without being runnable. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

A short script separates the variants from the attestations. It reads the raw index and prints, for each entry, its digest, its platform and its type.

digests.py
import json, sys
# usage: digests.py [os/arch] (stdin = raw index) ; with no argument, list everything
idx = json.load(sys.stdin)
want = sys.argv[1] if len(sys.argv) > 1 else None
print("entrees:", len(idx["manifests"]), file=sys.stderr)
for m in idx["manifests"]:
p = m.get("platform", {})
plat = p.get("os", "?") + "/" + p.get("architecture", "?")
ann = m.get("annotations", {})
if want:
if plat == want:
print(m["digest"])
else:
print(m["digest"], plat, ann.get("vnd.docker.reference.type", "-"), ann.get("vnd.docker.reference.digest", "-")[:19])
Terminal window
docker buildx imagetools inspect --raw 127.0.0.1:5055/demo/plateformes:1.0 | python3 digests.py
entrees: 4
sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202 linux/amd64 - -
sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f linux/arm64 - -
sha256:4e7ae54f13010e50cc1e768c6f0b871808a1ed6a278db43fb72bcbd32b1a24b7 unknown/unknown attestation-manifest sha256:d75b98894278
sha256:5b14946bee721bd75564a61ef3ae4fcc1ece8a7c565ff1cee1ccf93dc28d9367 unknown/unknown attestation-manifest sha256:c833aa8b3644

Never count the entries of an index to count the platforms: here 4 entries for 2 platforms. Read the platform of each entry. To get an index that contains only the variants, turn off provenance with --provenance=false.

Terminal window
docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile -t 127.0.0.1:5055/demo/plateformes:1.0-nop --provenance=false --push --progress=plain . 2>&1 | tail -6
docker buildx imagetools inspect --raw 127.0.0.1:5055/demo/plateformes:1.0-nop | python3 digests.py
entrees: 2
sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202 linux/amd64 - -
sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f linux/arm64 - -

The two variants have the same digests as in the previous index: only the index changes. The registry now lists both tags.

Terminal window
curl -s http://127.0.0.1:5055/v2/demo/plateformes/tags/list
{"name":"demo/plateformes","tags":["1.0","1.0-nop"]}

Turning provenance off removes information that is useful for auditing the build chain. Do it when a consuming tool does not understand the unknown/unknown entries, not by default.

Why can't a docker-container builder reach 127.0.0.1?

The default builder published to 127.0.0.1:5055 without difficulty. A docker-container builder fails on the same address. Recreate the multi builder and try the same publication.

Terminal window
docker buildx create --name multi --driver docker-container \
--driver-opt image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c --use
docker buildx build --builder multi --platform linux/amd64,linux/arm64 -f Dockerfile -t 127.0.0.1:5055/demo/plateformes:cont --push . 2>&1 | tail -3
multi
> exporting to image:
------
ERROR: failed to build: failed to solve: failed to push 127.0.0.1:5055/demo/plateformes:cont: failed to do request: Head "http://127.0.0.1:5055/v2/demo/plateformes/blobs/sha256:6f4ccdb83c2f8f3bde1bc6328322cb52da372ec2260b9ee8a5295f42e2155bff": dial tcp 127.0.0.1:5055: connect: connection refused

The connection refused message on 127.0.0.1:5055 comes from the fact that the builder runs in a container, on Docker's bridge network. For it, 127.0.0.1 is its own container, where nobody listens on that port, and not your machine. You have to recreate the builder, giving it the host network with --driver-opt network=host. The option cannot be changed on an existing builder.

Terminal window
docker buildx use default
docker buildx rm multi
docker buildx create --name multi --driver docker-container \
--driver-opt network=host --driver-opt image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c --use
docker buildx build --builder multi --platform linux/amd64,linux/arm64 -f Dockerfile -t 127.0.0.1:5055/demo/plateformes:cont --push . 2>&1 | tail -3
multi removed
multi
#17 pushing manifest for 127.0.0.1:5055/demo/plateformes:cont@sha256:39ce05f2618949d78e22273cde8dd205bc40599a60070009c054fb3137c388c0
#17 pushing manifest for 127.0.0.1:5055/demo/plateformes:cont@sha256:39ce05f2618949d78e22273cde8dd205bc40599a60070009c054fb3137c388c0 0.1s done
#17 DONE 0.9s

The publication succeeds. In exchange, this builder shares the machine's network and can reach its local services: keep this option for tests, and remove the builder afterwards.

Terminal window
docker buildx use default
docker buildx rm multi

The rm command answers multi removed.

How do you run each variant of the index?

An index tag is not proof: it can hide a wrong variant. The proof is to pull each variant by the digest of its manifest, read from the index, then run it. First read the two digests from the published index, with digests.py and its platform argument, and store them in two variables: no digest is typed by hand.

Terminal window
cd ~/multiarch
AMD=$(docker buildx imagetools inspect --raw 127.0.0.1:5055/demo/plateformes:1.0 | python3 digests.py linux/amd64)
ARM=$(docker buildx imagetools inspect --raw 127.0.0.1:5055/demo/plateformes:1.0 | python3 digests.py linux/arm64)
echo "AMD=$AMD ARM=$ARM"
entrees: 4
entrees: 4
AMD=sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202 ARM=sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f

The digests in the outputs that follow are those of the measurement: yours differ, and the variables carry them for you. The entrees: 4 line goes to the error output, so it is displayed but does not enter the variable. The variables exist only in this terminal: if you close it, replay this block while the registry is running.

Then delete the local copies, so that the test starts from the registry.

Terminal window
docker image rm demo/plateformes:cross demo/plateformes:emul demo/plateformes:piege demo/plateformes:run-arm64 127.0.0.1:5055/demo/plateformes:1.0 127.0.0.1:5055/demo/plateformes:1.0-nop

The command prints one Untagged: line per image removed. Then pull and run the amd64 variant, then the arm64 variant.

Terminal window
docker pull 127.0.0.1:5055/demo/plateformes@$AMD | tail -3
docker run --rm --platform linux/amd64 127.0.0.1:5055/demo/plateformes@$AMD
docker image inspect --format '{{.Architecture}}' 127.0.0.1:5055/demo/plateformes@$AMD
Digest: sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
Status: Downloaded newer image for 127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
GOOS=linux GOARCH=amd64 app=semrofetalp
amd64
Terminal window
docker pull 127.0.0.1:5055/demo/plateformes@$ARM | tail -3
docker run --rm --platform linux/arm64 127.0.0.1:5055/demo/plateformes@$ARM
docker image inspect --format '{{.Architecture}}' 127.0.0.1:5055/demo/plateformes@$ARM
Digest: sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f
Status: Downloaded newer image for 127.0.0.1:5055/demo/plateformes@sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f
127.0.0.1:5055/demo/plateformes@sha256:c833aa8b36441c1ecdc60a928aef3f2520442958e7d5195d71e3c8b0459a5e5f
GOOS=linux GOARCH=arm64 app=semrofetalp
arm64

Each run returns a GOARCH equal to the architecture announced by docker image inspect: the program of the variant is the one for its platform. If either GOARCH line had been wrong, as in the trap variant, you would have seen it here.

What does docker run --platform do depending on what is already local?

The docker run --platform command does not behave the same way depending on what Docker already has locally for that image. Three situations were measured, with Docker 29.9.2 and the containerd image store.

In the first, you pulled an index tag with a single platform. Delete the copies by digest, then pull only the amd64 variant of the index without attestations.

Terminal window
docker image rm 127.0.0.1:5055/demo/plateformes@$ARM 127.0.0.1:5055/demo/plateformes@$AMD
docker pull --platform linux/amd64 127.0.0.1:5055/demo/plateformes:1.0-nop
docker image ls --tree | sed -n '1p;/plateformes/,+3p'

The tree is read with docker image ls --tree: the arm64 variant appears there with 0B, that is, declared by the index but absent from the disk.

IMAGE ID DISK USAGE CONTENT SIZE EXTRA
127.0.0.1:5055/demo/plateformes:1.0-nop e804613147cb 16.8MB 5.3MB
├─ linux/amd64 d75b98894278 16.8MB 5.3MB
└─ linux/arm64 c833aa8b3644 0B 0B

Now run the arm64 variant, which is not local.

Terminal window
docker run --rm --platform linux/arm64 127.0.0.1:5055/demo/plateformes:1.0-nop
Unable to find image '127.0.0.1:5055/demo/plateformes:1.0-nop' locally
1.0-nop: Pulling from demo/plateformes
e5c2b96a0c80: Pulling fs layer
d3531b7a4f21: Pulling fs layer
e5c2b96a0c80: Already exists
d3531b7a4f21: Already exists
e5c2b96a0c80: Pull complete
Digest: sha256:e804613147cb4fb45c7ef6f9619df1abd15a2b184e923469f2dc5569e4722b61
Status: Image is up to date for 127.0.0.1:5055/demo/plateformes:1.0-nop
GOOS=linux GOARCH=arm64 app=semrofetalp

Docker pulled the missing layer of the arm64 variant and ran it. The message is misleading: Status: Image is up to date designates the index, already present, and not the variant, which has just been downloaded (the e5c2b96a0c80: Pull complete line shows it). Running without --platform stays on the machine's platform.

Terminal window
docker run --rm 127.0.0.1:5055/demo/plateformes:1.0-nop
GOOS=linux GOARCH=amd64 app=semrofetalp

In the second situation, the local image is a single manifest, pulled by its digest. No index lets Docker find another variant. Delete the index tag, pull the amd64 variant by digest and ask for arm64.

Terminal window
docker image rm 127.0.0.1:5055/demo/plateformes:1.0-nop
docker pull 127.0.0.1:5055/demo/plateformes@$AMD | tail -2
docker run --rm --platform linux/arm64 127.0.0.1:5055/demo/plateformes@$AMD
echo "code=$?"
Status: Downloaded newer image for 127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
Unable to find image '127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202' locally
127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202: Pulling from demo/plateformes
Digest: sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
Status: Image is up to date for 127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
docker: Error response from daemon: image with reference 127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202 was found but does not provide the specified platform (linux/arm64)
Run 'docker run --help' for more information
code=125

This time Docker fails with code 125 and the message was found but does not provide the specified platform (linux/arm64). A manifest digest designates a single platform: there is no variant to go and fetch. The same message is seen, with --pull=never, on a local single-manifest image: measured on the Alpine image pulled for amd64 only.

In the third situation, the image has just been built locally for a single platform, under a tag that matches no registry.

Terminal window
docker buildx build --platform linux/amd64 -f Dockerfile -t demo/plateformes:amd64only --load . 2>&1 | tail -3
docker run --rm --platform linux/arm64 demo/plateformes:amd64only
echo "code=$?"
#12 naming to docker.io/demo/plateformes:amd64only done
#12 unpacking to docker.io/demo/plateformes:amd64only 0.0s done
#12 DONE 0.1s
Unable to find image 'demo/plateformes:amd64only' locally
docker: Error response from daemon: pull access denied for demo/plateformes, repository does not exist or may require 'docker login'
Run 'docker run --help' for more information
code=125

Docker does not find the requested platform locally, tries to pull it from the registry that the name designates (Docker Hub, for demo/plateformes) and gets pull access denied. Code 125 is again Docker's own, not the program's. The table sums up the three cases.

What is localWhat docker run --platform linux/arm64 doesWhat you read
Index tag pulled for amd64 onlyPulls the arm64 variant and runs itImage is up to date, then GOARCH=arm64
Single amd64 manifest, referenced by digestFails: the image does not provide that platformCode 125, was found but does not provide the specified platform
Image built locally for amd64 onlyLooks for the platform in a registry that does not have itCode 125, pull access denied

For the first two cases, the remedy takes two steps: pull the wanted platform with docker pull --platform, then check the architecture with docker image inspect --platform and by running it, rather than trusting the Image is up to date message. For the third, build the wanted platform, or publish the index and pull it.

How do you declare the platforms you have not replayed?

Everything this lesson ran was run on a single amd64 machine, with arm64 emulated by QEMU. Running the image under emulation proves that the arm64 binary starts and answers; it does not prove its behavior on a real arm64 processor, which the lesson did not measure. An honest publication says, platform by platform, what was built, what was run and where.

PlatformBuiltRunWhereWhat it proves
linux/amd64yesyesnatively, on the measurement VMthe x86-64 binary answers GOARCH=amd64
linux/arm64yesyesunder QEMU, on the same VMthe ARM aarch64 binary starts and answers GOARCH=arm64; nothing about an arm64 host
others (386, s390x)nononothing

A platform that you announce in the index without having run it must appear as such in this table, or not be published. The build of linux/s390x with Dockerfile.run or Dockerfile.emul, which run a RUN under the target platform, does fail on this machine, because only the arm64 emulator is registered there (see the troubleshooting table). To qualify a platform, run it on hardware of that architecture, for example from a CI that has arm64 machines. This lesson did not replay that case.

Two guides extend this lesson. For a cluster that mixes both architectures, see Mix ARM and AMD64 nodes in a k3s cluster; to orchestrate several builds from a single file, see Docker Bake.

What to do when a multi-platform build or run fails?

Each error in the table was provoked during the measurements, with Docker 29.9.2 and Buildx 0.38.1. Read the left column as the symptom you see, and the last one as the action that fixes it.

SymptomCauseSolution
exec /bin/uname: exec format error, code 255, on docker run --platform linux/arm64No arm64 emulator registered in binfmt_miscRegister the emulation with tonistiigi/binfmt and --install arm64
exec /bin/sh: exec format error and exit code: 255 at a RUN step of a buildThe RUN runs a program of the target platform, with no emulatorRegister the emulation, or cross-compile and keep only COPY in the final stage
The --platform linux/s390x build of a Dockerfile that runs a RUN under the target platform fails with 255 while arm64 worksOnly qemu-aarch64 is registeredRegister the emulator of that architecture, or do not target it
failed to push 127.0.0.1:5055/…: connection refused with a docker-container builderThe builder runs on the bridge network: 127.0.0.1 is its own containerRecreate the builder with --driver-opt network=host
Status: Image is up to date although the arm64 variant has just been downloadedThe message is about the index, already local, not the variantCheck with docker image ls --tree and docker image inspect --platform
was found but does not provide the specified platform (linux/arm64), code 125The local image is a single manifest of another platformPull the index with docker pull --platform linux/arm64, or the digest of the right variant
pull access denied for demo/plateformes, code 125, on a docker run --platformThe local image does not have the platform and Docker looks in a registry that does not contain itBuild the wanted platform, or publish the index and pull it
Both variants answer GOARCH=amd64, file announces x86-64 everywhereFROM --platform=$BUILDPLATFORM without GOOS=$TARGETOS GOARCH=$TARGETARCH in go buildPass TARGETOS and TARGETARCH to the compilation, then run each variant
4 entries in the index for 2 platformsTwo provenance attestations, with the unknown/unknown platformRead the platform of each entry; --provenance=false if a tool rejects them

How do you clean everything up?

The demonstration leaves a registry, images, an emulation, possibly a builder, files and cache. Remove them in this order, without rm -r: each deletion names what it removes. The AMD variable is used to delete an image further down: if you changed terminals, recompute it before stopping the registry. Start with the registry and the builder.

Terminal window
docker buildx ls
docker volume ls
docker rm -f -v demo-registry
docker image rm registry@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
docker volume ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker
\_ default \_ default running v0.34.0 linux/amd64 (+3), linux/arm64
DRIVER VOLUME NAME
local f7ca5e2a38bef88fa3326fea36e7e4e3c573b507460c1127ecae2d5489df1916
demo-registry
Untagged: registry@sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
Deleted: sha256:d4032d4cc323c1731c707931ecada07a53076eac38e3b61ae57533f170f2c6c3
DRIVER VOLUME NAME

The registry:3 image declares an anonymous volume, which docker rm -f alone leaves in place. The -v option removes it with the container: compare the two docker volume ls lists, the registry's volume disappears from the second one and your other volumes stay. If docker buildx ls still shows the multi builder, remove it; if it no longer exists, docker buildx rm answers no builder "multi" found, which is harmless.

Terminal window
docker buildx use default
docker buildx rm multi

Then remove the emulation, then the lesson's images. Run the first command only if the list noted at the start (the ls of /proc/sys/fs/binfmt_misc) did not already contain qemu-aarch64: otherwise, you would remove an emulation that existed before the lesson.

Terminal window
docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 --uninstall qemu-aarch64
ls /proc/sys/fs/binfmt_misc
docker image rm demo/plateformes:amd64only 127.0.0.1:5055/demo/plateformes@$AMD tonistiigi/binfmt:qemu-v10.2.3-68@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c golang@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb
uninstalling: qemu-aarch64 OK
{
"supported": [
"linux/amd64",
"linux/amd64/v2",
"linux/amd64/v3",
"linux/386"
],
"emulators": [
"python3.13"
]
}
python3.13
register
status
Untagged: demo/plateformes:amd64only
Deleted: sha256:f8dd24ec566157b5199cf2be50e75e782e8f4f58342249ba4684ac552dde2e99
Untagged: 127.0.0.1:5055/demo/plateformes@sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
Deleted: sha256:d75b98894278e727eb1752efc3677cb3f242c13191d427acb33323b4493c4202
Untagged: tonistiigi/binfmt@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0
Deleted: sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0
Untagged: moby/buildkit@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c
Deleted: sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c
Untagged: golang@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb
Deleted: sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb

Check the state of the kernel with ls /proc/sys/fs/binfmt_misc, which must no longer list qemu-aarch64. Do not rely on docker buildx ls for this: after the removal, the default builder still showed linux/arm64 there in the measurements. That list stayed cached until the engine restarted; after sudo systemctl restart docker, it went back to linux/amd64, linux/amd64/v2 and linux/amd64/v3. The real check remains /proc/sys/fs/binfmt_misc.

Warning: docker builder prune -af empties the whole build cache of the builder, not only that of this lesson. Skip this command if other projects depend on it.

Terminal window
docker builder prune -af
docker image ls
ID RECLAIMABLE SIZE LAST ACCESSED
qqfegccgz1itogk5io3szu2lf* true 36.43MB About a minute ago
2e807td3hxxvpgioms1xqm33l* true 36.4MB About a minute ago
[...]
Total: 959.4MB
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
alpine:latest be2656061b53 26.6MB 8.22MB
alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a be2656061b53 26.6MB 8.22MB

The alpine:latest line belongs to the starting state of the measurement machine: it was not created by the lesson, and yours may differ.

Finally delete the lesson's files, one by one, then the empty folder.

Terminal window
cd ~/multiarch && for d in out-*; do rm $d/app; rmdir $d; done; rm Dockerfile Dockerfile.emul Dockerfile.piege Dockerfile.run go.mod main.go digests.py; cd ~; rmdir multiarch

The Alpine image can stay: it serves other lessons, and it keeps the arm64 variant pulled at the start of this one. If you also want to remove it, docker image rm followed by its reference is enough.

Practice

The lab starts from a small program to ship for two architectures. You publish to a local registry an index that references two real variants, then you run both. Only the state of the system is checked, with dsoxlab check, never the commands typed: the validation reads the published index, checks that it does contain the two requested platforms, and verifies at run time that the binary of each variant is the one for its architecture.

Key points

  1. In a Dockerfile, only RUN instructions launch a program of the target platform: they are the ones that require emulation. FROM and COPY launch no program. Without QEMU registered, a RUN of another architecture fails with exec format error and code 255.

  2. Emulation is registered and removed with docker run --privileged --rm tonistiigi/binfmt:qemu-v10.2.3-68@sha256:… --install arm64 then --uninstall qemu-aarch64, and is checked in /proc/sys/fs/binfmt_misc.

  3. A docker-container builder is added and removed (docker buildx create, docker buildx rm, then docker image rm of the pinned BuildKit image). On the bridge network, it cannot reach the host's 127.0.0.1: add --driver-opt network=host.

  4. On the containerd image store, the default builder builds two platforms with --platform linux/amd64,linux/arm64 --load; docker image ls --tree and docker image inspect --platform read each variant.

  5. Cross-compilation is faster than emulation: 8.9 s against 56.8 s on the measurement VM, with FROM --platform=$BUILDPLATFORM, TARGETOS and TARGETARCH. BUILDPLATFORM without GOARCH gives a correct index and a wrong binary.

  6. Never count the entries of an index: 4 entries for 2 platforms, 2 of them unknown/unknown attestations. Pull each variant by the digest of its manifest and run it.

  7. docker run --platform depends on what is local: a partial index pulls the missing variant under a misleading Image is up to date message, a single manifest gives code 125. Pull with docker pull --platform, then check at run time.

  8. A test under emulation is not a qualification on native hardware: declare, platform by platform, what was built, run, and where.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn