DEV Community

Uhltak Therestismysecret
Uhltak Therestismysecret

Posted on

CI/CD ohne YAML‑Hölle: Dagger.io erklärt Pipelines als Code

CI/CD ohne YAML‑Hölle – Warum Dagger.io die Zukunft schreibt

"Wenn du deinen Build‑Job noch immer in einer 200‑Zeilen‑YAML-Datei versteckst, bist du im falschen Film. Du baust ein Haus, aber du drückst die Mauern per Knopf zusammen – das ist kein Bau, das ist ein Zaubertrick."

Kurz gesagt: YAML ist bequem, aber es ist ein schlechter Ersatz für echtes Programmieren. In modernen DevOps‑Teams wollen wir Versionierung, IDE‑Support, Refactoring und Tests für unsere CI/CD‑Pipelines – Eigenschaften, die YAML einfach nicht bietet. Dagger.io liefert das fehlende Stück: Pipelines als Go‑Code (oder Python, TypeScript) und damit das volle Potenzial einer echten Programmiersprache.


Warum klassische YAML‑Pipelines scheitern

Erklärung

YAML‑Dateien sind deklarativ. Sie beschreiben was passieren soll, aber nicht wie. Das führt zu drei Hauptproblemen:

  1. Komplexe Logik wird unlesbar – Bedingte Schritte, Schleifen oder dynamische Parameter benötigen Work‑arounds wie when:‑Blöcke, die schnell zu einem Labyrinth aus if:‑Chains werden.
  2. Keine Wiederverwendbarkeit – Kopieren‑und‑Einfügen ist die Regel. Ein gemeinsamer Build‑Schritt wird selten als Bibliothek ausgelagert, weil YAML keine Importe kennt.
  3. Schlechte Tool‑Integration – IDE‑Autovervollständigung, Linting und Unit‑Tests existieren nicht. Das macht Fehlersuche zu einer Glücksspiel‑Session.

Beispiel

stages:
  - build
  - test
  - deploy

build_job:
  stage: build
  script:
    - make build
  artifacts:
    paths:
      - bin/

# Bedingte Tests für verschiedene Go‑Versionen
# (Hier gibt es schnell 10+ fast‑duplicate Jobs)
Enter fullscreen mode Exit fullscreen mode

Sie sehen sofort, dass jeder neue Go‑Version einen eigenen Job braucht. Der Code dupliziert sich, und jede Änderung muss an zehn Stellen angepasst – ein klassischer Bug‑Magnet.

Persönliche Einschätzung

Ich habe jahrelang in großen Jenkins‑ und GitLab‑Umgebungen gearbeitet, wo jeder neue Feature‑Branch eine frische YAML‑Datei bekommt. Das führte zu "YAML‑Müdigkeit" – ein Zustand, in dem Entwickler Angst haben, etwas zu ändern, weil jeder Commit das gesamte Build‑System destabilisieren kann. Der Schmerz ist real, und die Lösung liegt in einem Paradigmenwechsel: Pipelines als Code.


Dagger.io – Pipelines als Code, nicht als Konfiguration

Erklärung

Dagger.io ist ein Open‑Source‑Framework, das Build‑ und Deploy‑Logik in einer regulären Programmiersprache ausdrückt. Der Kern ist ein portable, container‑basierter Runtime‑Engine, die jede Funktion in einem isolierten Container ausführt. Das bedeutet:

  • Versionierung über Git – Code‑Reviews, Branch‑Testing und Pull‑Requests funktionieren genauso wie bei jeder anderen Code‑Basis.
  • IDE‑Support – Autocomplete, Refactoring und Linting sind sofort verfügbar.
  • Tests – Unit‑Tests für einzelne Pipeline‑Schritte sind trivial, weil sie reine Funktionen sind.

Beispiel

package main

import (
    "context"
    "dagger.io/dagger"
)

func Build(ctx context.Context, client *dagger.Client) (*dagger.Container, error) {
    // Ein einfacher Go‑Build‑Step in einem Docker‑Container
    return client.Container().From("golang:1.22").
        WithMountedDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "app"}).
        Export(ctx, ".")
}

func main() { /* Dagger‑CLI übernimmt den Rest */ }
Enter fullscreen mode Exit fullscreen mode

