CI/CD für Fortgeschrittene mit Azure DevOps

ci_cd_devops_azure
Die Idee für diese Artikelserie entstand aus der Situation bei einem Kunden, bei dem wir CI/CD einführten, weil der manuelle Arbeitsaufwand nicht mehr zu bewerkstelligen war. Die nachfolgenden Anleitungen sind also frisch aus der Praxis entstanden. Der Einfachheit halber haben wir den langen Weg des Ausprobierens und der Fehlersuche gekürzt und präsentieren hier nur das Endergebnis. Die Code-Ausschnitte sind beispielhaft, aber ausreichend, um die Funktionalität zu präsentieren.

Teil 1: Go, Docker und Self-hosted Build agents

Was ist Azure DevOps?

Azure DevOps ist eine Web-Plattform von Microsoft, die Tools für verschiedene Bereich im Umfeld von IT-Projekten bereitstellt:

  • Azure Boards für Projektmanagement
  • Azure Pipelines für CI/CD
  • Azure Repos für Source-Code-Management
  • Azure Test Plans für manuelles Testen
  • Azure Artifacts für Artefakt-Management

Die Tools greifen ineinander, so lassen sich zum Beispiel Work Items aus Azure Boards verknüpfen mit Pull Requests in Azure Repos. Bevor ein Pull Request gemergt werden darf, muss eine Pipeline in Azure Pipelines die Korrektheit des Codes bestätigen und schlussendlich lädt sie ein Artefakt nach Azure Artifacts.
Wir werden in dieser Artikelserie nur von Azure Repos und Azure Pipelines Gebrauch machen.

Die erste CI Pipeline – Go und Docker

Unser erster Anwendungsfall ist ein Microservice in Go, die mittels Docker installiert werden soll. Wir werden eine CI-Pipeline erstellen, die folgendes tun soll:

  • Bauen und Testen des Go-Microservice
  • Bauen eines Docker-Images
  • Hochladen des Docker-Images in eine Docker Registry

Azure DevOps bietet zwei Möglichkeiten, Pipelines zu erstellen: über eine grafische Oberfläche oder über YAML-Dateien, die im Git-Repo mit eingecheckt werden. Üblicherweise wird diese Datei in das Wurzelverzeichnis des Git-Repos unter dem Name azure-pipelines.yaml committet (der Name ist aber frei wählbar). Da wir unsere Pipelines im Team entwickeln, dokumentieren, Änderungen verfolgen und Abschnitte wiederverwenden wollen, entscheiden wir uns für die fortgeschrittene Variante mit den YAML-Dateien.

Der Microservice ist sehr einfach aufgebaut: er startet lediglich einen HTTP-Server mit einem REST-Endpunkt:

go.mod_
main.go
Dockerfile

Jetzt zum spannenden Teil: der CI-Pipeline. In Azure DevOps besteht der ausführbare Teil einer Pipeline aus Stages, eine Stage aus Jobs und ein Job aus Steps. Für unseren einfachen Fall reicht eine Stage mit einem Job völlig aus. Die Funktionalität eines Steps wird mittels eines Tasks beschrieben, zum Beispiel gibt es einen Go-Task, einen Docker-Task und einen Git-Checkout-Task. Mit dem Bash- oder Powershell-Task gibt es die Möglichkeit eigene Skripte auszuführen. Für komplexere Anwendungsfälle gibt es dann noch die Möglichkeit eigene Tasks in TypeScript zu entwickeln. Da der Build-Prozess schon komplett im Dockerfile definiert ist, benötigen wir als Build-Steps nur noch docker build und docker push. Dafür nutzen wir den Docker-Task.

Neben dem eigentlichen Build-Prozess können wir in der Pipeline definieren

  • bei welchen Events die Pipeline automatisch getriggert werden soll.
  • welche Variablen und Variablen-Gruppen verwendet werden sollen.
  • ob die Pipeline parametrisiert sein soll.
  • ob weitere Git-Repositories ausgecheckt werden sollen.

Alle diese Einstellungen können auch weggelassen werden, dann wird die Pipeline automatisch für jeden Git Push getriggert, hat keine Variablen und Parameter und es wird nur das eigene Git-Repository ausgecheckt. Das ist auch das Verhalten, das wir für unsere Pipeline wollen.

