I recently spent many hours figuring out how to build a docker image that could support multiple platforms at the same timeāfor me I needed to support linux/amd64
and linux/386
.
There were a few articles, but none explicitly spells out the different build scenarios and the minor differences in the instructions. In this article, I hope to cover the two scenarios that I ran into and the differences in instructions for each scenario.
Scenario 1: Creating a multi-platform docker image for a ready-to-go cross-platform binary
In this scenario, what I was trying to achieve is creating a docker image to run a JAR file, which should run on any system that has the Java runtime. The JAR file is already compiled locally on my machine. This scenario also applies if you have compiled a .NET Core binary, or similar technologies.
Setting up docker buildx
If you're reading this, I assume you're familiar with using docker build
. To create a multi-platform docker image, we have to use docker buildx
.
To get started, run the following commands:
$ docker buildx create --name mybuilder --bootstrap --platform linux/amd64,linux/386 --use
The command above creates a new buildx instance, named “mybuilder”. The --bootstrap
flag kicks of setting up a docker container for this build instance (the build happens inside a container). The --platform
argument specifies which platforms this builder will support. Finally the --use
flag makes this builder the default builder for buildx.
To view the builder instances you have, run
docker buildx ls
.
If you also happen to use a local (insecure) registry, providing a config to the buildx instance is necessary. For example:
$ cat mybuilderconfig.toml
[registry."myregistry.lan:5000"]
http = true
insecure = true
$ docker buildx create ... --use --config mybuilderconfig.toml
Build the Dockerfile
Next is just building our docker image. Below are examples for the build command and dockerfile:
$ docker buildx build \
--platform linux/amd64,linux/386 \ # Specify the platforms to include in the build
--t myregistry.lan:5000/myapp \ # Tag the image
--push \ # Push this image to the registry after building
-f Dockerfile \ # (Optional) Explicitly provide the dockerfile
. # Build context is the current directory
# Dockerfile
# --platform=$TARGETPLATFORM can be omitted, as that's the default behavior
FROM --platform=$TARGETPLATFORM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY build/myapp.jar .
CMD ["java", "myapp.jar"]
Notice the --platform=$TARGETPLATFORM
part in the dockerfile. This is where I was getting tripped up because most articles provide examples for the second scenario below and uses something different (read below).
Scenario 2: Building a binary using a docker image, then copy that binary to the final image (multi-stage docker build)
This scenario is typically for building native binaries. For example, building a Rust binary or Go binary. For example, a Rust binary built on an AMD64 container won't run on an ARM64 container. For this scenario, we have two options, depending on if the programming language has good support for cross-compilation.
With enough tweaks, all languages can mostly cross-compile. The options below outline the more “pragmatic” ways
For both options below, the build command is similar to the one described in scenario 1: docker buildx build ...
Option 2.A: Build on the target platform
In this option, we will build the binary on platforms we want to target, then copy the output to the final image.
The advantage of this option is that we are building the binary on the same OS & architecture as the running container, eliminating much incompatibility. For example, even if I'm running build on a Mac M1 (darwin/arm64
), this option would allow me to build a binary on linux/386
, then also run my app on linux/386
. And the build steps are typically simpler.
The disadvantage of this option is that docker will need to use emulation to run other architectures during the build, which is potentially slower and requires installing other tools such as QEMU.
A dockerfile for this build option looks typical for a multi-stage build dockerfile. Similar to scenario 1, the $TARGETPLATFORM
argument can be omitted. I just wanted to list it out explicitly to contrast with the later option.
# --platform=$TARGETPLATFORM can be omitted, as that's the default behavior
FROM --platform=$TARGETPLATFORM rust:slim-bullseye AS BUILDER
WORKDIR /src
COPY . . # this is the folder that contains Cargo.toml
RUN cargo build --release
# --platform can be omitted
FROM --platform=$TARGETPLATFORM debian:bullseye-slim
WORKDIR /app
COPY --from=BUILDER /src/target/myapp ./myapp
CMD ["/app/myapp"]
Option 2.B: Cross-compile on the build (docker host) platform
This option takes advantage of languages that have good support for cross compilation. Go is a good example. Compiling code on the native build (docker host) architecture typically results in faster builds.
The potential downsides are more complex build steps, and potential incompatibilities.
“Cross-compilation” technically also applies to programming languages such as Java/JVM, or C#/.NET Core. In scenario 1, the single binary could target all platforms. In this option 2.B, we still need to build a different binary for each platform.
A typical docker file for this scenario looks like below. Note the --platform=$BUILDPLATFORM
parameter in the build stage. This means if I'm on a Mac M1, the build platform will be darwin/arm64
, regardless which platform I'm targeting. During the compilation step, we then specify the GOOS
(Go-OS) and GOARCH
argument to the compiler for cross-compilation. After the build step, we then go back to the $TARGETPLATFORM
.
# --platform=$BUILDPLATFORM is required here
FROM --platform=$BUILDPLATFORM golang:1.19-bullseye AS BUILDER
WORKDIR /src
COPY . .
ARG TARGETOS TARGETARCH
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .
# --platform can be omitted
FROM --platform=$TARGETPLATFORM debian:bullseye-slim
WORKDIR /app
COPY --from=BUILDER /out/myapp ./myapp
CMD ["/app/myapp"]