QEMU Emulation

QEMU emulation runs binaries built for another processor architecture by translating their instructions, which is how an x64 machine builds an ARM64 image.

QEMU emulation runs a program compiled for one processor architecture on a machine with a different one, by translating the program's instructions into instructions the host CPU can execute. In a Docker build it is the mechanism that lets an x64 machine run the aarch64 binaries inside an arm64 base image, and the translated code runs far slower than the same binaries would on an ARM64 machine.

Two properties of that sentence explain most confusing build logs. The translation applies only to processes executing guest binaries, so parts of the same build stay at full speed. And nothing in the workflow file names the interpreter, so the only visible trace is a setup step near the top of the job.

Definition

QEMU is an open source machine emulator and virtualizer. It has two modes, and the difference decides what a Docker build is paying for.

User mode emulation runs a single foreign binary as an ordinary process on the host. QEMU translates that process's instructions and passes its system calls through to the host kernel. There is no guest kernel and no virtual hardware.

System mode emulation emulates a whole machine: a CPU, memory, devices, and a guest kernel booted on top of them. It is what you use to boot an operating system for another architecture.

Container image builds use user mode. A container shares the host kernel already, so the only thing missing on an x64 machine is a way to execute aarch64 user space binaries.

How the kernel routes a foreign binary

The connection between the two is binfmt_misc, a Linux kernel feature that maps the magic bytes at the start of a file to an interpreter that should run it. Registration writes one file per architecture under /proc/sys/fs/binfmt_misc. Each file names the ELF magic for that architecture, a mask, and the path to the QEMU binary that handles it.

Docker's multi-platform build documentation describes installation as registering the emulators with binfmt_misc, and it documents verifying the result by checking that F appears among the flags in /proc/sys/fs/binfmt_misc/qemu-* (Docker multi-platform builds, checked on 2026-08-13). That F flag makes the kernel open the interpreter at registration time and hold the reference, which is why the handler still works inside a container whose filesystem contains no copy of QEMU.

Once an entry exists, running an aarch64 binary on an x64 machine starts qemu-aarch64 with that binary as its argument. Without the entry the same command returns exec format error.

What the interpreter actually does

Translation happens a basic block at a time. QEMU reads a run of guest instructions up to the next branch, compiles it into host instructions, caches the result, and jumps into the cached form. A hot loop pays the translation cost once and then reuses it, so long compiles behave better than a per instruction model would predict, and still land well short of native speed.

System calls take a separate path. The interpreter catches each one, converts the arguments from the guest ABI to the host ABI, and forwards it to the host kernel. Work that makes many small calls, such as a linker walking thousands of object files, pays that conversion on every call.

Only part of a build passes through the interpreter:

Part of an emulated image buildWhat executes itTranslated
Guest binaries in the target rootfs: compilers, linkers, package managers, testsQEMU on the host CPUYes
System calls made by those binariesHost kernel, arguments converted firstPartly
The build engine, base image pulls, COPY and ADDHost CPU, nativelyNo
Kernel interfaces such as /proc/cpuinfo and nprocHost kernel, reporting host valuesNo

The last row is a frequent source of surprise. A step that sizes its parallelism from the detected core count reads host x64 information while running guest code, and a library that probes for CPU features can receive an answer the interpreter does not implement, which surfaces as Illegal instruction rather than as a clear emulation error.

Docker documents three strategies for producing multi-platform images: emulation with QEMU, multiple native build nodes, and cross compilation in a multi-stage build. It states that emulation with QEMU can be much slower than native builds, especially for compute heavy tasks like compilation and compression or decompression, and recommends the other two strategies where they apply.

Example

This workflow builds an arm64 image on an x64 machine. ubuntu-latest is GitHub's default hosted Linux label and is an x64 machine, so the docker/setup-qemu-action step is what makes the build possible at all.

name: image
on:
  push:
    branches: [main]

jobs:
  build-arm64:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-qemu-action@v3
        with:
          platforms: arm64

      - uses: docker/setup-buildx-action@v3

      - name: Build and push the arm64 image
        run: |
          docker buildx build \
            --platform linux/arm64 \
            --tag ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 \
            --push \
            .

After the setup step, the registration is visible in the job:

$ cat /proc/sys/fs/binfmt_misc/qemu-aarch64
enabled
interpreter /usr/bin/qemu-aarch64
flags: OCF

Every RUN step in the Dockerfile now executes inside a rootfs of aarch64 binaries, and each one reaches the CPU as translated code.

The same build on an ARM64 machine drops the setup step entirely. GitHub publishes ubuntu-24.04-arm as a hosted ARM64 label in its hosted runners reference, checked on 2026-08-13:

jobs:
  build-arm64:
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - name: Build and push the arm64 image
        run: |
          docker buildx build \
            --platform linux/arm64 \
            --tag ghcr.io/${{ github.repository }}:${{ github.sha }}-arm64 \
            --push \
            .

The Dockerfile, the tag, and the buildx command are identical. What changed is the architecture of the machine underneath, so --platform linux/arm64 now names the host's own architecture and the binaries execute directly.

BehaviorEmulated build on an x64 machineNative build on an ARM64 machine
binfmt_misc handlerRequired before the buildUnused
Guest instructionsTranslated by QEMU, then executedExecuted by the CPU
System callsIntercepted and convertedDirect
nproc and /proc/cpuinfoHost x64 valuesARM64 values
Common failure signaturesexec format error, Illegal instruction, hangs in JIT heavy stepsOrdinary build errors

A workflow that has to publish both architectures under one tag runs one job per architecture and joins the two results with a manifest list, which keeps the interpreter out of both jobs.

FAQ

What is QEMU emulation in a Docker build?

It is user mode emulation of a foreign processor architecture. QEMU translates the guest instructions of every binary inside the target rootfs into host instructions and forwards the guest syscalls to the host kernel, so an x64 machine can execute the aarch64 binaries in an arm64 base image.

How does the kernel know to hand a binary to QEMU?

Through binfmt_misc, a Linux kernel feature that maps the magic bytes at the start of a file to an interpreter. Registering an aarch64 entry writes a file under /proc/sys/fs/binfmt_misc naming the aarch64 ELF magic and the qemu-aarch64 binary, and the kernel routes matching binaries there instead of failing with exec format error.

Which build steps get slower under QEMU emulation?

The steps that execute guest binaries and burn CPU: compilers, linkers, minifiers, package managers that build from source, and tests run during the image build. Docker's multi-platform documentation states that emulation with QEMU can be much slower than native builds, especially for compute heavy tasks like compilation and compression or decompression.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.