Actual instructions to building a multi-platform docker image

  • Published on Sep 13, 2023

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
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
COPY . . # this is the folder that contains Cargo.toml
RUN cargo build --release

# --platform can be omitted
FROM --platform=$TARGETPLATFORM debian:bullseye-slim
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
COPY . .
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /out/myapp .

# --platform can be omitted
FROM --platform=$TARGETPLATFORM debian:bullseye-slim
COPY --from=BUILDER /out/myapp ./myapp
CMD ["/app/myapp"]