Warum CI/CD-Pipelines migrieren, wenn man sie mit dagger.io einfach portieren kann?

16.03.2023Stefan Welsch
Cloud CI/CD Dagger DevOps Go

Heute wollen wir uns ein Tool ansehen, welches häufige Probleme mit CI/CD-Pipelines in der Vergangenheit stehen lässt. So sei gesagt, wer an dieser Stelle nun ein Dependency Injection Framework erwartet, den muss ich enttäuschen. Leider wird der Name Dagger im Development Umfeld zweimal gebraucht. Einmal für das soeben erwähnte DI-Framework (https://dagger.dev/), und zum Zweiten für das Tool, welches wir heute in diesem Techup genauer betrachten (https://dagger.io/).

Aber was genau macht Dagger? Besucht man die Startseite von dagger.io, so wird man mit dem Satz empfangen: “Develop your CI/CD Pipelines as code and run them everywhere”. Das ist eine gewagte Aussage und ich will heute herausfinden, ob diese Aussage auch tatsächlich wahr ist.

Bevor wir nun tiefer in die technischen Details einsteigen, will ich euch ein paar Fakten über dagger an die Hand legen. Der erste Commit auf GitHub wurde am 01.Mai 2021 gemacht. Das Projekt ist damit schon bald zwei Jahre alt und wird auch noch fleissig weiterentwickelt. Der Kern des Projekts wurde in Go entwickelt. Da es mittlerweile aber auch SDK’s für Python, CUE und Node.js gibt, sind auch diese Sprachen mittlerweile vertreten. Das Projekt wurde von den Machern von docker.io ins Leben gerufen.

Schauen wir uns nun an, was Dagger kann und wofür wir es brauchen können. Dagger ist nicht dafür gedacht ein CI/CD-System zu ersetzen. Es will lediglich einen Layer einführen um sicherzustellen, dass es keinen Gap zwischen verschiedenen CI/CD-Systemen gibt. Entwickler können ihre CI/CD-Pipeline als Code definieren, was die Versionierung und Wiederverwendung von Pipeline-Konfigurationen ermöglicht. Es ermöglicht auch die Ausführung der Pipeline in isolierten Umgebungen unter Verwendung von Containern, was effizientere und konsistentere Builds ermöglicht. Dadurch ist der Aufwand eine Pipeline von System A nach System B zu migrieren natürlich auch recht gering. Darüber hinaus bietet dagger.io eine Reihe von APIs für die Integration mit externen Diensten und Tools, wodurch die Integration in bestehende Workflows und Systeme erleichtert wird. Kurz gesagt, du kannst dagger in einer GitHub Action nutzen, aber auch im Jenkins einsetzen.

Ein weiterer riesiger Vorteil ist natürlich, dass man die Pipelines auch direkt lokal testen kann, und es keinen Unterschied zur Ausführung auf dem CI/CD-System gibt.

Wie funktioniert dagger.io

Wie genau Dagger funktioniert zeigt die folgende Grafik:

Overview of dagger

Figure: Dagger Funktionsweise

Als erstes importiert man im Programm das entsprechende SDK. Aktuell gibt es wie gesagt SDK’s für die folgenden Sprachen: Golang, Python, CUE und Node.js.

Mit diesem SDK öffnet Dagger eine Verbindung zu der Dagger Engine. Hierbei wird entweder eine bereits vorhandene genutzt, oder on-the-fly eine neue erstellt. Mithilfe des SDK bereitet das Programm dann API-Anforderungen vor, die die auszuführenden Pipelines beschreiben, und sendet sie dann an die Engine. Für die Kommunikation mit der Engine wird ein eigenes Protokoll namens wire verwendet. Dieses Protokoll ist derzeit privat und daher auch noch nicht dokumentiert. Dies soll sich aber in Zukunft ändern, versprechen die Entwickler von Dagger. Das bedeutet, dass momentan lediglich das SDK die einzige dokumentierte API ist, die uns zur Verfügung steht.

Wenn die Engine eine API-Anforderung erhält, berechnet sie einen Directed Acyclic Graph (kurz: DAG, auf Deutsch: gerichteter azyklischer Graph) von Low-Level-Operationen, die zur Berechnung des Ergebnisses erforderlich sind, und beginnt mit der gleichzeitigen Verarbeitung von Operationen. Wenn alle Vorgänge in der Pipeline abgeschlossen sind, sendet die Engine das Ergebnis der Pipeline zurück ans Programm, wo das Ergebnis als Eingabe für neue Pipelines verwendet werden kann.

Dagger in der Praxis, mit Golang

Soweit zur Theorie. Schauen wir uns das ganze jetzt mal in der Praxis an. Ich will heute das Golang SDK und Cue nutzen um eine Pipeline zu bauen, welche mir meinen Golang-Code in ein Executable baut. Dazu schreibe ich mir das folgende Programm. Das Fehlerhandling habe ich aus Gründen der Lesbarkeit mal ignoriert. Nachfolgend also erstmal die Go Variante:

 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
package main

import (
	"context"
	"fmt"
	"os"
	// import the dagger SDK
	"dagger.io/dagger"
)

func main() {
	if err := build(context.Background()); err != nil {
		fmt.Println(err)
	}
}

func build(ctx context.Context) error {
	fmt.Println("Building with Dagger")

	// initialize Dagger client
	client, _ := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
	defer func(client *dagger.Client) {
		client.Close()
	}(client)

	// this is the src directory where the code resides
	src := client.Host().Directory(".")

	// create the container with the latest golang image
	golang := client.Container().From("golang:latest")

	// set the src dir in the container to the host path
	golang = golang.WithDirectory("/src", src).WithWorkdir("/src")

	// define the application build command
	path := "./"
	golang = golang.WithExec([]string{"go", "build", "-o", path})

	// get reference to executable file in container
	outputFileName := "dagger-techup"
	outputFile := golang.File(outputFileName)

	// write executable file from container to the host build/ directory in the current project
	outputDir := "./build/" + outputFileName
	outputFile.Export(ctx, outputDir)

	return nil
}

In Zeile 8 sehen wir den Import des Dagger SDK. Weiterhin definieren wir uns zwei Funktionen: main(), die eine Schnittstelle für den Benutzer bietet, um ein Argument an das Tool zu übergeben, und build(), die die Pipeline-Operationen definiert.

Die Funktion build() erstellt einen Dagger-Client mit dagger.Connect(). Dieser Client bietet eine Schnittstelle für die Ausführung von Befehlen gegen die Dagger-Engine. Anschliessend mounten wir uns das Projektverzeichnis in den Container und bauen unser Executable. Das Executable exportieren wir anschliessend wieder vom Container in das Projektverzeichnis.

Der grosse Vorteil dieser Methode ist, dass ein Go-Entwickler seine CI/CD-Pipeline direkt in der Sprache schreiben kann, in welcher er auch sein eigentliches Programm entwickelt hat. Nun wollen wir unsere CI/CD-Pipeline doch mal ausführen und schauen, was dabei herauskommt. Das gesamte Projekt steht euch übrigens auf Github zur Verfügung.

Schauen wir uns vor der Ausführung der Pipeline erstmal unsere Projektstruktur an:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
|-- README.md
|-- cue.mod
|   |-- dagger.mod
|   |-- dagger.sum
|   |-- module.cue
|   `-- pkg
|       |-- dagger.io
|				|-- ...
|-- dagger
|   `-- dagger.go
|-- dagger.cue
|-- dagger.iml
|-- go.mod
|-- go.sum
`-- main.go

Wir sehen, dass unsere Dagger-Pipeline im Unterordner dagger liegt. Wir können daher die Pipeline mit go run dagger/dagger.go ausführen und erhalten anschliessend die folgende Ausgabe.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
go run dagger/dagger.go

Building with Dagger
#1 resolve image config for docker.io/library/golang:latest
#1 DONE 0.6s

...

#8 copy /src/main /main
#8 DONE 0.0s

Anschliessend sollten wir in unserem Projektordner einen neuen build-Folder finden, in dem sich das gebaute Executable befindet.

Dagger in der Praxis, mit CUE

Nun schauen wir uns als nächstes an, wie wir die gleiche Pipeline auch in Cue aufbauen könnten. Auch diese sollte für einen Entwickler recht intuitiv zu verstehen sein.

 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
package dagger

import (
	"dagger.io/dagger"

	"dagger.io/dagger/core"
	"universe.dagger.io/go"
)

dagger.#Plan & {
  
  // write output from action cicd.build to build folder in project
	client: filesystem: "./build": write: contents: actions.cicd.build.output
  
  // define actions
	actions: {
    // get reference to source code
		source: core.#Source & {
			path: "."
			exclude: [
				"build",
				"*.cue",
				"*.md",
				".git",
			]
		}

    // start cicd pipeline of project
		cicd: {
			name: "build"

      // test code
			test: go.#Test & {
				source:  actions.source.output
				package: "./..."
			}

      // build code
			build: go.#Build & {
				source: actions.source.output
			}
		}
	}
}

Als erstes müssen wir uns auch hier das dagger.io SDK importieren und zusätzlich noch eine weitere Dependency, um unser Golang-Executable zu bauen.

Danach definieren wir uns Actions, welche nacheinander ausgeführt werden. Als erstes wird definiert, wo der Quellcode zu finden ist. Hier sagen wir einfach, dass der Path unser aktuelles Verzeichnis ist, da das dagger.cue-File im Root Verzeichnis der Projekts liegt. Nachdem der Pfad zum Quellcode klar ist, definieren wir eine Build Action, in der wir den Code testen und anschliessend bauen.

Nun wollen wir auch diese Pipeline lokal ausführen. Damit wir dies tun können, müssen wir uns allerdings erst die dagger-cue-CLI installieren. Hier gibt es eine Anleitung, wie man diese auf verschiedenen Betriebssystemen installieren kann. Da ich lokal auf einem Mac programmiere, kann ich einfach Homebrew nutzen.

1
brew install dagger/tap/dagger-cue

Sobald die CLI installiert ist, gebe ich erstmal den folgenden Befehl ein, um meine Dependencies zu laden:

1
2
3
4
5
dagger-cue project update

8:27AM INFO  system | installing all packages...
8:27AM INFO  system | installed/updated package universe.dagger.io@0.2.232
8:27AM INFO  system | installed/updated package dagger.io@0.2.232

Danach können wir dann den eigentlichen Build starten:

1
2
3
4
[] actions.build.getCode                                                                                
[] actions.build.goBuild.container
[] actions.build.test
[] actions.build.goBuild.container.export 

Wir sehen, dass auch hier ein Executable gebaut wurde und anschliessend in den build-Ordner exportiert wurde.

Wir haben nun auf zwei verschiedene Arten gesehen, wie wir eine CI/CD Pipeline mit dagger.io bauen können. Diese können nun im eigenen CI/CD-System eingebaut werden. Ich zeige dies kurz anhand einer GitHub Action. Für andere Systeme gibt es auf der Dagger-Website diverse Anleitungen.

GitHub bietet uns für die Ausführung der Dagger Action eine eigene GitHub Action. Diese können wir folgendermassen aufrufen:

 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
name: dagger-techup

on:
  push:
    branches:
      - main
env:
  APP_NAME: dagger-techup

jobs:
  dagger:
    runs-on: ubuntu-latest
    steps:
      - name: Clone repository
        uses: actions/checkout@v2

      # You need to run `dagger-cue project init` locally before and commit the cue.mod directory to the repository with its contents
      - name: Build App
        uses: dagger/dagger-for-github@v3
        # See all options at https://github.com/dagger/dagger-for-github
        with:
          version: 0.2
          # To pin external dependencies, you can use `project update github.com/[package-source]@v[n]`
          cmds: |
            project update
            do build            

Fazit

Wie wir sehen können ist es recht einfach Dagger in eine vorhandene Pipeline zu integrieren. Die Portabilität ist meines Erachtens ein klarer Pluspunkt für Dagger. Besonders mächtig wird Dagger, wenn man seine Pipeline in der vertrauten Programmiersprache, wie beispielsweise Golang schreiben kann. Hier hat man bei der Implementierung der Actions dann auch fast keine Einschränkungen mehr.

Ein Nachteil von Dagger ist die Dokumentation. Hier gibt es noch sehr viel Potenzial nach oben. Gerade bei den SDK’s muss man sehr lange suchen, bis man Informationen findet, die man braucht.

Ich werde Dagger aber weiter im Auge behalten, auch dem geschuldet, dass wir bei b-nova bereits die ersten Projekte auf Dagger umgestellt haben 🚀😄

Stefan Welsch

Stefan Welsch – Manitu, Pionier, Stuntman, Mentor. Als Gründer von b-nova ist Stefan immer auf der Suche nach neuen und vielversprechenden Entwicklungsfeldern. Er ist durch und durch Pragmatiker und schreibt daher auch am liebsten Beiträge die sich möglichst nahe an 'real-world' Szenarien anlehnen.