Innerhalb der Pipeline haben wir Zugriff auf einige vordefinierte Variablen, die uns u.a. Auskunft geben über den Namen, die Organisation und den ausgecheckten Commit des Git-Repos. Eine Liste aller vordefinierten Variablen gibt es hier. Natürlich lassen sich auch zur Laufzeit eigene Variablen definieren und so Daten zwischen den Steps weiterreichen. Wir nutzen

  • $(Build.SourcesDirectory), den Pfad auf dem Build-Agent, in dem das Git-Repo ausgecheckt ist als Pfad-Präfix für den Zugriff auf Dateien.
  • $(Build.Repository.Name), den Name des Git-Repositories als Name für das Docker-Image.
  • $(Build.SourceVersion), den Commit-Hash als Tag für das Docker-Image

Die fertige Pipeline-Definition sieht demnach wie folgt aus:

azure_pipeline_yaml

Als Ziel-ContainerRegistry haben wir hier ‚docker-hub‘ angegeben. Das ist ein Verweis auf eine sogenannte Service Connection, die in Azure DevOps ganz allgemein eine Verbindung zu einem externen Dienst beschreibt. So kann die Pipeline diese nutzen, ohne dass irgendwelche Anmeldedaten direkt in der Pipeline gespeichert werden müssen. Zum Erstellen einer neuen Service Connection gehen wir auf die „Projekt-Settings“ und dort unter „Pipelines“ –> „Service Connections“. Wir erstellen eine Connection zu einer Docker Registry in Docker Hub. Voraussetzung dafür ist ein (kostenloser) Docker-Account. Natürlich könnten wir auch eine beliebige andere Docker-Registry verwenden.

docker_registry

Als nächstes müssen wir unsere Zugangsdaten für den Docker-Hub Account eingeben. Wichtig: als Passwort muss vorher ein Access Token in Docker Hub erstellt werden.

Docker-ID

Nachdem wir alle Dateien committet haben, müssen wir nur noch die Pipeline in der Oberfläche von Azure DevOps anlegen und dabei auf unsere azure-pipelines.yaml verweisen. Dazu klicken wir unter „Pipelines“ auf „Create Pipeline“:

Unsere azure-pipelines.yaml liegt in Azure Repos:
azure_pipeline
Nach dem Auswählen des korrekten Git-Repos (in unserem Fall „example-go-project“) erkennt Azure DevOps automatisch unsere azure-pipelines.yaml, weil sie die einzige YAML-Datei ist.
Ein wohlüberlegter Klick auf „Run“ und wir können endlich die Früchte unserer Arbeit sehen:
Wie wir erkennen können wurde das Docker-Image gebaut und automatisch ins Docker Hub hochgeladen:
docker_hub
Ein schneller Test in einer lokalen Shell bestätigt, dass alles einwandfrei funktioniert hat und das Docker-Image jetzt überall verwendet werden kann:
Unit Tests und Code Coverage

Zu jeder guten Pipeline gehört auch automatisiertes Testen. Darum erweitern wir unser Go-Projekt nun um einen Unit-Test und bauen in der Pipeline einen Step ein, der die Tests ausführt. Falls Tests fehlschlagen, soll die Pipeline abbrechen und das Docker-Image weder gebaut noch gepusht werden.

Unser erster Test startet einen HTTP-Request an den Standalone-Server und prüft die Antwort:

main_test.go

Lokal läuft der Test schonmal:

Dann fügen wir die Tests jetzt zur Pipeline hinzu. Azure DevOps bietet zwei vordefinierte Tasks für Go-Projekte an: GoTool und Go. Mit GoTool wählen wir die Go-Version für die Pipeline aus, mit Go können wir dann beliebige Go-Commands ausführen. Vor dem Ausführen der Tests bauen wir unser Go-Projekt. Auch wenn das nicht unbedingt nötig wäre, hilft es doch bei der Fehlersuche, ob ein Fehler schon beim Bauen (syntaktischer Fehler) oder erst beim Ausführen der Tests (semantischer Fehler) auftritt. Zum Bauen sind zwei Schritte nötig: go mod download zum Herunterladen der Bibliotheken und go build zum Kompilieren. Die Tests werden danach mit go test ausgeführt. Wir erweitern also in die Pipeline wie folgt:

Nach dem Committen und Pushen sollte die Pipeline automatisch gestartet werden, die Anwendung bauen und den Test ausführen:
test_successfull
So langsam bekommen wir ein Gespür für CI und die Umsetzung in Azure DevOps. Um sicherzugehen, dass auch der Negativ-Fall funktioniert, ändern wir jetzt den Code, sodass der Test fehlschlägt:
main.go
Wie erwartet schlägt die Pipeline fehl und bricht ab bevor das Docker-Image gebaut wird:
test_failed

