Docker Multi-Arch: Solving the ‘Exec Format Error’ for ARM64 and AMD64

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

The 2 AM Architecture Nightmare

My phone buzzed at 2 AM with a P1 incident alert. A developer had just pushed a critical hotfix, and while the CI/CD pipeline flashed green, half of our production nodes were stuck in a CrashLoopBackOff. The logs revealed a cryptic, frustrating error: standard_init_linux.go:211: exec user process caused "exec format error".

The problem was simple but devastating. We had recently migrated our worker nodes to AWS Graviton (ARM64) to take advantage of that 40% better price-performance ratio. However, our build server was still a traditional Intel (AMD64) machine. We had pushed x86 instructions to ARM processors that didn’t speak the language. This is the classic trap of modern DevOps: we work in a multi-architecture world, but our build tools often stay stuck in the past.

Why Single-Architecture Images Fail

Life used to be simple when everything ran on AMD64. Now, ARM64 is everywhere. It powers Apple’s M3 chips, AWS Graviton3, and Google Cloud’s Tau T2A instances. If you build an image on your local MacBook and push it to a Linux server, you risk a mismatch. The same happens when a CI runner builds an image that eventually lands on an edge device or a Raspberry Pi.

The “Exec Format Error”

When you encounter exec format error, the binary inside your container is incompatible with the host’s CPU instruction set. You cannot force this to work. You need a container image that packages the specific binaries required for whatever hardware it happens to land on. Multi-architecture images solve this by bundling multiple platforms under a single tag.

The Solution: Docker Buildx and QEMU

You don’t need to maintain separate Dockerfiles or complex pipelines for this. Instead, we use Buildx and QEMU to handle the heavy lifting.

What is Buildx?

Buildx is a Docker CLI plugin that replaces the standard docker build command. It leverages the Moby BuildKit engine to create “builder instances.” These instances can compile for multiple platforms simultaneously and wrap them into a single manifest list.

How QEMU Bridges the Gap

An AMD64 machine cannot natively run ARM instructions. QEMU (Quick Emulator) acts as a translation layer. It allows Buildx to run non-native commands during the build process. For instance, QEMU lets an Intel host run npm install or apt-get for an ARM target by mimicking the ARM environment in software.

Practical Implementation: Building for Both Worlds

I have used this workflow in production for over two years. It is the most stable way to ensure your images run everywhere without manual intervention.

Step 1: Initialize the Multi-Arch Builder

Docker’s default driver doesn’t support multi-arch builds. You must create a new builder instance using the docker-container driver to unlock these features.

# Create a new builder named 'mybuilder'
docker buildx create --name mybuilder --use

# Boot the builder and check capabilities
docker buildx inspect --bootstrap

Check the output for supported platforms. If linux/arm64 isn’t there, you need to enable emulation in the next step.

Step 2: Enable Emulation with QEMU

On most Linux distributions and standard GitHub Actions runners, you need to register QEMU handlers. This tells the kernel how to handle foreign binaries.

# Register the binfmt_misc handlers
docker run --privileged --rm tonistiigi/binfmt --install all

Run docker buildx ls again. You should now see a massive list of supported platforms, including linux/amd64, linux/arm64, and even linux/riscv64.

Step 3: Build and Push

This is where the orchestration happens. We swap docker build for docker buildx build. The --platform flag defines our targets, and --push sends the entire package to your registry.

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t your-registry.com/my-app:v1.0.2 \
  --push .

Docker builds the image twice—once for each architecture. It then pushes both versions to the registry along with a Manifest List. When a user pulls my-app:v1.0.2, the Docker engine automatically detects the host’s CPU and fetches the correct version. It just works.

Automating with GitHub Actions

Manual builds are fine for local testing, but production requires automation. GitHub Actions provides official modules that make this setup painless.

Here is a production-ready workflow (.github/workflows/docker-build.yml):

name: Build Multi-Arch Image

on:
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: user/app:latest

Common Pitfalls to Avoid

Even with the right tools, there are a few traps that can break your build or slow you down.

  • Build Speed: Emulation is resource-intensive. Building an ARM64 image on an AMD64 runner via QEMU can be 5x slower than a native build. If your 5-minute build suddenly takes 25 minutes, consider using native ARM64 runners.
  • Hardcoded Binaries: Avoid RUN curl -O https://example.com/tool-x86_64 in your Dockerfile. This will break the ARM version. Use the TARGETARCH argument to fetch the correct binary dynamically.
# Handling architecture dynamically
ARG TARGETARCH
RUN curl -L https://github.com/some/tool/releases/download/v1.0/tool-${TARGETARCH} -o /usr/local/bin/tool

The Bottom Line

Multi-architecture builds are a requirement for modern cloud infrastructure. They eliminate the “it works on my machine” excuse when your machine is an M3 Mac and your server is a Xeon processor. By integrating Buildx and QEMU into your CI/CD pipeline, you make your software portable and future-proof. You can finally stop worrying about architecture-driven outages and focus on shipping features instead.

Share: