Schluss mit YAML: Cloud-Infrastruktur in purem Java definieren, testen und deployen

Als Java-Entwickler brüsten wir uns gerne mit professioneller, ingenieursmäßiger Entwicklung: Typsicherheit, Unit-Tests, Refactoring, Code-Reviews. Dann müssen wir ein Deployment durchführen und schreiben plötzlich hunderte Zeilen YAML, akzeptieren mangelnde Ausdrucksstärke, fehlende Abstraktionen und fehlende Flexibilität als alternativlos. Oder schlimmer: Wir loggen uns in die Cloud-Konsole ein und klicken uns zum Deployment durch.

Dieser Artikel möchte eine Alternative vorstellen: Cloud-Infrastruktur in Java definieren, mit denselben Werkzeugen und Best Practices, die wir für Anwendungscode verwenden. Wir bauen ein funktionierendes Beispiel: einen Java-Service, der auf AWS ausgerollt wird, und decken den gesamten Lebenszyklus ab: von der Ressourcendefinition über Tests bis hin zu CI/CD. Das Beispiel verwendet Quarkus, die gezeigten Infrastruktur-Patterns lassen sich aber genauso auf Spring Boot, Jakarta EE oder jedes andere containerisierte Java-Framework anwenden. Der begleitende Code ist hier verfügbar: github.com/wlami/stop-writing-yaml-javapro.

Comic-style illustration of a computer monitor with code on screen and cloud infrastructure components (databases, servers, firewalls, containers) rising from the code into a connected architecture above the display.
When your Java code builds the cloud: infrastructure as code turns resource definitions into real architecture.

Historischer Hintergrund

Das Management von IT- bzw. Cloud-Infrastruktur hat sich in mehreren Phasen entwickelt:

Die manuelle Provisionierung über Cloud-Konsolen und Dashboards brachte das “Schneeflocken”-Problem mit sich: Jede Umgebung war einzigartig, nicht reproduzierbar und anfällig für menschliche Fehler.

Die Skriptbasierte Automatisierung mit Shell-Skripten oder Python verbesserte die Reproduzierbarkeit, war aber häufig nicht idempotent: das gleiche Skript zweimal auszuführen konnte unterschiedliche Ergebnisse liefern oder beim zweiten Durchlauf fehlschlagen.

Deklaratives Infrastructure-as-Code löste das Idempotenz-Problem. Werkzeuge wie CloudFormation, Terraform und Kubernetes machten YAML und domänenspezifische Sprachen wie HCL (die Sprache von Terraform) populär. Diese Konfigurationsformate definieren einen “Soll-Zustand”, und die darunterliegenden Werkzeuge gleichen anschließend die tatsächliche Infrastruktur mit dem deklarierten Zustand ab und beseitigen etwaige Abweichungen.

In dieser dritten Phase befinden sich die meisten Organisationen heute und hier beginnen die Probleme.

Wo deklarative DSLs an ihre Grenzen stoßen

YAML und HCL sind Konfigurationsformate, keine Programmiersprachen. Ressourcen über mehrere Verfügbarkeitszonen mit wechselnden Konfigurationen erstellen? In Java ist das eine Schleife mit einer if-Bedingung. In Konfigurationssprachen wie HCL hingegen ist das Ausdrücken solcher Logik umständlich oder schlicht unmöglich.

Das Fehlen ordentlicher Abstraktionsmechanismen führt zu Copy-Paste-Wiederverwendung: genau das Anti-Pattern, das wir im Anwendungscode niemals zulassen würden. CloudFormation-Templates überschreiten bei mäßig komplexen Setups regelmäßig die 1000-Zeilen-Marke.

Die Lücke in der Developer Experience ist erheblich. In der Java-IDE gibt es Autovervollständigung, Typprüfung, sicheres Refactoring und sofortiges Test-Feedback. In YAML bricht eine falsche Einrückung das Deployment stillschweigend ab. Refactoring bedeutet Suchen und Ersetzen über Dateien hinweg. Testen heißt: in die Cloud deployen und warten.

Ein konkretes Beispiel, wie diese Lücke aussieht. Eine SQS-Queue in CloudFormation-YAML:

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  ProcessorQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: processor-queue
      VisibilityTimeout: 300
      MessageRetentionPeriod: 86400

Neuere Infrastructure-as-Code-Werkzeuge gehen einen Schritt weiter und erlauben die Definition von Ressourcen in einer vollwertigen Programmiersprache. Hier dieselbe Queue in Java, mit dem IaC-Werkzeug Pulumi:

