post

SvelteKit Adapter AWS: Framework trifft Cloud

Warum ich einen eigenen SvelteKit-Adapter für AWS gebaut habe und was die Übersetzung zwischen Framework-Build und Lambda, S3 und CloudFront über beide Seiten verrät.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen

Wenn das Deployment-Modell eines Frameworks und die Zielplattform nicht zusammenpassen, entsteht in Projekten immer dasselbe Nebenprodukt: ein handgeschriebenes Deploy-Skript. Es kopiert Build-Ausgaben in einen S3-Bucket, zippt irgendwo einen Lambda-Handler, setzt Cache-Header von Hand und invalidiert am Schluss eine CloudFront-Verteilung. Das funktioniert – bis das Framework die Struktur seiner Build-Ausgabe ändert, bis ein Header vergessen wird oder bis die eine Person das Projekt verlässt, die das Skript noch erklären kann. Danach hat das Team ein Deployment, dem niemand mehr traut, und jedes Release wird zur Mutprobe.

Genau an diesem Punkt stehen Anfang 2022 SvelteKit-Anwendungen, die auf AWS laufen sollen. SvelteKit selbst ist noch Beta – Version 1.0 ist angekündigt, aber nicht erschienen – und bringt für dieses Problem ein bemerkenswert klares Konzept mit: Adapter. Es gibt adapter-node für den eigenen Server, adapter-static für rein statische Seiten, adapter-vercel und adapter-netlify für die jeweiligen Plattformen. Einen offiziellen Adapter für AWS gibt es nicht. Also habe ich einen geschrieben: sveltekit-adapter-aws, ein Open-Source-Projekt, das eine SvelteKit-App per AWS CDK auf Lambda, S3 und CloudFront ausrollt. Beim Bauen ist mir einiges über beide Seiten aufgefallen – über das Framework und über die Cloud. Darum geht es hier.

Adapter sind eine Architekturgrenze

Ein SvelteKit-Adapter ist im Kern ein Node-Modul mit einer einzigen wesentlichen Funktion: adapt(). Nach dem eigentlichen Build ruft SvelteKit diese Funktion auf und übergibt ein builder-Objekt, über das der Adapter die Build-Ausgaben abholt: den Client-Code für den Browser, den Server-Code für das Rendering, die vorgerenderten Seiten. Was der Adapter daraus macht, ist seine Sache. adapter-node baut einen startbaren HTTP-Server, adapter-static eine Verzeichnisstruktur für beliebige Webserver, adapter-vercel genau das Format, das die Vercel-Plattform erwartet.

Diese Grenze ist bewusst gezogen. Das Framework weiß, wie aus Routen, Komponenten und Endpunkten eine Anwendung wird. Es weiß nicht – und will nicht wissen –, wo diese Anwendung später läuft. Alles Plattformspezifische wandert hinter die Adapter-Schnittstelle. Für den Anwender reduziert sich die Entscheidung auf eine Zeile in der svelte.config.js:

import { adapter } from 'sveltekit-adapter-aws';
import preprocess from 'svelte-preprocess';

export default {
  preprocess: preprocess(),
  kit: {
    adapter: adapter({
      autoDeploy: true,
      FQDN: 'demo.example.com',
      MEMORY_SIZE: 256,
      LOG_RETENTION_DAYS: 7,
    }),
  },
};

Mit autoDeploy: true rollt der Build-Schritt die Anwendung direkt aus – svelte-kit build endet dann nicht mit einem Verzeichnis, sondern mit einem laufenden CloudFormation-Stack. Das ist für Prototypen und kleine Projekte angenehm; für alles andere lässt sich über cdkProjectPath eine eigene CDK-App einhängen, die den Adapter-Output als Baustein verwendet.

Warum es den AWS-Adapter nicht gab

Dass Vercel und Netlify eigene Adapter haben und AWS nicht, ist kein Zufall und kein Versäumnis. Vercel und Netlify sind Plattformen: Sie kontrollieren beide Seiten der Schnittstelle und können exakt definieren, welches Artefakt sie erwarten. AWS ist keine Plattform in diesem Sinn, sondern ein Baukasten. Es gibt keinen einzelnen Dienst namens „SvelteKit-Hosting", sondern Dutzende Primitive, aus denen man sich eines von vielen möglichen Deployments zusammensetzt.

