Verteilte Deployment Pipelines mit Github Actions

Portrait von Tim Lohse

veröffentlicht am 14.04.2022
geschrieben von Tim Lohse

Wie wir bei stadt.werk eine verteilte Build und Deployment Pipeline mit Github Actions umgesetzt haben.

Einführung

Es kann viele Gründe dafür geben, dass Build und Deployment einer neuen Software nicht im selben Repository mit Github Actions durchgeführt werden sollen oder können. Man hält vielleicht seinen Quellcode bei Github unter Versionskontrolle und schätzt die einfache Integration von Github Actions Workflows, ist aber vom Funktionsumfang anderer CI/CD Lösungen (Continuous Integration / Continuous Delivery) wie CirclCI oder Jenkins begeistert und möchte eine kombinierte / verteilte Pipeline erstellen. Außerdem können die Anforderungen der eigenen Infrastruktur auch eine treibende Kraft für das Bedürfnis nach einer verteilten Build und Deployment Pipeline sein. So verhält es sich auch im Fall unseres Softwareprojekts mit dem internen Kürzel “C4”. Dieser Blog-Eintrag soll eine Beschreibung unseres Lösungsansatzes sein und einen Einblick in die Herausforderungen während der praktischen Umsetzung gewähren.

Infrastrukturumgebung

Eine kurze Übersicht über die vorhandenen Strukturen:

Im Backend setzen wir auf eine klassische REST API, welche das Web-Frontend mit Informationen versorgen soll und mit NestJs entwickelt wird. Der Quellcode für die verschiedenen Microservices wird in einem Monorepopository c4_app_backend versioniert und weiterentwickelt. Da ein Monorepository eine Vielzahl von Projekten beinhalten soll und dies ohne klare Struktur schnell zu einem komplexen Wirrwarr an Abhängigkeiten führen kann, setzen wir auf Nx als Verwaltungswerkzeug. Nx bringt neben einem Command Line Interface (CLI) zum Generieren und Verwalten von Nx Entitäten, eine eigene Ordnerstruktur mit sich. (siehe Abb. 1)

Abb. 1: Nx OrdnerstrukturAbb. 1: Nx Ordnerstruktur

In einem Standard Nx Workspace gibt es einen apps- und einen libs-Ordner für Nx Projekte und Bibliotheken. Zum Stand dieses Blogeintrags sind dort sechs Nx Projekte mit diversen privaten und geteilten Bibliotheken abgelegt, welche durch eine Build Pipeline in einem Github Actions Workflow pro Umgebung (Staging und Produktion) zu Docker Images gepackt und in Github Packages abgelegt werden.

Die gebauten Docker Images der Microservices werden als Pods in einem Kubernetes (K8s) Cluster pro Umgebung, in AWS EKS, gehostet und sind anschließend durch AWS Load Balancer und K8s Ingresses aus dem Internet erreichbar. Die Cluster enthalten zudem Monitoring und Logging Funktionalitäten (Grafana, Prometheus und Loki), sowie ein Alfresco Content Management System (CMS). Zur Authorisierung der Microservices setzen wir auf den oAuth2.0 Standard durch Keycloak, einer Open Source Identity und Access Management Lösung von RedHat. Das Deployment der Docker Images in das K8s Cluster einer jeden Umgebung geschieht automatisiert durch einen Flux Controller, welcher per Infrastructure-as-Code K8s Konfigurationsdateien aus unserem Repository c4_app_backend_deployment ausliest und Änderungen übernimmt.

Einen Gesamtüberblick dieser Build und Deployment Kette findet sich im nachfolgenden Schaubild.

Abb. 2: Build und Deployment ÜberblickAbb. 2: Build und Deployment Überblick