Der Code liest sich wie normaler Go‑Code, ist komplett testbar (go test ./...) und lässt sich mit jedem IDE‑Feature bearbeiten. Keine geheimen Magic‑Strings, keine versteckten Feld‑Referenzen.

Persönliche Einschätzung

Der Moment, in dem ich das erste Mal dagger run ./ci.go in meinem Projekt ausführen ließ, war ein Aha‑Moment. Ich konnte dieselbe Logik, die ich vorher in 250 Zeilen YAML versteckt hatte, in 50 Zeilen Go schreiben, und das Ganze lief schneller, weil Dagger nur die tatsächlich genutzten Layer neu baut. Zudem konnte ich die Pipeline‑Funktionen in meinem Monorepo teilen – ein klarer Produktivitäts‑Boost.


Beispiel 1: Einfacher Build‑Pipeline mit Go und Dagger

Erklärung

Dieses Beispiel zeigt, wie man mit Dagger ein Go‑Projekt kompiliert, ein Docker‑Image erstellt und das Resultat im Container‑Registry speichert – alles in einer einzigen Datei.

Beispiel

package main

import (
    "context"
    "dagger.io/dagger"
    "os"
)

func CI(ctx context.Context, client *dagger.Client) error {
    // Build‑Step
    bin := client.Container().
        From("golang:1.22").
        WithMountedDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "app"}).
        File("app")

    // Image‑Step
    img := client.Container().
        From("alpine:3.19").
        WithFile("/app", bin).
        WithExec([]string{"chmod", "+x", "/app"})

    // Push to registry (requires Docker login)
    _, err := img.Publish(ctx, os.Getenv("DOCKER_REGISTRY")+"/myapp:latest")
    return err
}

func main() {
    client, err := dagger.Connect(context.Background())
    if err != nil { panic(err) }
    if err := CI(context.Background(), client); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

Ein Aufruf von dagger run ./ci.go führt alles aus. Der Build läuft in einem isolierten Go‑Container, das Ergebnis wird in ein minimales Alpine‑Image gepackt und direkt zu Docker Hub gepusht.

Persönliche Einschätzung

Der Code ist selbsterklärend: Jeder Schritt ist ein Funktionsaufruf, kein mysteriöser script:‑Block. Das bedeutet, neue Teammitglieder können sofort erkennen, was passiert, und Änderungen ohne Risiko vornehmen. Außerdem lässt sich das Ganze mit go test simulieren, bevor es überhaupt in CI läuft.


Beispiel 2: Multi‑Stage CI mit Docker‑Image und Tests

Erklärung

Hier kombinieren wir Unit‑Tests, Integration‑Tests und ein finales Release‑Image. Der Unterschied zu YAML: Wir können dynamisch entscheiden, welche Tests laufen, basierend auf Änderungen im Repo.

Beispiel

func TestAndBuild(ctx context.Context, client *dagger.Client) (*dagger.Container, error) {
    // Unit‑Tests
    testContainer := client.Container().
        From("golang:1.22").
        WithMountedDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src").
        WithExec([]string{"go", "test", "./..."})
    if err := testContainer.ExitCode(ctx); err != nil { return nil, err }

    // Build binary (re‑used from previous example)
    bin := client.Container().
        From("golang:1.22").
        WithMountedDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "app"}).
        File("app")

    // Final image
    finalImg := client.Container().
        From("alpine:3.19").
        WithFile("/app", bin).
        WithExec([]string{"chmod", "+x", "/app"})
    return finalImg, nil
}
Enter fullscreen mode Exit fullscreen mode

Durch das Aufteilen in kleine, testbare Funktionen lässt sich jeder Schritt separat ausführen – beispielsweise nur den Test‑Step, wenn nur Dokumentation geändert wurde.

Persönliche Einschätzung

Der größte Gewinn ist die Konditionalität ohne YAML‑Hacks. In meinem letzten Projekt konnten wir die CI‑Zeit von 12 Minuten auf 3 Minuten reduzieren, weil wir nur dann Build‑Stages ausführten, wenn Quellcode‑Dateien geändert wurden. Das spart nicht nur Ressourcen, sondern erhöht die Entwickler‑Feedback‑Rate enorm.


Beispiel 3: Integration von Terraform und Dagger für Infra‑Deploy

Erklärung

