# CDK Constructs: Abstraktion mit Haftung

URL: https://www.mikebild.dev/de/blog/cdk-constructs-abstraktion-mit-haftung/

Eine Abstraktion, die man importiert, ist Verantwortung, die man abgibt – so lautet die stillschweigende Annahme, wenn ein Team das erste fertige CDK Construct in sein Projekt zieht. Drei Zeilen Code, ein `cdk deploy`, und S3-Bucket, CloudFront-Distribution und Bucket-Policy stehen. Die Details hat ja jemand anderes durchdacht.

Genau diese Annahme halte ich für falsch, und zwar in beide Richtungen. Wer ein Construct benutzt, gibt keine Verantwortung ab – er importiert fremde Entscheidungen in sein eigenes CloudFormation-Template, auf seine eigene AWS-Rechnung und in seine eigene Angriffsfläche. Und wer ein Construct veröffentlicht, hat nicht einfach Code geteilt – er haftet ab jetzt für Defaults, für Upgrade-Pfade und für alles, was bei fremden Leuten in Produktion entsteht. Ein Construct ist Abstraktion mit Haftung. Das unterscheidet es von fast allem, was ich sonst über npm beziehe.

## Drei Ebenen, drei verschiedene Versprechen

Das CDK sortiert seine Bausteine in drei Ebenen, und die Haftungsfrage stellt sich auf jeder anders. L1-Constructs (`CfnBucket`, `CfnDistribution`) sind generierte Eins-zu-eins-Abbildungen der CloudFormation-Ressourcen. Sie versprechen nichts außer Vollständigkeit: jede Property, kein Default, keine Meinung. L2-Constructs (`s3.Bucket`, `lambda.Function`) sind handgeschriebene Kapseln mit Meinung – sinnvolle Defaults, Komfortmethoden wie `grantRead`, automatisch verdrahtete IAM-Policies. L3-Constructs schließlich, im CDK-Sprachgebrauch auch Patterns, verdrahten mehrere Dienste zu einem fachlichen Baustein: „statische Website mit CDN", „API mit Lambda und DynamoDB".

Je höher die Ebene, desto größer das Versprechen – und desto größer die Haftung. Ein L1-Construct kann mich kaum überraschen, weil es nichts entscheidet. Ein L3-Construct entscheidet fast alles, und ich sehe es dem Code nicht an. Genau deshalb sind L3-Constructs so verführerisch: Sie lesen sich wie Fachlichkeit („eine Website", „eine API") und verstecken, dass darunter ein Bündel sehr konkreter Betriebsentscheidungen liegt. Was dabei wirklich passiert, zeigt erst die Synthese:

```mermaid
flowchart LR
  L3[L3: StaticSite<br/>eigenes Pattern] --> L2[L2: s3.Bucket<br/>CloudFrontWebDistribution]
  L2 --> L1[L1: CfnBucket<br/>CfnDistribution]
  L1 --> SYNTH[cdk synth]
  SYNTH --> TPL[CloudFormation-Template<br/>mehrere hundert Zeilen]
  TPL --> DEPLOY[cdk deploy]
  DEPLOY --> RES[echte Ressourcen:<br/>Kosten, Rechte, Angriffsfläche]
```

Der Pfeil ganz rechts ist der Punkt, an dem sich ein Construct von einer Bibliotheksfunktion unterscheidet. Eine Funktion aus lodash erzeugt zur Laufzeit ein Array. Ein Construct erzeugt eine CloudFront-Distribution, die Geld kostet, einen Bucket, der öffentlich sein kann oder nicht, und IAM-Statements, die jemandem etwas erlauben. Wenn `map` einen Fehler hat, schlägt ein Test fehl. Wenn ein Construct einen Fehler hat, liegt ein Bucket offen im Internet oder eine Removal Policy löscht beim Stack-Abbau Produktionsdaten. Das ist kein Grund, auf Abstraktion zu verzichten – aber ein Grund, sie anders zu behandeln.

## Ein eigenes Construct und seine unbequemen Entscheidungen

Mir wurde das richtig klar, als ich selbst ein kleines Construct gebaut habe: statische Website in S3, ausgeliefert über CloudFront. Der dritte oder vierte handgeschriebene Stack dieser Art im Umfeld, immer dieselben sechzig Zeilen, also lag die Extraktion nahe. Der erste Wurf war schnell fertig. Interessant wurde es bei den Fragen, die der erste Wurf offen ließ.

