# CDN Construct: eine kleine Infrastruktur-API

URL: https://www.mikebild.dev/de/blog/cdn-construct-infrastruktur-api/

Rund 280 Zeilen CloudFormation, verteilt auf zwei Stacks in zwei Regionen – das war zuletzt der Preis dafür, eine statische Website „richtig" auf AWS auszurollen. Vierzehn Ressourcen für eine Handvoll HTML-Dateien: ein S3-Bucket, eine Bucket-Policy, eine Origin Access Identity, eine CloudFront-Distribution mit Cache- und Fehlerverhalten, ein ACM-Zertifikat samt DNS-Validierung – zwingend in us-east-1, deshalb der zweite Stack –, dazu A- und AAAA-Alias-Records in Route53. Nichts davon ist für sich genommen schwierig. Aber alles zusammen ist Verdrahtung, die bei jedem neuen Projekt wieder ansteht und jedes Mal ein kleines bisschen anders gerät.

Genau diese Wiederholung hat mich dazu gebracht, die ganze Kette in ein eigenes, kleines AWS-CDK-Construct zu packen: `cdk-cdn-construct`. Statische Inhalte – eine Single-Page-Application oder eine klassische statische Website – landen in einem S3-Bucket und werden über CloudFront als CDN ausgeliefert. Der Aufruf im eigenen Stack schrumpft auf wenige Zeilen. Das ist der sichtbare Gewinn. Der interessantere Gewinn ist ein anderer, und um den geht es in diesem Artikel: Ein gutes Construct ist eine Schnittstelle mit Meinung. Es macht Entscheidungen explizit, die vorher in Copy-and-paste-Templates versteckt waren, und es lässt genau die Knöpfe frei, die von Projekt zu Projekt wirklich variieren.

## Was da eigentlich verdrahtet wird

Bevor die Abstraktion trägt, lohnt der Blick auf das, was sie kapselt. Eine statische Website hinter CloudFront besteht aus mehr Teilen, als der Satz „S3-Bucket mit CDN davor" vermuten lässt:

- ein privater S3-Bucket, bei dem Block Public Access vollständig aktiv ist
- eine Origin Access Identity (OAI), über die CloudFront – und nur CloudFront – lesend auf den Bucket zugreift
- die passende Bucket-Policy, die genau dieser Identität `s3:GetObject` erlaubt
- eine CloudFront-Distribution mit HTTPS-Zwang, Default Root Object und sinnvollem Fehlerverhalten für Client-seitiges Routing
- ein ACM-Zertifikat in us-east-1 samt DNS-Validierung
- A- und AAAA-Alias-Records in der Route53-Zone, die auf die Distribution zeigen
- der eigentliche Upload des Build-Outputs, inklusive Cache-Invalidierung nach dem Deployment

Als Diagramm sieht die Verdrahtung so aus:

```mermaid
flowchart LR
  Browser[Browser] -->|HTTPS| CF[CloudFront-Distribution]
  CF -->|nur via OAI| S3[S3-Bucket<br/>privat, Block Public Access]
  ACM[ACM-Zertifikat<br/>us-east-1] -.->|TLS| CF
  R53[Route53<br/>A/AAAA-Alias] -.->|löst Domain auf| CF
  Build[Build-Output<br/>z. B. ./build] -->|Upload + Invalidierung| S3
```

Jede dieser Kanten ist eine Entscheidung. Der Bucket könnte auch öffentlich sein und Website-Hosting direkt anbieten – dann bräuchte es keine OAI, aber der Bucket wäre am CDN vorbei erreichbar und HTTPS auf der eigenen Domain fiele weg. Das Zertifikat könnte importiert statt DNS-validiert sein. Die Fehlerseiten könnten auf `404.html` zeigen statt auf `index.html`. In handgeschriebenen Templates stehen diese Entscheidungen nirgends als Entscheidung. Sie stehen da als 280 Zeilen YAML, und ob eine Abweichung Absicht oder Versehen ist, sieht man ihnen nicht an.

## Vom Template zur API