Um konkret nachzuschauen welcher Test warum fehlgeschlagen, müssen wir aber im Log nachschauen. Bei einem einzigen Test ist das kein Problem, aber wenn wir hunderte Tests haben, haben wir nicht die Zeit, hier durch tausende Zeilen durchzuscrollen, um die Tests zu finden, die fehlgeschlagen sind. Außerdem sehen wir auch nicht sofort wieviel Prozent der Tests fehlgeschlagen sind. Zum Glück bietet Azure DevOps hier eine Schnittstelle zur Bereitstellung von Test-Ergebnissen im JUnit-XML-Format. Um diese nutzen zu können, müssen wir aber die Ausgabe von go test in dieses Format umwandeln.

Erfreulicherweise hat diese Arbeit schon ein anderer für uns erledigt und ein entsprechendes Go-Tool geschrieben: https://github.com/jstemmer/go-junit-report. Ebenfalls interessiert uns die Test-Coverage. Hier gibt es ebenfalls eine Schnittstelle von Azure DevOps und fertige Tools für die Konvertierung ins richtige Format.

Für diesen ganzen komplexen Ablauf erstellen wir einen Bash-Task, der folgendes tun wird: Erst lädt er die nötigen Tools herunter, dann führt er die Tests aus, wobei er sich den Return-Code für später merkt. Wir wollen nämlich den Return-Code von go test als Return-Code des ganzen Steps verwenden, damit Azure DevOps weiß, ob der Step fehlgeschlagen ist oder nicht. Vorher müssen wir aber noch den Report und die Coverage aufbereiten, sowohl im Erfolgsfall als auch im Fehlerfall. Anschließend fügen wir noch die zwei Tasks PublishTestResults und PublishCodeCoverageResults ein. Hier ist wichtig, die condition: succeededOrFailed() hinzuzufügen. Normalerweise werden nachfolgende Steps nicht ausgeführt, wenn ein Step fehlschlägt (d.h. der Standard-Wert ist condition: succeeded()) aber mit condition: succeededOrFailed() werden sie auch ausgeführt, wenn vorherige Schritte fehlgeschlagen sind, im Gegensatz zu condition: always() aber nicht, wenn die Pipeline manuell abgebrochen wurde.

Notiz am Rande, falls die Builds auf einem Self-hosted Build-Agent laufen sollen: der Task PublishCodeCoverageResults erwartet, dass auf dem Build-Agent eine .NET Runtime installiert ist.

Hier nun die fertige Pipeline:

azure-pipelines.yaml
Nach dem erfolgreichen Durchlauf des Builds sehen wir nun die Test-Ergebnisse und die Coverage grafisch aufbereitet in zwei neuen Tabs:
test_report
success
test_report
Ebenso sehen wir im Falle eines fehlgeschlagenen Tests eine genaue Meldung über den Fehler:
test_fail_report
Fazit

Azure DevOps bietet eine gute Möglichkeit, schnell und komfortabel Pipelines zu erstellen. Die grafische Oberfläche ist schlicht und leicht verständlich und gerade für Einsteiger ins Thema DevOps empfehlenswert. Hierfür bietet sich das Erstellen von Pipelines über die grafische Oberfläche Drop an. Die Anbindung an externe Services ist ebenfalls einfach und schnell erledigt. Nichtsdestotrotz ist es durch den Wechsel zur YAML-Syntax für Pipelines auch für komplexere Anwendungsfälle geeignet. So lassen sich nahezu beliebig komplexe Pipelines definieren. In diesem Artikel haben wir uns bisher nur einen Bruchteil der Features von Azure DevOps angeschaut.

In Teil 2 werden wir:

  • ein weiteres Go-Projekt inklusive Pipeline als Abhängigkeit in unser erstes Go-Projekt einbinden.
  •  eine Pipeline-Template zur Wiederverwendung für weitere zukünftige Pipelines erstellen.
  • einen intelligenten Versionierungs-Algorithmus nach Semantic Versioning in die Pipeline einbauen.

Letzte Beiträge

Erfolgreiche Transition der IT-Landschaft der Thüga Aktiengesellschaft und Übernahme des IT-Supports

digatus und Gubbi bündeln ihre Expertise in einer strategischen Partnerschaft

digatus unterstützt Infrareal bei der Übernahme des Pharma- und Biotech-Standorts in Orth