/writing/the-dockerfile-flag-that-ships-the-wrong-architecture

The Dockerfile Flag That Silently Ships the Wrong Architecture

Pods on our arm64 nodes were dying instantly with:

exec /entrypoint.sh: exec format error

“Exec format error” means the kernel was handed a binary it can’t run. On a mixed- architecture cluster the obvious hypothesis is that an amd64-only image got scheduled onto an arm64 node — a missing platform in the manifest, or a node selector we forgot.

So I checked the manifest:

docker manifest inspect <image>:<tag>

Both platforms present. linux/amd64 and linux/arm64, distinct digests, no unknown/unknown attestation entries polluting the list. It looked perfect. Which is why this took a while.

The one-line cause

The Dockerfile started with:

FROM --platform=$BUILDPLATFORM python:3.12-slim

$BUILDPLATFORM is the architecture of the machine doing the build. $TARGETPLATFORM is the architecture you’re shipping to. The flag pins that stage to the builder’s architecture — amd64, in CI.

That flag exists for a real and good reason: multi-stage cross-compilation. You pin the builder stage to the native architecture so compilation runs fast without emulation, then COPY --from=builder the artifacts into a final stage that resets to $TARGETPLATFORM. Build fast, ship correct.

The trap is what happens without that final stage. In a single-stage Dockerfile, or in a multi-stage one whose last stage never resets, every platform variant you push contains the build host’s binaries. Buildx dutifully builds “both platforms” — by running the same amd64 stage twice — and pushes them as separate manifest entries.

Why the manifest looked fine

This is the part worth internalizing.

Each platform entry in a manifest list has a config blob, and that config blob has an architecture field. Buildx writes arm64 in the arm64 entry’s config because that’s the platform it was asked to produce. The field is a declaration, not a measurement. Nothing in the manifest is derived from the layer contents.

So docker manifest inspect confirmed exactly what it’s able to confirm: the index has the right shape, and each entry claims the right platform. Layer digests even differed between the two entries — which made it look more legitimate, not less, though the difference came from build metadata rather than from any actual architectural difference in the compiled code.

Manifest metadata is a lie detector that doesn’t detect lies. It validates structure. It never validates that the bytes inside match the label outside.

How to actually check

Pull the specific platform and look at a binary:

docker pull --platform linux/arm64 <image>:<tag>
CID=$(docker create <image>:<tag>)
docker cp $CID:/bin/bash /tmp/check-bash
docker rm $CID
file /tmp/check-bash
# MUST report "ARM aarch64". If it says "x86-64", the build is broken.

/bin/bash is a convenient probe because it comes from the base image, so it reflects what the base actually resolved to. If your entrypoint is a compiled binary of your own, check that too — a mixed image where the base is right and your artifact is wrong is entirely possible with a partially-correct multi-stage setup.

One command, ten seconds, and it’s the only check in this whole story that inspects reality rather than a claim about reality.

The fix

For a pure-Python image — or anything else where “building” means installing packages rather than compiling your own source — drop the flag entirely:

FROM python:3.12-slim

With QEMU configured, or with native runners per platform, buildx resolves the correct base image and installs the correct architecture’s wheels into each variant. There’s nothing to cross-compile, so there’s no reason to pin a builder stage.

Only reach for --platform=$BUILDPLATFORM when you have a genuine multi-stage cross-compile — and when you do, make sure the final stage resets to the target:

FROM --platform=$BUILDPLATFORM golang:1.23 AS builder
ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/app

FROM gcr.io/distroless/static      # <- inherits TARGETPLATFORM. This line is the fix.
COPY --from=builder /out/app /app

What I took from it

A flag copied from an example is a flag you haven’t read. --platform=$BUILDPLATFORM appears in a lot of multi-arch tutorials, always as part of a cross-compilation pattern, and it’s easy to lift the first line of the pattern without the last one. The first line alone is worse than nothing — it produces a wrong image that looks right.

Verify at the layer that can be wrong. The manifest was the wrong place to look because the manifest can’t be wrong in this way; it’s generated from the same intent that produced the bad build. file on an extracted binary is the first check in the chain that consults ground truth.

Add the verification to CI once. The check is three lines and it catches an entire class of silent failure. We build multi-arch images regularly; nobody remembers to manually inspect a binary on the day it matters, and by then the image is a tag someone else’s manifest already points at.