var queue = new Queue("processor-queue", QueueArgs.builder()
    .visibilityTimeoutSeconds(300)
    .messageRetentionSeconds(86400)
    .build());

Beide definieren dieselbe Ressource. Aber in der Java-Version vervollständigt die IDE visibilityTimeoutSeconds automatisch, der Compiler lehnt visibilityTimeoutSecond (ohne das abschließende “s”‘”) ab, und per STRG+Klick lässt sich der Code von QueueArgs öffnen, um jede verfügbare Eigenschaft nachzulesen. In der YAML-Version hingegen erfährt man von Tippfehlern erst, wenn das Deployment fehlschlägt.

Wenn Infrastruktur unsichtbar wird

Eine Geschichte von einem früheren Arbeitgeber: Monate nachdem ich gegangen war, löschte ein Kollege einen S3-Bucket, der aussah wie ein “Demo-Bucket”, den ich per ClickOps erstellt hatte. Ohne Code, ohne Dokumentation, ohne nachvollziehbare Abhängigkeiten wusste niemand, wozu dieser Bucket diente… bis internes Developer-Tooling ausfiel.

Das ist kein Problem, das auf ClickOps beschränkt ist. Jede Infrastruktur, die sich nicht mit den üblichen Entwicklerwerkzeugen entdecken, refactoren oder testen lässt, ist gefährdet. Ohne Compile-Time-Checks, ohne STRG+Klick-Navigation, ohne “Find All Usages” wird kritische Infrastruktur unsichtbar, bis etwas kaputtgeht.

Infrastruktur in echten Programmiersprachen

Die Idee ist einfach: Infrastruktur genauso behandeln wie Anwendungscode. Echte Programmiersprachen mit den etablierten Werkzeugen verwenden.

Die Landschaft

Es gibt mehrere Werkzeuge, die es erlauben, Infrastrukturcode auf diese Weise zu definieren. Das AWS Cloud Development Kit (CDK) ermöglicht die Definition von AWS-Infrastruktur in TypeScript, Python, Java und anderen Sprachen, ist jedoch auf AWS beschränkt. HashiCorp CDKTF (CDK for Terraform) brachte Unterstützung für Programmiersprachen in Terraforms Multi-Cloud-Modell, wurde aber Ende 2025 eingestellt.

Pulumi ist ein quelloffenes, Multi-Cloud-fähiges Infrastructure-as-Code-Werkzeug, das vollwertige Programmiersprachen unterstützt, darunter Java. Die Core-Engine steht unter der Apache-2.0-Lizenz. Dieser Artikel verwendet Pulumi für seine Beispiele, aber die zugrunde liegende Idee: Infrastruktur als testbarer, refactorbarer Code, lässt sich auf jedes Infrastructure-as-Code-Werkzeug übertragen, das allgemeine Programmiersprachen nutzt.

Wie Pulumi funktioniert

Bei Pulumi schreibt man eine ganz normale Java-Anwendung, die Infrastruktur beschreibt. Die Pulumi-CLI führt das Programm aus und provisioniert Ressourcen über Cloud-Provider hinweg.

Das folgende Diagramm zeigt die Laufzeitarchitektur. Das Java-Programm kommuniziert mit der Pulumi-Engine, die Ressourcenoperationen an Provider-Plugins weiterleitet. Jeder Provider übersetzt Ressourcendeklarationen in Cloud-API-Aufrufe. Der Zustand wird zwischen den Durchläufen persistiert, sodass die Engine die minimale Menge an nötigen Änderungen berechnen kann:

Die zentralen Konzepte:

Ressourcen sind stark typisierte Java-Objekte, die Cloud-Ressourcen repräsentieren (S3-Buckets, RDS-Datenbanken, Lambda-Funktionen). Man instanziiert sie, setzt Eigenschaften, und Pulumi kümmert sich um die Reihenfolge der Erstellung und Abhängigkeiten.

Outputs kapseln Werte, die noch nicht bekannt sind, wie etwa die ID oder den Netzwerk-Endpunkt einer Ressource, bevor sie erstellt wird. Output<T> ist konzeptionell vergleichbar mit CompletableFuture<T>: Man komponiert Outputs über .applyValue() und Output.format(), statt direkt auf die Werte zuzugreifen, da sie erst zum Zeitpunkt der Erstellung der Resourcen bekannt sein werden.

Provider sind Plugins, die mit Cloud-APIs kommunizieren: AWS, Azure, GCP, Kubernetes und über 120 weitere. Sie bieten typsicheren Zugriff auf jede Ressourcen-Eigenschaft.