```typescript
import { Construct, RemovalPolicy } from '@aws-cdk/core';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as deployment from '@aws-cdk/aws-s3-deployment';

export interface StaticSiteProps {
  readonly sourcePath: string;
  readonly retainBucket?: boolean;
}

export class StaticSite extends Construct {
  public readonly siteBucket: s3.Bucket;
  public readonly distribution: cloudfront.CloudFrontWebDistribution;

  constructor(scope: Construct, id: string, props: StaticSiteProps) {
    super(scope, id);

    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      'OriginAccessIdentity'
    );

    this.siteBucket = new s3.Bucket(this, 'SiteBucket', {
      encryption: s3.BucketEncryption.S3_MANAGED,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: props.retainBucket
        ? RemovalPolicy.RETAIN
        : RemovalPolicy.DESTROY,
    });
    this.siteBucket.grantRead(originAccessIdentity);

    this.distribution = new cloudfront.CloudFrontWebDistribution(this, 'Distribution', {
      originConfigs: [
        {
          s3OriginSource: {
            s3BucketSource: this.siteBucket,
            originAccessIdentity,
          },
          behaviors: [{ isDefaultBehavior: true }],
        },
      ],
    });

    new deployment.BucketDeployment(this, 'DeploySite', {
      sources: [deployment.Source.asset(props.sourcePath)],
      destinationBucket: this.siteBucket,
      distribution: this.distribution,
    });
  }
}
```

Jede dieser Zeilen ist eine Betriebsentscheidung, die ich für alle künftigen Nutzer treffe. Der Bucket ist nicht öffentlich, sondern nur über die Origin Access Identity erreichbar – das ist die richtige Entscheidung, aber sie ist nicht selbstverständlich: das nackte `s3.Bucket` aus dem CDK setzt bis heute keinen Public-Access-Block, wenn man nichts angibt. Die Verschlüsselung ist eingeschaltet, obwohl der Inhalt einer öffentlichen Website sie streng genommen nicht braucht – weil niemand später diskutieren soll, warum ausgerechnet dieser Bucket unverschlüsselt ist. Und die Removal Policy steht per Default auf `DESTROY`.

Über diesen letzten Default habe ich am längsten nachgedacht. Das CDK selbst behält Buckets beim Stack-Abbau (`RETAIN`), weil ein Bucket mit Inhalt nicht ohne Weiteres gelöscht werden kann und weil Datenverlust der teuerste aller Fehler ist. Für ein Website-Construct, dessen Bucket-Inhalt bei jedem Deployment neu aus dem Build entsteht, ist `RETAIN` aber der falsche Schutz: Er hinterlässt bei jedem abgerissenen Test-Stack einen Waisen-Bucket, und nach drei Monaten weiß niemand mehr, welche davon weg können. Also `DESTROY` als Default und `retainBucket` als Ausweg für die Fälle, in denen der Bucket doch mehr enthält als Build-Artefakte. Vielleicht ist das die falsche Abwägung – aber sie steht jetzt in einer Zeile Interface-Definition und ist damit diskutierbar. Vorher steckte sie unausgesprochen in sechzig Zeilen kopiertem Stack-Code.

Genauso bewusst sind die beiden `public readonly`-Felder. Ein Construct, das seine inneren Ressourcen versteckt, zwingt seine Nutzer beim ersten Sonderfall zum Fork. Wer an die `CfnDistribution` heran muss, kommt über `this.distribution.node.defaultChild` dran und kann per `addPropertyOverride` alles übersteuern, was meine Abstraktion nicht vorgesehen hat:

```typescript
const cfnDistribution = site.distribution.node
  .defaultChild as cloudfront.CfnDistribution;

cfnDistribution.addPropertyOverride(
  'DistributionConfig.ViewerCertificate.MinimumProtocolVersion',
  'TLSv1.2_2019'
);
```

Diese Escape Hatches sind kein Schönheitsfehler der Abstraktion, sondern Teil des Vertrags. Eine Abstraktion ohne Ausweg ist in der Infrastruktur eine Sackgasse, und Sackgassen fallen immer erst auf, wenn man schon drinsteht.

## cdk synth ist der Wahrheitstest

