Multi-Plattform Docker-Images mit Github Actions bauen

  • von Nicolai Mainiero

Nicolai Mainiero, Softwarearchitekt bei sidion

Mit der zunehmenden Verbreitung von Computern mit ARM64 Architektur, wie zum Beispiel Raspberry Pi, AWS Gravitron oder Ampere Altra von Microsoft, zur Entwicklung und Betrieb von Anwendungen müssen auch Build-Pipelines für Containerimages etabliert werden, die nicht nur für eine sondern mehrere Plattformen Images bauen kann.

Github Actions

GitHub Actions ist eine Plattform für kontinuierliche Integration und Bereitstellung (CI/CD), mit der die Build-, Test- und Bereitstellungspipeline automatisiert werden kann. Diese Workflows werden durch Ereignisse, die im Repository stattfinden gestartet. Die Auswahl an möglichen Ereignissen ist sehr umfangreich, das kann ein neu erzeugtes Issue oder eine Änderung an einem Issue sein, ein neuer Pull-Request oder ein Fork sein. Darüber hinaus kann auch noch gefiltert werden, dass zum Beispiel nur ein Push auf den main Branch den Workflow startet. Eine vollständige Beschreibung wie ein Workflow gestartet werden kann findet sich bei Github ^1. Um ein Build zu automatisieren, bietet es sich an auf Push Ereignisse zu reagieren.

on:
  push:
    branches: [ main ]
    # Publish semver tags as releases.
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ main ]

Die Workflows werden als .yaml deklarativ beschrieben. Diese Beschreibung muss dann in einem speziellen Verzeichnis (.github/workflows) im Repository abgelegt werden. Github wertet diese Dateien dann entsprechend aus und startet bei einem passenden Ereignis den Workflow. In das Verzeichnis kann mehr als eine Datei abgelegt werden, um verschiedene Workflows zu managen.

Marketplace

Öffnet man eine solche Workflow Beschreibung zur Bearbeitung direkt auf Github, bekommt man einen Editor, der neben der Dokumentation auch den Marketplace anzeigt. Dieser Marktplatz bietet unzählige sogenannter Actions an, die in einem Workflow verwendet werden können. Von so grundsätzlichen Dingen wie zum Beispiel die richtige Java Version zu installieren und den PATH zu konfigurieren, über eine Cache Action, die Artifakte und Dependencies zwischenspeichert bis zu einer Deploy Action für AWS ECS.

Für diesen Anwendungsfall benötigen wir vor allem die von Docker zur Verfügung gestellten Actions, um ein Container Image zu bauen. Zunächst wird ein geeigneter Job im Workflow definiert. Dieser ist notwendig, um den Runner auszuwählen mit dem die nachfolgenden Schritte ausgeführt werden. Github bietet hier eine Auswahl verschiedener vorkonfigurierter Umgebungen an (siehe ^2). Für dieses Beispiel ist die Ubuntu Umgebung die geeignetste.

jobs:
  docker:

    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

Actions

Jetzt sind nur noch die Notwendigen Einzelschritte in dem Job zu definieren. Im Wesentlichen sind das folgende fünf Stück, um das Container Image zu bauen: Checkout, Setup von QEMU, Setup von Docker Buildx, Login bei Docker Hub und Metadaten bestimmen, Build und Push zu Docker Hub.

Checkout

Eine von Github bereitgestellte Standardfunktion um das Repository in einer bestimmten Version auszuchecken. Wenn nichts Weiteres angegeben wird, wird das aktuelle Repository mit Hilfe des personal access token ausgecheckt. Es wird auch nur ein einziger Commit und nicht die gesamte Historie ausgecheckt.

- name: Checkout repository
  uses: actions/checkout@v2

QEMU

Die QEMU Action von docker installiert den gleichnamigen Systememulator, damit die verschiedenen Systemarchitekturen nachfolgend zur Verfügung stehen. Neben ARM64 kann hier auch noch riscv64 oder arm ausgewählt werden. Standardmäßig werden alle Architekturen installiert.

- name: Set up QEMU
  uses: docker/setup-qemu-action@v1

Buildx