Stacks sind isolierte Instanzen des Programms. Derselbe Java-Code wird in verschiedene Umgebungen deployt: Dev, Staging, Produktion, jeweils mit eigener Konfiguration und separatem Zustand:

Das ermöglicht auch Stacks pro Entwickler, sodass diese isoliert arbeiten können, ohne eine gemeinsame Laufzeitumgebung teilen zu müssen. Die Stack-Konfiguration beinhaltet eingebautes Secrets-Management: Als Secret markierte Werte werden verschlüsselt, bevor sie in die Konfigurationsdatei geschrieben werden, und bleiben im State verschlüsselt und in der CLI-Ausgabe maskiert. Ein externer Secrets-Manager ist dafür nicht erforderlich.

Komponenten bündeln mehrere Ressourcen zu wiederverwendbaren Abstraktionen: das Infrastruktur-Äquivalent zum Extrahieren einer Klasse aus dupliziertem Code.

Eine echte Anwendung bauen

Lasst uns etwas Konkretes bauen: einen containerisierten Java-Service, der Nachrichten aus einer Queue liest, verarbeitet und die Ergebnisse in eine PostgreSQL-Datenbank schreibt. Das Ganze deployt auf AWS. In diesem Beispiel verwenden wir Quarkus, ein auf Container optimiertes Java-Framework und eine Alternative zu Spring Boot. Die gezeigten Infrastruktur-Patterns funktionieren mit jeder containerisierten Java-Anwendung.

Projektstruktur

Wir haben zwei separate Gradle-Projekte:

  • quarkus-processor/: die Anwendung mit ihrem Dockerfile
  • infrastructure/: Pulumi-Infrastrukturcode

Das Infrastruktur-Projekt bindet das Pulumi SDK und die Provider-Pakete ein:

dependencies {
    implementation 'com.pulumi:pulumi:[1.3,2.0)'
    implementation 'com.pulumi:aws:6.+'
    implementation 'com.pulumi:docker-build:0.+'
}

Der Einstiegspunkt ist eine gewöhnliche main-Methode. Innerhalb von Pulumi.run() definiert man die Ressourcen:

public static void main(String[] args) {
    Pulumi.run(ctx -> {
        // all resource definitions go here
    });
}

Ressourcen definieren

Für die vollständige Anwendung benötigen wir eine Virtual Private Cloud mit Subnetzen und Security Groups, eine SQS-Warteschlange, eine RDS-PostgreSQL-Datenbank, ein Container-Registry-Repository (ECR), einen Docker-Image-Build und einen ECS-Fargate-Service. Die gesamte Implementierung umfasst etwa 200 Zeilen Java – verglichen mit über 1.000 Zeilen äquivalentem CloudFormation-YAML.

Hier der Docker-Image-Build, der die Quarkus-Anwendung kompiliert und als Teil des Infrastruktur-Deployments nach ECR pusht:

var image = new Image("app-image", ImageArgs.builder()
    .context(BuildContextArgs.builder()
        .location("../quarkus-processor")
        .build())
    .push(true)
    .tags(ecrRepository.repositoryUrl()
        .applyValue(url -> List.of(url + ":latest")))
    .registries(RegistryArgs.builder()
        .address(ecrRepository.repositoryUrl())
        .username(authToken.applyValue(token -> token.userName()))
        .password(authToken.applyValue(token -> token.password()))
        .build())
    .build());

Beim Ausführen von pulumi up ruft Pulumi Docker auf, um die Quarkus-Anwendung anhand ihres Dockerfiles zu bauen, pusht das Image nach ECR und provisioniert den ECS-Task, der dieses Image referenziert. Anwendungscode und Infrastruktur werden gemeinsam deployt.

Infrastruktur-Logik in Java

200 Zeilen reine Ressourcendefinitionen erledigen die Aufgabe, aber echte Infrastruktur hat fachliche Anforderungen, die variieren. So brauchen verschiedene Umgebungen beispielsweise unterschiedliche Datenbank-Konfigurationen: Produktion braucht Datenbank-Replicas und eine längere Backup-Aufbewahrung. Die Entwicklungsumgebung hingegen nicht. In einer Konfigurationssprache wie HCL oder YAML bedeutet das Ausdrücken solcher bedingten Logik den Rückgriff auf Workarounds: countfor_each, ternäre Ausdrücke und verschachtelte Bedingungen, die schwer zu lesen und unmöglich zu testen sind.