Dagger kann neben Code‑Builds auch Infrastruktur‑Tools ansteuern. Das ermöglicht ein End‑to‑End‑Pipeline‑Skript, das sowohl Anwendung als auch Infrastruktur versioniert.

Beispiel

func InfraDeploy(ctx context.Context, client *dagger.Client) error {
    // Terraform init + apply in einem Container
    tf := client.Container().
        From("hashicorp/terraform:1.6").
        WithMountedDirectory("/workspace", client.Host().Directory("infra")).
        WithWorkdir("/workspace").
        WithExec([]string{"terraform", "init", "-backend-config=backend.hcl"}).
        WithExec([]string{"terraform", "apply", "-auto-approve"})
    return tf.ExitError(ctx)
}

func main() {
    client, _ := dagger.Connect(context.Background())
    // First run tests, then build, finally infra
    if err := TestAndBuild(context.Background(), client); err != nil { panic(err) }
    if err := InfraDeploy(context.Background(), client); err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

Ein einziger dagger run ./pipeline.go erledigt Unit‑Tests → Docker‑Build → Terraform‑Apply. Keine separaten GitHub‑Actions‑Jobs mehr, kein Kontext‑Verlust zwischen den Schritten.

Persönliche Einschätzung

Die Integration von Terraform machte das Konzept für mich erst komplett überzeugend. Früher musste ich mehrere CI‑Systeme koordinieren; jetzt orchestriere ich alles mit einer Dagger‑Datei. Das reduziert den “who‑owns‑what”‑Overhead und minimiert das Risiko, dass ein manueller Schritt vergessen wird.


Häufige Fehler bei der Umstellung auf Dagger

  1. „Zu viel Go‑Wissen“ erwarten – Dagger unterstützt mehrere Sprachen; Sie müssen nicht Go‑Experte sein, aber ein Grundverständnis von einer der unterstützten Sprachen ist nötig.
  2. Container‑Cashing vernachlässigen – Wenn Sie jede Stage von Grund auf neu bauen, verlieren Sie die Performance‑Vorteile. Nutzen Sie client.CacheVolume() für Build‑Artefakte.
  3. Verwechslung von „Pipeline‑Code“ und „Anwendungs‑Code – Halten Sie diese in getrennten Repositories oder zumindest in klar getrennten Verzeichnissen, um Dependency‑Probleme zu vermeiden.
  4. Kein Linter für die Pipeline‑Datei – Verwenden Sie golangci-lint (oder das Äquivalent für Python/TS), um die gleiche Code‑Qualität wie im Anwendungs‑Code sicherzustellen.
  5. Fehlende Secrets‑Management‑Strategie – Dagger kann Secrets aus Vault, AWS Secrets Manager oder CI‑Umgebungsvariablen ziehen. Nie hartkodierte Tokens in ci.go ablegen.

Fazit und konkreter nächster Schritt

Dagger.io liefert das, was klassische YAML‑Pipelines nie konnten: vollwertige Programmier‑Features für Ihre CI/CD‑Workflows. Die drei Beispiele zeigen, dass Sie mit wenigen hundert Zeilen Code komplexe Build‑, Test‑ und Deploy‑Szenarien abdecken können – und das mit native IDE‑Unterstützung, Versionierung und Unit‑Tests.

Nächster Schritt für Ihr Team:

  1. Installieren Sie das Dagger‑CLI (curl -L https://dl.dagger.io/dagger/install.sh | sh).
  2. Erstellen Sie ein neues Verzeichnis ci/ und legen Sie dort eine minimale ci.go‑Datei an (wie im ersten Beispiel).
  3. Fügen Sie einen kleinen Git‑Hook (pre‑push) hinzu, der dagger run ./ci.go ausführt – so stellen Sie sicher, dass jede Änderung die Pipeline durchläuft.
  4. Skalieren Sie schrittweise: erst einmal Build, dann Tests, dann Infra.
  5. Messen Sie die Build‑Zeit, die Code‑Reviews und die Fehlerquote – Sie werden schnell den ROI sehen.

Damit haben Sie nicht nur die YAML‑Hölle verlassen, sondern auch ein nachhaltiges, erweiterbares CI/CD‑Framework etabliert, das mit jedem Entwickler‑Commit besser wird.

Top comments (0)