Building multi-architecture images with GitHub Actions
At work we have a cluster of IBM Power 9 systems running OpenShift. The problem with this environment is that nobody runs Power 9 on their desktop, and Docker Hub only offers automatic build support for the x86 architecture. This means there’s no convenient options for building Power 9 Docker images…or so I thought.
It turns out that Docker provides GitHub actions that make the process of producing multi-architecture images quite simple.
The code demonstrated in this post can be found in my hello-flask GitHub repository.
Configuring secrets⌗
There is some information we need to provide to our workflow that we don’t want to hardcode into configuration files, both for reasons of security (we don’t want to expose passwords in the repository) and convenience (we want other people to be able to fork this repository and run the workflow without needing to make any changes to the code).
We can do this by configuring “secrets” in the repository on GitHub. You
can configure secrets by visiting the “Secrets” tab in your repository
settings (https://github.com/<USERNAME>/<REPOSITORY>/settings/secrets
),
For this workflow, we’re going to need two secrets:
DOCKER_USERNAME
– this is our Docker Hub username; we’ll need this both for authentication and to set the namespace for the images we’re building.DOCKER_PASSWORD
– this is our Docker Hub password, used for authentication.
Within a workflow, we can refer to these secrets using syntax like ${{ secrets.DOCKER_USERNAME }}
(you’ll see example of this later on).
Creating a workflow⌗
In the repository containing your Dockerfile
, create a
.github/workflows
directory. This is where we will place the files that
configure GitHub actions. In this directory, create a file called
build_images.yml
(the particular name isn’t important, but it’s nice to
make names descriptive).
We’ll first give this workflow a name and configure it to run for pushes on
our master
branch by adding the following to our build_images.yml
file:
---
name: 'build images'
on:
push:
branches:
- master
Setting up jobs⌗
With that boilerplate out of the way, we can start configuring the jobs
that will comprise our workflow. Jobs are defined in the jobs
section of
the configuration file, which is a dictionary that maps job names to their
definition. A job can have multiple actions. For this example, we’re going
to set up a docker
job that will perform the following steps:
- check out the repository
- prepare some parameters
- set up qemu, which is used to provide emulated environments for building on architecture other than the host arch
- configure the docker builders
- authenticate to docker hub
- build and push the images to docker hub
We start by providing a name for our job and configuring the machine on
which the jobs will run. In this example, we’re using ubuntu-latest
;
other options include some other Ubuntu variants, Windows, and MacOS (and
you are able to host your own custom builders, but that’s outside the scope
of this article).
jobs:
docker:
runs-on: ubuntu-latest
steps:
Checking out the repository⌗
In our first step, we use the standard actions/checkout action to check out the repository:
- name: Checkout
uses: actions/checkout@v2
Preparing parameters⌗
The next step is a simple shell script that sets some output parameters we will be able to consume in subsequent steps. A script can set parameters by generating output in the form:
::set-output name=<name>::<value>
In other steps, we can refer to these parameters using the syntax
${{ steps.<step_name>.output.<name> }}
(e.g. ${{ steps.prep.output.tags }}
).
We’re going to use this step to set things like the image name (using our
DOCKER_USERNAME
secret to set the namespace), and to set up several tags
for the image:
- By default, we tag it
latest
- If we’re building from a git tag, use the tag name instead of
latest
. Note that here we’re assuming that git tags are of the formv1.0
, so we strip off that initialv
to get a Docker tag that is just the version number. - We also tag the image with the short commit id
- name: Prepare
id: prep
run: |
DOCKER_IMAGE=${{ secrets.DOCKER_USERNAME }}/${GITHUB_REPOSITORY#*/}
VERSION=latest
SHORTREF=${GITHUB_SHA::8}
# If this is git tag, use the tag name as a docker tag
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
fi
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
# If the VERSION looks like a version number, assume that
# this is the most recent version of the image and also
# tag it 'latest'.
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
fi
# Set output parameters.
echo ::set-output name=tags::${TAGS}
echo ::set-output name=docker_image::${DOCKER_IMAGE}
Set up QEMU⌗
The docker/setup-qemu action installs QEMU static binaries, which are used to run builders for architectures other than the host.
- name: Set up QEMU
uses: docker/setup-qemu-action@master
with:
platforms: all
Set up Docker builders⌗
The docker/setup-buildx action configures buildx, which is a Docker CLI plugin that provides enhanced build capabilities. This is the infrastructure that the following step will use for actually building images.
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@master
Authenticate to Docker Hub⌗
In order to push images to Docker Hub, we use the docker/login-action
action to authenticate. This uses the DOCKER_USERNAME
and
DOCKER_PASSWORD
secrets we created earlier in order to establish
credentials for use in subsequent steps.
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Build and push the images⌗
This final step uses the [docker/build-push-action][] to build the images
and push them to Docker Hub using the tags we defined in the prep
step.
In this example, we’re building images for amd64
, arm64
, and ppc64le
architectures.
- name: Build
uses: docker/build-push-action@v2
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/ppc64le
push: true
tags: ${{ steps.prep.outputs.tags }}
The complete workflow⌗
When we put all of the above together, we get:
---
name: 'build images'
on:
push:
branches:
- master
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Prepare
id: prep
run: |
DOCKER_IMAGE=${{ secrets.DOCKER_USERNAME }}/${GITHUB_REPOSITORY#*/}
VERSION=latest
SHORTREF=${GITHUB_SHA::8}
# If this is git tag, use the tag name as a docker tag
if [[ $GITHUB_REF == refs/tags/* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
fi
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
# If the VERSION looks like a version number, assume that
# this is the most recent version of the image and also
# tag it 'latest'.
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
fi
# Set output parameters.
echo ::set-output name=tags::${TAGS}
echo ::set-output name=docker_image::${DOCKER_IMAGE}
- name: Set up QEMU
uses: docker/setup-qemu-action@master
with:
platforms: all
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@master
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v2
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/ppc64le
push: true
tags: ${{ steps.prep.outputs.tags }}
You can grab the hello-flask repository and try this out yourself.
You’ll need to set up the secrets described earlier in this article, but
then for each commit to the master
branch you will end up a new image,
tagged both as latest
and with the short git commit id.
The results⌗
We can use the docker manifest inspect
command to inspect the output of
the build step. In the output below, you can see the images build for our
three target architectures:
$ docker manifest inspect !$
docker manifest inspect larsks/hello-flask
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 3261,
"digest": "sha256:c6bab778a9fd0dc7bf167a5a49281bcd5ebc5e762ceeb06791aff8f0fbd15325",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 3261,
"digest": "sha256:3c02f36562fcf8718a369a78054750382aba5706e1e9164b76bdc214591024c4",
"platform": {
"architecture": "arm64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 3262,
"digest": "sha256:192fc9acd658edd6b7f2726f921cba2582fb1101d929800dff7fb53de951dd76",
"platform": {
"architecture": "ppc64le",
"os": "linux"
}
}
]
}
Caveats⌗
This process assumes, of course, that your base image of choice is available for your selected architectures. According to Docker:
Most of the official images on Docker Hub provide a variety of architectures. For example, the busybox image supports amd64, arm32v5, arm32v6, arm32v7, arm64v8, i386, ppc64le, and s390x.
So if you are starting from one of the official images, you’ll probably be in good shape. On the other hand, if you’re attempting to use a community image as a starting point, you might find that it’s only available for a single architecture.