In Java ist es eine Funktion.

Betrachten wir eine Methode, die Service-Level-Agreement (SLA) Stufen auf Datenbank-Konfigurationen abbildet. SLATier ist ein Enum mit drei Werten: BRONZE, SILVER und GOLDDatabaseConfig ist ein Record, der die Infrastrukturentscheidungen abbildet: ob Aurora oder einfaches RDS verwendet werden soll, wie viele Tage Backups aufbewahrt werden, ob Monitoring aktiviert ist und wie viele Read Replicas erstellt werden.

public static DatabaseConfig getConfigForSLA(SLATier tier) {
    return switch (tier) {
        case BRONZE -> DatabaseConfig.builder()
            .useAurora(false)  // Einzelne RDS Instanz
            .backupDays(1)
            .monitoring(false)
            .replicaCount(0)
            .build();
        case SILVER -> DatabaseConfig.builder()
            .useAurora(true)   // Aurora Cluster
            .backupDays(7)
            .monitoring(true)
            .replicaCount(1)   // 1 read replica
            .build();
        case GOLD -> DatabaseConfig.builder()
            .useAurora(true)   // Aurora Cluster
            .backupDays(30)
            .monitoring(true)
            .replicaCount(2)   // 2 Read Replicas
            .build();
    };
}

Das ist eine reine Funktion. Sie nimmt ein Enum entgegen und gibt ein Record zurück. Keine Cloud-Aufrufe, keine Pulumi-Engine, keine Seiteneffekte. Wir werden sie im nächsten Abschnitt mit JUnit testen.

Die zurückgegebene Konfiguration steuert die Ressourcenerstellung mit gewöhnlichem Kontrollfluss. Ein if-Statement wählt die Datenbank-Engine. Eine for-Schleife erstellt Replikate:

var config = getConfigForSLA(SLATier.SILVER);

if (config.useAurora()) {
    //Aurora Cluster definieren
    var cluster = new Cluster("app-db", ClusterArgs.builder()
        .engine("aurora-postgresql")
        .databaseName("processor")
        .backupRetentionPeriod(config.backupDays())
        .storageEncrypted(true)
        .build());
    //Replicas für höhere Verfügbarkeit hinzufügen
    for (int i = 0; i < config.replicaCount(); i++) {
        new ClusterInstance("app-db-replica-" + i,
            ClusterInstanceArgs.builder()
                .clusterIdentifier(cluster.id())
                .instanceClass("db.t3.medium")
                .engine("aurora-postgresql")
                .build());
    }
} else {
    new Instance("app-db", InstanceArgs.builder()
        .engine("postgres")
        .instanceClass("db.t3.medium")
        .allocatedStorage(20)
        .backupRetentionPeriod(config.backupDays())
        .build());
}

BRONZE erstellt eine einzelne RDS-Instanz. SILVER erstellt einen Aurora-Cluster mit einem Read Replica. GOLD erstellt Aurora mit zwei Replikaten und 30-Tage-Backups. Man ändert den Enum-Wert, und die Infrastruktur ändert sich mit.

Das ist die Art von Logik, die in Java natürlich ist, aber in einer Konfigurationssprache umständlich. Ein if/else, das zwischen zwei verschiedenen Ressourcentypen wählt, und eine Schleife, die eine variable Anzahl von Ressourcen auf Grundlage eines berechneten Werts erstellt. Das Äquivalent in HCL erfordert eine Umstrukturierung der Konfiguration rund um count und bedingte Ausdrücke, die die eigentliche Absicht verschleiern.

Ressourcen zu Komponenten bündeln

Wenn solche Logik wächst, kommt der Punkt, an dem man sie zur Wiederverwendung kapseln sollte. Pulumi bietet dafür einen Mechanismus: Komponenten-Ressourcen. Eine Komponente ist eine Java-Klasse, die die Pulumi-Klasse ComponentResource erweitert und in ihrem Konstruktor Kind-Ressourcen erstellt. Von außen sieht sie aus wie eine einzelne Ressource mit eigenen Inputs und Outputs. Intern kann sie beliebig viele echte Cloud-Ressourcen erzeugen.

Im Begleit-Repository ist die obige Datenbank-Logik in eine ManagedDatabaseComponent gekapselt, und das ECS-Fargate-Setup (Cluster, Task-Definition, IAM-Rollen, Logging) in eine ContainerServiceComponent. Das Hauptprogramm verwendet sie wie folgt:

