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.