post

CDK-Anfänge: Infrastruktur als Programmiersprache

Mit dem CDK rückt Infrastruktur in dieselbe Werkzeugkette wie Anwendungscode, wodurch Tests, Reviews und logische Identitäten plötzlich über den Bestand von Produktionsdaten entscheiden.

CDK-Anfänge: Infrastruktur als Programmiersprache

Seit ein paar Wochen liegt das AWS Cloud Development Kit als Developer Preview vor, und es verändert spürbar, wie ich über Infrastruktur nachdenke. Statt YAML zu schreiben und zu hoffen, dass die Einrückung stimmt, schreibe ich TypeScript. Ich bekomme Typen, Autovervollständigung, Schleifen, Funktionen und Klassen. Das klingt nach einer reinen Komfortverbesserung, ist aber eine Verschiebung der Verantwortung. Wer Infrastruktur in einer echten Programmiersprache beschreibt, erbt alle Werkzeuge dieser Sprache. Er erbt aber auch ihre Fehlerklassen.

Ich nehme im Folgenden einen kleinen, plausiblen Stack als roten Faden: einen S3-Bucket, in den Nutzer Dateien laden, und eine Lambda-Funktion, die diese Dateien verarbeitet. An diesem Beispiel lässt sich die interessante Architekturfrage besser zeigen als an einer Folie voller Bullet Points.

Vom Template zum Programm

CloudFormation kennt jeder, der länger mit AWS arbeitet. Man beschreibt den Zielzustand deklarativ, und der Dienst gleicht ab. Das Modell ist robust, aber das Format ist mühsam. Wiederholung lässt sich nur über Parameter und Mappings ausdrücken, und sobald eine Vorlage über tausend Zeilen wächst, verliert man den Überblick darüber, welche Ressource eigentlich von welcher abhängt.

Das CDK dreht die Richtung um. Ich schreibe Code, und dieser Code erzeugt am Ende wieder eine CloudFormation-Vorlage. Dieser Schritt heißt Synthese. Wichtig ist, dass am Boden weiterhin CloudFormation steht. Das CDK ersetzt den Provisionierungsmechanismus nicht, es setzt eine Programmierschicht darüber. Der Vorteil ist offensichtlich: Abstraktion, Wiederverwendung, Typprüfung. Der Preis ist, dass zwischen meinem Quelltext und den realen Ressourcen jetzt ein Übersetzungsschritt liegt, dessen Ergebnis ich kennen sollte.

In der aktuellen Vorabversion ist die Bibliothek nach Diensten aufgeteilt. Es gibt ein Kernpaket und je ein Paket pro AWS-Dienst. Wer S3 und Lambda nutzt, installiert genau diese Pakete. Das ist ungewohnt granular, hat aber den Effekt, dass man die eigenen Abhängigkeiten klein hält.

Die drei Ebenen: App, Stack, Construct

Das Modell hat drei Schichten, und es lohnt sich, sie sauber auseinanderzuhalten, weil jede eine andere Aufgabe hat.

Ganz unten stehen die Constructs. Ein Construct ist ein Baustein. Es kapselt eine oder mehrere CloudFormation-Ressourcen samt sinnvoller Voreinstellungen. Ein S3-Bucket ist ein Construct, eine Lambda-Funktion ist ein Construct, und ein selbst geschriebenes Modul, das beide zu einem Upload-Verarbeiter verbindet, ist ebenfalls eines. Constructs lassen sich verschachteln, und genau daraus entsteht die Wiederverwendbarkeit.

Eine Ebene darüber liegt der Stack. Er ist die Einheit, die deployt wird, und entspricht eins zu eins einem CloudFormation-Stack. Was in einem Stack liegt, lebt und stirbt gemeinsam.

Ganz oben sitzt die App. Sie ist der Einstiegspunkt und bündelt einen oder mehrere Stacks. Wenn die App durchläuft, wird für jeden Stack eine Vorlage erzeugt.