Für die andere Seite der Haftung – die des Konsumenten – gibt es ein Werkzeug, das ich für wichtiger halte als jede Dokumentation: die Synthese. `cdk synth` schreibt das CloudFormation-Template heraus, das aus dem ganzen Construct-Baum entsteht. Das Template lügt nicht. Es enthält jede Ressource, jede Policy, jeden Default, den irgendein Construct irgendwo gesetzt hat.

```bash
npm install @aws-cdk/aws-s3@1.57.0 @aws-cdk/aws-cloudfront@1.57.0

cdk synth StaticSiteStack > synthesized.yaml

# what did the abstraction actually decide for me?
grep -A 6 'PublicAccessBlockConfiguration' synthesized.yaml
grep -B 2 'DeletionPolicy' synthesized.yaml

# and before every deployment: what changes against reality?
cdk diff StaticSiteStack
```

Bevor ich ein fremdes Construct einsetze, baue ich ein Minimalbeispiel, synthetisiere und lese. Nicht das ganze Template Zeile für Zeile – aber gezielt die Stellen, an denen fremde Defaults teuer oder gefährlich werden. Das kostet eine halbe Stunde und hat sich bisher jedes Mal bezahlt gemacht: Einmal fand ich auf diesem Weg eine CloudFront-Price-Class, die weltweit auslieferte, wo Europa gereicht hätte; ein anderes Mal eine Log-Konfiguration, die schlicht fehlte, was erst beim ersten Vorfall aufgefallen wäre. Bei einem Pattern aus drei Zeilen Anwendungscode kommen gern mehrere hundert Zeilen Template heraus, und irgendwo darin stehen die Antworten auf die Fragen, die das README nicht beantwortet:

- Welche IAM-Statements entstehen, und stehen darin Wildcards, die ich nicht bestellt habe?
- Welche Ressourcen kosten laufend Geld, auch wenn niemand das System benutzt?
- Was ist öffentlich erreichbar, was ist verschlüsselt, was hat welche `DeletionPolicy`?
- Welche Properties kann ich über das Interface ändern – und für welche brauche ich schon am ersten Tag den Escape Hatch?

AWS hat in diesem Frühjahr mit den Solutions Constructs eine eigene Bibliothek solcher Patterns gestartet: geprüfte Zwei-Dienst-Kombinationen wie CloudFront vor API Gateway vor Lambda, mit Defaults entlang des Well-Architected Framework. Ich habe einige davon gelesen, teils benutzt, und finde vor allem den Quellcode lehrreich – dort sieht man, wie viele Zeilen Default-Logik nötig sind, damit „CloudFront plus S3" wirklich vernünftig verdrahtet ist, inklusive der Details, die in handgeschriebenen Stacks regelmäßig fehlen. Genau das ist der Wert einer fremden L3-Abstraktion: nicht, dass ich nichts mehr wissen muss, sondern dass jemand die Entscheidungen schon einmal getroffen und aufgeschrieben hat, gegen die ich meine eigenen halten kann. Wie sich solche Prüfungen automatisieren lassen, statt an Disziplin zu hängen, habe ich mir in [Policy as Code für CDK](https://www.mikebild.dev/de/blog/policy-as-code-fuer-cdk/) schon einmal angesehen – Synthese und Regelprüfung gehören für mich inzwischen zusammen.

## Was der Autor schuldet

Bleibt die Frage, was man sich einhandelt, wenn man selbst veröffentlicht. Nach einem halben Jahr mit dem eigenen Construct und etlichen gelesenen fremden habe ich dafür eine vorläufige Liste, und sie ist länger, als mir beim Extrahieren der ersten sechzig Zeilen klar war.

Da sind zuerst die Defaults, über die oben schon alles steht – mit dem Zusatz, dass ein veröffentlichter Default praktisch nicht mehr zu ändern ist. Wer ihn verschärft, bricht bestehende Deployments; wer ihn lockert, macht bestehende Nutzer unbemerkt unsicherer. Ein Default ist ein Versprechen mit sehr langer Laufzeit.