var database = new ManagedDatabaseComponent("app-db",
    DatabaseArgs.builder()
        .slaTier(SLATier.SILVER)
        .username("appuser")
        .password(config.requireSecret("dbPassword"))
        .databaseName("processor")
        .build());

var service = new ContainerServiceComponent("processor-service",
    ContainerServiceArgs.builder()
        .image(imageRef)
        .environment(Map.of(
            "DATABASE_URL", database.connectionString(),
            "QUEUE_URL", queue.url()))
        .slaTier(SLATier.SILVER)
        .port(8080)
        .build(),
    subnetIds, sgIds);

Der Aufrufer muss nicht wissen, ob ManagedDatabaseComponent eine einzelne RDS-Instanz oder einen Aurora-Cluster mit Replikaten erstellt. Diese Entscheidung ist intern, gesteuert durch die SLA-Stufe.

Ein Hinweis zum Passwort des Datenbank-Users: config.requireSecret("dbPassword") liest einen Wert aus der Stack-Konfigurationsdatei, wo er verschlüsselt gespeichert ist:

infrastructure:dbPassword:
  secure: v1:LoMrsybSW3y3T+YY:Zw1vs1Ey2U8s8+Qf2CzC2p7vds0R2NalQP6LVA==

Pulumi entschlüsselt ihn zum Deployment-Zeitpunkt und gibt ein Output<String> zurück. Das Secret propagiert durch den Ressourcengraph mit demselben Schutz: Es wird in Logs maskiert und in der State-Datei verschlüsselt. In Terraform sind im State gespeicherte Werte standardmäßig in Klartext. Hier ist die Verschlüsselung der Standard für jeden als Secret markierten Wert umgesetzt.

pulumi up evaluiert das Programm, baut den Abhängigkeitsgraph und zeigt eine Vorschau:

Previewing update (dev)

     Type                                    Name                         Plan
 +   pulumi:pulumi:Stack                     infrastructure-dev            create
 +   ├─ custom:database:ManagedDatabase      app-db                       create
 +   │  ├─ aws:rds:Cluster                   app-db                       create
 +   │  ├─ aws:rds:ClusterInstance           app-db-primary               create
 +   │  └─ aws:rds:ClusterInstance           app-db-replica-0             create
 +   ├─ custom:container:ContainerService    processor-service            create
 +   │  ├─ aws:cloudwatch:LogGroup           processor-service-logs       create
 +   │  ├─ aws:ecs:Cluster                   processor-service-cluster    create
 +   │  ├─ aws:iam:Role                      processor-service-exec-role  create
 +   │  ├─ aws:iam:Role                      processor-service-task-role  create
 +   │  ├─ [...]                             [...]
 +   │  ├─ aws:ecs:TaskDefinition            processor-service-task       create
 +   │  └─ aws:ecs:Service                   processor-service-service    create
 +   ├─ aws:ec2:Vpc                          app-vpc                      create
 +   ├─ aws:ec2:Subnet                       app-subnet-a                 create
 +   ├─ aws:ec2:Subnet                       app-subnet-b                 create
 +   ├─ aws:ec2:SecurityGroup                app-sg                       create
 +   ├─ aws:sqs:Queue                        processor-queue              create
 +   ├─ aws:ecr:Repository                   app-repo                     create
 +   └─ docker-build:Image                   app-image                    create

Resources: +21 to create

Die Baumstruktur zeigt, wie Komponenten Ressourcen organisieren. app-db und processor-service erscheinen als übergeordnete Knoten, die ihre Kind-Ressourcen gruppieren. Nach der Bestätigung baut Pulumi den Container, pusht ihn in ECR und provisioniert alle 21 Ressourcen. Ein erneuter Durchlauf ohne Codeänderungen führt zu keiner Änderung der Infrastruktur: Pulumi vergleicht seinen State mit dem Programm und erkennt, dass keine Änderungen notwendig sind.

Infrastruktur mit JUnit testen

Die oben eingeführte Methode getConfigForSLA ist eine reine Funktion: Sie nimmt ein SLATier und gibt ein DatabaseConfig zurück. Keine Pulumi-Engine, keine Cloud-APIs, keine Mocks. Sie ist mit reinem JUnit testbar:

@Test
void bronzeSLAUsesSingleRDS() {
    var config = ManagedDatabaseComponent.getConfigForSLA(SLATier.BRONZE);
    assertFalse(config.useAurora(),
        "Bronze tier should use simple RDS, not Aurora");
    assertEquals(0, config.replicaCount());
    assertEquals(1, config.backupDays());
}