flowchart TD
  App["App (Einstiegspunkt)"] --> Stack["Stack (Deployment-Einheit)"]
  Stack --> C1["Construct: S3-Bucket"]
  Stack --> C2["Construct: Lambda-Funktion"]
  C1 --> Synth["Synthese"]
  C2 --> Synth
  Synth --> CFN["CloudFormation-Vorlage"]
  CFN --> AWS["Ressourcen in der Cloud"]

Diese Hierarchie ist kein Selbstzweck. Sie bestimmt, was unabhängig geändert werden kann und was nicht. Zwei Dinge im selben Stack teilen ihr Schicksal beim Rollback. Zwei Dinge in getrennten Stacks lassen sich getrennt aktualisieren, müssen dann aber ihre Verbindung explizit über Referenzen ausdrücken.

Der Stack als Code

So sieht der erwähnte Upload-Verarbeiter in der aktuellen Bibliothek aus. Bewusst klein gehalten, damit die Entscheidung sichtbar bleibt und nicht im Beiwerk verschwindet.

import cdk = require("@aws-cdk/core");
import s3 = require("@aws-cdk/aws-s3");
import lambda = require("@aws-cdk/aws-lambda");

class UploadStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Speicher für eingehende Dateien.
    // RETAIN hält den Bucket auch dann, wenn der Stack gelöscht wird.
    const uploads = new s3.Bucket(this, "Uploads", {
      removalPolicy: cdk.RemovalPolicy.RETAIN
    });

    const handler = new lambda.Function(this, "ProcessUpload", {
      runtime: lambda.Runtime.NODEJS_10_X,
      handler: "index.handler",
      code: lambda.Code.fromAsset("dist/handler"),
      environment: {
        // Der Bucket-Name wird zur Laufzeit aufgeloest, nicht hart kodiert.
        BUCKET_NAME: uploads.bucketName
      }
    });

    // Statt von Hand eine IAM-Policy zu schreiben:
    // das Construct erteilt die noetigen Rechte und nicht mehr.
    uploads.grantReadWrite(handler);
  }
}

const app = new cdk.App();
new UploadStack(app, "UploadStack");
app.synth();

Drei Stellen verdienen einen zweiten Blick. Der Aufruf uploads.grantReadWrite(handler) schreibt keine Policy von Hand, sondern leitet aus der Beziehung der beiden Constructs ab, welche Aktionen die Funktion auf dem Bucket braucht. Das ist mehr als Bequemlichkeit, denn es macht das Prinzip der minimalen Rechte zum Standardweg statt zur Ausnahme. Die Zeile BUCKET_NAME: uploads.bucketName zeigt, dass bucketName zur Synthesezeit kein fertiger String ist, sondern ein Verweis, den CloudFormation später auflöst. Und removalPolicy: RETAIN ist die kleine, unscheinbare Zeile, von der gleich noch die Rede sein wird, weil ihr Fehlen teuer wird.

Was hier auffällt: Es ist normaler Code. Ich kann ihn refaktorieren, in eine Funktion auslagern, über eine Liste von Umgebungen iterieren. Genau das ist der Reiz. Und genau hier beginnt die Verantwortung.

Was man sich mit der Programmiersprache einhandelt

Solange Infrastruktur in YAML lebt, ist sie statisch und langweilig, aber gut überschaubar. Sobald sie Code ist, gelten die Gesetze des Codes. Ein Tippfehler in einer Schleife erzeugt nicht eine falsche Ressource, sondern fünfzig. Eine geänderte Konstante wirkt überall dort, wo sie gelesen wird. Eine Abstraktion, die zu viel versteckt, verbirgt am Ende auch Kosten und Rechte vor demjenigen, der den Stack reviewt.

Daraus folgt für mich die Kernaussage dieses Textes: Wenn Infrastruktur programmierbar wird, muss sie mit derselben Sorgfalt behandelt werden wie Anwendungscode. Sie braucht Tests, sie braucht Reviews, und sie braucht ein Verständnis dafür, was beim Ändern passiert. Das ist keine Bürde, die das CDK uns aufdrückt, sondern die ehrliche Konsequenz daraus, eine echte Sprache zu benutzen.

Tests sind dabei kein Beiwerk. Die Bibliothek bringt bereits ein Assertion-Modul mit, mit dem sich das synthetisierte Ergebnis prüfen lässt, ohne überhaupt zu deployen:

