Skalierung und Betrieb Teil 1: Stateless, Reconciliation und Deployments
Track Konzepte · Verteilte Systeme und Betrieb · ca. 45 Min.
Worum es geht
Ein Service läuft auf einem Server. Dann kommen mehr Nutzer, und du startest einen zweiten Server. Plötzlich verliert ein Kunde seinen Warenkorb, weil sein zweiter Klick auf der anderen Instanz landet. Das ist das erste Thema: Skalierung (scaling) und warum Instanzen zustandslos (stateless) sein müssen.
Danach geht es um zwei Fragen, die in jedem Freelance-Projekt irgendwann kommen: Wie hält ein System seinen Soll-Zustand, wenn Instanzen abstürzen (Reconciliation Loop, deklarativer Zustand)? Und wie kommt neuer Code sicher in Produktion (Deployment-Strategien, Blue/Green, Canary)? Teil 2 (Observability, SLO und Incident-Kultur) beantwortet danach, woher du weißt, ob es gesund ist.
Am Ende kannst du jeden dieser Mechanismen in wenigen Zeilen Python nachbauen oder durchrechnen. Docker und Kubernetes laufen nicht im Browser. Darum werden dort nur die Konzepte erklärt, und du übst sie als Simulation oder Rechnung. Alle Zahlen und Ausgaben in dieser Lektion stammen aus ausgeführtem Code.
Plane ehrlich 45 Minuten ein: etwa 20 Minuten Lesen, etwa 25 Minuten für drei Übungen.
Von JS/TS her gedacht
Du kennst das aus Node: Eine Express-App mit express-session und dem Standard-Speicher im Arbeitsspeicher funktioniert lokal perfekt. Sobald du zwei Instanzen startest (PM2 im Cluster-Modus, zwei Container, zwei Serverless-Aufrufe), gehört jedem Prozess ein eigener Speicher.
| Konzept | JS/TS | Python (hier) |
|---|---|---|
| Zustand im Prozess | const sessions = new Map() |
self.warenkoerbe = {} |
| Zustand extern | Redis, Datenbank | dasselbe, hier ein Store-Objekt als Ersatz |
| Instanzen pro Rechner | PM2 cluster, mehrere Container | mehrere Objekte hinter einem RoundRobin |
| Feature Flag | if (flags.isOn("neuer-checkout")) |
if flags.ist_an(...) |
Das Problem in TypeScript (hier als JavaScript mit Node ausgeführt). Zwei Instanzen, jede mit eigenem Map, und ein Load Balancer, der abwechselnd verteilt:
const instanzen = [new Map(), new Map()]; // Instanz A und Instanz B
let zaehler = 0;
function anfrage(aktion, sid, artikel) {
const sitzungen = instanzen[zaehler++ % instanzen.length]; // Round Robin
if (aktion === "add") sitzungen.set(sid, [...(sitzungen.get(sid) ?? []), artikel]);
return sitzungen.get(sid) ?? [];
}
anfrage("add", "s1", "Buch"); // landet auf A
anfrage("add", "s1", "Stift"); // landet auf B
console.log(anfrage("get", "s1")); // A fragt: nur Buch
console.log(anfrage("get", "s1")); // B fragt: nur StiftAusgabe:
[ 'Buch' ]
[ 'Stift' ]
Beide Instanzen arbeiten korrekt, aber jede kennt nur ihren Teil. In Übung 1 reparierst du genau das, in Python.
Konzept
Schritt 1: Skalierung und Stateless-Design
Es gibt zwei Wege, mehr Last zu tragen:
- Vertikale Skalierung (scale up): ein größerer Rechner (mehr CPU, mehr RAM). Einfach, keine Codeänderung, aber es gibt eine Obergrenze, und ein Rechner bleibt ein einzelner Ausfallpunkt (single point of failure).
- Horizontale Skalierung (scale out): mehr gleiche Instanzen hinter einem Load Balancer. Fast unbegrenzt, und fällt eine Instanz aus, laufen die anderen weiter. Preis: Der Code muss damit umgehen können, dass Anfragen auf verschiedene Instanzen fallen.
Der verbreitete Verteil-Algorithmus ist Round Robin: reihum, Instanz A, B, A, B. Der Load Balancer weiß nichts von deinen Nutzern.
Eine Instanz ist zustandslos (stateless), wenn sie nach einer Anfrage nichts behält, was die nächste Anfrage braucht. Alles, was über eine Anfrage hinaus leben muss (Warenkorb, Login-Sitzung, hochgeladene Datei), liegt in einem externen Store: Datenbank, Redis (in-memory Key-Value-Store) oder Objektspeicher. Dann ist jede Instanz austauschbar, und du kannst sie jederzeit starten, stoppen oder ersetzen.
Dieselbe Situation in Python. Der Store ersetzt hier Redis. Wie bei einem echten Netzwerk-Store bekommst du beim Lesen eine Kopie (er speichert JSON-Text). Eine Liste, die du liest und veränderst, ändert den Store nicht, bis du sie mit set zurückschreibst.
Ausgabe:
['Stift']
['Buch', 'Heft']
Der Kunde legt drei Artikel in den Warenkorb und sieht je nach Instanz zwei verschiedene, unvollständige Warenkörbe. Die Reparatur: Die Instanz bekommt den Store von außen und liest und schreibt nur dort.
Ausgabe:
['Buch', 'Stift', 'Heft']
['Buch', 'Stift', 'Heft']
Hinweise zum Einordnen (allgemeines Fachwissen): Sticky Sessions (der Load Balancer schickt denselben Nutzer immer zur selben Instanz) sind eine Notlösung. Sie verlieren den Zustand trotzdem, wenn die Instanz stirbt, und sie verteilen die Last ungleichmäßig. Das Lesen, Ändern und Zurückschreiben oben ist außerdem nicht atomar: Zwei gleichzeitige Anfragen derselben Sitzung können sich überschreiben. Das Muster kennst du aus konzepte/01 (Lost Update).
Schritt 2: Betrieb, deklarativer Zustand und Reconciliation Loop
Vier Begriffe, die du einordnen können musst. Du führst sie hier nicht aus, aber du triffst sie im Projekt ständig:
| Begriff | Was es ist |
|---|---|
| Container | Ein Paket aus Anwendung und allen Abhängigkeiten, das überall gleich läuft. Dein Python-Service plus die richtige Python-Version in einem Image. |
| Orchestrierung (orchestration) | Ein System (z. B. Kubernetes), das viele Container auf viele Rechner verteilt, neu startet und aktualisiert. |
| Infrastructure as Code (IaC) | Server, Netzwerke und Datenbanken werden in Dateien beschrieben (z. B. mit Terraform), nicht von Hand geklickt. Die Dateien liegen in Git. |
| CI/CD | Continuous Integration: jeder Commit wird automatisch gebaut und getestet. Continuous Delivery/Deployment: der geprüfte Stand wird automatisch ausgeliefert. |
Der wichtigste Gedanke dahinter ist der deklarative Zustand (declarative state). Du schreibst nicht auf, was getan werden soll (imperativ: “starte drei Container”), sondern wie es aussehen soll (deklarativ: “es laufen drei Container mit Version v2”). Ein Controller vergleicht ständig den Soll-Zustand (desired state) mit dem Ist-Zustand (actual state) und korrigiert die Differenz. Diese Schleife heißt Reconciliation Loop. Kubernetes arbeitet so (allgemeines Fachwissen).
Warum ist das stark? Weil die Schleife idempotent ist (siehe konzepte/01): Wenn Ist schon gleich Soll ist, tut sie nichts. Stürzt eine Instanz ab, ändert sich der Ist-Zustand, und der nächste Durchlauf repariert ihn, ohne dass jemand eine Anleitung für “Instanz ist abgestürzt” geschrieben hat. GitOps geht einen Schritt weiter: Der Soll-Zustand liegt in einem Git-Repository, ein Agent gleicht das Cluster damit ab. Eine Änderung an der Produktion ist dann ein Commit (Review, Historie, Rollback per git revert).
Eine einfache Version, mit Zuständen als Dictionary Name -> Version. Die Funktion reconcile liefert eine Liste von Aktionen, anwenden führt sie aus:
Ausgabe:
Runde 1 [('stoppe', 'alt-1'), ('aktualisiere', 'web-1', 'v2'), ('starte', 'web-3', 'v2')]
Runde 2 []
nach Absturz: [('starte', 'web-2', 'v2')]
Runde 2 ist leer: Es gibt nichts zu tun. Nach dem Absturz erkennt dieselbe Funktion die Lücke und liefert genau die eine Aktion. Diese Funktion schreibst du in Übung 4.
Schritt 3: Deployment-Strategien und Progressive Delivery
Ein neuer Stand soll in Produktion, ohne dass alle Nutzer ein Risiko tragen. Drei Wege:
| Strategie | Ablauf | Vorteil | Preis |
|---|---|---|---|
| Rolling Update | Instanzen werden nacheinander ersetzt | braucht kaum Zusatzressourcen | Alt und Neu laufen zeitweise gemischt, Rollback dauert |
| Blue/Green | Zwei komplette Umgebungen: Blue (alt) läuft, Green (neu) wird aufgebaut und getestet, dann schaltest du den Verkehr auf einmal um | Rollback ist ein Umschalten | doppelte Ressourcen, alle Nutzer sehen sofort den neuen Stand |
| Canary | Der neue Stand bekommt zuerst einen kleinen Anteil des Verkehrs (z. B. 5 %), dann mehr | ein Fehler trifft nur wenige Nutzer | braucht Metriken und eine Entscheidungsregel |
Der Name kommt vom Kanarienvogel im Bergwerk: Er zeigt die Gefahr früh an. Progressive Delivery ist die Verallgemeinerung: Der Anteil wächst stufenweise (5 %, 25 %, 50 %, 100 %), und an jeder Stufe entscheidet eine Regel automatisch: weiter, warten oder Abbruch mit Rollback. Dazu kommen Feature Flags: Der Code wird ausgeliefert, aber die neue Funktion ist per Schalter aus und wird getrennt vom Deployment für einen Teil der Nutzer eingeschaltet. So trennst du Deployment (Code ist auf dem Server) von Release (Nutzer sehen die Funktion).
Das Herz eines Canary ist die Regel: Vergleiche die Fehlerrate des Canary mit der der Baseline (dem alten Stand, der parallel läuft). Zwei Fallen:
- Zu wenig Daten. Bei 20 Requests ist ein einzelner Fehler schon 5 %.
- Vergleich mit einem festen Wert statt mit der Baseline. Hat der alte Stand gerade selbst eine Störung (z. B. wegen einer kaputten Datenbank), wäre jeder Canary “schuldig”.
Ausgabe:
1 Fehler in 20 Requests = 5.000%
1 Fehler in 200 Requests = 0.500%
10 Fehler in 2000 Requests = 0.500%
Die Regel schreibst du in Übung 2. Dort ist genau festgelegt, ab wann der Canary als schlechter gilt.
Falle
- “Stateless” nur behaupten. Ein lokaler Cache, eine Datei im Container oder eine Variable auf Modulebene sind Zustand im Prozess. Beim Skalieren oder bei einem Neustart verschwindet er oder ist je Instanz verschieden.
- Sticky Sessions als Dauerlösung. Sie verdecken das Problem, bis die Instanz stirbt.
- Canary ohne Mindestmenge und ohne Baseline. Zu kleine Stichproben erzeugen Fehlalarme, ein fester Grenzwert beschuldigt den Canary für fremde Störungen.
Übungen
Übung 1: Stateless-Refactoring (ca. 8 Min.)
Die Klasse Server unten ist unfertig. Sie bekommt den Store und einen Namen von außen (wie eine Instanz, die beim Start die Adresse von Redis erhält). Schreibe hinzufuegen und warenkorb so, dass alle Instanzen denselben Warenkorb sehen:
hinzufuegen(sid, artikel)hängt den Artikel an den Warenkorb der Sitzungsid.warenkorb(sid)liefert die Liste (leer, wenn es keinen Warenkorb gibt).- Die Instanz selbst behält nichts zwischen den Aufrufen.
store.getliefert eine Kopie oderNone,store.setschreibt.
Schritt 1 zeigt dieselbe Idee schon als fertigen Code. Versuche es zuerst aus dem Kopf, bevor du nach oben scrollst: Genau das Lesen, Ändern und Zurückschreiben ist der Kern. Der Check startet zwei Instanzen hinter einem Round Robin, lässt sie abwechselnd arbeiten, ersetzt eine Instanz durch eine neue (Neustart) und prüft, dass ein zweiter, getrennter Store nichts davon sieht.
Was bekommst du von store.get, und wann ist das Ergebnis im Store angekommen? Welche Werte darf self über mehrere Aufrufe hinweg behalten?
class Server:
def __init__(self, store, name):
self.store = store
self.name = name
def hinzufuegen(self, sid, artikel):
korb = self.store.get(sid) or []
korb.append(artikel)
self.store.set(sid, korb)
def warenkorb(self, sid):
return self.store.get(sid) or []
ServerLesen, ändern, zurückschreiben: store.get liefert eine Kopie, also ändert append allein nichts. Erst store.set bringt die Änderung zu den anderen Instanzen. self hält nur den Verweis auf den Store, keine Daten. Ein lokaler Cache wäre wieder Zustand im Prozess und würde veralten.
Übung 2: Canary-Entscheidung (ca. 8 Min.)
Schreibe entscheide(c_req, c_fehler, b_req, b_fehler). Die vier Zahlen sind Requests und Fehler von Canary (c_) und Baseline (b_) in der aktuellen Stufe. Die Regel, in dieser Reihenfolge:
- Hat der Canary weniger als 200 Requests, gib
"warten"zurück (zu wenig Daten). - Berechne
rate_c = c_fehler / c_requndrate_b = b_fehler / b_req. - Gib
"abbruch"zurück, wenn beides gilt:rate_cist mindestens doppelt so hoch wierate_b, undrate_cliegt mindestens 0,005 (0,5 Prozentpunkte) überrate_b. - Sonst
"weiter".
Der Check prüft viele Fälle, auch die, in denen die Baseline keinen Fehler hat, und danach eine Progressive-Delivery-Reihe von Stufen.
Welche Regel muss vor allen anderen laufen, damit wenige Daten nie zu einem Abbruch führen? Und welche Werte kann die Baseline annehmen, bei denen eine Rechnung in Python anders reagiert als in JavaScript?
def entscheide(c_req, c_fehler, b_req, b_fehler):
if c_req < 200:
return "warten"
rate_c = c_fehler / c_req
rate_b = b_fehler / b_req
if rate_c >= 2 * rate_b and rate_c - rate_b >= 0.005:
return "abbruch"
return "weiter"
entscheideDie Mindestmenge steht zuerst, sonst führen kleine Stichproben zu Fehlalarmen. rate_c >= 2 * rate_b statt rate_c / rate_b >= 2 vermeidet die Division durch null (in Python ein ZeroDivisionError, in JavaScript Infinity). Die absolute Untergrenze verhindert, dass 1 Fehler bei einer fehlerfreien Baseline sofort abbricht. Der Vergleich mit der Baseline statt mit einem festen Wert verhindert, dass der Canary für eine Störung haftet, die auch den alten Stand trifft.
Übung 3: Reconciliation Loop schreiben (ca. 8 Min.)
Schreibe reconcile(soll, ist). Beide Parameter sind Dictionaries Name -> Version, z. B. {"web-1": "v2"}. Die Funktion liefert eine Liste von Aktionen, die den Ist-Zustand zum Soll-Zustand machen:
("starte", name, version): der Name fehlt im Ist.("stoppe", name): der Name ist im Ist, aber nicht im Soll.("aktualisiere", name, version): der Name ist in beiden, aber mit anderer Version.versionist die Soll-Version.- Namen mit gleicher Version bekommen keine Aktion.
- Die Liste ist nach Name sortiert, und
sollundistwerden nicht verändert.
anwenden(ist, aktionen) ist schon da (aus Schritt 2). Auch reconcile stand in Schritt 2 schon. Schreibe es zuerst selbst und vergleiche danach. Der Check wendet deine Aktionen auf viele Zustände an und prüft: Danach ist Ist gleich Soll, und ein zweiter Durchlauf liefert keine Aktionen mehr (idempotent).
Über welche Menge von Namen musst du gehen, damit weder fehlende noch überzählige Instanzen vergessen werden? Welche drei Fälle gibt es für einen Namen, und welcher vierte Fall erzeugt keine Aktion?
def reconcile(soll, ist):
aktionen = []
for name in sorted(set(soll) | set(ist)):
if name not in ist:
aktionen.append(("starte", name, soll[name]))
elif name not in soll:
aktionen.append(("stoppe", name))
elif ist[name] != soll[name]:
aktionen.append(("aktualisiere", name, soll[name]))
return aktionen
reconcileDie Vereinigung beider Namensmengen deckt fehlende und überzählige Instanzen ab. Die Funktion beschreibt nur die Differenz. Ist alles gleich, ist die Liste leer, darum ist ein zweiter Durchlauf automatisch leer (idempotent). Eine Version, die für jeden Namen im Soll immer “starte” liefert, wäre es nicht.
Merksatz
Mach Instanzen zustandslos, damit du sie beliebig ersetzen und skalieren kannst, beschreibe den Soll-Zustand deklarativ und lass eine Reconciliation Loop ihn halten, und rolle neuen Code stufenweise mit Abbruchregel aus.
Prüfstein
- Zwei Instanzen hinter einem Round Robin zeigen dem Kunden zwei verschiedene Warenkörbe. Woran liegt das, wie reparierst du es, und was ist die Notlösung, die du nicht als Dauerlösung nehmen solltest?
- Was unterscheidet einen Reconciliation Loop von einem Skript, das “starte drei Container” ausführt, wenn nachts eine Instanz abstürzt?
Weiter geht es in Teil 2: Observability, SLO und Incident-Kultur.
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “7. Verteilte Systeme und Betrieb” (Skalierung: horizontal vs. vertikal, Stateless-Design; Betrieb: Container, Orchestrierung, Infrastructure as Code, CI/CD, Blue/Green, Canary); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 7 “Betrieb” (Reconciliation Loop, deklarativer Zustand, GitOps, Progressive Delivery, Feature Flags).
Über die Quelle hinaus (allgemeines Fachwissen): die Erklärungen zu Stateless-Design, Round Robin und Sticky Sessions, die Beschreibung von Container, Orchestrierung, IaC und CI/CD, die Arbeitsweise von Kubernetes als Reconciliation Loop, Rolling Update und die Vor- und Nachteile der Deployment-Strategien, die Canary-Regel samt Schwellen (Mindestmenge 200, Faktor 2, 0,5 Prozentpunkte: willkürlich gewählte Lehrwerte, keine Empfehlung). Alle Zahlen im Text stammen aus dem Ausführen des Codes dieser Lektion (Python 3 und Node), nicht aus Messungen realer Systeme.