Building Multi-Platform Docker Images with Travis CI and BuildKit

This is a lengthy note. If you don’t quite feel reading and only need the working example, go directly to the Travis CI build file.

The more I delve into the world of Raspberry Pi, the more I notice that “regular and boring” things on ARM are harder than I expected.

People build and distribute software exclusively for amd64. You read another “Kubernetes something” tutorial, that went viral on Twitter, and is fancy to try it out. Still, all helm charts, or whatever the author prefered, use Docker images built exclusively for amd64.

Docker toolchain has added the support for building multi-platform images in 19.x. However, it’s available only under the “experimental” mode. The topic of building multi-platform Docker images yet feels underrepresented.

But first, what are multi-platform Docker images?

When a client, e.g. Docker client, tries to pull an image, it must negotiate the details about what exactly to pull with the registry. The registry provides a manifest that describes the digest of the requested image, the volumes the image consists of, the platform this image can run on, etc. Optionally, the registry can provide a manifests list, which, as the name suggests, is a list of several manifests bundled into one. With the manifests list in hands, the client can figure out the particular digest of the image it needs to pull.

So multi-platform Docker images are just several images, whose manifests are bundled into the manifests list.

Imagine we want to pull the image golang:1.13.6-alpine3.10. Docker client will get the manifests list from Dockerhub. This list includes digests of several images, each built for the particular platform. If we’re on Raspberry Pi, running the current Raspbian Linux, which is arm/v7, the client will pick the corresponding image’s digest. Alternatively, we could choose to pull the image arm32v7/golang:1.13.6-alpine3.10 instead, and we ended up with the same image with the digest d72fa60fb5b9. Of course, to use a single universal image name, i.e. golang, on every platform is way more convenient.

You can read more about manifests in Docker registry documentation.

Does it mean I need to build different Docker images, for each platform I want to support?

Well, yes. This is how, official images are built.

For every platform, the image is built and pushed to the registry under the name <platform>/<image>:<tag>, e.g. amd64/golang:1-alpine. And next, a manifests list, that combines all those platform-specific images, is built and pushed with the simple name <image>:<tag>.

Docker’s BuildKit provides a toolkit that, among other nice things, allows building multi-platform images on a single host. BuildKit is used inside Docker’ buildx project, that is part of the recent Docker version.

One can use buildx, but, for this post, I wanted to try out, what would it look like to use BuildKit directly. For profefe, the system for continuous profiling of Go services, I set up Travis CI, that builds a multi-platform Docker image and pushes them to Dockerhub.

profefe is written in Go. That simplifies things, because, thanks to Go compiler, I don’t have to think about how to compile code for different platforms. The same Dockerfile will work fine on every platform.

Here’s how “deploy” stage of the build job looks like (see travis.yml on profefe’s GitHub).

dist: bionic

language: go
go:
  - 1.x

jobs:
  include:
    - stage: deploy docker
      services: docker
      env:
        - PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7"
      install:
        - docker container run --rm --privileged multiarch/qemu-user-static --reset -p yes
        - docker container run -d --rm --name buildkitd --privileged moby/buildkit:latest
        - sudo docker container cp buildkitd:/usr/bin/buildctl /usr/local/bin/
        - export BUILDKIT_HOST="docker-container://buildkitd"
      script: skip
      deploy:
        - provider: script
          script: |
            buildctl build \
              --progress=plain \
              --frontend=dockerfile.v0 \
              --local context=. --local dockerfile=. \
              --opt filename=contrib/docker/Dockerfile \
              --opt platform=$PLATFORMS \
              --opt build-arg:VERSION=\"master\" \
              --opt build-arg:GITSHA=\"$TRAVIS_COMMIT\" \
              --output type=image,\"name=profefe/profefe:git-master\",push=true
          on:
            repo: profefe/profefe
            branch: master
      before_deploy:
        - echo "$DOCKER_PASSWORD" | docker login --username "$DOCKER_USERNAME" --password-stdin
      after_failure:
        - buildctl debug workers ls
        - docker container logs buildkitd

It’s a lot happening here, but I’ll describe the most critical parts.

Let’s start with dist: bionic.

We run the builds under Ubuntu 18.04 (Bionic Beaver). To be able to build multi-platform images on a single amd64 host, BuildKit uses QEMU to emulate other platforms. That requires Linux kernel 4.8, so even Ubuntu 16.04 (Xenial Xerus) should work.

The top-level details on how the emulation works are very well described in https://www.kernel.org/doc/html/latest/admin-guide/binfmt-misc.html

In short, we tell the component of the kernel (binfmt_misc) to use QEMU when the system executes a binaries built for a different platform. The following call in the “install” step is what’s doing that:

- docker container run --rm --privileged multiarch/qemu-user-static --reset -p yes

Under the hood, the container runs a shell script from QEMU project, that registers the emulator as an executor of binaries from the external platforms.

If you think, that running a docker container to do the manipulations with the host’s OS looks weird, well… I can’t agree more. Probably, a better approach would be to install qemu-user-static, which would do the proper setup. Unfortunately, the current package’s version for Ubuntu Bionic doesn’t do the registration as we need it. I.e. its post-install doesn’t add the "F" flag (“fix binaries”), which is crucial for our goal. Let’s just agree,that docker-run will do ok for the demonstrational purpose.

- docker container run -d --rm --name buildkitd --privileged moby/buildkit:latest
- sudo docker container cp buildkitd:/usr/bin/buildctl /usr/local/bin/
- export BUILDKIT_HOST="docker-container://buildkitd"

This is another “docker-run’ism”. We start BuildKit’s buildkitd daemon inside the container, attaching it to the Docker daemon that runs on the host (“privileged” mode). Next, we copy buildctl binary from the container to the host system and set BUILDKIT_HOST environment variable, so buildctl knew where its daemon runs.

Alternatively, we could install BuildKit from GitHub and run the daemon directly on the build host. YOLO.

before_deploy:
  - echo "$DOCKER_PASSWORD" | docker login --username "$DOCKER_USERNAME" --password-stdin

To be able to push the images to the registry, we need to log in providing Docker credentials to host’s Docker daemon. The credentials are set as Travis CI’s encrypted environment variables ([refer to Travis CI docs])](https://docs.travis-ci.com/user/environment-variables/)).

buildctl build \
  --progress=plain \
  --frontend=dockerfile.v0 \
  --local context=. --local dockerfile=. \
  --opt filename=contrib/docker/Dockerfile \
  --opt platform=$PLATFORMS \
  --opt build-arg:VERSION=\"master\" \
  --opt build-arg:GITSHA=\"$TRAVIS_COMMIT\" \
  --output type=image,\"name=profefe/profefe:git-master\",push=true

This is the black box where everything happens. Magically!

We run buildctl stating that it must use the specified Dockerfile; it must build the images for defined platforms (I specified linux/amd64,linux/arm64,linux/arm/v7), create a manifests list tagged as the desired image (profefe/profefe:<version>), and push all the images to the registry.

buildctl debug workers ls shows what platforms does BuildKit on this host support. I listed only those I’m currently intrested with.

And that’s all. This setup automatically builds and pushes multi-platform Docker images for profefe (https://hub.docker.com/p/profefe/profefe) on a commit to project’s “master” branch on GitHub.


As I hope you’ve seen, support for multi-platform is getting easier and things that were hard a year ago are only mildly annoying now :)

If you have any comments or suggestions, reach out to me on Twitter or discuss this note on r/docker Reddit.

Some more reading on the topic: