Kiss Goodbye to QEMU: Unleash the Power of Native GitHub Runners for Multi-Arch Docker Images

In the cutthroat world of DevOps, where speed and efficiency are the holy grail, building multi-architecture Docker images used to be a pain in the neck. But hold on tight! With the advent of native GitHub runners, you can finally ditch the quirky QEMU emulation and embrace the lightning speed of native builds. This guide will take you by the hand and lead you through the dark arts of setting up a multi-architecture build process using GitHub Actions.

GitHub - sredevopsorg/multi-arch-docker-github-workflow: How to build a Multi-Architecture Docker Image in Github Actions using multiple runners without QEMU
How to build a Multi-Architecture Docker Image in Github Actions using multiple runners without QEMU - sredevopsorg/multi-arch-docker-github-workflow

Overview: The Two-Headed Beast

This workflow is like a two-headed beast, split into two main jobs:

Build Job: The Workhorse

  • Matrix Strategy: This bad boy builds images for each platform separately, like a well-oiled machine.
  • Docker Context and Buildx Setup: Configures the native builds, so you don't have to mess with QEMU.
  • GHCR Login and Push: Uploads the image by its unique digest, keeping things tidy.
  • Artifact Export: Saves the digest for later use, because we're smart like that.

Merge Job: The Brain

  • Digest Download: Retrieves the digests from the build job, like a detective gathering clues.
  • Metadata Generation: Ensures consistency with the built images, because we like things neat.
  • Multi-Arch Manifest Creation: Combines the images into a single, powerful manifest.
  • Manifest Push: Uploads everything to GHCR, enabling Docker to magically select the correct image based on architecture.

This two-step process results in a "fat" image – a monstrous creation that delivers the right binary for any user's architecture, no questions asked.

Features: The Cool Stuff

  • Native Builds: We're using GitHub's native runners, so say adios to QEMU and its shenanigans.
  • Multi-Arch Manifest: Merges per-architecture images into one, because we're efficient like that.
  • Build Caching: Speeds up subsequent builds, because who has time to wait?
  • OCI Metadata: Enhances image provenance with labels and annotations, making your images look professional.
  • Concurrency Control: Cancels outdated builds using GitHub Actions concurrency, keeping things clean and tidy.

Getting Started: Let's Roll Up Our Sleeves

Prerequisites: What You Need

  • A GitHub public repository (because the arm64 runners are picky and only work with public repos).
  • A valid Dockerfile in the repository root, ready to be built.

Setup: The Nitty-Gritty

  1. Fork the Repository and edit the multi-build.yaml file.
  2. Review and Customize:
    • Dockerfile: Tweak it to suit your application's dark desires.
    • Workflow File: Adjust the parameters in .github/workflows/multi-build.yaml as needed.

A Step-by-Step Detailed Explanation: The Gory Details

File: multi-build.yaml

Let's dissect this beast, shall we?

name: Build multi arch Docker Image with separate Github Runners

on:
  workflow_dispatch:
  push:
    branches:
      - main
    paths:
      - 'Dockerfile'
      - '.github/workflows/multi-build.yaml'
env:
  GHCR_IMAGE: ghcr.io/${{ github.repository }}
#(...)
  • This workflow is a masterpiece that builds a multi-arch Docker image using GitHub Actions. It leverages separated GitHub Runners with native support for ARM64 and AMD64 architectures, completely avoiding the dreaded QEMU emulation.
  • It uses Docker Buildx, a powerful tool, to build and push the image to the GitHub Container Registry (GHCR).
  • GHCR_IMAGE: Defines the name of the Docker image to be built and pushed to GHCR. The image name is cleverly derived from the GitHub repository name and the GHCR URL, resulting in a format like ghcr.io/owner/repo.
# (...)

permissions:
  contents: read
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
#(...)
  • permissions: Sets global permissions for the workflow. These can be overridden at the job level if needed.
  • concurrency: This ensures that only one job in the group runs at a time. If a new job is triggered, the previous one is mercilessly canceled.
# (...)

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        platform:
          - linux/amd64
          - linux/arm64
    permissions:
      attestations: write
      actions: read
      checks: write
      contents: write
      deployments: none
      id-token: write
      issues: read
      discussions: read
      packages: write
      pages: none
      pull-requests: read
      repository-projects: read
      security-events: read
      statuses: read
#(...)
  • build: This job is the workhorse, building the Docker image for each platform specified in the matrix.
  • matrix: Defines the platforms: linux/amd64 and linux/arm64. The build job will run for each of these platforms.
  • permissions: Sets specific permissions for the build job, allowing it to write to GHCR and read from the repository.
# (...)

    runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }}
#(...)
  • runs-on: This cleverly selects the appropriate runner based on the platform:
    • For linux/amd64, it uses the latest Ubuntu runner.
    • For linux/arm64, it uses an Ubuntu 24.04 ARM runner.
# (...)

    name: Build Docker image for ${{ matrix.platform }}
    steps:
      -
        name: Prepare environment for current platform 
        id: prepare
        run: |
          platform=${{ matrix.platform }}
          echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV

      - name: Checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

      - name: Docker meta default
        id: meta
        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
        with:
          images: ${{ env.GHCR_IMAGE }}
#(...)
  • The prepare step sets up the environment for the current platform. It replaces '/' in the platform name with '-' and sets it as an environment variable (PLATFORM_PAIR). This is useful for naming artifacts and other resources that can't handle '/'.
  • Checkout: This step uses actions/checkout@v4.2.2 to clone the repository into the runner's workspace.
  • Docker meta default: This step uses docker/metadata-action@v5.7.0 to generate metadata for the Docker image, including the image name, tags, and labels. This metadata will be used later for building and pushing the image.
# (...)

      - name: Set up Docker Context for Buildx

        id: buildx-context
        run: |
          docker context create builders

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
        with:
          endpoint: builders
          platforms: ${{ matrix.platform }}

      - name: Login to GitHub Container Registry
        # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action.
        # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication.
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
#(...)
  • Set up Docker Context for Buildx: Creates a new context named "builders" for Buildx, allowing it to use the Docker daemon for building images.
  • Set up Docker Buildx: Configures Buildx (docker/setup-buildx-action@v3.10.0) with the specified context and platforms.
  • Login to GitHub Container Registry: Logs in to GHCR using docker/login-action@v3.4.0 with the GitHub actor's username and the GITHUB_TOKEN secret.
# (...)

      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
        env:
          DOCKER_BUILDKIT: 1
        with:
          context: .
          platforms: ${{ matrix.platform }}
          labels: ${{ steps.meta.outputs.labels }}
          annotations: ${{ steps.meta.outputs.annotations }}
          outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true
          cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }}
          cache-to: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }}
#(...)
  • Build and push by digest: This is where the magic happens. It uses docker/build-push-action@v6.15.0 to build the image with the specified context and platforms. The image is built with the labels and annotations generated earlier.
  • outputs: Configures the action to push the image by digest, enabling better caching and versioning.
  • cache-from and cache-to: Enable caching for the build process, storing the cache in GitHub Actions cache and scoping it to the repository, branch, and platform.
# (...)

      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digest      
        uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
        with:
          name: digests-${{ env.PLATFORM_PAIR }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1
#(...)
  • Export digest: Creates a directory /tmp/digests and saves the digest of the built image to a file. The digest uniquely identifies the built image.
  • Upload digest: Uploads the digest file to GitHub Actions artifact storage using actions/upload-artifact@v4.6.1. The artifact is named digests-${{ env.PLATFORM_PAIR }} and is retained for 1 day.
# (...)
  merge:
    name: Merge Docker manifests
    runs-on: ubuntu-latest
    permissions:
      attestations: write
      actions: read
      checks: read
      contents: read
      deployments: none
      id-token: write
      issues: read
      discussions: read
      packages: write
      pages: none
      pull-requests: read
      repository-projects: read
      security-events: read
      statuses: read

    needs:
      - build
    steps:
      - name: Download digests
        uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true
  • merge: This job merges the Docker manifests for the different platforms built in the previous job.
  • needs: - build: Ensures that the build job completes successfully before starting.
  • Download digests: Downloads the digest files uploaded in the build job using actions/download-artifact@v4.1.9. The files are merged into the /tmp/digests directory.
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
        with:
          images: ${{ env.GHCR_IMAGE }}
          annotations: |
            type=org.opencontainers.image.description,value=${{ github.event.repository.description || 'No description provided' }}
          tags: |
            type=raw,value=main,enable=${{ github.ref_name == 'main' }}
            type=raw,value=latest,enable=${{ github.ref_name == 'main' }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
        with:
          driver-opts: |
            network=host

      - name: Login to GitHub Container Registry
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Get execution timestamp with RFC3339 format
        id: timestamp
        run: |
          echo "timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT
  • Docker meta: Generates metadata for the Docker image using docker/metadata-action@v5.7.0.
  • Set up Docker Buildx: Sets up Buildx again (docker/setup-buildx-action@v3.10.0).
  • Login to GitHub Container Registry: Logs in to GHCR (docker/login-action@v3.4.0).
  • Get execution timestamp with RFC3339 format: Gets the current UTC time in RFC3339 format for annotating the Docker manifest list.
      - name: Create manifest list and pushs
        working-directory: /tmp/digests
        id: manifest-annotate
        continue-on-error: true
        run: |
              docker buildx imagetools create \
                $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
                --annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \
                --annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \
                --annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \
                --annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \
                $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *)

      - name: Create manifest list and push without annotations
        if: steps.manifest-annotate.outcome == 'failure'
        working-directory: /tmp/digests
        run: |
              docker buildx imagetools create  $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
                $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *)

      - name: Inspect image
        id: inspect
        run: |
          docker buildx imagetools inspect '${{ env.GHCR_IMAGE }}:${{ steps.meta.outputs.version }}'
  • Create manifest list and push: Creates a manifest list for the Docker images built for different platforms using docker buildx imagetools create. The manifest list is annotated with metadata and pushed to GHCR.
  • Create manifest list and push without annotations: If the previous step fails, this step creates the manifest list without annotations and pushes it to GHCR.
  • Inspect image: Inspects the created manifest list using docker buildx imagetools inspect to verify its contents.

There you have it! A complete, step-by-step guide to building multi-arch Docker images like a pro, using native GitHub Runners and leaving QEMU in the dust. Now go forth and conquer the world of multi-architecture deployments!

GitHub - sredevopsorg/multi-arch-docker-github-workflow: How to build a Multi-Architecture Docker Image in Github Actions using multiple runners without QEMU
How to build a Multi-Architecture Docker Image in Github Actions using multiple runners without QEMU - sredevopsorg/multi-arch-docker-github-workflow
Nicolás Georger

Nicolás Georger

Self-taught IT professional driving innovation & social impact with cybernetics, open source (Linux, Kubernetes), AI & ML. Building a thriving SRE/DevOps community at SREDevOps.org. I specialize in simplifying solutions through cloud native technologies and DevOps practices.