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_misc lives inside the Linux VM, not on macOS. Run the cat above through a throwaway --privileged container (docker run --rm --privileged alpine cat /proc/sys/fs/binfmt_misc/qemu-x86_64) to inspect the VM's registration.

How binfmt routes an amd64 binary to QEMU The kernel reads the ELF magic bytes, matches a binfmt_misc entry, and hands the amd64 binary to the QEMU interpreter for translation to arm64. binfmt Dispatch Path exec amd64 ELF x86-64 magic bytes binfmt_misc match qemu-x86_64 QEMU TCG translate to arm64 No registered handler means the middle box is empty and exec fails.
When no handler is registered, the kernel has nothing to hand the amd64 binary to and returns exec format error.

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

  1. Register the QEMU handlers with the fix-binary flag. The tonistiigi/binfmt image installs statically linked QEMU interpreters into the kernel's binfmt_misc and sets the F flag 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 amd64

    On Docker Desktop the handlers are usually pre-installed; run this only if the diagnostic showed no qemu-x86_64 entry. On a plain Linux Docker Engine (including inside WSL2) it is required after every VM or daemon reset unless you persist it.

  2. Verify the handler and its flags. Confirm the entry exists, is enabled, and carries the F flag 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, an interpreter /usr/bin/qemu-x86_64 line, and flags: OCF (the F is the one that matters). A missing F is the usual cause of "it worked in a bare shell but broke inside a build".

  3. Pin the platform explicitly rather than relying on defaults. State linux/amd64 on the service so nobody has to remember a --platform flag, 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 the amd64 variant and runs it through the registered handler.

  4. 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:ro

    Rosetta translates the same x86-64 code far faster than QEMU's TCG, so a build that took minutes under QEMU often returns to a workable feedback loop.

  5. 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 buildx instead of emulating a compile. Emulation is the right tool for running an occasional amd64-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.

Order of operations to run an amd64 image on arm64 Four ordered steps from registering binfmt, through verifying the flag, pinning the platform, to selecting Rosetta for speed. Enable and Tune Emulation 1 — install binfmt handlers 2 — verify enabled + F flag 3 — pin platform: linux/amd64 4 — route via Rosetta for speed
Registration makes amd64 runnable; verifying the flag and choosing Rosetta makes it usable.

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.

Runtime of the same CPU loop native, under QEMU, and under Rosetta Bar chart comparing wall-clock seconds for a compute loop run natively on arm64, emulated with QEMU, and emulated with Rosetta. CPU Loop Wall Time (seconds) arm64 native 1.3s amd64 Rosetta 3.4s amd64 QEMU 14.8s
Same loop, three backends: Rosetta cut the emulation penalty from roughly 11x down to under 3x on this host.

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 amd64 from a systemd unit or a docker compose bootstrap service with restart: on-failure so a fresh boot re-registers before any amd64 container 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 ships amd64. An explicit platform: linux/amd64 removes the guesswork: the daemon never silently pulls an unusable variant, and a teammate on an x86-64 machine 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 arm64 runner and an amd64 runner 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 local amd64 runs are emulated so nobody benchmarks performance against them.
Diagnosing exec format error versus slow emulation A decision based on whether the amd64 container fails to exec or merely runs slowly, leading to registering binfmt or switching to Rosetta. Which Symptom? Does the container exec? or exec format error Fails to exec install binfmt handlers Runs but slow enable Rosetta / build native
A hard exec failure is a registration problem; a slow-but-working container is a translation-backend problem.

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/v4 baseline may fault under Rosetta and need the QEMU fallback. WSL2: binfmt registration lives in the WSL2 distro's kernel and is lost on wsl --shutdown. Re-run docker run --privileged --rm tonistiigi/binfmt --install amd64 after a restart, or register qemu-user-static from the distro so handlers survive reboots. macOS (Docker Desktop): The binfmt_misc mount is inside the Linux VM, not on the Mac. Inspect and modify it only through a --privileged container; editing anything under /proc/sys/fs/binfmt_misc on 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.