Ein AWS-Adapter muss deshalb etwas tun, was die Plattform-Adapter nicht tun müssen: eine Architektur festlegen. Welcher Dienst beantwortet SSR-Anfragen? Wo liegen die statischen Dateien? Wer verteilt den Traffic? Das sind Entscheidungen, die sonst ein Architekt in einem Design-Dokument trifft – hier stecken sie in einem npm-Paket. Genau das macht so einen Adapter zu einem interessanten Stück Software: Er ist geronnene Architektur, versioniert und wiederverwendbar.

Die Architektur: drei Dienste, eine Anwendung

Die Aufteilung, für die ich mich entschieden habe, folgt dem Charakter der Build-Ausgaben. Eine gebaute SvelteKit-App zerfällt in zwei grundverschiedene Teile: statische Dateien (Client-Bundles, Assets, vorgerenderte Seiten), die sich beliebig cachen lassen, und einen Server-Teil, der pro Anfrage rechnen muss. Statisches gehört in S3, Dynamisches in eine Lambda-Funktion, und davor sitzt CloudFront als gemeinsamer Eingang, der anhand des Pfads entscheidet, wohin eine Anfrage geht.

flowchart LR
  B[Browser] --> CF[CloudFront<br/>Verteilung]
  CF -->|/_app/*, Assets,<br/>vorgerenderte Seiten| S3[S3 Bucket<br/>statische Dateien]
  CF -->|alle übrigen<br/>Anfragen| GW[API Gateway<br/>HTTP API]
  GW --> L[Lambda<br/>SvelteKit SSR]
  R53[Route 53 + ACM<br/>eigene Domain] -.-> CF

Provisioniert wird das Ganze mit dem AWS CDK, und zwar in der Version 2, die erst im Dezember 2021 allgemein verfügbar geworden ist – das Projekt setzt also auf ziemlich frischem Fundament auf. CDK v2 räumt vor allem das Paket-Chaos der v1 auf: eine Bibliothek aws-cdk-lib statt Dutzender einzeln zu versionierender Module. Für einen Adapter, der als npm-Abhängigkeit in fremde Projekte wandert, ist das ein spürbarer Unterschied, denn Versionskonflikte zwischen CDK-Teilpaketen waren in v1 ein Dauerthema. Der Kern des Stacks, leicht gekürzt:

import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';

export class AWSAdapterStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps) {
    super(scope, id, props);

    const serverHandler = new lambda.Function(this, 'ServerFunction', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset(process.env.SERVER_PATH!),
      memorySize: parseInt(process.env.MEMORY_SIZE ?? '128'),
      timeout: Duration.seconds(15),
      logRetention: RetentionDays.ONE_WEEK,
    });
    // ... HTTP API, S3 bucket, CloudFront distribution, Route 53 records
  }
}

Ein Detail, das leicht untergeht: Die Log-Aufbewahrung steht bewusst auf sieben Tagen. CloudWatch-Logs ohne Aufbewahrungsregel wachsen unbegrenzt, und bei einer SSR-Funktion, die jede Seitenanfrage protokolliert, summiert sich das schneller, als man denkt. Solche Betriebs-Voreinstellungen gehören meiner Erfahrung nach in den Adapter, nicht in eine Wiki-Seite mit Empfehlungen.

Vom Lambda-Event zur Framework-Antwort

Die eigentliche Übersetzungsarbeit passiert an zwei Stellen. Die erste ist der Build: Der Server-Teil der SvelteKit-Ausgabe wird mit esbuild zu einer einzigen CommonJS-Datei gebündelt – Format cjs, Ziel Node 14, die eingebauten Node-Module bleiben extern. Lambda bekommt damit ein Artefakt ohne node_modules-Verzeichnis, was Paketgröße und Kaltstart gutgetan hat. Die zweite Stelle ist die Laufzeitumgebung selbst: Ein kleiner Handler-Shim nimmt das Event von API Gateway entgegen, formt daraus die Anfrage-Struktur, die der SvelteKit-Server erwartet, und übersetzt die Antwort zurück:

// index.js – bridges API Gateway events to the SvelteKit server
import { init, render } from './app.js';

init();

export async function handler(event) {
  const { rawPath, rawQueryString, headers, requestContext } = event;

  const response = await render({
    method: requestContext.http.method,
    path: rawPath,
    query: new URLSearchParams(rawQueryString),
    headers,
    rawBody: event.body
      ? Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8')
      : undefined,
  });

  return {
    statusCode: response.status,
    headers: response.headers,
    body: response.body,
  };
}

Dieser Shim ist unspektakulär, aber er ist der Ort, an dem Framework-Welt und Cloud-Welt tatsächlich aufeinandertreffen. Hier entscheidet sich, ob Binärdaten korrekt durchgereicht werden, ob mehrfache Set-Cookie-Header überleben und ob Query-Parameter mit Sonderzeichen heil ankommen. Jede dieser Kleinigkeiten habe ich nicht am Schreibtisch entworfen, sondern nach einem konkreten Fehlverhalten nachgezogen. Der gesamte Ablauf vom Quellcode bis zum Stack sieht so aus:

flowchart LR
  SRC[SvelteKit<br/>Quellcode] --> BUILD[svelte-kit build<br/>via Vite]
  BUILD --> ADAPT[adapter adapt&#40;&#41;<br/>sortiert Ausgaben]
  ADAPT --> ESB[esbuild<br/>SSR-Bundle, cjs]
  ADAPT --> STATIC[statische Dateien<br/>+ Prerendered]
  ESB --> CDK[AWS CDK v2<br/>synth + deploy]
  STATIC --> CDK
  CDK --> STACK[CloudFormation-Stack<br/>Lambda, S3, CloudFront]

Gegen eine bewegliche API bauen

Der unbequemste Teil des Projekts hat nichts mit AWS zu tun. SvelteKit ist Beta, und die Adapter-Schnittstelle gehört zu den Teilen, die sich am häufigsten bewegen. Methoden auf dem builder-Objekt wurden umbenannt, zusammengelegt und im Verhalten geändert; die Struktur der Server-Ausgabe hat sich ebenfalls mehr als einmal verschoben. Ein Adapter, der gegen SvelteKit von Oktober gebaut wurde, kompiliert im Dezember womöglich noch – und erzeugt trotzdem ein kaputtes Deployment.

Für ein Open-Source-Projekt heißt das: Die Issues kommen nicht von eigenen Fehlern, sondern von der Drift zwischen der Framework-Version des Nutzers und der Version, gegen die der Adapter entwickelt wurde. Ein typischer Fehlerbericht beginnt mit „funktioniert bei mir nicht" und endet nach etwas Grabarbeit bei einer SvelteKit-Version, die drei Wochen neuer ist als meine letzte Anpassung. Dagegen helfen nur unspektakuläre Maßnahmen: die getestete SvelteKit-Version im Projekt festnageln, Beispiel-Repositories aktuell halten, Änderungen am Framework zeitnah nachziehen. Das ist der reale Preis dafür, gegen eine Beta zu bauen – und ich halte ihn für angemessen. Wer wartet, bis eine Schnittstelle stabil ist, überlässt die Erfahrung aus der Zwischenzeit anderen. Nebenbei zwingt einen ein Adapter dazu, das Framework wirklich zu verstehen: Man arbeitet nicht mit der dokumentierten Oberfläche, sondern mit den Build-Ausgaben und ihren Annahmen.

Was der Adapter beim Build konkret übernimmt, ist überschaubar und genau deshalb gut wartbar:

  • Client-Bundles, Assets und vorgerenderte Seiten in ein Artefakt-Verzeichnis sortieren
  • den Server-Teil mit esbuild zu einer Lambda-tauglichen Datei bündeln
  • den Handler-Shim für API Gateway daneben legen
  • einen CDK-Stack synthetisieren und auf Wunsch direkt ausrollen
  • Domain, Zertifikat und DNS-Einträge über Route 53 und ACM verdrahten, wenn ein FQDN gesetzt ist

Was Serverless nicht abnimmt

Auf der anderen Seite der Grenze wartet die zweite Ernüchterung: Auch mit fertigem Adapter ist AWS keine Plattform, die einem den Betrieb abnimmt. Serverless verschiebt Arbeit, es beseitigt sie nicht. Ein paar Punkte sind mir beim Betreiben eigener SvelteKit-Deployments deutlich geworden.

Kaltstarts sind real, aber handhabbar. Eine per esbuild gebündelte SSR-Funktion startet in dieser Größenordnung schnell genug für die meisten Seiten – wer aber schwere Abhängigkeiten in den Server-Teil zieht, bezahlt das bei jeder kalten Anfrage. Die Speichergröße ist bei Lambda zugleich der CPU-Regler: Die 128-MB-Voreinstellung ist für Rendering oft zu knapp, und mehr Speicher kann Antwortzeiten so weit verbessern, dass es sich trotz höherem Preis pro Millisekunde rechnet. Messen statt raten.

Dazu kommt das Caching. CloudFront entlastet die Funktion nur, wenn die Cache-Schlüssel und Header stimmen – eine SSR-Antwort mit Cache-Control auf private trifft die Lambda bei jedem Aufruf. Für kleine Umschreibungen direkt an der Kante gibt es seit letztem Jahr CloudFront Functions, die deutlich leichtgewichtiger sind als Lambda@Edge; für das eigentliche Rendering sind beide der falsche Ort. Und CloudFormation macht Deployments nachvollziehbar und rückrollbar, aber nicht schnell: Eine Änderung an der CloudFront-Verteilung kann etliche Minuten dauern. Wer von Vercel kommt, muss sich an diese Taktung erst gewöhnen.

Übrig bleibt eine ehrliche Arbeitsteilung. Der Adapter beantwortet die Frage „wie kommt die App nach AWS" – diese Fragen bleiben beim Team:

  • Beobachtbarkeit: welche Logs, Metriken und Alarme zeigen, dass das Rendering leidet?
  • Kosten: was kosten Anfragen, Datenverkehr und Logs bei realem Traffic?
  • Sicherheit: welche Rechte hat die SSR-Funktion, und welche braucht sie wirklich?
  • Lebenszyklus: wer zieht Framework- und Adapter-Updates nach, und wie wird ein Rollback geprobt?

Unterm Strich

Das Projekt hat mir zwei Dinge gezeigt, die über SvelteKit und AWS hinausreichen. Erstens: Die Adapter-Schnittstelle ist ein gelungenes Beispiel dafür, wie ein Framework eine Grenze ziehen kann, ohne sich für eine Plattform zu entscheiden. Alles Plattformspezifische – von der Verzeichnisstruktur bis zur Architekturentscheidung – bekommt einen klaren Ort, und dieser Ort ist versionierbar, testbar und austauschbar. Dass eine Einzelperson in einem überschaubaren Repository einen kompletten AWS-Unterbau für ein Framework bereitstellen kann, spricht für den Schnitt dieser Schnittstelle.

Zweitens: Ein Deployment-Ziel ist nie nur Konfiguration. Der Weg von svelte-kit build zu einem laufenden CloudFront-Endpunkt besteht aus lauter kleinen Entscheidungen – Bundling, Routing-Regeln, Cache-Verhalten, Speichergrößen, Log-Aufbewahrung –, und jede davon hat Konsequenzen im Betrieb. Ein handgeschriebenes Deploy-Skript trifft dieselben Entscheidungen, nur unsichtbar und unversioniert. Ein Adapter macht sie explizit und teilt sie mit allen, die dasselbe Problem haben. Genau deshalb lohnt es sich, so etwas als Open Source zu bauen, auch wenn das Framework noch Beta ist und die Schnittstelle unter den Händen wackelt: Die Alternative ist nicht „kein Adapter", sondern hundert private Skripte, die alle dieselben Fehler noch einmal machen.

Weiterführende Quellen

Kommentare