import { expect, haveResource } from "@aws-cdk/assert";

test("Bucket bleibt beim Loeschen des Stacks erhalten", () => {
  const app = new cdk.App();
  const stack = new UploadStack(app, "TestStack");

  expect(stack).to(haveResource("AWS::S3::Bucket", {
    DeletionPolicy: "Retain"
  }, undefined, true));
});

Dieser Test deployt nichts. Er prüft die erzeugte Vorlage. Damit lässt sich eine Regel festschreiben, die sonst nur im Kopf eines erfahrenen Kollegen existiert: dieser Bucket darf nicht versehentlich verschwinden. Genau solche Prüfungen unterscheiden einen Stack, der eine Demo überlebt, von einem, der einen Personalwechsel überlebt.

Der Fehler, der wirklich passiert

Der gefährlichste Fehler im CDK ist kein Syntaxfehler, denn den fängt der Compiler ab. Er hat mit der logischen Identität von Ressourcen zu tun, und er ist deshalb so tückisch, weil der Code danach völlig in Ordnung aussieht.

CloudFormation identifiziert jede Ressource über eine logische ID. Das CDK erzeugt diese ID aus dem Pfad des Constructs im Baum, also im Wesentlichen aus dem zweiten Argument des Konstruktors, dem Namen. In meinem Beispiel heißt der Bucket "Uploads". Angenommen, jemand findet diesen Namen unschön und benennt ihn aus reiner Kosmetik um:

const uploads = new s3.Bucket(this, "UploadBucket", {
  removalPolicy: cdk.RemovalPolicy.RETAIN
});

Funktional ändert sich nichts. Der Code kompiliert, die Tests zum Verhalten der Funktion laufen weiter. Aber die logische ID hat sich geändert. Für CloudFormation bedeutet das nicht: dieselbe Ressource mit neuem Namen. Es bedeutet: die alte Ressource ist verschwunden, eine neue ist hinzugekommen. Beim nächsten Deployment wird also ein neuer, leerer Bucket angelegt, und der alte wird entfernt. Die Anwendung zeigt anschließend auf einen leeren Speicher. Hat der Bucket keine schützende Policy, sind die Daten weg.

An dieser Stelle rettet die unscheinbare Zeile von vorhin. Steht removalPolicy: RETAIN im Code, wird der alte Bucket nicht gelöscht, sondern aus dem Stack entlassen und bleibt als verwaiste Ressource bestehen. Die Daten überleben, auch wenn die Anwendung sie nicht mehr findet, bis jemand manuell aufräumt. Fehlt diese Policy, greift das Standardverhalten, und der alte Bucket wird mit dem alten Stack-Zustand entfernt. Bei einem leeren Bucket ist das ärgerlich. Bei einem mit Produktionsdaten ist es ein Vorfall mit Meldepflicht.

Der entscheidende Punkt ist nicht die einzelne Policy. Der Punkt ist, dass eine harmlose Umbenennung im Code eine zerstörerische Operation in der Infrastruktur auslöst, und dass diese Folge im Quelltext nicht sichtbar ist. Sie wird erst im cdk diff sichtbar, das eine Ersetzung als solche markiert. Wer den Diff vor dem Deployment liest, sieht den Schaden kommen. Wer ihn überspringt, weil das letzte Deployment ja auch durchlief, sieht ihn danach.

$ cdk diff
Resources
[-] AWS::S3::Bucket Uploads      destroy
[+] AWS::S3::Bucket UploadBucket  create

Daraus folgt eine konkrete Betriebsregel: Bei Stacks mit Zustand ist der Diff kein optionaler Zwischenschritt, sondern Teil des Reviews. Und zustandsbehaftete Ressourcen, also Buckets, Datenbanken, Tabellen, bekommen eine explizite Removal-Policy, damit eine Stack-Löschung oder eine Ersetzung niemals stillschweigend Daten mitnimmt.

Abstraktion ist eine Entscheidung, keine kostenlose Annehmlichkeit