Das AWS CDK bietet mit seinen Construct-Ebenen ein brauchbares Vokabular dafür. L1-Constructs sind generierte Eins-zu-eins-Abbilder der CloudFormation-Ressourcen. L2-Constructs sind handgeschriebene Klassen mit Defaults und Hilfsmethoden – `Bucket` statt `CfnBucket`. Und L3-Constructs, in der Dokumentation auch Patterns genannt, bündeln mehrere Ressourcen zu einem Muster mit Absicht. AWS selbst pflegt seit letztem Jahr mit den Solutions Constructs eine ganze Bibliothek solcher Muster.

Mein Construct ist so ein L3-Baustein, technisch als eigener Stack umgesetzt, damit er sich als Ganzes in eine bestehende CDK-App einhängen lässt. Die Benutzung sieht so aus:

```typescript
#!/usr/bin/env node
import { App } from '@aws-cdk/core';
import { CdkCdnConstructStack } from 'cdk-cdn-construct';

const app = new App();

// ... your own stacks ...

new CdkCdnConstructStack(app, 'MyWebsite', {
  namespace: 'my-app-namespace',
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
});
```

Was von außen konfigurierbar bleibt, folgt bewusst einer schlichten Konvention über Umgebungsvariablen – Name, Build-Verzeichnis, Domain:

```
CDK_DEFAULT_REGION=eu-central-1
CDK_DEFAULT_ACCOUNT=default
CDK_CDN_NAME=your-host-and-bucket-name
CDK_CDN_PATH=./build
CDK_CDN_DOMAIN=example.com
```

Das ist der ganze öffentliche Umfang. Kein Schalter für die Preisklasse der Distribution, keiner für TTLs, keiner für die Frage, ob der Bucket vielleicht doch öffentlich sein soll. Das ist keine Faulheit, sondern der Kern der Sache. Eine Schnittstelle mit Meinung sagt: Diese Punkte sind entschieden, und zwar so. Wer eine andere Meinung braucht, soll sie nicht durch einen Parameter hindurchfädeln, sondern ein anderes Construct nehmen – oder ein eigenes schreiben.

Das Projekt ist dabei ehrlicherweise Work in Progress, ein Baustein aus der eigenen Werkstatt und kein poliertes Produkt. Aber gerade deshalb taugt es als Anschauungsmaterial: Die Frage, welche Knöpfe eine Infrastruktur-API frei lässt, stellt sich bei einem kleinen Construct genauso wie bei einer großen Plattform – nur dass man sie hier noch in einer Kaffeepause diskutieren kann.

## Ein Blick ins Innere

Im Inneren passiert genau das, was vorher im Template stand – nur einmal, benannt und mit Defaults, die man im Code-Review lesen kann. Der Kern lässt sich mit den CDK-v1-Bausteinen so skizzieren:

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