Die Tests für SILVER und GOLD folgen derselben Struktur und prüfen Aurora mit 1 bzw. 2 Replikaten. Das Muster lässt sich auf andere Komponenten übertragen. Die ContainerServiceComponent extrahiert die Ressourcenzuweisung in statische Methoden:

@Test
void silverTierAllocatesCorrectResources() {
    assertEquals(512, ContainerServiceComponent.getContainerCpu(SLATier.SILVER, 256));
    assertEquals(1024, ContainerServiceComponent.getContainerMemory(SLATier.SILVER, 512));
    assertEquals(2, ContainerServiceComponent.getDesiredCount(SLATier.SILVER));
    assertEquals(30, ContainerServiceComponent.getLogRetentionDays(SLATier.SILVER));
}

Diese Tests laufen in Millisekunden als Teil eines normalen ./gradlew test. Keine Pulumi-CLI, keine Cloud-Credentials, keine kostenverursachenden Deployments. Das Designprinzip: Infrastrukturentscheidungen in reine Methoden extrahieren, diese Methoden mit Standard-JUnit testen.

Was testen und was nicht?

Infrastrukturtests sollten die eigenen Entscheidungen verifizieren, nicht die API des Cloud-Providers. Sinnvolle Tests decken SLA-zu-Konfiguration-Mappings, Namenskonventionen, Tagging-Logik sowie eigene Validierungen ab. Wir sollten nicht testen, ob AWS tatsächlich eine RDS-Instanz erstellt, wenn man die API aufruft. Das liegt in der Verantwortung des Cloud-Providers.

Die Testpyramide gilt für Infrastruktur genauso wie für Anwendungscode:

Unit-Tests fangen Konfigurationsfehler auf, bevor Cloud-Ressourcen erstellt werden. Integrationstests (Deployment in kurzlebige Stacks) verifizieren, dass die Infrastruktur tatsächlich Ende-zu-Ende funktioniert.

Ein Hinweis zu Policies

Pulumis Policy-as-Code-Feature (CrossGuard) ermöglicht organisationsweite Leitplanken: unverschlüsselten Speicher blockieren, Tagging erzwingen und Instanzgrößen begrenzen. Aktuell können Policies nur in TypeScript oder Python verfasst werden, gelten jedoch für alle Ressourcen, unabhängig von der Pulumi-Sprache, in der sie erstellt werden.

Policies und Tests dienen unterschiedlichen Zwecken. Tests verifizieren die Komponentenlogik während der Entwicklung. Policies erzwingen organisatorische Standards während des Deployments, unabhängig davon, welches Team den Code geschrieben hat oder in welcher Sprache.

CI/CD: Eine Pipeline für Anwendung und Infrastruktur

Da die Infrastruktur Java-Code ist, passt sie in dieselbe CI/CD-Pipeline wie die Anwendung.

Der GitHub-Actions-Workflow hat zwei Jobs:

Bei Pull Requests führt die Pipeline JUnit-Tests aus und anschließend pulumi preview. Dabei wird ein Diff zwischen der aktuellen Infrastruktur und dem Sollzustand berechnet, ohne etwas zu verändern. Das Diff wird als Pull-Request-Kommentar gepostet, sodass Reviewer genau sehen können, welche Ressourcen erstellt, aktualisiert oder gelöscht werden, bevor der Code gemergt wird.

Beim Merge nach main führt die Pipeline dieselben Tests aus und anschließend pulumi up. Pulumi baut den Anwendungscontainer, pusht ihn in ECR und provisioniert oder aktualisiert alle Ressourcen. Schlägt ein Schritt fehl, stoppt das Update. Bereits erstellte Ressourcen bleiben bestehen, und die State-Datei zeichnet den Teilfortschritt auf, sodass das nächste pulumi up dort weitermacht, wo aufgehört wurde.

Infrastrukturänderungen werden zu Pull Requests. Wenn ein Entwickler eine Umgebungsvariable zum ECS-Task hinzufügen oder die Datenbank skalieren muss, ändert er den Infrastrukturcode im selben PR, Tests laufen, Reviewer sehen das Diff, und die Änderung wird beim Merge ausgerollt.

Umgebungen

Verschiedene Umgebungen verwenden denselben Code mit unterschiedlichen Konfigurationen. Dev bekommt eine einzelne kleine RDS-Instanz und einen ECS-Task. Produktion bekommt den Aurora-Cluster mit Read-Replicas. Derselbe Java-Code, verschiedene Config-Dateien, komplett getrennter State.

