The letter A styled as Alchemists logo. lchemists
Published April 1, 2021 Updated February 18, 2024
Cover
Docker Multi-Platform Images

I recently switched from an Apple Intel machine to an Apple Silicon machine which caused some complications with Docker. I discovered a new and interesting situation: multi-platform images. Due to being on an Apple Silicon machine (ARM 64), my images were not compatible with existing architectures like Apple Intel, Circle CI, Heroku, etc. In fact, I had to learn how multi-platform builds work, which might be of interest to others facing similar problems.

Quick Start

To get started quickly, install the following:

Next, launch OrbStack (you don’t need Docker Desktop running). Then, run the following.

docker run --privileged --rm tonistiigi/binfmt --install all
docker buildx create --name multiarch --platform linux/arm64,linux/amd64
docker buildx use multiarch
docker buildx install

That’s it, you’re ready to build multi-platform images. Congrats!

The rest of this article will explain the above in greater detail.

OrbStack

Technically, you don’t need OrbStack but find the UI less bulky than Docker Desktop while being more performant. OrbStack also simplifies dealing with multi-platform images (especially with the buildx integration) and makes running the following possible:

docker run --privileged --rm tonistiigi/binfmt --install all

The above is important for installing and gaining access to the buildx command.

BuildX

Luckily, building for multiple platforms is supported via Docker’s buildx command. You can read up on buildx by following the link or printing help information from the command line:

docker buildx --help

This will yield the following output:

Usage:  docker buildx [OPTIONS] COMMAND

Extended build capabilities with BuildKit

Options:
      --builder string   Override the configured builder instance

Management Commands:
  imagetools  Commands to work on images in registry

Commands:
  bake        Build from a file
  build       Start a build
  create      Create a new builder instance
  du          Disk usage
  inspect     Inspect current builder instance
  ls          List builder instances
  prune       Remove build cache
  rm          Remove a builder instance
  stop        Stop builder instance
  use         Set the current builder instance
  version     Show buildx version information

Run 'docker buildx COMMAND --help' for more information on a command.

Builder Instance Creation

By default, Docker images will use your current system’s architecture. For an Apple Silicon machine, this means ARM 64. In order to remain compatible for local use while also working on other platforms you’ll need to build for multiple platforms at once. This is where knowing how to configure and use builder instances can be helpful.

To create a builder instance, you’ll want to run the following command:

docker buildx create --name multiarch --platform linux/arm64,linux/amd64

I opted to name my builder instance, multiarch, but you can use whatever you like. For platforms (i.e. --platform), I only needed to support Apple Silicon (ARM 64) and AMD 64 machines.

Once your builder instance is created, you can view your listing by running the following command:

docker buildx ls

In my case, this will output the following:

NAME/NODE    DRIVER/ENDPOINT  STATUS  BUILDKIT PLATFORMS
multiarch *  docker-container
  multiarch0 orbstack         running v0.12.5  linux/arm64*, linux/amd64*, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/mips64, linux/arm/v7, linux/arm/v6
default      docker
  default    default          running v0.12.5  linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/arm/v7, linux/arm/v6, linux/mips64
orbstack     docker
  orbstack   orbstack         running v0.12.5  linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/mips64le, linux/arm/v7, linux/arm/v6, linux/mips64

Notice multiarch has an asterisk next to it. This is because I switched from default — what you initially start with — to multiarch by running the following command:

docker buildx use multiarch

The above is critical to registering the desired platforms you want to build with Docker.

Builder Instance Deletion

At risk of being Captain Obvious, you can remove an existing builder instance by running the following:

docker buildx rm multiarch

Doing so will remove the multiarch builder instance and immediately default you back to the default builder instance.

BuildX Alias

Before we continue, though, I want to pause and point out that you can alias buildx as build which you are probably more familiar with if you’ve spent any time building Docker images in the past. To do this, run the following:

docker buildx install

If, at any time, you are not happy with this setup, you can uninstall the alias by running:

docker buildx uninstall

Using the alias avoids having to constantly type: docker build buildx. For the rest of this article, I’ll assume you are using this alias which will be important when discussing further command line usage in this article.

Building Multiple Platforms

Now that we’ve discussed buildx and builder instances, we can focus on using our Dockerfile to build images for multiple platforms via a single command. That command is:

docker build --platform linux/arm64,linux/amd64 --tag bkuhlmann/alpine-base:latest .

In my case, I’m building an Alpine Linux base image which will produce the following output as it builds for both platforms (truncated for brevity):

[+] Building 15.8s (11/17)
 => [internal] booting buildkit                                                                 2.0s
 => => pulling image moby/buildkit:buildx-stable-1                                              1.6s
 => => creating container buildx_buildkit_multiarch0                                            0.4s
 => [internal] load build definition from Dockerfile                                            0.0s
 => => transferring dockerfile: 1.68kB                                                          0.0s
 => [internal] load .dockerignore                                                               0.0s
 => => transferring context: 152B                                                               0.0s
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3.13.3                    2.9s
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3.13.3                    3.0s
 => [auth] library/alpine:pull token for registry-1.docker.io                                   0.0s
 => [internal] load build context                                                               0.0s
 => => transferring context: 412B                                                               0.0s
 => [linux/amd64 2/5] WORKDIR /usr/src                                                          0.0s
 => [linux/arm64 2/5] WORKDIR /usr/src                                                          0.0s
 => [linux/amd64 3/5] RUN set -o nounset                                                        9.8s
 => => # (30/43) Installing gdbm (1.19-r0)
 => => # (31/43) Installing libsasl (2.1.27-r10)
 => => # (32/43) Installing libldap (2.4.57-r1)
 => => # (33/43) Installing npth (1.6-r0)
 => => # (34/43) Installing sqlite-libs (3.34.1-r0)
 => => # (35/43) Installing gnupg (2.2.27-r0)
 => [linux/arm64 3/5] RUN set -o nounset                                                        9.8s
 => => # (7/23) Installing libatomic (10.2.1_pre1-r3)
 => => # (8/23) Installing libgphobos (10.2.1_pre1-r3)
 => => # (9/23) Installing isl22 (0.22-r0)
 => => # (10/23) Installing mpfr4 (4.1.0-r0)
 => => # (11/23) Installing mpc1 (1.2.0-r0)
 => => # (12/23) Installing gcc (10.2.1_pre1-r3)

There are a couple aspects of the above output, I’d like to highlight for you. The first is the first line where you see: Building 15.8s (11/17). This output gives you total time, in seconds, of the build and will keep updating in real time until the build is complete. The last number, 11/17, lets you know how many steps (11) of the entire process (17) are complete.

The last portion of the above output focuses on real time build output for all architectures:

[linux/amd64 3/5]
[linux/arm64 3/5]

These platforms are built in parallel but, since I’m on an Apple Silicon machine, the ARM 64 build will finish first while the AMD 64 build will take longer. Once the build finishes, you might be eager to use your newly built image but find they’re not listed via the following command:

docker images

This lack of image information means you have to inform Docker to either load the image for local use or push to the Docker Registry, which is definitely different behavior from what you might be used to when building for a single platform only. I first build for my local platform for exploration and testing purposes:

docker build --load --tag $USER/alpine-base:latest .

Then, when ready to release for public consumption, I’ll use the following:

docker build --platform linux/arm64,linux/amd64 --tag $USER/alpine-base:latest --push .

With the first example, the trick is to use --load to immediately load your newly built image for local use. However, when deploying for public use, you’ll want to specify all platforms your image supports (i.e. --platform) and use --push to push to the Docker Registry.

I’m unaware of a way to build once for local use and multiple platforms via a single command. You have to build for local use and then build again for release/deployment purposes. Luckily, if you haven’t made any further changes to your Dockerfile, releasing will only consist of building the corresponding images for platforms which are not your current platform.

Workflow

Putting this all together, here is the workflow I’ve settled on so far:

# Build
docker build --load --tag $USER/alpine-base:latest .
noti --title "Alchemists Docker Built: alpine-base:latest"

# Test
docker run --disable-content-trust --pull never --interactive --tty --rm $USER/alpine-base:latest bash

# Release
docker build --platform linux/amd64,linux/arm64 --tag $USER/alpine-base:latest --push .
noti --title "Alchemists Docker Released: alpine-base:latest"

💡 Noti is one of my recommended Homebrew Formulas which is handy for being notified of long running build/release processes when they are finished.

Examples

Should you need further working examples of everything I’ve discussed thus far, I’d recommend checking out these projects:

For the resulting images, you can visit Docker Hub and study the multiple platforms supported per image:

Resources

The following are additional resources that might be of help to you when building for multiple platforms.

Conclusion

I’m quite happy I can now build all of my images for multiple platforms with minimal effort and hope this is of help to you too. 🎉