Dann die Upgrade-Pfade. Das CDK erscheint derzeit fast wöchentlich in neuer Version, wir stehen bei 1.5x, und Module wie CloudFront tragen noch den Experimental-Stempel, dürfen sich also in Minor-Releases ändern. Ein Construct, das ich veröffentliche, spannt sich über diese Bewegung: Meine Nutzer heben ihre CDK-Version an und erwarten, dass mein Construct mitkommt. Peer Dependencies auf die richtigen `@aws-cdk`-Pakete, zeitnahe Releases nach Upstream-Änderungen, ein Changelog, das Template-Änderungen erwähnt – das ist keine Kür, das ist die Hälfte der Arbeit. Und weil das CDK über jsii auch Python-, Java- und .NET-Bindings erzeugt, entscheidet schon die API-Form (keine TypeScript-Spezialitäten, die jsii nicht abbilden kann) darüber, wen ich als Nutzer überhaupt erreichen kann.

Dazu kommen Tests, und zwar auf der richtigen Ebene. Unit-Tests gegen die eigene TypeScript-Logik prüfen nur die halbe Wahrheit; haftbar bin ich für das Template. Mit `@aws-cdk/assert` lässt sich beides festnageln – einzelne Zusicherungen (`expect(stack).to(haveResource('AWS::S3::Bucket', { PublicAccessBlockConfiguration: … }))`) für die Entscheidungen, die nie kippen dürfen, und ein Snapshot des ganzen synthetisierten Templates, damit jede Änderung der Ausgabe im Review sichtbar wird statt erst beim Nutzer. Seit ich das so halte, sind mir zwei Upstream-Änderungen aufgefallen, bevor sie ein Release erreicht haben – beide hätten das Template verändert, ohne dass sich eine Zeile meines Codes geändert hätte.

Und schließlich die Template-Ausgabe selbst. Logische IDs im Template entstehen aus dem Construct-Pfad; wer beim Refactoring seines Constructs Kinder umbenennt oder umhängt, ändert die IDs – und CloudFormation ersetzt daraufhin Ressourcen, statt sie zu ändern. Bei einer CloudFront-Distribution ist das eine halbe Stunde Wartezeit, bei einem Bucket mit Daten ein Zwischenfall. `cdk diff` gegen einen ausgerollten Beispiel-Stack gehört deshalb bei mir vor jedes Release des Constructs, aus demselben Grund, aus dem es vor jedes Deployment gehört. Seit Juli gibt es mit CDK Pipelines (Developer Preview) einen Baustein, der solche Prüfungen in eine selbst-mutierende Deployment-Pipeline zieht – noch frisch, aber die Richtung stimmt: Auch die Auslieferung eines Constructs ist Infrastruktur und will als solche behandelt werden.

## Unterm Strich

Vor gut einem Jahr habe ich [darüber geschrieben, was sich ändert, wenn Infrastruktur zur Programmiersprache wird](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/). Constructs sind die Fortsetzung davon: Wenn Infrastruktur Code ist, bekommt sie auch die Eigenschaften von Code – Wiederverwendung, Paketierung, Versionierung. Nur die Fehlerkosten bleiben die von Infrastruktur.

Deshalb halte ich beide bequemen Haltungen für falsch. „Ich schreibe alles selbst, dann weiß ich, was drin ist" skaliert nicht und reproduziert in jedem Stack dieselben vergessenen Details. „Ich nehme das fertige Pattern, dann muss ich es nicht verstehen" verwechselt Abstraktion mit Absolution. Was für mich trägt, liegt dazwischen: L3-Abstraktionen ja, gern auch eigene – aber gelesen vor dem Einsatz, synthetisiert vor dem Vertrauen, und beim Veröffentlichen mit dem Bewusstsein, dass man ab jetzt für fremde Produktionsumgebungen mitentscheidet. Ein Construct ist kein Snippet. Es ist ein Vertrag über Defaults, Auswege und Upgrade-Pfade, und `cdk synth` ist das Dokument, in dem dieser Vertrag schwarz auf weiß steht.

## Weiterführende Quellen

- AWS Solutions Constructs auf GitHub: https://github.com/awslabs/aws-solutions-constructs
- AWS CDK auf GitHub: https://github.com/aws/aws-cdk
- Constructs im CDK Developer Guide: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html
- Ankündigung der AWS Solutions Constructs (AWS-Blog): https://aws.amazon.com/blogs/aws/aws-solutions-constructs-a-library-of-architecture-patterns-for-the-aws-cdk/
- CDK Pipelines Developer Preview (AWS-Blog): https://aws.amazon.com/blogs/developer/cdk-pipelines-continuous-delivery-for-aws-cdk-applications/
