Ch. 13 · Docker

Docker Multi-Platform Images and Native Dependencies

How one tag serves amd64 and arm64, why native modules break across CPUs, and how to build multi-platform images by emulating or cross-compiling.

~5 min readadvancedupdated Oct 7, 2026

An image built on an Apple Silicon laptop and pushed to a registry, then pulled onto an x86 production server: the container dies on start with exec format error. Or the image starts, then crashes the first time it loads a native library. Both come from the same fact. Containers aren’t virtual machines; they run directly on the host’s CPU and kernel, so the machine code inside has to match the host’s architecture.

The examples were run on an arm64 Mac (Docker Desktop, containerd image store, BuildKit). Where a step would need images for another architecture that weren’t on the machine, that’s noted.

One tag, several images

Pull alpine:3.20 on an arm64 host and you get arm64 binaries. Pull the same tag on an x86 server and you get x86 binaries. The tag points at an image index (also called a manifest list), which lists one image manifest per platform. With the containerd image store, docker image ls --tree shows the structure:

alpine:3.20                d9e853e87e55       13.7MB         4.17MB
├─ linux/amd64             c64c687cbea9           0B             0B
├─ linux/arm/v6            37753b296504           0B             0B
├─ linux/arm/v7            bd05c4d38cbe           0B             0B
├─ linux/arm64/v8          45e09956dc66       13.6MB         4.09MB
├─ linux/386               4ec3ead63e75           0B             0B
Text

The index knows about many platforms, but only the arm64 variant was downloaded, because that’s what this host needed. docker run picks the matching entry automatically, and --platform linux/amd64 asks for a different one.

Images you build yourself are single-platform by default: whatever the builder’s architecture is. Push that from a Mac and the registry holds an arm64-only image. An x86 server pulling it either refuses with “no matching manifest for linux/amd64” or, if the tag has only one manifest and no index, pulls it anyway and fails at start-up.

What exec format error actually means

The kernel loads executables by reading their header. An ELF binary records its target CPU:

$ file hello
hello: ELF 64-bit LSB executable, ARM aarch64, version 1 (GNU/Linux), statically linked, ...
Text

Ask an x86-64 kernel to execute that and it returns ENOEXEC, which surfaces as exec /app/hello: exec format error. Nothing in Docker translates instructions. The exec format error guide walks through diagnosing it, including the other cause (a script without a shebang line).

Docker Desktop hides this on Macs by registering QEMU user-mode emulation through the kernel’s binfmt_misc mechanism, so docker run --platform linux/amd64 works there, slowly. On a plain Linux server, emulation exists only if someone installed it (docker run --privileged --rm tonistiigi/binfmt --install all is the common way). This is why “it runs on my Mac with --platform linux/amd64” doesn’t prove much: the laptop is emulating; production isn’t.

Native dependencies are where it really breaks

Pure interpreted code is portable: a JavaScript file or a Python module runs on any CPU that has the interpreter. The interpreter itself comes from the base image, which is per-platform, so that part takes care of itself. The trouble is compiled pieces your dependencies bring:

  • Node packages with native addons (bcrypt, sharp, better-sqlite3, canvas) compile or download a .node shared library for the current platform during npm install.
  • Python wheels like numpy, cryptography and psycopg2-binary are published per platform and per libc (manylinux for glibc, musllinux for Alpine’s musl).
  • Go binaries with cgo, Rust crates linking system libraries, and Java apps with JNI libraries.

The classic failure: a developer runs npm install on macOS, and the Dockerfile does COPY . ., so the macOS arm64 node_modules overwrites what the image installed. The container starts and then throws Error: ... invalid ELF header or “was compiled against a different Node.js version”. The same happens with a multi-stage build that installs on Debian and copies node_modules into an Alpine runtime: right CPU, wrong libc. Fixes: exclude node_modules in .dockerignore, install dependencies inside the build for the target platform, and keep build and runtime stages on the same distribution family.

Building for several platforms

docker buildx build --platform linux/amd64,linux/arm64 builds every platform and pushes an index. For each platform BuildKit runs the Dockerfile with the target’s base image; for foreign platforms, RUN steps go through QEMU. That’s correct for everything, including native addons, and often painfully slow: compiling C extensions under emulation can be 5 to 20 times slower than natively.

The faster pattern runs the compiler natively and tells it the target. BuildKit sets platform arguments automatically:

FROM --platform=$BUILDPLATFORM node:22-bookworm AS build
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
RUN echo "built on $BUILDPLATFORM for $TARGETPLATFORM (GOOS=$TARGETOS GOARCH=$TARGETARCH)" > /info.txt

FROM scratch
COPY --from=build /info.txt /info.txt
dockerfile
$ docker build --platform linux/amd64,linux/arm64 -t arch-demo .
#5 [linux/arm64 build 2/2] RUN echo "built on linux/arm64 for linux/arm64 (GOOS=linux GOARCH=arm64)" > /info.txt
#6 [linux/arm64->amd64 build 2/2] RUN echo "built on linux/arm64 for linux/amd64 (GOOS=linux GOARCH=amd64)" > /info.txt
$ docker image ls --tree
arch-demo:latest        78b1a91405c1       14.2kB         6.05kB
├─ linux/amd64          7425c63d162f       1.07kB         1.07kB
└─ linux/arm64          577844c02cd2       9.26kB         1.07kB
Text

Both build stages ran natively on arm64 (linux/arm64->amd64 in the log means “native builder producing for amd64”), and the result is one tag with two variants. In a real Dockerfile the RUN line would be GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app ., which Go does natively. Rust with cross, Zig, .NET and Java (bytecode is portable) fit the same pattern. For C toolchains and most native npm or pip packages, cross-compiling is harder, so teams either accept emulation or use native builders: BuildKit can drive several nodes (docker buildx create --append with an arm64 node and an amd64 node) and send each platform to the matching machine. CI providers now offer arm64 runners for exactly this.

Note

Producing a multi-platform image locally needs the containerd image store (the default in recent Docker Desktop) or pushing straight to a registry with --push. The classic image store can hold only one platform per tag.

Interview practice

Your team moves CI to arm64 runners to save money. The next release of a Python API, based on python:3.12-slim, deploys to the existing x86 Kubernetes cluster and every pod fails with exec /usr/local/bin/python3: exec format error. Someone suggests adding --platform linux/amd64 to the build. Explain the failure, evaluate that fix, and propose a better setup.

Answer

The arm64 runners built single-platform arm64 images, since a build defaults to the builder’s architecture. The x86 nodes pulled them, and the kernel couldn’t execute the arm64 Python interpreter, hence exec format error on the very first binary.

Adding --platform linux/amd64 works, but on an arm64 runner it means every RUN step runs under QEMU emulation. pip install of packages with C extensions that lack prebuilt wheels compiles under emulation, which can turn a 3-minute build into a 30-minute one, and you’ve lost the cost saving.

Better: build --platform linux/amd64,linux/arm64 and push an index, so the image works on both clusters and on developer Macs. Use native builders per platform (an amd64 runner or a remote amd64 BuildKit node alongside the arm64 one) so nothing is emulated, or accept emulation only for the amd64 variant if prebuilt wheels make it cheap. Add a CI check that inspects the pushed manifest and fails if a required platform is missing, and run smoke tests on each architecture before deploying.

Keep going

More in Docker

esc