Secrets wie Datenbankpasswörter funktionieren stackübergreifend gleich. Jede Umgebung hat ihre eigenen verschlüsselten Werte in ihrer Config-Datei. In CI-Pipelines wird die Entschlüsselungs-Passphrase über ein Pipeline-Secret injiziert (z.B. ein GitHub-Actions-Secret).

Dasselbe Muster funktioniert in jedem CI/CD-System. GitLab CI, Jenkins, Azure DevOps: Sie führen pulumi preview bei PRs und pulumi up bei Merges aus.

Wiederverwendbare Komponenten als Shared Libraries

Komponenten lassen sich als JARs paketieren und im internen Maven-Repository veröffentlichen. Teams konsumieren sie als Maven-/Gradle-Abhängigkeiten:

dependencies {
    implementation 'com.yourcompany.platform:container-service:2.1.0'
    implementation 'com.yourcompany.platform:managed-database:1.5.0'
}

Das verändert die Rolle des Plattformteams. Statt als Gatekeeper, die Infrastruktur manuell zu provisionieren, werden sie zu Library-Autoren, die organisatorische Standards in versionierte Komponenten kodieren. Anwendungsteams konsumieren diese Komponenten und arbeiten selbstständig, innerhalb der Leitplanken, die der Komponentencode erzwingt.

Wenn das Plattformteam eine Änderung ausrollen muss (z.B. Performance Insights auf allen Gold-Tier-Datenbanken aktivieren), aktualisiert es die Komponente und veröffentlicht eine neue Version. Teams übernehmen sie nach ihrem eigenen Zeitplan, genauso wie sie jedes andere Library-Update übernehmen würden.

Pulumi unterstützt auch Multilanguage-Komponenten: Man kann eine Komponente in Java schreiben, und Pulumi generiert typisierte SDKs für TypeScript, Python und Go. Das ist in mehrsprachigen Organisationen relevant, in denen eine Standardisierung auf eine einzige Programmersprache nicht realistisch ist.

Umgang mit bestehender Infrastruktur

Diese Frage wird immer gestellt: “Wir haben hunderte Ressourcen, die bereits in Produktion laufen. Wir können nicht von vorne anfangen.”

Das muss man auch nicht.

Ressourcen importieren

Pulumi kann bestehende Cloud-Ressourcen über die pulumi import-Operation in seinen State übernehmen. Man schreibt den Java-Code, der eine Ressource beschreibt, und weist Pulumi an, sie zu importieren statt neu zu erstellen:

var existingDb = new DbInstance("legacy-db", DbInstanceArgs.builder()
    .identifier("production-user-db")
    .engine("postgres")
    .instanceClass("db.t3.medium")
    .allocatedStorage(100)
    // ... Andere properties, die den aktuellen Zustand beschreiben
    .build(), CustomResourceOptions.builder()
        .import_("production-user-db")  // Unterstrich, da "import" in Java reserviert ist
        .build());

Pulumi liest den aktuellen Zustand aus AWS, gleicht ihn mit dem Code ab und übernimmt die Ressource. Ab diesem Zeitpunkt wird Drift zwischen Code und tatsächlicher Infrastruktur bei jedem pulumi preview erkannt.

Koexistenz mit Terraform

Pulumi und Terraform können verschiedene Ressourcen in derselben Umgebung verwalten, ohne sich in die Quere zu kommen, da sie getrennte States pflegen. Man kann neue Services mit Pulumi starten, während bestehende Infrastruktur in Terraform bleibt. Über den Terraform-State-Provider kann Pulumi auch Terraform-State-Dateien lesen, um auf Outputs zuzugreifen. So kann das Pulumi-Programm etwa einen Datenbank-Connection-String aus Terraform-verwalteten Ressourcen nachschlagen, ohne diese zu migrieren.

YAML zu Code konvertieren

Der Befehl pulumi convert konvertiert CloudFormation, Terraform-HCL und Kubernetes-YAML in Pulumi-Code in jeder unterstützten Sprache:

# Convert Kubernetes YAML to Java
pulumi convert --from kubernetes --language java --out ./pulumi-app

# Convert CloudFormation to Java
pulumi convert --from cloudformation --language java --out ./pulumi-infra

Der generierte Code ist ein Ausgangspunkt, kein Endprodukt. Der eigentliche Wert entsteht nach der Konvertierung: Fünf kopierte Datenbankdefinitionen können in einer Schleife zusammengefasst werden, wiederholte Konfigurationen werden zu Methoden, und hartcodierte Werte werden zu Parametern.