Die offene Frage zum Zeitpunkt der Konzeptionsphase war, wie wir die neu gebauten Versionen der Docker Images automatisiert in den jeweiligen K8s Konfigurationsdateien aus c4_app_backend_deployment aktualisieren können, sodass Flux nach dem GitOps-Prinzip diese erkennt und in die Umgebungen übernimmt. Für unsere Lösung dieser Herausforderung ist besonders die Ordnerstruktur in c4_app_backend_deployment eine wichtige Grundlage (siehe Abb. 3), welche die Umgebungen der Anwendung widerspiegelt und eine Konfigurationsdatei für jeden Backend Service enthält. Diese Dateien enthalten neben K8s-spezifischen Informationen auch die Docker Image Version, sodass Flux anschließend einen Pod im Cluster aufbauen kann.

Abb. 3: c4_app_backend_deployment OrdnerstrukturAbb. 3: c4_app_backend_deployment Ordnerstruktur

Der Build Prozess

Sehen wir uns zunächst den Quellcode der Build Pipeline an, um mit den Möglichkeiten der CLI Tools bei jedem Push auf den develop oder main Branch herauszufinden, welche Nx Projekte verändert wurden, um diese neu bauen zu können. Am Beispiel der Staging Build Pipeline (Code 1) lässt sich zunächst in den ersten Schritten des Workflows keine große Besonderheit erkennen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
name: Backend Deployment to AWS C4-Test Account
on:
  push:
    branches: [ develop ]
jobs:
  build_backend_artifact:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
      - name: Fetch information about branches and commits
        run: git fetch origin main
      - name: Cache node_modules
        id: node_modules
        uses: actions/cache@v2
        with:
          path: node_modules
          key: ${{ runner.os }}-node_modules
      - name: Npm audit
        run: npm audit --audit-level=critical
      - name: Npm install
        run: npm install
      - name: Derive appropriate SHAs for base and head for `nx affected` commands
        uses: nrwl/nx-set-shas@v2
        with:
          main-branch-name: ${{ github.base_ref}}
      - name: Build affected projects in nx workspace and output array
        id: set-matrix
        run: |
          MATRIX=(`./node_modules/.bin/nx affected:apps --plain --base=$NX_BASE --head=$NX_HEAD`)
          echo "::set-output name=matrix::$(jq --compact-output --null-input '$ARGS.positional' --args "${MATRIX[@]}")"          
      - name: Debug variables
        run: |
                    echo affected projects: ${{ steps.set-matrix.outputs.matrix }}
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}
Code 1: Staging Build Pipeline

Jedoch ist bereits das Auschecken des Repository Codes mit der Option fetch-depth: 0 überaus wichtig, sodass mit git fetch origin main die gesamte Versionshistorie für die nachfolgenden Nx CLI Befehle vorhanden ist. Da es sich bei den Microservices um NodeJs Quellcode handelt, welcher mit externen Npm Modulen angereichert wird, darf Caching, Auditing und die Installation dieser nicht fehlen. Der Befehl nx affexted:apps, welcher mit den Umgebungsvariablen NX_BASE und NX_HEAD aus der externen Action nrwl/nx-set-shas befüllt wird, erkennt Änderungen an Nx Projekten, welche zwischen dem Base-Commit und dem Head-Commit aufgetreten sind und gibt eine Liste der Projektnamen aus. Diese Liste benutzen wir, um damit eine Github Actions Matrix zu befüllen und als Ergebnis des Jobs auszugeben.

