There are often circumstances where software is compiled and packaged into artifacts that must function on multiple operating systems (OS) and processor architectures. It is almost impossible to execute an application on a different OS/architecture platform than the one it was designed for. That’s why it’s a common practice to build releases for many different platforms. This can be difficult to accomplish when the platform you are using to build artifacts is different from the platforms you want to target for deployment. For instance, developing an application on Windows and deploying it to Linux and macOS machines involves provisioning and configuring build machines for each of the operating systems and architecture platforms you’re targeting. Multi-OS builds within pipelines can be achieved using a variety of techniques, but due to the stringent characteristics of processor architectures, artifacts must be compiled and produced on the same hardware that they are targeting.

Docker is a modern way to package applications into immutable and deployable artifacts in the form of Docker images and containers. As with traditional artifact packaging, Docker images also experience the same processor architecture build constraints. Docker images must also be built on the hardware architectures they’re intended to run on. In this post I’ll discuss how to build Docker images within CI pipelines that target multiple processor architectures such as linux/amd64, linux/arm64, linux/riscv64, etc.

Getting started

Let’s take a look at an example code repository, built by Chad Metcalf, that demonstrates how to package an application into multi-architecture Docker images. We’re only going to focus on the continuous integration aspects of building these multi-architecture Docker images. The CircleCI config.yml file defines the CI pipeline build instructions. It is found in the .circleci/config.yml directory. In this post I’m going to focus on the .circleci/config.yml file and the MakeFile file from this repo.

Makefiles can be viewed as build/compile directives that are required by the make utility that automates the build processes. The Makefile in this project contains the directives and commands that are executed from the CI pipeline.

Using Docker BuildX

Before I go deeper into the Makefile and config.yml file break downs, I want to take a moment to discuss BuildX, which is a currently a CLI plugin that extends the Docker CLI with the full set of features provided by the Moby BuildKit builder toolkit. It provides the same user experience as docker build with many new features like creating scoped builder instances and building against multiple nodes concurrently.

At the time of this writing, the BuildX feature is still in the experimental status, and requires a few environment configurations on the machine where Docker images will be built. The following are BuildX install directions for Docker version 19.03 and higher. The complete BuildX installation instructions can be found here, and below are the TL;DR instructions for a Linux machine with Docker 19.03 installed. The following commands compile and build the BuildX binary from source and installs it into the Docker plugin directory:

export DOCKER_BUILDKIT=1
docker build --platform=local -o . git://github.com/docker/buildx
mkdir -p ~/.docker/cli-plugins
mv buildx ~/.docker/cli-plugins/docker-buildx

You can also download the latest BuildX binaries for your OS here, and install it using these BuildX release binary directions.

After installing BuildX on your Docker builder machine, you can take advantage of all the BuildX capabilities:

I suggest you take the time to get better familiar with BuildX features. It is an essential technology when building multi-architecture Docker images, and it is heavily used in the examples below.

Configuring your CI pipeline

The config.yml file in the example project leverages the Makefile file and its functionality to execute the appropriate commands to complete the multi-architecture builds. This config.yml demonstrates how to leverage a single build job using a machine executor. This may seem a bit out of the norm since CircleCI provides the ability to build Docker images using the Docker executor.

The Docker platform leverages sharing and managing its host operating system kernels vs. the kernel emulation found in virtual machines (VMs). Since running Docker containers share the host OS kernel, they are architecturally very different from VMs. VMs are not based on container technology. They are made up of the user-spaces and kernel-spaces of an operating system. VM server hardware is virtualized, and each VM has its own isolated OS and apps. It shares hardware resources from the host, and can emulate various processor architectures/kernels within the VM. The kernel and hardware emulation capabilities of VMs are the main reasons the machine executor is the best choice for building multi-architecture Docker images.

Let’s take a look at the config.yml in the example project:

version: 2.1
jobs:
  build:
    machine:
      image: ubuntu-1604:202007-01
    environment:
      DOCKER_BUILDKIT: 1
      BUILDX_PLATFORMS: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386,linux/arm/v7,linux/arm/v6
    steps:
      - checkout
      - run:
          name: Unit Tests
          command: make test
      - run:
          name: Log in to docker hub
          command: |
            docker login -u $DOCKER_USER -p $DOCKER_PASS
      - run:
          name: Build from dockerfile
          command: |
            TAG=edge make build
      - run:
          name: Push to docker hub
          command: |
            TAG=edge make push
      - run:
          name: Compose Up
          command: |
            TAG=edge make run
      - run:
          name: Check running containers
          command: |
            docker ps -a
      - run:
          name: Check logs
          command: |
            TAG=edge make logs
      - run:
          name: Compose down
          command: |
            TAG=edge make down
      - run:
          name: Install buildx
          command: |
            BUILDX_BINARY_URL="https://github.com/docker/buildx/releases/download/v0.4.2/buildx-v0.4.2.linux-amd64"

            curl --output docker-buildx \
              --silent --show-error --location --fail --retry 3 \
              "$BUILDX_BINARY_URL"

            mkdir -p ~/.docker/cli-plugins

            mv docker-buildx ~/.docker/cli-plugins/
            chmod a+x ~/.docker/cli-plugins/docker-buildx

            docker buildx install
            # Run binfmt
            docker run --rm --privileged tonistiigi/binfmt:latest --install "$BUILDX_PLATFORMS"
      - run:
          name: Tag golden
          command: |
            BUILDX_PLATFORMS="$BUILDX_PLATFORMS" make cross-build

