Martin Ahrer

Thinking outside the box

Docker Multiplatform Build

2024-09-12 6 min read Martin

The Arm CPU architecture is getting more and more popular even on desktop developer devices.

A comparative analysis of Arm, AMD, and Intel costs in the Amazon cloud has shown that Graviton2 processors can be significantly more cost-efficient than other platforms. The study compared the 16xlarge instances based on the m6g (Graviton2, Arm), m5a (EPYC1, AMD), and m5n (Xeon Cascade Lake, Intel) for the 64-vCPU count. Not only are the Arm-based instances cheaper than AMD and Intel, they can achieve 40% better performance per dollar when translating the time to completion of SPEC tests to hours and multiplying the result by hourly cost.
— https://bell-sw.com/blog/application-cost-reduction-with-arm-servers

Still we traditionally have Amd64 based hardware powering developer workstations, continuous delivery pipelines, and production workload. Especially with Apple now offering Arm based devices we find ourselves in a heterogeneous landscape.

As a result it makes sense to look into building multiplatform builds in order to support all these devices throughout the full application lifecycle.

Docker Desktop

The Docker Desktop is a development environment targeted for a Developer workstation.

Lets’s explore the docker image build command to build the Dockerfile below. It offers a --platform argument according to the build command documentation.

Dockerfile
FROM alpine:3

ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH

LABEL version=0.3.0

ENV HASHICORP_RELEASES=https://releases.hashicorp.com
ENV NAME=levant
ENV VERSION=0.3.0

RUN wget ${HASHICORP_RELEASES}/${NAME}/${VERSION}/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip -q -P /tmp && \
    unzip -d /usr/local/bin /tmp/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip
❯ docker image build --platform=linux/arm64,linux/amd64 .
[+] Building 0.0s (0/0)
ERROR: docker exporter does not currently support exporting manifest lists

In order to fix this we have to turn on the following Docker Desktop option Use containerd for pulling and storing images, restart the Docker engine, and re-run the build command.

❯ docker image build --platform=linux/amd64,linux/arm64 --tag levant .
[+] Building 5.4s (9/9) FINISHED                                                                                             docker:desktop-linux
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 428B
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3
 => [internal] load .dockerignore
 => => transferring context: 2B
 => CACHED [linux/arm64 1/2] FROM docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => => resolve docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => CACHED [linux/amd64 1/2] FROM docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => => resolve docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => [linux/amd64 2/2] RUN wget https://releases.hashicorp.com/levant/0.3.0/levant_0.3.0_linux_amd64.zip -q -P /tmp && unzip -d /usr/local/bin /tmp/levant_0.3.0_linux_amd64.zip
 => [linux/arm64 2/2] RUN wget https://releases.hashicorp.com/levant/0.3.0/levant_0.3.0_linux_arm64.zip -q -P /tmp && unzip -d /usr/local/bin /tmp/levant_0.3.0_linux_arm64.zip
 => exporting to image
 => => exporting layers
 => => exporting manifest sha256:4f5044832ae4494f80ded3f17b9e2ed751babbe7da318b70e6300e364c7ed988
 => => exporting config sha256:d49976849bb1f58bca412c7d2852cca9244b325a9e5943042c2424426dd7882b
 => => exporting attestation manifest sha256:69ef5ab826e89366d94c0c8836521e04ea85d48e7a82bacdd2104e9e64af0382
 => => exporting manifest sha256:6e7502be2f3e5ba7f0398279036ad0a87159a0f4a1b503f13209011a8f951377
 => => exporting config sha256:04deaffc6ade4fd55966b9885fff1a80e0635889174caf9ec9a631d0ffc1fcd5
 => => exporting attestation manifest sha256:6153b6f5444db39bb8c4eb68a5e362abd56038564c24e6be519ee08eb47c4e9f
 => => exporting manifest list sha256:abfeb4fbb0ce7a99da47965db5938c24e0383839e66d2d0dde9061e14a92d288
 => => naming to docker.io/library/levant:latest
 => => unpacking to docker.io/library/levant:latest

When we run a multiplatform build, it’s executed by a builder, which, as seen in the console output, is identified as docker:desktop-linux. This builder is included as part of the Docker Desktop installation.

With Docker Desktop, you can easily build images for any platform that has support installed alongside it. This makes it incredibly convenient for developers to get started with cross-platform development.

What if you need to run a multiplatform build in a standard Linux environment or within a build pipeline running inside a container? Let’s explore how to tackle this task.

Multiplatform build in a native Linux Docker installation

First, we need to configure the system for cross-platform builds. Docker offers multiple strategies for this, as outlined in the Docker documentation.

One approach is to emulate the target platform using QEMU. To do this, we’ll need to install the necessary QEMU components on the system.

Another option is to use dedicated build nodes that run on the target platform, allowing you to create buildx builder nodes tailored to specific architectures.

For simplicity, we’ll proceed with the first option, which is easier to set up. This involves installing QEMU support. The container image provided by tonistiigi/binfmt contains everything required to install and register QEMU components for multiple platforms.

Assuming we are running an Intel based workstation, so we install Arm support installed as shown by the example below.

docker container run --privileged --rm tonistiigi/binfmt --install arm64

Next we have to create a builder that can run the multiplatform build.

docker buildx create --name container --driver docker-container --bootstrap --use (1)
1We have to use the docker-container driver to run the BuildKit daemon in a container.

Now we are all set for trying to build the same image again, this time without all the convenience that Docker Desktop brings.

❯ docker image build --platform=linux/amd64,linux/arm64 --tag levant --builder=container .
[+] Building 1.6s (8/8) FINISHED                                                                                                                                                                           docker-container:container
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 428B
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [linux/arm64 1/2] FROM docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => => resolve docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => [linux/amd64 1/2] FROM docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => => resolve docker.io/library/alpine:3@sha256:beefdbd8a1da6d2915566fde36db9db0b524eb737fc57cd1367effd16dc0d06d
 => CACHED [linux/amd64 2/2] RUN wget https://releases.hashicorp.com/levant/0.3.0/levant_0.3.0_linux_amd64.zip -q -P /tmp &&     unzip -d /usr/local/bin /tmp/levant_0.3.0_linux_amd64.zip
 => CACHED [linux/arm64 2/2] RUN wget https://releases.hashicorp.com/levant/0.3.0/levant_0.3.0_linux_arm64.zip -q -P /tmp &&     unzip -d /usr/local/bin /tmp/levant_0.3.0_linux_arm64.zip
WARNING: No output specified with docker-container driver. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load (1)
1As we are using the docker-container driver, the built image is only available in the BuildKit caches and needs to be exported (loaded) into docker.

With that approach we should be able to implement a multiplatform build for any of the major container based pipeline products (e.g. GitLab, Github actions, …​).

Dockerfile support

When building images exclusively for the platform the engine is running on, there’s no need for additional configuration. The engine automatically aligns with the platform, pulling the correct images (e.g., using the FROM instruction). Similarly, the package manager ensures that packages for the appropriate platform are downloaded.

However, there are cases when you may need to install software not available through popular package managers. This often involves downloading binaries or compressed archives. In scenarios where the software is packaged for multiple platforms, it becomes crucial to select and download the correct version for your environment.

Let’s revisit the Dockerfile we’ve worked with earlier.

FROM alpine:3

ARG TARGETOS
ARG TARGETARCH
ARG TARGETPLATFORM

LABEL version=0.3.0

ENV HASHICORP_RELEASES=https://releases.hashicorp.com
ENV NAME=levant
ENV VERSION=0.3.0

RUN wget ${HASHICORP_RELEASES}/${NAME}/${VERSION}/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip -q -P /tmp && \
unzip -d /usr/local/bin /tmp/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip

The engine automatically provides details about the target platform when building an image. This includes the variables TARGETPLATFORM, TARGETOS, and TARGETARCH, with the latter two representing the components of TARGETPLATFORM. The only action we have to take is to declare these variables as ARG.

We can use those variables with the FROM instruction (also in multi-staged builds) or in any executed instruction.

FROM --platform=$TARGETPLATFORM alpine:3 (1)
# ...
RUN wget ${HASHICORP_RELEASES}/${NAME}/${VERSION}/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip -q -P /tmp && \
unzip -d /usr/local/bin /tmp/${NAME}_${VERSION}_${TARGETOS}_${TARGETARCH}.zip
1Passing the platform argument here is not required as it is the default and only shown for demonstration purpose.

In some cases, the value of TARGETARCH may not align with the architecture identifier used by the installable artifact. When this happens, it’s necessary to map or translate the architecture value, as shown in the example below.

ARCH="${TARGETARCH}"
if [ "${ARCH}" = "amd64" ]; then ARCH="x86_64"; fi;
RUN wget https://artifacts.elastic.co/downloads/logstash/logstash-8.15.1-${TARGETOS}-${ARCH}.tar.gz -P /tmp