Anhand der veränderten Nx Projekte in dieser Matrix starten wir den Bauprozess der Docker Images parallel in einzelnen Jobs. (siehe Code 2)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
deploy_backend_artifact:
  if: ${{ !contains(needs.build_backend_artifact.outputs.matrix, '[]') }}
  name: Deploy to develop
  needs: build_backend_artifact
  runs-on: ubuntu-latest
  strategy:
    matrix:
      project: ${{ fromJson(needs.build_backend_artifact.outputs.matrix) }}
  env:
    Environment: staging
  steps:
    - name: Checkout repository
      uses: actions/checkout@v2
      with:
        fetch-depth: 0
    - name: Create short sha for commit
      run: echo "SHORT_SHA=`echo $GITHUB_SHA | cut -c1-8`" >> $GITHUB_ENV
    - name: 'Get Previous tag'
      id: previoustag
      uses: 'WyriHaximus/github-action-get-previous-tag@v1'
    - name: Create image name
      run: echo "IMAGE_NAME=`echo ${{ matrix.project }}-${{ env.Environment }}:${{ steps.previoustag.outputs.tag }}-${{ env.SHORT_SHA }}`" >> $GITHUB_ENV
    - name: Debug variables
      run: |
        echo project: ${{ matrix.project }}
        echo previous tag: ${{ steps.previoustag.outputs.tag }}
        echo long sha: $GITHUB_SHA
        echo short sha: ${{ env.SHORT_SHA }}
        echo image name: ${{ env.IMAGE_NAME }}        
    - name: Login and build docker image
        run: |
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker build -t ghcr.io/stadtwerk/c4_app_backend/${{ env.IMAGE_NAME }} . --build-arg project=${{ matrix.project }} --cache-from ghcr.io/stadtwerk/c4_app_backend/${{ matrix.project }}:latest          
    - name: Publish docker image
      run: docker push ghcr.io/stadtwerk/c4_app_backend/${{ env.IMAGE_NAME }}
Code 2: Staging Docker Image Build Prozess Job

Sollten keine Projekte verändert worden sein, bricht der Workflow durch die IF-Bedingungen ab. Zunächst werden einige Umgebungsvariablen erzeugt, welche für die Benennung des Images nötig sind. Anschließend loggen wir uns mit dem GITHUB_TOKEN in Github Packages ein, erzeugen anhand eines Dockerfile aus dem Root Ordner des Repository ein Docker Image und veröffentlichen es.

Im Repository c4_app_backend_deployment befindet sich ein Workflow (siehe Code 3), in dem zunächst die Umgebung überprüft und anschließend die alte Version des jeweiligen Docker Images in den zuvor angesprochenen K8s Konfigurationsdateien mit dem sed Befehl gefunden und durch die neue Version ersetzt wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
jobs:
  update_develop_helm_chart:
    if: ${{ github.event.inputs.environment == 'staging' }}
    runs-on: ubuntu-latest
    steps:
      - name: Debug variables
        run: |
          echo environment: ${{ github.event.inputs.environment }}
          echo project: ${{ github.event.inputs.project }}
          echo image sha: ${{ github.event.inputs.image }}          
      - name: Checkout repository
        uses: actions/checkout@v2
      - name: Sed k8s configuration
        run: |
          cd apps/test
          sed -e 's#image:.*$#image: ${{ github.event.inputs.image }}#g' -i c4-${{ github.event.inputs.project }}-backend.yaml          
      - name: Setup git config
        run: |
          git config user.name "GitHub Actions Bot"
          git config user.email "<>"          
      - name: Commit updated k8s configuration
        run: |
          git pull
          git add .
          git commit -m "[K8s-Update] Updated ${{ github.event.inputs.project }} k8s test configuration"
          git push origin main          
  update_production_helm_chart:
    if: ${{ github.event.inputs.environment == 'production' }}
    runs-on: ubuntu-latest
    steps:
      - name: Debug variables
        run: |
          echo environment: ${{ github.event.inputs.environment }}
          echo project: ${{ github.event.inputs.project }}
          echo image sha: ${{ github.event.inputs.image }}          
      - name: Checkout repository
        uses: actions/checkout@v2
      - name: Sed k8s configuration
        run: |
          cd apps/prod
          sed -e 's#image:.*$#image: ${{ github.event.inputs.image }}#g' -i c4-${{ github.event.inputs.project }}-backend.yaml          
      - name: Setup git config
        run: |
          git config user.name "GitHub Actions Bot"
          git config user.email "<>"          
      - name: Commit updated k8s configuration
        run: |
          git pull
          git add .
          git commit -m "[K8s-Update] Updated ${{ github.event.inputs.project }} k8s production configuration"
          git push origin main          
Code 3: c4_app_backend_deployment Workflow

Anschließend werden die geänderten Dateien zur Git Stage hinzugefügt, zu einem Commit mit dem Prefix “[K8s-Update]” hinzugefügt und an das Github Repository gesendet, sodass Flux im Anschluss die Änderungen erkennen kann.