As you may have noticed, most of the command: keys in this config file execute the functions defined in the Makefile. This pattern produces much less YAML syntax in the config file, but does complicate what’s actually being executed in the Makefile.

Next, I’m going to focus on explaining the critical command: keys in this config file.

version: 2.1
jobs:
  build:
    machine:
      image: ubuntu-1604:202007-01
    environment:
      DOCKER_BUILDKIT: 1
      BUILDX_PLATFORMS: linux/amd64,linux/arm64,linux/ppc64le,linux/s390x,linux/386,linux/arm/v7,linux/arm/v6
    steps:
      - checkout
      - run:
          name: Unit Tests
          command: make test
      - run:
          name: Log in to docker hub
          command: |
            docker login -u $DOCKER_USER -p $DOCKER_PASS
      - run:
          name: Build from dockerfile
          command: |
            TAG=edge make build
      - run:
          name: Push to docker hub
          command: |
            TAG=edge make push

In the above code, the build is using a machine executor and assigning values to the DOCKER_BUILDKIT variable that enables Docker access to the experimental features and BuildX. The BUILDX_PLATFORMS variable is the list of OS and processor architectures that will produce Docker images. This list is targeting the Linux OS and a variety of processor architectures.

The remaining run: and command: keys demonstrate how to execute the application’s unit tests, authenticate to Docker Hub in order to pull and push images, build a Docker image using the Dockerfile found in the /app directory, and push that image to Docker Hub.

NOTE: The docker login step above ensures that your requests to Docker Hub are authenticated. Whenever you pull images from, or push images to, Docker Hub with CircleCI, we recommend logging in to your Docker Hub account for both docker pull and docker push steps in your CircleCI config. Logging in will make sure that your jobs have access to a higher Docker Hub rate limit.

      - run:
          name: Install buildx
          command: |
            BUILDX_BINARY_URL="https://github.com/docker/buildx/releases/download/v0.4.2/buildx-v0.4.2.linux-amd64"

            curl --output docker-buildx \
              --silent --show-error --location --fail --retry 3 \
              "$BUILDX_BINARY_URL"

            mkdir -p ~/.docker/cli-plugins

            mv docker-buildx ~/.docker/cli-plugins/
            chmod a+x ~/.docker/cli-plugins/docker-buildx

            docker buildx install
            # Run binfmt
            docker run --rm --privileged tonistiigi/binfmt:latest --install "$BUILDX_PLATFORMS"

In the code snippet above, the BuildX feature is being used to install the BuildX binary and configure it for usage in the executor. The BuildX tool can build multi-architecture images using a variety of strategies but the easiest method is to use Qemu emulation. It is a generic, open source machine emulator and virtualizer. When BuildKit needs to run a binary for a different architecture, it will automatically load it through a binary registered in the binfmt_misc handler. For QEMU binaries registered with binfmt_misc on the host OS to work transparently inside containers, they must be registed with the fix_binary flag.

The docker run --rm --privileged tonistiigi/binfmt:latest --install "$BUILDX_PLATFORMS" command pulls and spawns a binfmt container for every platform listed in the $BUILD_PLATFORMS variable defined earlier in the file.

      - run:
          name: Tag golden
          command: |
            BUILDX_PLATFORMS="$BUILDX_PLATFORMS" make cross-build

The above code snippet specifies the last command to execute in the pipeline. It builds the multi-architecture Docker images we want to target. The command: key is making a call to the cross-build function defined inside the Makefile, so let’s take a look at the underlying commands associated with this function.

# Makefile cross-build function

.PHONY: cross-build
cross-build:
	@docker buildx create --name mybuilder --use
	@docker buildx build --platform ${BUILDX_PLATFORMS} -t ${PROD_IMAGE} --push ./app

The code snippet above is the actual cross-build make command, which creates a new BuildX builder instance. It follows that with the docker buildx build command that triggers the process to build an individual Docker image for every platform listed in the ${BUILDX_PLATFORMS} environment variable. This is fed into the --platform flag of the command. The -tflag tags/names the Docker images and the --push flag will automatically push the build result to a Docker registry. In this case, it is Docker Hub.

Summary

This post demonstrated how to build various Docker images for multiple operating systems and processor architectures from within a CI pipeline. This post also briefly introduced the Docker BuildX feature, which is currently an experimental utility that is expected to become the defacto build utility in future releases of Docker. I consider BuildX to be the next-gen Docker image building tool that will enable expansive, advanced, and optimized capabilities to enhance the current image building experience.

I also briefly discussed some of the intricacies of building Docker images that target multiple operating systems and platform architectures, which highlight the technical differences between Docker containers and VMs. Though seemingly similar at an abstract view, they are fundamentally different at their cores. Finally, I’ll reiterate that Docker has implemented new Docker Hub rate limits which requires all calls to Docker Hub to be authenticated. Whenever you pull images from, or push images to, Docker Hub on CircleCI, we recommend logging in to your Docker Hub account for both docker pull and docker push steps in your CircleCI config.

Thank you for following this post and I hope you found it useful. Please feel free to reach out with feedback on Twitter @punkdata.