Building multi-arch Docker images with Github Actions
Docker supports building images for multiple platforms, or architectures, such
as linux/amd64
or linux/arm64
. Doing so on your development machine is very easy
and straightforward, thanks to buildx. And if you're building software with
multi-arch support in mind (you should!), chances are you will end up having to
release it to Docker Hub (or any private registry) at some point, in automated fashion.
Github Actions is a perfect tool for a job like this! We could set up Docker image building flow just like we would have done for running application tests or any CI/CD pipelines. As for actually building multi-arch Docker images, there are two options (that I know of): build all at once or in parallel.
The goal is to automate building and pushing of Docker images for multiple platforms
to both Docker Hub and Github Container Registry when a new git tag (ie. v1.2.3
)
is pushed out to Github. Platforms selected for testing: linux/amd64, linux/arm64, linux/arm/v7 and linux/arm/v5.
Prerequisites
- Docker Hub account. Make sure to add
DOCKER_USERNAME
andDOCKER_PASSWORD
to actions secrets. - Github API token with
write:packages
scope, set asGH_TOKEN
in secrets. - A working
Dockerfile
in your project.
Sequential build
name: docker
env:
DOCKER_REPO: username/testapp
GHCR_REPO: ghcr.io/username/testapp
on:
push:
tags:
- "v*"
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Set vars
id: vars
run: |
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
# Prepare build environment
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Github Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GH_TOKEN }}
- name: Build and push images
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v5
tags: |
${{ env.DOCKER_REPO }}:${{ steps.vars.outputs.version }}
${{ env.GHCR_REPO }}:${{ steps.vars.outputs.version }}
The echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
line is not necessary
if the tags you're pushing are in x.x.x
format (unlike vx.x.x
) since the convention
for docker image tags is generally org/reponame:x.x.x
. You still might need
it to set other vars in step outputs later.
With docker/build-push-action@v3 we essentially building an image for all platforms in one go and then push it out to both container registries. For a few of my applications it roughly takes 20-25 minutes to complete, not to mention random networking issues and timeouts that could break the whole job and you'll have to start over. The workflow is simple, but slow. For most cases that's what you'd want to use.
In parallel
Building images for multiple platforms in parallel is taking advantage of matrix jobs. In our case we're splitting the primary build job into 4 smaller chunks that run independently, which allows us to retry them independently as well.
Main difference in the process is first we're pushing out arch-specific images as
username/testapp:0.1.0-linux-amd64
(for git tag v0.1.0
), and only after all images
are built and pushed successfully, we're creating a unified image tag via docker manifest
command, executed in a separate release
job.
name: docker
on:
push:
tags:
- "v*"
env:
DOCKER_REPO: username/testapp
GHCR_REPO: ghcr.io/username/testapp
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
platform:
- linux/amd64
- linux/arm64
- linux/arm/v5
- linux/arm/v7
steps:
- name: Set vars
id: vars
run: |
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
echo "platform=$(echo -n ${{ matrix.platform }} | sed 's/\//-/g')" >> $GITHUB_OUTPUT
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Github Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GH_TOKEN }}
- name: Build docker images
uses: docker/build-push-action@v3
with:
context: .
push: true
platforms: ${{ matrix.platform }}
tags: |
${{ env.DOCKER_REPO }}:${{ steps.vars.outputs.version }}-${{ steps.vars.outputs.platform }}
${{ env.GHCR_REPO}}:${{ steps.vars.outputs.version }}-${{ steps.vars.outputs.platform }}
release:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: build
steps:
- name: Set vars
id: vars
run: |
echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to Github Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GH_TOKEN }}
- name: Create Docker Hub manifest
run: |
docker manifest create $DOCKER_REPO:${{ steps.vars.outputs.version }} \
$DOCKER_REPO:${{ steps.vars.outputs.version }}-linux-amd64 \
$DOCKER_REPO:${{ steps.vars.outputs.version }}-linux-arm64 \
$DOCKER_REPO:${{ steps.vars.outputs.version }}-linux-arm-v5 \
$DOCKER_REPO:${{ steps.vars.outputs.version }}-linux-arm-v7
- name: Create GHCR manifest
run: |
docker manifest create $GHCR_REPO:${{ steps.vars.outputs.version }} \
$GHCR_REPO:${{ steps.vars.outputs.version }}-linux-amd64 \
$GHCR_REPO:${{ steps.vars.outputs.version }}-linux-arm64 \
$GHCR_REPO:${{ steps.vars.outputs.version }}-linux-arm-v5 \
$GHCR_REPO:${{ steps.vars.outputs.version }}-linux-arm-v7
- name: Push manifests
run: |
docker manifest push $DOCKER_REPO:${{ steps.vars.outputs.version }}
docker manifest push $GHCR_REPO:${{ steps.vars.outputs.version }}
Oh, btw, pushing images to Github Registry is even simpler if you opt-in to use Github Actions permissions model. We can skip setting up the API token altogether with:
permissions:
contents: read
packages: write
With parallel builds we are able to cut the total job time more than in half, and maintain some
flexibility: if a platform-specific image build fails for whatever reason, we could
intervene and retry it (or do it automatically). Then release
job will pick up
again and finish off the remainder of workflow.
Side effects
All this is great, but I would lie if I said there would be no gotchas. First, overall workflow became a bit more complex: our example is pretty basic but quite lengthy already, so adding in extra bits/steps/processes will make it much harder to grok.
Second, as a side effect of pushing intermediary images to the registry, we end
up with tons of unwanted tags. Let's say our final multi-arch image is myapp:0.1.0
,
but you'll find a handful of platform-specific tags as well, like myapp:0.1.0-linux-amd64
.
Not a big deal if you don't mind, but cleaning those out will require extra work,
(using a tool like regctl) and not all container registries may allow deleting
tags via API, so keep that in mind.