Buildx ist ein Plugin, dass das normal Docker CLI um weitere Features, wie zum Beispiel die Möglichkeit gegen mehrere Knoten gleichzeitig ein Image bauen zu können, nachrüstet. Genau dieses Feature erlaubt es verschiedene QEMU Konten zu starten und diese dann zum Bauen der verschiedenen Architekturen zu verwenden.

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v1

Login bei Docker Hub

Damit das Image dann auch auf Docker Hub verfügbar wird, muss sich dort angemeldet werden. Dazu wird auf Docker Hub ein Access Token erzeugt und im Repository als Secret hinterlegt. In dem Workflow kann dann darauf mit ${{ secrets.DOCKER_TOKEN }} zugegriffen werden.

# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
  if: github.event_name != 'pull_request' # Nur ausführen, wenn es kein PR ist
  uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
  with:
    registry: ${{ env.REGISTRY }}
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_TOKEN }}

Metadaten bestimmen, Build und Push

Zuletzt werden noch die Metadaten für das Image bestimmt. Neben dem Namen des Images können auch die Tags angepasst werden. Beispielsweise könnten Branches eigene Tags erhalten. Danach kann das Image dann gebaut und auf Docker Hub hochgeladen werden. Hier ist es nun entscheidend alle Plattformen anzugeben, für die man das Image gebaut haben möchte.

# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
  id: meta
  uses: docker/metadata-action@v3
  with:
    images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
    tags: |
      type=ref,enable=true,priority=600,prefix=,suffix=,event=branch
      type=ref,enable=true,priority=600,prefix=,suffix=,event=tag
      type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr

# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
  uses: docker/build-push-action@v2
  with:
    context: ./
    platforms: linux/amd64,linux/arm64 # Hier die gewünschen Architekturen auswählen
    push: ${{ github.event_name != 'pull_request' }} # Nur ausführen, wenn es kein PR ist
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}

Die vollständige Pipeline sieht dann so aus:

name: ci

# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.

on:
  push:
    branches: [ main ]
    # Publish semver tags as releases.
    tags: [ 'v*.*.*' ]
  pull_request:
    branches: [ main ]

env:
  # Use docker.io for Docker Hub if empty
  REGISTRY: docker.io
  # github.repository as <account>/<repo>
  IMAGE_NAME: ${{ 'nmainiero' }}/${{ 'ecs-logging-demo' }}


jobs:
  docker:

    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Image name
        run:   echo "$IMAGE_NAME"
        shell: bash

      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v1

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v1

      # Login against a Docker registry except on PR
      # https://github.com/docker/login-action
      - name: Log into registry ${{ env.REGISTRY }}
        if: github.event_name != 'pull_request'
        uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}

      # Extract metadata (tags, labels) for Docker
      # https://github.com/docker/metadata-action
      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v3
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,enable=true,priority=600,prefix=,suffix=,event=branch
            type=ref,enable=true,priority=600,prefix=,suffix=,event=tag
            type=ref,enable=true,priority=600,prefix=pr-,suffix=,event=pr
      # Build and push Docker image with Buildx (don't push on PR)
      # https://github.com/docker/build-push-action
      - name: Build and push Docker image
        uses: docker/build-push-action@v2
        with:
          context: ./
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Pipeline Aktuell halten

Diese Pipeline kann man mit Hilfe eines weiteren Workflows aktuell halten. Dazu gibt es den Github Dependabot, der automatisch PRs erzeugt, wenn eine Dependency in einer neuen Version erschienen ist. Dazu kann man einfach die Datei .github/dependabot.yml mit folgendem Inhalt erstellen:

version: 2
updates:
  # Maintain dependencies for GitHub Actions
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "daily"

Fazit

Github Actions sind ein flexibles und mächtiges Tool, dass es erlaubt schnell, unkompliziert und automatisch Docker Images für verschiedene Rechnerarchitekturen zu bauen und auf Docker Hub zur Verfügung zu stellen. Durch den modularen Aufbau in einzelne Schritte können diese bei Bedarf ausgetauscht werden, um zum Beispiel das Image in einer anderen Registry zu veröffentlichen.

Durch den Einsatz von QEMU und Buildx können Images für verschiedene Architekturen gebaut werden, auch wenn die CI-Pipeline auf einer ganz anderen Architektur basiert. Damit ist es problemlos möglich die wirtschaftlichste Lösung, unabhängig von der Architektur, auszuwählen.

Zurück