Soweit fehlt nur noch eine Lösung zum Start des Workflows in c4_app_backend_deployment, mit den dazugehörigen Informationen zur Anpassung der K8s Konfigurationsdateien. Diese Informationen sind der Name des neu gebauten Docker Images, die Umgebung, für die das Image gebaut wurde, und der Name des veränderten Nx Projekts, sodass die richtige Konfigurationsdatei angepasst werden kann.

Ein Blick in die Dokumentation von Github Actions liefert mehrere mögliche Events, welche als Auslöser für einen Workflow fungieren können. Da wir einen Trigger benötigten, welcher in c4_app_backend nach dem erfolgreichen Durchlauf des Build Prozesses manuell ausgeführbar ist und einen Payload enthalten kann, kommen zwei mögliche Events in Frage: repository_dispatch und workflow_dispatch.

Lösungsansatz repository_dispatch:

Auf den ersten Blick scheint repository_dispatch die perfekte Lösung für unser Problem zu sein. Durch einen HTTP POST Request gegen den Endpunkt /repos/{owner}/{repo}/dispatches lässt sich manuell ein Event erzeugen, welches als Auslöser für den Workflow in c4_app_backend_deployment dient. Zusätzlich können die benötigten Informationen dem Request als Payload übergeben werden.

Jedoch ist bei der praktischen Umsetzung mit repository_dispatch schnell ein starke Limitation aufgefallen. Das repository_dispatch Event bezieht sich ausschließlich auf den Standard Branch eines Ziel Repository (siehe Code 3), was es für uns in der Entwicklungsphase unmöglich gemacht hat das Event zu testen, da wir bei stadt.werk auf eine Branching Strategie mit Feature Branches setzen. Das bedeutet, dass wir pro Umgebung einen langlebigen Branch (main / develop) verwalten und neue Features auf temporären Branches entwickelt und anschließend nach erfolgreichem Pull Request in die langlebigen Branches eingefügt werden. (siehe Abb. 4)

Abb. 4: Limitation repository_dispatch EventAbb. 7: Limitation repository_dispatch Event

Lösungsansatz workflow_dispatch:

Glücklicherweise gibt es außerdem das workflow_dispatch Event, welches sich kaum vom repository_dispatch Event unterscheidet. Es lässt sich ebenfalls durch einen HTTP POST Request manuell ausführen und kann die benötigten Informationen als Payload übergeben. Der Unterschied liegt darin, dass eine größere Flexibilität bei der Ansprache spezifischer Workflows in unterschiedlichen Branches gegeben ist, was es für die perfekte Lösung unseres Problem macht.

Somit ergibt sich folgende Lösungsstruktur. Der Build Prozess in c4_app_backend aus Code 1 wird um einen POST Request mit cURL ergänzt und übergibt die notwendigen Informationen. (siehe Code 4)

1
2
3
4
5
6
7
- name: POST workflow dispatch against https://api.github.com/repos/{owner}/{repo}/dispatches
  run: |
     curl -X POST \
       -H "Accept: application/vnd.github.v3+json" \
       -H "Authorization: token ${{ secrets.CI_TOKEN }}" \
       -d '{"ref": "main", "inputs": { "environment":"${{ env.Environment }}","project":"${{ matrix.project }}","image": "ghcr.io/stadtwerk/c4_app_backend/${{ env.IMAGE_NAME }}"}}' \
       https://api.github.com/repos/stadtwerk/c4_app_backend_deployment/actions/workflows/c4_backend_deployment.yml/dispatches     
Code 4: workflow_dispatch POST Request

Dieser dient als Auslöser für den Workflow in c4_app_backend_deployment aus Code 3. (siehe Code 5)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
name: Deploy staging and production images of c4 backend services
on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Environment of the newly built image'
        required: true
      project:
        description: 'Name of project image was created for'
        required: true
      image:
        description: 'Name of newly created image within c4_app_backend repository'
        required: true
Code 5: c4_app_backend_deployment Workflow Trigger