One tag that resolves to an x86 image on a CI runner and an arm64 image on a Graviton node is a manifest list, and building one is two flags. The work is in the Dockerfile, because an inference image is mostly compiled dependencies and those are where cross-platform builds either take ninety seconds or take an hour.
What a multi-platform image is
A registry tag does not have to point at an image. It can point at an index — an OCI image index, or a Docker manifest list — which is a small JSON document listing one manifest digest per platform. When a runtime pulls the tag it reads the index, finds the entry whose os and architecture match its own, and pulls only that image. Nothing on the node knows or cares that other platforms exist.
That is why this is worth doing rather than maintaining my-inference:1-amd64 and my-inference:1-arm64: the deployment manifest, the Helm values file and the terraform variable all keep one string, and the node picks. It also means a mistake is silent in a specific way — an index with only one entry pulls fine on the platform it has and fails on the other with a message about no matching manifest for the requested platform.
Getting a builder that can do it
The default builder in older Docker Engine versions writes into the classic image store, which cannot hold a manifest list, so a multi-platform build has to either go straight to a registry or use the containerd image store. Docker documents that Docker Desktop and Engine 29.0 and later use containerd by default; below that, create a builder with the docker-container driver.
- Create and bootstrap a builder:
docker buildx create --name mg-builder --driver docker-container --bootstrap --use. Thedocker-containerdriver runs BuildKit in its own container, which is what gives you the cache and platform support the default driver lacks. - Install emulation for the platform you are not on:
docker run --privileged --rm tonistiigi/binfmt --install all. This registers QEMU handlers with the kernel’s binfmt_misc so an arm64 binary can execute on an amd64 host. Docker’s own documentation warns it may be slow for compute-heavy work, and compiling a Python extension is compute-heavy work. - Confirm what the builder claims to support with
docker buildx inspect --bootstrap, which prints aPlatforms:line.
Building and pushing both
With the builder in place the build itself is one command, and --push is not optional when using the docker-container driver — there is nowhere local to put a manifest list.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.example.com/team/inference:1.4.0 \
--cache-to type=registry,ref=registry.example.com/team/inference:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.com/team/inference:buildcache \
--push .
The two cache flags matter more here than on a single-platform build. BuildKit keeps a separate cache chain per platform, so a build without a shared registry cache redoes the expensive arm64 stage from nothing on every CI run. mode=max exports intermediate stages too, which is what lets a later build reuse the dependency-installation layer rather than only the final one.
The part that decides whether this is bearable
BuildKit gives every stage a set of predefined build arguments, and using them is the difference between compiling under emulation and not compiling at all. BUILDPLATFORM is the builder’s native platform; TARGETPLATFORM, TARGETOS and TARGETARCH describe what is being produced. Pinning a build stage to --platform=$BUILDPLATFORM runs it natively and asks the toolchain to emit output for the target instead.
FROM --platform=$BUILDPLATFORM golang:1.23 AS proxy
ARG TARGETOS TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/proxy ./cmd/proxy
That works cleanly for Go and Rust. It does not work for a Python inference image, because the expensive step there is pip install, and pip does not cross-compile. What pip does instead is look for a wheel matching the interpreter and the platform tag. If a matching manylinux_2_28_aarch64 wheel exists, the arm64 leg of your build is a download and an unpack, and the whole thing takes about as long as the amd64 leg. If it does not exist, pip falls back to building from source, under QEMU, and a package with a C or C++ extension can turn a two-minute build into a very long one.
So the check to run before committing to this is not about buildx at all. It is whether every pinned dependency publishes an aarch64 wheel for your Python version. Where one does not, the options are to pin an older version that does, to drop the dependency, or to build that leg on a native arm64 runner and join the two with docker buildx imagetools create instead of emulating it. Docker also supports attaching a second native node to one builder with docker buildx create --append, which is the cleanest form of the same idea: each platform is built on hardware that speaks it.
The same question decides whether a GPU inference image is multi-platform at all. CUDA base images do not publish every tag for every architecture, and the arm64 builds are a different, separate line of tags. Check what your exact base tag offers with docker buildx imagetools inspect before assuming a --platform list will resolve — the failure is a manifest lookup error at build time, not something you discover at deploy.
Verifying what you pushed
The index is a real object in the registry and you can read it back without pulling gigabytes:
docker buildx imagetools inspect registry.example.com/team/inference:1.4.0
Name: registry.example.com/team/inference:1.4.0
MediaType: application/vnd.oci.image.index.v1+json
Manifests:
Name: ...@sha256:...
Platform: linux/amd64
Name: ...@sha256:...
Platform: linux/arm64
Two entries with the right platforms is the whole acceptance test. If only one appears, the usual cause is a build that ran without --push and quietly loaded a single-platform image into the local store. Add --raw to print the index JSON itself when you need to see the digests, for instance to reference an exact platform manifest from an image promotion step.
One operational note for anything downstream that pins digests: the digest of the index is not the digest of any of the images inside it. A Kubernetes deployment pinned to the index digest still resolves per node; a deployment pinned to a platform manifest digest will fail to schedule anywhere else. Pin the index.
Top comments (0)