Wann Sollte man migrieren?

Nicht alles muss migriert werden. Stabile Terraform-Module, die Infrastruktur verwalten, die sich selten ändert, können so bleiben, wie sie sind. Der Fokus sollte auf Infrastruktur liegen, die sich häufig ändert, in ihrer aktuellen Form schwer zu verwalten ist oder an Services gekoppelt ist, die aktiv weiterentwickelt werden.

Die pragmatische Frage sollte deswegen nicht “Sollen wir alles migrieren?” lauten, sondern “Wo reduziert Migration tatsächlich Fehler oder macht das Team schneller?”.

Infrastruktur, die niemand anfasst, kann bleiben, wie sie ist, bis es einen echten Grund zur Änderung gibt.

Trade-offs

Die Verwendung einer vollwertigen Programmiersprache für die Infrastruktur hat ihren Preis. Man sollte die Kosten verstehen, bevor man sich auf den Ansatz festlegt.

Mehr Boilerplate bei einfachen Fällen. Eine DSL, die für Ressourcendeklarationen optimiert ist, wird bei der reinen Deklaration von Ressourcen immer kompakter sein. Ein Drei-Zeilen-HCL-Block wird zu sechs oder sieben Zeilen Java mit Buildern und .build()-Aufrufen. Wenn die Infrastruktur aus einer Handvoll statischer Ressourcen ohne Logik besteht, ist eine DSL möglicherweise die bessere Wahl. Der Kipppunkt kommt, wenn Infrastruktur echte Logik beinhaltet: Bedingungen, Schleifen, umgebungsspezifisches Verhalten. Dort beginnen DSLs zu straucheln.

Zukünftige Werte/Platzhalter. Jedes Werkzeug, das imperativen Code verwendet, um Ressourcen zu erstellen, die noch nicht existieren, benötigt eine Methode, um noch unbekannte Werte zu repräsentieren. Pulumi nutzt dafür Output<T>, während CDK Token verwendet. Das ist keine Eigenart eines bestimmten Werkzeugs, sondern eine Konsequenz des Modells. Das Komponieren dieser Platzhalter erzeugt Reibung: String-Konkatenation wird zu Output.format(), und Code, der Outputs durch mehrere Ressourcen fädelt, liest sich eher wie reaktive Programmierung als wie gewöhnliches Java.

Team-Bereitschaft. Infrastruktur in Java zu schreiben macht aus Infrastruktur-Ingenieuren keine Java-Entwickler, und Java zu schreiben macht aus Anwendungsentwicklern keine Infrastruktur-Experten. Der Ansatz funktioniert am besten, wenn Teams bereits eine Überlappung zwischen diesen Kompetenzen haben oder bereit sind, sie gezielt aufzubauen. Ohne diese Investition kann eine gemeinsame Sprache zu einer gemeinsamen Quelle der Verwirrung werden.

Diese Kosten fallen vornehmlich am Anfang an. Die Rendite skaliert mit der Komplexität: Je mehr Logik, Umgebungen und teamübergreifende Wiederverwendung die Infrastruktur umfasst, desto stärker zahlt sich die Investition aus.

Fazit

Wir haben folgendes Beispiel untersucht: einen Quarkus-Service, der mit seiner gesamten Infrastruktur in Java definiert ist. Der Infrastrukturcode nutzt Standard-Sprachfeatures: Switch-Expressions für SLA-Tier-Mappings, Schleifen für Read-Replicas, Records für Konfigurationsdaten. Die Tests laufen mit JUnit, das Deployment erfolgt über GitHub Actions, und die Komponenten sind wiederverwendbare Java-Klassen.

Das zentrale Argument dreht sich nicht speziell um Pulumi. Es geht darum, dass Infrastrukturcode dieselben Engineering-Praktiken verdient, die wir auf Anwendungscode anwenden: Typsicherheit, Tests, Refactoring, Code-Reviews. Werkzeuge wie Pulumi und AWS CDK machen das möglich. Ob der Ansatz zum eigenen Team passt, hängt von der Komplexität der Infrastruktur ab und davon, wie viel echte Logik enthalten ist, im Gegensatz zu statischen Ressourcendeklarationen.

Die vollständige Implementierung (Komponenten, Tests, CI/CD-Workflow) findet sich unter github.com/wlami/stop-writing-yaml-javapro.

Total
0
Shares
Previous Post

Saubere Styles in Vaadin: CSS statt getStyle().set()

Next Post

Internationalisierung (i18n) in einer Vaadin-Anwendung

Related Posts