Emulating amd64 Containers on arm64 with QEMU
An amd64 image that runs everywhere in CI dies on an Apple Silicon laptop with exec /bin/sh: exec format error, or it starts but takes ten times longer than it should because every instruction is being translated one at a time. This page registers the QEMU binfmt handlers that let an arm64 kernel run linux/amd64 containers, then shows how to tell "not registered" apart from "registered but slow" and how to reclaim most of the lost speed. It is a how-to under cross-platform container builds, part of the broader containerized local environment patterns.
Diagnostic
There are two distinct symptoms and they need different fixes, so name which one you have before touching anything. The first is a hard failure: the container never runs its entrypoint.
#!/usr/bin/env bash
set -euo pipefail
docker run --rm --platform linux/amd64 alpine:3.19 uname -m
If binfmt is not registered, the daemon pulls the amd64 image and then the kernel refuses to execute its x86-64 ELF binary:
Unable to find image 'alpine:3.19' locally
3.19: Pulling from library/alpine
Status: Downloaded newer image for alpine:3.19
exec /bin/uname: exec format error
That message means the kernel found no interpreter registered for the x86-64 binary format. The second symptom is the opposite: the same command works but everything is glacial. Confirm the container is genuinely running as amd64 and time a CPU-bound workload against the native architecture:
#!/usr/bin/env bash
set -euo pipefail
echo "host arch: $(uname -m)"
echo "emulated: $(docker run --rm --platform linux/amd64 alpine:3.19 uname -m)"
echo "native: $(docker run --rm --platform linux/arm64 alpine:3.19 uname -m)"
echo "== amd64 (emulated) =="
time docker run --rm --platform linux/amd64 python:3.12-slim \
python -c "sum(i*i for i in range(20_000_000))"
echo "== arm64 (native) =="
time docker run --rm --platform linux/arm64 python:3.12-slim \
python -c "sum(i*i for i in range(20_000_000))"
On an M-series host the emulated run commonly finishes five to fifteen times slower than the native one. A real time gap of that magnitude on pure compute is the signature of instruction-by-instruction translation, not a disk or network problem. To see what the kernel actually has registered, read the binfmt_misc entry directly:
#!/usr/bin/env bash
set -euo pipefail
if [ -f /proc/sys/fs/binfmt_misc/qemu-x86_64 ]; then
cat /proc/sys/fs/binfmt_misc/qemu-x86_64
else
echo "no qemu-x86_64 handler registered"
fi
A healthy handler prints enabled, an interpreter path, and a flags: line. The flag that matters for containers is F (fix-binary): without it the interpreter is resolved in the host mount namespace at exec time and will not be visible inside the container's root filesystem, producing intermittent no such file or directory failures on the QEMU binary itself.
macOS (Docker Desktop):
/proc/sys/fs/binfmt_misclives inside the Linux VM, not on macOS. Run thecatabove through a throwaway--privilegedcontainer (docker run --rm --privileged alpine cat /proc/sys/fs/binfmt_misc/qemu-x86_64) to inspect the VM's registration.
Root cause
Apple Silicon is an arm64 (aarch64) machine. Its CPU cannot decode x86-64 machine code, and unlike a same-architecture container there is no hardware virtualization path that would make one possible — a container shares the host kernel and the host CPU's instruction set. To run a linux/amd64 image on this hardware, something has to translate x86-64 instructions into arm64 instructions at runtime, and that something is QEMU running in user-mode emulation.
The Linux kernel wires QEMU in through binfmt_misc. That subsystem lets you register a userspace interpreter for a binary format keyed by its leading magic bytes. Docker (or the tonistiigi/binfmt image) writes a registration for the x86-64 ELF magic that points at a statically linked qemu-x86_64 binary. From then on, when the kernel is asked to exec an amd64 program, it transparently invokes qemu-x86_64 <program> instead. If that registration is missing, exec fails immediately — that is the exec format error.
The slowness is inherent to how QEMU translates. Its Tiny Code Generator (TCG) reads blocks of guest x86-64 instructions, compiles them to host arm64 instructions, and caches the result, but it is still dynamic binary translation with no silicon assist. CPU-bound and syscall-heavy code pays the worst penalty because every arithmetic op and every trap crosses the translation layer; memory-bound or I/O-bound work suffers less. This is why a Python loop or a native compile crawls while a mostly-idle web service feels almost normal. On macOS specifically there is a faster path — Rosetta 2 does the x86-64-to-arm64 translation in a purpose-built layer Apple ships for its own binaries, and Docker Desktop can route amd64 emulation through Rosetta instead of QEMU, which typically closes most of the gap.
Resolution
Register the QEMU handlers with the fix-binary flag. The
tonistiigi/binfmtimage installs statically linked QEMU interpreters into the kernel'sbinfmt_miscand sets theFflag so the interpreter is loaded at registration time and remains valid inside every container namespace:#!/usr/bin/env bash set -euo pipefail docker run --privileged --rm tonistiigi/binfmt --install amd64On Docker Desktop the handlers are usually pre-installed; run this only if the diagnostic showed no
qemu-x86_64entry. On a plain Linux Docker Engine (including inside WSL2) it is required after every VM or daemon reset unless you persist it.Verify the handler and its flags. Confirm the entry exists, is
enabled, and carries theFflag before relying on it:#!/usr/bin/env bash set -euo pipefail docker run --rm --privileged alpine:3.19 \ sh -c 'grep -H . /proc/sys/fs/binfmt_misc/qemu-x86_64'Look for
enabled, aninterpreter /usr/bin/qemu-x86_64line, andflags: OCF(theFis the one that matters). A missingFis the usual cause of "it worked in a bare shell but broke inside a build".Pin the platform explicitly rather than relying on defaults. State
linux/amd64on the service so nobody has to remember a--platformflag, and so the emulated architecture is recorded in version control:# docker-compose.yml services: legacy-api: image: registry.example.com/legacy-api:1.8.0 platform: linux/amd64 ports: - "8080:8080"Bring it up with
docker compose up legacy-api; the daemon pulls theamd64variant and runs it through the registered handler.Switch macOS emulation to Rosetta. In Docker Desktop, enable Settings → General → "Use Rosetta for x86_64/amd64 emulation on Apple Silicon" (requires macOS 13+ and the VirtioFS file sharing backend). For a container you orchestrate under Docker Desktop's Kubernetes or a VM you provision yourself, mounting the Rosetta share and pointing binfmt at it achieves the same routing:
# docker-compose.yml — Rosetta-backed amd64 service on Apple Silicon services: legacy-api: image: registry.example.com/legacy-api:1.8.0 platform: linux/amd64 volumes: - /run/host-services/rosetta:/mnt/rosetta:roRosetta translates the same
x86-64code far faster than QEMU's TCG, so a build that took minutes under QEMU often returns to a workable feedback loop.Reserve emulation for running, not building, images. If your real goal is to produce a multi-architecture image, build each architecture on native hardware with
buildxinstead of emulating a compile. Emulation is the right tool for running an occasionalamd64-only dependency locally; it is the wrong tool for a hot build loop, which is far better served by the native-first ordering described in optimizing Docker Compose for fast local rebuilds.
Expected output
With the handler registered, the emulated container reports the emulated architecture and the entrypoint runs cleanly:
host arch: arm64
emulated: x86_64
native: aarch64
Reading the binfmt_misc entry now shows an enabled handler with the fix-binary flag set:
/proc/sys/fs/binfmt_misc/qemu-x86_64:enabled
/proc/sys/fs/binfmt_misc/qemu-x86_64:interpreter /usr/bin/qemu-x86_64
/proc/sys/fs/binfmt_misc/qemu-x86_64:flags: OCF
The x86_64 reported by uname -m inside an arm64 host proves the translation layer is active. After enabling Rosetta, re-running the timed Python loop from the diagnostic should show the emulated real time collapse toward the native figure — often from a five-to-fifteen-times penalty under QEMU down to a small multiple. The numbers below are representative of the same 20-million-iteration loop measured three ways on one M-series laptop; treat them as a shape, not a guarantee, since the exact ratio depends on how syscall- and branch-heavy the workload is.
Prevention
Registration and platform choice both drift, so encode them rather than leaving them to each engineer's shell history.
- Persist binfmt registration. On a plain Linux daemon the handlers vanish on reboot. Run
tonistiigi/binfmt --install amd64from a systemd unit or adocker composebootstrap service withrestart: on-failureso a fresh boot re-registers before anyamd64container starts. On Docker Desktop, the handlers are restored with the VM, so no persistence step is needed. - Pin
platform:in Compose for every image that only shipsamd64. An explicitplatform: linux/amd64removes the guesswork: the daemon never silently pulls an unusable variant, and a teammate on anx86-64machine gets the identical pin. Keep the native default for everything else so you are not emulating by accident. - Build multi-arch images on native runners in CI, not by emulation. A CI matrix with an
arm64runner and anamd64runner produces each image on real silicon and assembles a manifest list, which is both faster and free of QEMU's rare translation bugs. Keeping local and CI architectures aligned is part of the broader CI parity validation reference; document in the README that localamd64runs are emulated so nobody benchmarks performance against them.
Platform caveats
Apple Silicon (ARM64): QEMU works but is the slow path. Prefer Rosetta (Docker Desktop 4.16+, macOS 13+, VirtioFS backend) for amd64 emulation. Rosetta cannot emulate AVX-512 and a few other extensions, so an image compiled for a modern
x86-64-v3/v4baseline may fault under Rosetta and need the QEMU fallback. WSL2: binfmt registration lives in the WSL2 distro's kernel and is lost onwsl --shutdown. Re-rundocker run --privileged --rm tonistiigi/binfmt --install amd64after a restart, or registerqemu-user-staticfrom the distro so handlers survive reboots. macOS (Docker Desktop): Thebinfmt_miscmount is inside the Linux VM, not on the Mac. Inspect and modify it only through a--privilegedcontainer; editing anything under/proc/sys/fs/binfmt_miscon the host will not exist.
Rollback
#!/usr/bin/env bash
set -euo pipefail
# Remove the amd64 QEMU handler and drop platform pins.
docker run --privileged --rm tonistiigi/binfmt --uninstall qemu-x86_64
git checkout HEAD -- docker-compose.yml
docker compose up -d --remove-orphans
Frequently Asked Questions
Why do I get exec format error only for some images?
Because those images are amd64-only and your kernel has no interpreter registered for the x86-64 binary format. Multi-architecture images resolve to their native arm64 variant on Apple Silicon and run without emulation, so they never hit the error. A single-architecture amd64 image has nothing native to fall back to, so the kernel tries to exec x86-64 machine code directly and fails. Registering the QEMU binfmt handler with tonistiigi/binfmt --install amd64 gives the kernel an interpreter and the same image runs.
Is Rosetta always faster than QEMU for amd64 containers?
For most workloads on Apple Silicon, yes — Rosetta's translation is substantially faster than QEMU's TCG, especially for CPU-bound code. The exception is instruction sets Rosetta does not implement, such as AVX-512; a binary that uses them will fault under Rosetta and you must fall back to QEMU, which supports a wider instruction range at the cost of speed. Enable Rosetta by default and keep the QEMU handler installed as a fallback for images that need the broader coverage.
What does the F flag in the binfmt entry actually do?
F is the fix-binary flag. Without it, the kernel resolves the interpreter path (the QEMU binary) lazily at exec time in the caller's mount namespace. Inside a container that namespace is the container's root filesystem, which does not contain /usr/bin/qemu-x86_64, so the exec fails. With F, the kernel opens the interpreter once at registration and holds that file open, so it stays valid across every namespace. The tonistiigi/binfmt installer sets F for you, which is why it is the reliable way to register handlers for containerized use.
Should I emulate amd64 to build multi-arch images, or build them natively?
Build natively whenever you can. Emulating a full compile under QEMU is where the penalty hurts most and where translation bugs are most likely to surface. Use a CI matrix with real arm64 and amd64 runners, build each architecture on its own hardware, and join them into a manifest list with buildx. Reserve local emulation for running an occasional amd64-only image, not for producing release artifacts on a hot build loop.