Der Aufruf grantReadWrite ist ein gutes Beispiel für eine gelungene Abstraktion, weil er Wiederholung entfernt und einen sicheren Default erzwingt. Es gibt aber die andere Sorte. Ein selbst geschriebenes Construct, das einen ganzen Microservice mit Datenbank, Queue, Funktion und Alarm kapselt, ist verlockend. Es macht den aufrufenden Code kurz. Es macht aber auch unsichtbar, welche zwölf Ressourcen und welche Rechte dabei entstehen.

Eine gute Abstraktion im CDK reduziert Wiederholung und kapselt vernünftige Voreinstellungen, ohne zu verschleiern, was sie kostet und welche Berechtigungen sie verteilt. Die Frage, die ich vor jedem eigenen Construct stelle, lautet deshalb nicht, wie elegant es ist, sondern ob jemand beim Review noch versteht, was er einkauft. Wenn die Antwort nur lautet, dass es funktioniert, ist die Abstraktion noch nicht reif.

Hier hilft, das Ergebnis der Synthese gelegentlich anzusehen. Ein cdk synth schreibt die fertige Vorlage auf die Konsole. Bei einem unscheinbaren Stack sind das schnell ein paar Hundert Zeilen, und es lohnt sich, dieses Verhältnis zwischen kurzem Code und umfangreicher Vorlage einmal bewusst wahrzunehmen. Es ist der ehrlichste Indikator dafür, wie viel die Abstraktion gerade für einen tut.

Wie sich das in eine Pipeline einfügt

Infrastruktur als Code entfaltet ihren Wert erst, wenn niemand mehr von Hand deployt. Ich habe dafür eine kleine GitHub Action gebaut, die in einem Container das CDK installiert und deploy ausführt, mit den AWS-Zugangsdaten aus den Repository-Secrets. Der Reiz liegt darin, dass derselbe Mechanismus, der Anwendungscode baut und ausliefert, jetzt auch die Infrastruktur ausliefert. Ein Pull Request ändert dann nicht nur den Dienst, sondern auch seinen Unterbau, und beides durchläuft denselben Review.

Genau an dieser Stelle schließt sich der Kreis zur Verantwortung. Wenn ein Merge ausreicht, um Produktionsinfrastruktur zu verändern, dann ist der Review der letzte Ort, an dem ein Mensch eingreifen kann. Der Diff gehört in diesen Review. Die Tests laufen vor dem Deployment. Und für Regeln, die nicht verhandelbar sind, etwa dass kein Bucket öffentlich lesbar sein darf, lohnt sich eine maschinelle Prüfung der synthetisierten Vorlage, eine Art Policy as Code für den eigenen CDK-Output. So wandert Wissen, das sonst von der Tagesform des Reviewers abhängt, in eine Prüfung, die immer gleich streng ist.

Was ich nach den ersten Wochen mitnehme

Das CDK ist noch jung, die API bewegt sich, und manches wird sich bis zur stabilen Version ändern. Trotzdem ist die Richtung klar und sie überzeugt mich. Infrastruktur wird testbar, wiederverwendbar und refaktorierbar. Das ist ein echter Gewinn gegenüber handgeschriebenem YAML.

Der Gewinn ist aber an eine Haltung gebunden. Wer Infrastruktur programmiert, muss sie wie Software behandeln, mit allem, was dazugehört: kleine, prüfbare Bausteine, ein Test für jede Annahme, die teuer wäre, wenn sie bricht, ein gelesener Diff vor jedem Deployment und eine bewusste Entscheidung darüber, was eine Abstraktion verbergen darf und was nicht. Die logische ID und die Removal-Policy sind dabei keine Randnotizen, sondern die Stellen, an denen sich entscheidet, ob ein Refactoring harmlos bleibt oder Daten kostet.

Der Werkzeugwechsel von YAML zu TypeScript ist also der kleinere Teil der Geschichte. Der größere Teil ist, dass Betrieb und Codequalität jetzt dasselbe Thema sind. Wer das annimmt, bekommt eine Infrastruktur, die man lesen, prüfen und mit Vertrauen ändern kann. Wer es ignoriert, hat am Ende nur die alten Fehler in einer neuen Sprache.

Weiterführende Quellen

Kommentare

Kommentar schreiben