const websiteBucket = new s3.Bucket(this, 'WebsiteBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});
websiteBucket.grantRead(originAccessIdentity);

const certificate = new acm.DnsValidatedCertificate(this, 'WebsiteCertificate', {
  domainName: props.domainName,
  hostedZone: props.hostedZone,
  region: 'us-east-1', // CloudFront accepts ACM certificates from us-east-1 only
});

const distribution = new cloudfront.Distribution(this, 'WebsiteDistribution', {
  defaultBehavior: {
    origin: new origins.S3Origin(websiteBucket, { originAccessIdentity }),
    viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  },
  domainNames: [props.domainName],
  certificate,
  defaultRootObject: 'index.html',
});

new route53.ARecord(this, 'WebsiteAliasRecord', {
  zone: props.hostedZone,
  recordName: props.domainName,
  target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
```

Drei Details daran finde ich bemerkenswert, weil sie zeigen, was ein L2/L3-Zuschnitt gegenüber rohem CloudFormation tatsächlich leistet.

Erstens: `websiteBucket.grantRead(originAccessIdentity)` ist eine Zeile. Dahinter steht die komplette Bucket-Policy mit dem kanonischen Principal der OAI – ein Stück JSON, das ich in Template-Zeiten regelmäßig aus dem letzten Projekt kopiert habe, inklusive der Gelegenheit, dabei etwas zu verschleppen. Die Methode drückt die Absicht aus („diese Identität darf lesen"), nicht den Mechanismus.

Zweitens: `DnsValidatedCertificate` mit explizitem `region: 'us-east-1'`. CloudFront akzeptiert nur Zertifikate aus dieser einen Region, egal wo der Rest des Stacks liegt. Wer das zum ersten Mal übersieht, bekommt beim Deployment eine Fehlermeldung, die auf das Zertifikat zeigt, aber nicht auf die Ursache – mir ist das seinerzeit mit einem Zertifikat in eu-central-1 passiert, und die Suche hat einen halben Nachmittag gekostet. Im Construct ist dieser Stolperstein jetzt eine kommentierte Zeile. Ein Kollege hat im Review genau darauf gezeigt und gefragt, warum die Region fest verdrahtet ist – und genau dieses Gespräch ist der Punkt: Die Regel steht jetzt an einer Stelle, an der man sie diskutieren kann, statt in drei Projekten implizit richtig oder falsch kopiert zu sein.

Drittens: Der Alias-Record entsteht im selben Zug wie die Distribution. In der Template-Welt waren DNS-Einträge gern der manuelle Rest, „den machen wir nachher in der Konsole" – und drei Monate später wusste niemand mehr, warum die Staging-Umgebung auf eine gelöschte Distribution zeigte. Wenn der Record Teil desselben Bausteins ist, gibt es diesen Rest nicht.

## Defaults sind Meinung, nicht Magie

Der übliche Einwand gegen solche Bausteine lautet: Abstraktion versteckt, was passiert, und in der Cloud heißt versteckt schnell teuer oder unsicher. Der Einwand trifft schlechte Constructs, aber er trifft nicht das Prinzip. Der Unterschied liegt darin, ob Defaults implizit oder explizit sind.

Ein kopiertes Template hat auch Defaults – sie heißen nur nicht so. Sie sind das, was der Autor des ersten Templates vor zwei Jahren für richtig hielt, verteilt über 280 Zeilen, ohne Namen und ohne Begründung. Ein Construct dagegen zwingt zur Entscheidung: Was ist fest? Was ist Parameter? Was ist bewusst nicht vorgesehen? Diese drei Fragen einmal sauber zu beantworten hat mich beim Bau des Constructs mehr Zeit gekostet als die eigentliche Verdrahtung – und es war die besser investierte Zeit.

Beim CDN-Baustein sind die festen Meinungen zum Beispiel: Der Bucket ist privat, immer. Ausgeliefert wird ausschließlich über CloudFront, immer über HTTPS. Das Zertifikat wird per DNS validiert und liegt dort, wo CloudFront es verlangt. Frei bleiben Domain, Namensraum und der Pfad zum Build-Output – also genau die Dinge, die zwei Projekte tatsächlich unterscheiden. Alles andere zu öffnen hieße, die Wiederholung nur zu verschieben: Wer zwanzig Parameter durchreicht, hat am Ende wieder CloudFormation, nur mit anderen Klammern.

Es gibt dabei eine Grenze, die ich anfangs unterschätzt habe: Ein Construct ist eine API, und Infrastruktur-APIs haben härtere Kompatibilitätsregeln als Bibliotheks-APIs. Wenn ich in einer Bibliothek eine Klasse umbenenne, brechen Builds. Wenn ich in einem Construct die Struktur ändere, ändern sich unter Umständen die logischen IDs der Ressourcen – und CloudFormation ersetzt dann Dinge, statt sie zu ändern. Bei einem S3-Bucket mit Inhalt ist das keine theoretische Sorge. Ein Construct zu versionieren heißt deshalb nicht nur, semantische Versionsnummern zu vergeben, sondern sich zu merken, welche Änderungen ein Replacement auslösen. Das steht in keinem Typsystem, und genau deshalb gehört es in die Meinung des Bausteins.

## Wann sich ein eigenes Construct lohnt

Man muss dieses Rad nicht selbst bauen. Die Solutions Constructs von AWS decken das Muster „CloudFront zu S3" ab, und für viele Teams ist das die richtige Wahl. Ein eigenes Construct lohnt sich aus meiner Sicht dann, wenn die Meinung, die man kapseln will, spezifischer ist als die eines generischen Bausteins: eigene Namenskonventionen, eine feste Deploy-Konvention über Umgebungsvariablen, ein bestimmtes Fehlerverhalten für Single-Page-Applications, die hauseigene Tagging-Pflicht. Also immer dann, wenn man merkt, dass man den generischen Baustein in jedem Projekt mit denselben fünf Handgriffen nachjustiert – dann sind diese fünf Handgriffe das eigentliche Construct.

Der Prozess dorthin folgt einem einfachen Ablauf, den man auch als Zyklus lesen kann:

```mermaid
flowchart TD
  A[Verdrahtung im Projekt<br/>zum zweiten Mal getippt] --> B[Entscheidungen benennen:<br/>fest, Parameter, ausgeschlossen]
  B --> C[Construct extrahieren<br/>mit minimaler Prop-Fläche]
  C --> D[In zwei Projekten einsetzen]
  D --> E{Nachjustiert?}
  E -->|ja, immer gleich| B
  E -->|nein| F[Meinung trägt]
```

Wichtig ist die Reihenfolge: erst die Wiederholung erleben, dann extrahieren. Ein Construct, das vor dem zweiten Projekt entsteht, kapselt keine Erfahrung, sondern eine Vermutung – und Vermutungen mit zwanzig Parametern erkennt man daran, dass sie sich nicht trauen, etwas festzulegen.

Umgekehrt gilt: Nicht jede Wiederholung verdient eine Abstraktion. Zwei ähnliche Stacks sind billiger als ein schlechtes gemeinsames Construct, das bei jeder Abweichung um einen Schalter wächst. Die ehrliche Frage ist, ob die Meinung stabil ist. Bei „statische Website hinter CloudFront" bin ich mir da inzwischen sicher – das Muster hat sich über mehrere Projekte hinweg nicht bewegt, nur die Domains und Build-Verzeichnisse haben gewechselt. Das ist genau das Profil, bei dem sich die Extraktion trägt.

## Unterm Strich

Aus 280 Zeilen in zwei Regionen werden ein Import, ein Konstruktoraufruf und fünf Umgebungsvariablen. Diese Rechnung ist real, aber sie ist nicht der eigentliche Ertrag. Der eigentliche Ertrag ist, dass die Infrastruktur-Entscheidung jetzt eine Adresse hat. Wenn jemand fragt, warum der Bucket privat ist, warum das Zertifikat in us-east-1 liegt oder warum es keinen TTL-Schalter gibt, dann zeigt die Antwort auf eine Stelle im Code – mit Namen, Kommentar und Git-Historie. Vorher zeigte sie auf ein Template, das irgendwann irgendwo kopiert wurde.

Ich habe lange Infrastruktur als etwas behandelt, das man beschreibt, und Anwendungscode als etwas, das man entwirft. Das CDK hebt diese Trennung auf, und ein kleines eigenes Construct ist der Punkt, an dem man das wirklich spürt: Auf einmal gelten die vertrauten Fragen des API-Designs – was ist öffentlich, was ist Default, was ist ausgeschlossen, was bricht bei einer Änderung – für Buckets und Distributionen genauso wie für Klassen und Module. Eine kleine Infrastruktur-API zu bauen ist deshalb weniger ein DevOps-Thema als eine ganz normale Entwurfsübung. Nur dass ein schlechter Default hier nicht in einer Exception endet, sondern in einer Rechnung oder einem öffentlichen Bucket.

## Weiterführende Quellen

- Projekt-Repository: [github.com/MikeBild/cdk-cdn-construct](https://github.com/MikeBild/cdk-cdn-construct)
- AWS CDK v1: [Construct Library](https://docs.aws.amazon.com/cdk/api/v1/docs/aws-construct-library.html)
- CloudFront: [Restricting access to S3 origins mit Origin Access Identity](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html)
- ACM: [Unterstützte Regionen – Zertifikate für CloudFront in us-east-1](https://docs.aws.amazon.com/acm/latest/userguide/acm-regions.html)
- AWS Solutions Constructs: [github.com/awslabs/aws-solutions-constructs](https://github.com/awslabs/aws-solutions-constructs)
