Wir müssen reden. Über YAML. Und warum wir uns seit Jahren einreden lassen, dass es eine gute Idee ist, komplexe Logik in einer Konfigurationssprache zu definieren, deren größtes Feature die Empfindlichkeit für Leerzeichen ist. Wer hat nicht schon Stunden damit verbracht, einen Build zu debuggen, nur um festzustellen, dass eine Einrückung falsch war? Wer hat nicht schon geflucht, weil dieselbe Logik für GitHub Actions, GitLab CI und den lokalen Test komplett anders implementiert werden muss?
CI/CD-Pipelines sind keine simplen Konfigurationsdateien mehr. Sie sind kritische Software. Sie kompilieren, testen, scannen, deployen und sichern unsere Anwendungen. Es ist an der Zeit, dass wir sie auch so behandeln: als Code. Genau hier setzt Dagger.io an und verspricht nicht weniger als eine Revolution für unsere Build-Prozesse. Statt YAML schreiben wir Go, Python oder TypeScript. Statt voneinander abhängiger Plattformen eine portable Engine. Ein Hype-Train oder der längst überfällige Paradigmenwechsel? Schnallt euch an, wir machen den Praxistest.
Was ist Dagger.io und was macht es anders?
Stellen wir uns eine typische CI-Pipeline vor: Sie ist eine Abfolge von Schritten, die in einer .gitlab-ci.yml oder einer .github/workflows/main.yml definiert sind. Jeder Schritt ist im Grunde ein String, der in einer Shell auf einem Runner ausgeführt wird. Die Logik, wie diese Schritte zusammenhängen, wann Caches invalidiert werden und wie Artefakte übergeben werden, ist tief in der Syntax und den Features der jeweiligen CI-Plattform vergraben. Ein Umzug? Ein Albtraum.
Dagger dreht dieses Prinzip um. Das Herzstück ist die Dagger Engine, die auf jedem System mit einem Container-Runtime (wie Docker oder Podman) läuft. Anstatt der Engine mit YAML zu sagen, was sie tun soll, schreiben wir ein Programm in einer gängigen Programmiersprache (z.B. Go), das die Dagger API aufruft. Unser Go-Programm beschreibt die Pipeline: \"Nimm dieses Basis-Image, mounte diesen Quellcode, führe diesen Befehl aus, nimm das Ergebnis und packe es in ein neues Image.\" Die Dagger Engine nimmt diese API-Aufrufe entgegen, baut daraus einen gerichteten azyklischen Graphen (DAG) und führt die notwendigen Schritte parallel und hochgradig gecacht aus. Der Clou: Das Go-Programm läuft überall, wo Go läuft. Die Pipeline ist also von Natur aus portabel.
Beispiel 1: Das \"Hello World\" in Dagger
Vergessen wir komplexe Builds für einen Moment. Wie sieht das absolute Minimum aus? Wir wollen einfach einen Container starten und einen Befehl ausführen. Normalerweise: docker run alpine echo \"Hello, Docker\". Mit Dagger wird daraus ein kleines Go-Programm.
Voraussetzungen: Go und Docker (oder eine andere OCI-kompatible Runtime) müssen installiert sein.
-
Projekt initialisieren:
go mod init dagger-hello go get dagger.io/dagger@v0.11.0
go -
Pipeline-Code (
main.go):
package main import ( \"context\" \"fmt\" \"os\" \"dagger.io/dagger\" ) func main() { // Initialisiere einen Kontext ctx := context.Background() // Verbinde dich mit der Dagger Engine, leite Logs auf Stdout client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout)) if err != nil { panic(err) } defer client.Close() // Definiere die Pipeline: Container von alpine:latest nehmen // und einen Befehl ausführen. output, err := client.Container().From(\"alpine:latest\"). WithExec([]string{\"echo\", \"Hello from Dagger\"}). Stdout(ctx) if err != nil { panic(err) } // Gib das Ergebnis aus fmt.Println(output) } -
Ausführen:
go run main.go
Das Terminal zeigt die Logs der Dagger Engine, wie sie das Image zieht und den Befehl ausführt, und gibt am Ende Hello from Dagger aus.
Meine Einschätzung
Zugegeben, für ein simples echo ist das ein gewaltiger Overkill. Aber hier passiert Magie im Hintergrund! client.Container().From(...).WithExec(...) ist keine direkte Ausführung. Es ist eine Deklaration. Erst der Aufruf von .Stdout(ctx) am Ende löst die Ausführung des gesamten Graphen aus. Dagger ist \"lazy\". Das Geniale daran ist, dass ich meine Pipeline in meinem Lieblings-Editor mit Autovervollständigung, Typprüfung und Debugger schreiben kann. Ich kann go run main.go auf meinem MacBook ausführen und erhalte exakt dasselbe Ergebnis wie später im GitHub-Runner unter Linux. Das Gefühl, einen Build-Fehler mit einem Breakpoint im Code zu finden, anstatt 20 git push zu machen, ist unbezahlbar.
Praxisbeispiel: Multi-Stage-Docker-Build mit Dagger
Jetzt wird's ernst. Ein klassisches Szenario: Wir haben eine Go-Anwendung, die wir bauen und in ein minimales Scratch-Image packen wollen. In einem Dockerfile wäre das ein klassischer Multi-Stage-Build. In YAML-basierten CIs wird das oft schon fummelig, wenn es darum geht, Artefakte zwischen den Stages zu übergeben. Mit Dagger definieren wir das als eine logische Kette von Funktionsaufrufen.
Beispiel 2: Go-Anwendung bauen und veröffentlichen
Stellen wir uns eine simple hello-world Go-Webanwendung vor. Die Aufgabe ist: Baue die Anwendung in einem Go-Build-Container und kopiere das statisch gelinkte Binary in ein leeres scratch-Image.
Pipeline-Code (ci/main.go):
package main
import (
\"context\"
\"fmt\"
\"os\"
\"dagger.io/dagger\"
)
func main() {
ctx := context.Background()
client, err := dagger.Connect(ctx, dagger.WithLogOutput(os.Stdout))
if err != nil {
panic(err)
}
defer client.Close()
// 1. Host-Verzeichnis mit dem Quellcode holen
src := client.Host().Directory(\".\")
// 2. Build-Container mit der richtigen Go-Version
golang := client.Container().From(\"golang:1.22-alpine\")
// 3. Mount Quellcode, setze Working Dir und baue die Anwendung
builder := golang.WithMountedDirectory(\"/src\", src).
WithWorkdir(\"/src\").
WithEnvVariable(\"CGO_ENABLED\", \"0\"). // Wichtig für statisches Linken
WithExec([]string{\"go\", \"build\", \"-o\", \"app\"})
// 4. Extrahiere das kompilierte Binary aus dem Builder-Container
binary := builder.File(\"/src/app\")
// 5. Erstelle ein minimales Produktions-Image von scratch
// Kopiere das Binary hinein und setze den Entrypoint
prodImage := client.Container().From(\"scratch\").
WithFile(\"/app\", binary).
WithEntrypoint([]string{\"/app\"})
// 6. Veröffentliche das Image (z.B. auf Docker Hub)
// Für lokale Tests kann man es als Tarball exportieren
addr, err := prodImage.Publish(ctx, \"my-user/my-app:latest\")
if err != nil {
panic(err)
}
fmt.Printf(\"Published image to: %s\
\", addr)
}
Meine Einschätzung
Das ist auf den ersten Blick mehr Code als ein Dockerfile. Aber schaut genau hin: Der Code ist selbstdokumentierend. Jeder Schritt ist eine klar benannte Variable (src, golang, builder, binary, prodImage). Ich kann jede dieser Zwischenstufen separat testen oder inspizieren. Wollte ich das Binary lokal speichern anstatt es zu publizieren? Kein Problem: binary.Export(ctx, \"./myapp\"). Das ist Komponierbarkeit, wie wir sie aus der Softwareentwicklung kennen.
Der größte Vorteil ist die automatische Parallelisierung und das Caching. Dagger analysiert die Abhängigkeiten. Wenn sich mein Quellcode nicht ändert, kann Dagger den builder-Schritt komplett aus dem Cache nehmen. Wenn ich nur den Entrypoint des prodImage ändere, werden nur die letzten Schritte neu ausgeführt. Dieses Caching ist viel granularer und effektiver als das Layer-Caching von Docker, weil es auf dem Inhalt der Dateien und Befehle basiert, nicht auf der Reihenfolge der Zeilen in einer Datei.
Integration: Dagger in GitHub Actions ausführen
Die größte Stärke von Dagger ist die Portabilität. Eine Pipeline, die lokal läuft, sollte ohne Änderungen in der Cloud laufen. Beweisen wir es. Wie integrieren wir unsere Dagger-Pipeline in einen etablierten CI-Provider wie GitHub Actions? Die Antwort ist entwaffnend einfach: fast gar nicht.
Das CI-System wird degradiert. Es ist nur noch ein dummer Trigger, der unseren intelligenten Dagger-Client ausführt. Die ganze Logik bleibt in unserem Go-Code.
Beispiel 3: Die minimalistische GitHub Actions Workflow-Datei
Anstatt Dutzende oder Hunderte Zeilen YAML zu schreiben, die den Build-Prozess beschreiben, enthält unsere Workflow-Datei nur noch das Nötigste, um unsere Dagger-Anwendung zu starten.
.github/workflows/ci.yml:
name: Dagger CI
on:
push:
branches: [ \"main\" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Schritt 1: Code auschecken
- name: Checkout repository
uses: actions/checkout@v4
# Schritt 2: Go-Umgebung einrichten
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
# Schritt 3: Dagger Pipeline ausführen
# Das ist alles! Die ganze Logik steckt in 'ci/main.go'.
- name: Run Dagger Pipeline
run: go run ./ci
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
DOCKER_PASS: ${{ secrets.DOCKER_PASS }}
Meine Einschätzung
Das ist für mich der absolute Game-Changer. Seht euch diese YAML-Datei an. Sie ist winzig, verständlich und wird sich wahrscheinlich nie wieder ändern. Die gesamte Komplexität ist dorthin gewandert, wo sie hingehört: in versionierten, testbaren und wiederverwendbaren Code. Wenn wir morgen zu GitLab CI oder CircleCI wechseln, bleibt unsere ci/main.go zu 100% identisch. Wir müssen nur eine neue, ähnlich simple Trigger-Datei für die neue Plattform erstellen. Das ist das Ende des Vendor Lock-ins auf der CI-Ebene. Wir können unsere Pipelines wie eine Bibliothek behandeln, sie in einem eigenen Repository verwalten und in Dutzenden von Projekten importieren. Das ist ein strategischer Vorteil, dessen Tragweite man kaum überschätzen kann.
Häufige Fehler und Fallstricke bei Dagger.io
Natürlich ist nicht alles Gold, was glänzt. Der Umstieg erfordert ein Umdenken, und es gibt ein paar Hürden:
Das Caching-Mindset verstehen: Dagger ist aggressiv beim Caching. Das ist toll für die Performance, aber anfangs verwirrend. Wenn ein Schritt nicht wie erwartet neu ausgeführt wird, liegt es fast immer am Cache. Man muss lernen, wie man den Cache gezielt bricht, z.B. indem man einen neuen
WithEnvVariable(\"CACHE_BUSTER\", \"...\")einfügt.Anfängliche Verbosity: Wie im ersten Beispiel gezeigt, kann eine simple Aufgabe in Dagger-Code sehr aufgebläht wirken. Der Trick besteht darin, sofort in Abstraktionen zu denken. Anstatt Code zu kopieren, schreibt man wiederverwendbare Go-Funktionen wie
func buildGoApp(...)oderfunc publishImage(...).Die Lernkurve: Man muss nicht nur die Dagger API lernen, sondern auch die zugrundeliegende Programmiersprache gut beherrschen. Wer von YAML kommt und noch nie richtig Go, Python oder TypeScript geschrieben hat, wird eine steile Lernkurve vor sich haben.
Debugging der Engine: Während das Debugging des eigenen Pipeline-Codes im IDE ein Traum ist, kann das Debugging von Problemen in der Dagger Engine selbst (selten, aber kommt vor) oder in der Interaktion mit der Container-Runtime knifflig sein. Die Logs sind gut, aber man blickt auf eine zusätzliche Abstraktionsebene.
Fazit: Für wen lohnt sich der Umstieg auf Dagger.io?
Dagger.io ist keine Lösung für jeden. Wer nur eine kleine private Webseite mit einer dreizeiligen Build-Anweisung hat, für den ist der Aufwand zu hoch. Aber für professionelle Entwicklungsteams, die ihre CI/CD-Prozesse als kritische Infrastruktur betrachten, ist Dagger eine Offenbarung.
Lohnt sich für:
- Teams, die unter komplexen, brüchigen und langsamen YAML-Pipelines leiden.
- Organisationen, die Multi-Cloud- oder Hybrid-Strategien fahren und Vendor Lock-in vermeiden wollen.
- Entwickler, die ihre Pipelines lokal testen und debuggen wollen, bevor sie den Code pushen.
- Plattform-Engineering-Teams, die standardisierte, wiederverwendbare Build-Komponenten für die ganze Firma bereitstellen wollen.
Eher nicht für:
- Absolute Anfänger, die eine rein visuelle, No-Code-Lösung suchen.
- Sehr einfache Projekte, bei denen eine Handvoll Shell-Befehle ausreicht.
Ich persönlich bin überzeugt:
Top comments (0)