Serverless-Workflows mit AWS Step Functions
Step Functions machen aus verschachtelten Lambda-Handlern eine deklarative State Machine: sichtbarer Ablauf, explizite Retry- und Catch-Pfade, Standard oder Express.
In meinen Serverless-Schulungen gibt es einen Moment, der sich fast jedes Mal wiederholt. Wir haben zwei, drei Lambda-Funktionen gebaut, sie funktionieren einzeln, und dann kommt die Frage aus der Gruppe: „Und wie hängen die jetzt zusammen?" Meistens folgt darauf der Reflex, eine Funktion die nächste aufrufen zu lassen. Die erste validiert, ruft die zweite, die berechnet und ruft die dritte, die benachrichtigt. Auf dem Whiteboard sieht das noch ordentlich aus. Zwei Wochen später sitze ich mit demselben Team vor genau diesem Code und wir suchen gemeinsam, an welcher Stelle der Prozess eigentlich hängengeblieben ist.
Genau an diesem Punkt lohnt es sich, einen Schritt zurückzutreten und über AWS Step Functions zu sprechen. Der Service dreht die Frage um. Statt den Ablauf in verschachtelten Handlern zu verstecken, beschreibt man ihn deklarativ als State Machine – und der Zustand wird sichtbar, statt in Aufruf-Ketten zu verschwinden.
Das Problem: Orchestrierung im Code
Schauen wir uns zuerst an, wie die Reflex-Lösung typischerweise aussieht. Eine Lambda-Funktion, die eine Bestellung verarbeitet, könnte intern so orchestrieren:
exports.handler = async (event) => {
const order = await validateOrder(event);
if (!order.valid) {
await notifyFailure(order);
return { status: "rejected" };
}
if (order.amount > 1000) {
await requestManualReview(order);
} else {
await autoApprove(order);
}
const [inventory, payment] = await Promise.all([
reserveInventory(order),
chargePayment(order),
]);
return finalize(order, inventory, payment);
};
Auf den ersten Blick ist das lesbar. Aber jeder dieser Aufrufe kann fehlschlagen, und sobald man Robustheit will, wächst der Code. Was passiert, wenn chargePayment einen transienten Fehler wirft? Dann braucht man einen Retry mit Backoff. Was, wenn reserveInventory klappt, aber chargePayment endgültig scheitert? Dann muss die Reservierung wieder zurückgerollt werden. Nach und nach füllt sich der Handler mit try/catch-Blöcken, Zählern und Timeout-Logik. Der eigentliche fachliche Ablauf – validieren, entscheiden, parallel ausführen, abschließen – verschwindet unter der Fehlerbehandlung.
Und der unangenehmste Teil kommt im Betrieb. Wenn eine Bestellung stecken bleibt, gibt es keinen Ort, an dem man den Zustand ablesen kann. Man hat CloudWatch-Logs und muss rekonstruieren, wie weit der Prozess gekommen ist. Bei einer einzigen Funktion geht das noch. Bei fünf Funktionen, die sich gegenseitig aufrufen, wird es zur Detektivarbeit. Ich habe an anderer Stelle über die Grundlagen und Grenzen von Serverless geschrieben – die Koordination mehrerer Funktionen gehört ganz klar zu den Grenzen, an die man mit reinem Code schnell stößt.
Die Idee: der Ablauf als State Machine
Step Functions verlagert die Orchestrierung aus dem Code heraus in eine deklarative Beschreibung. Diese Beschreibung ist in der Amazon States Language (ASL) formuliert, einem JSON-Format. Man definiert Zustände, deren Übergänge und die Fehlerpfade – die einzelnen Lambda-Funktionen bleiben klein und machen jeweils genau eine Sache.
Der entscheidende Gewinn: Der Ablauf steht nicht mehr in einem Handler, sondern liegt als eigenständiges Artefakt vor, das die Konsole grafisch darstellt. Man sieht bei jeder Ausführung, in welchem Zustand sie sich befindet, welcher Übergang genommen wurde und wo etwas fehlgeschlagen ist.
Eine ASL-Definition hat auf oberster Ebene wenige Pflichtfelder. StartAt benennt den Einstiegszustand, States enthält die Zustände als Objekt. Jeder Zustand hat einen Type und verweist mit Next auf den Folgezustand oder markiert sich mit "End": true als terminal.
Bevor wir in eine konkrete Definition einsteigen, hier die acht Zustandstypen, die 2021 zur Verfügung stehen – das ist der komplette Baukasten:
Taskführt Arbeit aus, typischerweise eine Lambda-Funktion.Choiceverzweigt anhand von Bedingungen, das if/else der State Machine.Parallelführt mehrere Zweige gleichzeitig aus und sammelt deren Ergebnisse.Mapwendet einen Ablauf auf jedes Element einer Liste an, 2021 nur als Inline-Variante.Passreicht Eingabe durch oder injiziert feste Daten, nützlich zum Testen.Waitpausiert für eine Dauer oder bis zu einem Zeitpunkt.Succeedbeendet die Ausführung erfolgreich.Failbeendet die Ausführung mit einem Fehler.
Mit diesen acht Bausteinen lassen sich erstaunlich viele Prozesse abbilden, ohne eine Zeile Orchestrierungscode zu schreiben.
Ein Task mit Retry und Catch
Fangen wir mit dem Kern an: einem Task-Zustand, der eine Lambda-Funktion aufruft und Fehler deklarativ behandelt. Das ist der Teil, der im Handler-Ansatz am meisten Code kostet – und in ASL zu ein paar Feldern schrumpft.
{
"Comment": "Order processing workflow",
"StartAt": "ValidateOrder",
"States": {
"ValidateOrder": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ValidateOrder",
"Retry": [
{
"ErrorEquals": ["Lambda.ServiceException", "Lambda.SdkClientException"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}
],
"Catch": [
{
"ErrorEquals": ["States.ALL"],
"Next": "NotifyFailure"
}
],
"Next": "CheckAmount"
},
"NotifyFailure": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:NotifyFailure",
"End": true
}
}
}
Hier steckt viel drin, deshalb der Reihe nach. Der Retry-Block ist ein Array von Retrier-Objekten. ErrorEquals benennt, auf welche Fehler dieser Retrier reagiert – hier zwei transiente Lambda-Service-Fehler, die man nicht dem fachlichen Code anlasten sollte. IntervalSeconds legt die erste Wartezeit fest, BackoffRate multipliziert sie bei jedem weiteren Versuch (Default 2.0), MaxAttempts deckelt die Wiederholungen (Default 3). Ohne explizite Angabe würde AWS diese Defaults verwenden.
Der Catch-Block greift, wenn die Retries erschöpft sind oder ein Fehler auftritt, den kein Retrier abdeckt. Jeder Catcher braucht ErrorEquals und ein Next, das den Übergang beschreibt. States.ALL ist der Auffang-Fehler, der alles matcht – und genau deshalb gibt es zwei Regeln, die man kennen muss: States.ALL muss allein in seinem ErrorEquals stehen und als letzter Eintrag kommen. Steht danach noch ein weiterer Retrier oder Catcher, ist die Definition ungültig.
Ein wichtiges Detail bei der Reihenfolge: Erst werden die passenden Retrier abgearbeitet, und nur wenn keiner mehr greift, wird der Catcher-Übergang genommen. Retry vor Catch, immer in dieser Ordnung. Das ist genau das Verhalten, das man im Code mühsam von Hand nachbauen würde – hier ist es Konvention der Sprache.
Ein Punkt, der in Schulungen oft für Diskussion sorgt: Nicht jeder States.-Fehler wird von allem gefangen. States.TaskFailed etwa matcht so ziemlich alles außer States.Timeout – wer also nur auf States.TaskFailed retryt und eine Zeitüberschreitung erwartet, wird überrascht. Und States.DataLimitExceeded, der bei Überschreitung des Payload-Limits (2021 sind das 256 KB zwischen den Zuständen) auftritt, wird von States.ALL nicht gefangen. Solche Fehler muss man bewusst vermeiden, nicht abfangen.
Verzweigen mit Choice
Nach der Validierung soll der Prozess entscheiden. Kleine Bestellungen werden automatisch freigegeben, große gehen in die manuelle Prüfung. Im Handler war das ein if, in ASL ist es ein Choice-Zustand.
{
"CheckAmount": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.amount",
"NumericGreaterThan": 1000,
"Next": "ManualReview"
}
],
"Default": "FanOut"
},
"ManualReview": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:RequestManualReview",
"Next": "FanOut"
}
}
Der Choice-Zustand enthält ein Choices-Array. Jede Regel besteht aus einer Variable (ein Pfad in die aktuelle Eingabe, hier $.amount), einem Vergleichsoperator wie NumericGreaterThan, StringEquals oder BooleanEquals, und einem Next. Man kann Bedingungen auch mit And, Or und Not verschachteln. Trifft keine Regel zu, greift Default.
Genau dieses Default ist ein häufiger Stolperstein. Choice hat kein End und keinen impliziten Ausgang. Ohne Default und ohne passenden Treffer läuft die Ausführung in einen States.NoChoiceMatched-Fehler. Ich empfehle deshalb, Default immer zu setzen, selbst wenn man theoretisch alle Fälle abgedeckt glaubt.
Parallel: mehrere Zweige gleichzeitig
Für Reservierung und Zahlung, die unabhängig voneinander laufen, gibt es den Parallel-Zustand. Er führt mehrere Branches gleichzeitig aus – jeder Branch ist selbst eine kleine State Machine mit eigenem StartAt und States.
{
"FanOut": {
"Type": "Parallel",
"Branches": [
{
"StartAt": "ReserveInventory",
"States": {
"ReserveInventory": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ReserveInventory",
"End": true
}
}
},
{
"StartAt": "ChargePayment",
"States": {
"ChargePayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:ChargePayment",
"End": true
}
}
}
],
"Next": "Finalize"
},
"Finalize": {
"Type": "Task",
"Resource": "arn:aws:lambda:eu-central-1:123456789012:function:Finalize",
"End": true
}
}
Der Output eines Parallel-Zustands ist ein geordnetes Array – ein Eintrag pro Branch, in der Reihenfolge der Definition. Finalize bekommt also beide Ergebnisse gebündelt übergeben. Auch Parallel unterstützt Retry und Catch auf Zustandsebene, ebenso der Map-Zustand, wenn man eine Liste iterativ abarbeitet.
Wie Daten zwischen den Zuständen fließen
Sobald man das erste Mal ernsthaft mit ASL arbeitet, stolpert man über eine Frage, die im Handler-Code nie auftaucht: Was genau bekommt der nächste Zustand als Eingabe? In einer JavaScript-Funktion reicht man Werte einfach als Argumente weiter. In einer State Machine ist die Weitergabe geregelt, und die Regeln muss man kennen, sonst gehen Daten unbemerkt verloren.
Jeder Zustand bekommt ein JSON-Objekt als Eingabe und gibt ein JSON-Objekt als Ausgabe. Vier Felder steuern diesen Fluss. InputPath filtert, welcher Ausschnitt der Eingabe überhaupt beim Zustand ankommt. Parameters baut daraus das Objekt, das die Lambda-Funktion tatsächlich erhält. ResultPath bestimmt, an welcher Stelle das Ergebnis in die Eingabe eingefügt wird. OutputPath filtert zuletzt, was an den Folgezustand weitergereicht wird.
Der häufigste Stolperstein steckt in ResultPath. Ohne Angabe steht der Default auf $, und das heißt: Das Ergebnis des Tasks ersetzt die komplette Eingabe. Wer nach ValidateOrder im Choice-Zustand noch auf $.amount zugreifen will, muss das Ergebnis mit "ResultPath": "$.validation" unter einen eigenen Schlüssel legen, sonst ist die ursprüngliche Bestellung weg. Genau dieser Default ist der Grund, warum ein Choice-Zustand danach ins Leere greift: Der Schlüssel, den die Bedingung erwartet, existiert nach dem Task gar nicht mehr. Solche Fehler wirken wie eine falsche Bedingung, sind in Wahrheit aber ein Datenfluss-Problem.
Der Ablauf als Bild
Das eigentliche Argument für Step Functions ist die Sichtbarkeit. Was ich oben in JSON beschrieben habe, stellt die Konsole als Graph dar. Und genau diese Struktur lässt sich als Diagramm nachzeichnen:
flowchart TD
Start([Start]) --> Validate[ValidateOrder<br/>Task]
Validate --> Check{CheckAmount<br/>Choice}
Check -->|amount > 1000| Review[ManualReview<br/>Task]
Check -->|else| FanOut[FanOut<br/>Parallel]
Review --> FanOut
FanOut --> Reserve[ReserveInventory]
FanOut --> Charge[ChargePayment]
Reserve --> Finalize[Finalize<br/>Task]
Charge --> Finalize
Finalize --> Done([Succeed])
Validate -.Catch States.ALL.-> Notify[NotifyFailure<br/>Task]
Die gestrichelte Kante ist der Fehlerpfad, den der Catcher beschreibt. Bei einer laufenden Ausführung färbt die Konsole die durchlaufenen Zustände ein, sodass man auf einen Blick sieht, wo der Prozess steht oder wo er hängengeblieben ist. Diesen Blick habe ich im Handler-Ansatz nie – und das ist der Unterschied zwischen „wir müssen die Logs durchsuchen" und „schau einfach in die Ausführung".
Standard oder Express
Bei der Erstellung einer State Machine muss man sich für einen Typ entscheiden, und diese Entscheidung ist danach fix. Wer sich vertut, baut eine neue State Machine. Deshalb lohnt es sich, die beiden Typen früh zu verstehen.
Standard ist der Typ für lang laufende, auditierbare Prozesse. Eine Ausführung darf bis zu ein Jahr dauern, die Ausführung ist exactly-once, und die vollständige Historie steht bis zu 90 Tage über die API bereit. Abgerechnet wird pro Zustandsübergang. Wichtig für die Kostenrechnung: Jeder Retry-Versuch ist ein eigener Übergang und zählt mit. Das visuelle Debugging in der Konsole gibt es nur bei Standard.
Express ist für hochfrequente, kurze Abläufe gedacht. Eine Ausführung darf höchstens fünf Minuten laufen. Asynchron ausgeführt ist die Garantie at-least-once, synchron über StartSyncExecution (verfügbar seit November 2020) at-most-once. Abgerechnet wird nach Anzahl der Ausführungen sowie Dauer und Speicher. Eine Ausführungshistorie in der Konsole gibt es nicht – man ist auf CloudWatch Logs angewiesen, und dafür muss Logging aktiv sein.
Meine Faustregel aus der Praxis: Bestellprozesse, Freigabe-Workflows, alles mit menschlicher Beteiligung oder Prüfpflicht gehört zu Standard. Event-Verarbeitung mit hohem Durchsatz, kurze Transformationen, IoT-Ingestion – das ist Express-Territorium. Der idempotente Charakter kurzer Express-Abläufe passt zur at-least-once-Semantik, während man bei einem Bezahlvorgang die exactly-once-Garantie von Standard haben will.
Wann Step Functions – und wann nicht
Ich rate nicht dazu, jede Aneinanderreihung von zwei Funktionen zur State Machine zu machen. Wenn eine Funktion eine andere aufruft und das war es, ist ein direkter Aufruf oder ein Event völlig in Ordnung. Der Aufwand von Step Functions zahlt sich ab dem Punkt aus, an dem es echte Ablauflogik gibt: Verzweigungen, Parallelität, Retries mit unterschiedlichen Strategien, Kompensation bei Teilfehlern, oder wenn man den Zustand für den Betrieb sichtbar braucht.
Ein zweites Kriterium ist die Fehlerbehandlung. Sobald man merkt, dass der Orchestrierungs-Handler mehr try/catch als Fachlogik enthält, ist das ein starkes Signal. Diese Behandlung deklarativ auszulagern macht den Code der einzelnen Funktionen wieder klein und lesbar – jede Funktion konzentriert sich auf ihre eine Aufgabe, die Koordination liegt in der State Machine.
Und Step Functions ersetzt nicht die anderen Serverless-Bausteine, sondern koordiniert sie. Die Lambda-Funktionen bleiben, die Datenbank bleibt, die Authentifizierung – etwa mit Cognito User Pools – bleibt an ihrem Platz. Step Functions ist die Schicht darüber, die den Ablauf beschreibt.
Fazit
Der Weg von verschachtelten Handlern zu einer State Machine ist vor allem ein Wechsel der Perspektive. Statt den Ablauf im Code zu verstecken, macht man ihn zum Artefakt: StartAt, ein paar Zustände, Next-Übergänge, und die Fehlerpfade als Retry und Catch direkt am Zustand. Die einzelnen Funktionen werden dadurch kleiner, weil sie die Koordination nicht mehr tragen müssen, und der Betrieb wird ruhiger, weil der Zustand jeder Ausführung sichtbar ist.
Die acht Zustandstypen sind schnell gelernt, und mit Task, Choice und Parallel deckt man bereits die meisten realen Abläufe ab. Wer die Reihenfolge von Retry und Catch verinnerlicht hat, Default bei Choice nicht vergisst und die Wahl zwischen Standard und Express bewusst trifft, hat das Fundament beisammen. In meinen Schulungen ist es fast immer dieser eine Moment – wenn das Team zum ersten Mal die eingefärbte Ausführung in der Konsole sieht – an dem die Idee klickt. Der Ablauf ist kein Geheimnis mehr, das in Logs vergraben liegt, sondern ein Bild, auf das alle schauen können.
Weiterführende Quellen
- Repository zur Serverless-Schulung mit den ASL-Beispielen dieses Artikels
- AWS Step Functions – Zustände (States)
- Amazon States Language
- Standard vs. Express Workflows
- Fehlerbehandlung: Retry und Catch
- Task-Zustand
- Choice-Zustand
- Tutorial: State Machine mit Lambda
Kommentare
Kommentar schreiben