flowchart TB
subgraph VMHOST["VM-Welt: jede VM hat einen eigenen Kernel"]
direction TB
H1["Hypervisor auf der Hardware"] --> VM1["VM 1: eigener Kernel, Prozesse"]
H1 --> VM2["VM 2: eigener Kernel, Prozesse"]
end
subgraph CWELT["Container-Welt: ein gemeinsamer Kernel"]
direction TB
K["Host-Kernel mit Namespaces und cgroups"] --> C1["Container A: nur Prozesse"]
K --> C2["Container B: nur Prozesse"]
end
Betriebssystem und Performance-Denken: Prozesse, I/O-Modelle, Container von innen, Perzentile und Tail Latency
Track Konzepte · Betriebssystem und Performance · ca. 70 Min.
Worum es geht
Dein Service antwortet im Durchschnitt in 40 ms, und trotzdem beschweren sich Nutzer über “hängende” Seiten. Dein Node-Server schafft 10.000 offene Verbindungen, aber ein einziger JSON.parse auf einem riesigen Body lässt alle 10.000 warten. Dein Container hat ein Speicherlimit, und eines Tages ist er einfach weg (Exit-Code 137). Alle drei Fälle haben dieselbe Wurzel: Du kennst die Schicht unter deinem Code nicht gut genug, und du misst mit dem falschen Maß.
Am Ende der Lektion kannst du zwei Fragen beantworten und belegen:
- Warum ist p99 oft wichtiger als der Durchschnitt, und wie entsteht Tail Latency? Du hast selbst Perzentile berechnet, die Verstärkung durch viele parallele Teilanfragen ausgerechnet und in einer Simulation gesehen, dass Wartezeiten bei hoher Auslastung explodieren.
- Was macht ein Container auf Kernel-Ebene, und was isoliert er nicht? Du kennst Namespaces, cgroups, Image-Layer und den Unterschied zu einer VM.
Dazu kommen die Grundlagen darunter: Prozesse, Syscalls, Dateideskriptoren, Signale, Berechtigungen und die I/O-Modelle, auf denen Node.js, nginx und asyncio aufbauen. Echte Prozesse, einen Kernel oder Container gibt es im Browser nicht. Du arbeitest darum mit Simulationen, Rechnungen und Zuordnungen. Die Zahlen im Text stammen alle aus ausgeführtem Code.
Plane ehrlich 70 Minuten ein: etwa 35 Minuten Lesen, 35 Minuten Übungen (fünf Stück). Eine gute Pause ist nach Übung 3, danach kommen der Event Loop und der Container-Fall. Verwandte Lektionen: konzepte/07a und 07b (Retries und Load Shedding), konzepte/09b (SLO und Observability), konzepte/17 (Netzwerk).
Von JS/TS her gedacht
Du kennst die Folgen schon, nur vielleicht nicht die Ursachen. Ein synchroner Aufruf in Node friert alles ein:
setTimeout(() => console.log("timer"), 0);
const t = Date.now();
while (Date.now() - t < 200) {} // 200 ms Rechenarbeit im Event Loop
console.log("busy fertig");Ausgeführt mit Node (die Datei als JavaScript, ohne Typannotationen):
busy fertig
timer
Der Timer war für “sofort” angemeldet, kommt aber erst nach der Schleife dran, weil es nur einen Thread gibt, der den Event Loop betreibt. Genau das simulierst du in Übung 4.
| Konzept | JS/TS (Node) | Python (hier) |
|---|---|---|
| Umgebungsvariable | process.env.NAME |
os.environ["NAME"] |
| stdin und stdout | process.stdin, console.log |
sys.stdin, print |
| Signal abfangen | process.on("SIGTERM", ...) |
signal.signal(signal.SIGTERM, ...) |
| Event Loop | libuv unter Node (bitte prüfen) | asyncio.run(...) |
| Rechenarbeit auslagern | worker_threads |
ProcessPoolExecutor, loop.run_in_executor |
| Perzentile | Bibliotheken (z. B. für Metriken) | statistics.quantiles (anderes Verfahren, siehe unten) |
Konzept
Schritt 1: Prozess, Kernel und Syscalls
Ein Prozess (process) ist ein laufendes Programm mit eigenem virtuellem Adressraum, einer eigenen Tabelle offener Dateien, eigenen Umgebungsvariablen und einer Prozess-ID (PID). Dein Code darf nicht direkt auf Festplatte, Netzwerkkarte oder fremden Speicher zugreifen. Dafür gibt es den Kernel: Der läuft privilegiert, und dein Programm bittet ihn über einen Systemaufruf (syscall) um Hilfe: open, read, write, socket, fork. Jeder Syscall ist ein Wechsel in den Kernel-Modus und zurück und kostet Zeit. Darum versuchen schnelle Systeme, Syscalls zu sparen (das ist das Motiv von epoll und io_uring in Schritt 2).
Virtueller Speicher und Paging. Jeder Prozess sieht Adressen ab 0 für sich allein. Der Kernel (mit Hilfe der Hardware) übersetzt sie in echte Speicherplätze, und zwar in Blöcken fester Größe, den Seiten (pages). Üblich sind 4096 Byte (4 KiB, bitte prüfen, hängt von der Architektur ab). Die Adresse 10000 liegt dann auf Seite 2 an Position 1808:
Ausgabe:
(2, 1808)
(3, 5)
Passen nicht alle Seiten in den Arbeitsspeicher, lagert der Kernel selten genutzte Seiten auf die Platte aus (paging, swapping). Greift dein Programm auf eine ausgelagerte Seite zu, gibt es einen Seitenfehler (page fault), und der Zugriff dauert um Größenordnungen länger als ein normaler. Das ist eine klassische Quelle für einzelne sehr langsame Anfragen (siehe Schritt 4).
Context Switch. Es gibt meist mehr Threads als CPU-Kerne. Der Kernel wechselt darum ständig, wer gerade rechnen darf, und sichert dabei Register und Zustand des alten Threads. Dieser Context Switch kostet Zeit und entwertet die Prozessor-Caches. Tausende Threads, die alle auf I/O warten, sind darum teuer.
Dateideskriptor (file descriptor, FD): Für den Kernel ist fast alles eine Datei, auch Sockets, Pipes und das Terminal. Ein geöffnetes Ding wird im Prozess durch eine kleine Zahl dargestellt. Die ersten drei haben feste Rollen: 0 ist stdin (Eingabe), 1 ist stdout (Ausgabe), 2 ist stderr (Fehlerausgabe). Lokal ausgeführt (print(sys.stdin.fileno(), sys.stdout.fileno(), sys.stderr.fileno())):
0 1 2
Darum gibt es in der Shell programm > ausgabe.txt 2> fehler.txt: Der FD 1 und der FD 2 werden getrennt umgeleitet. Ein Prozess hat ein Limit für offene FDs. Läuft es voll, schlägt jedes weitere open und jedes accept auf einem Socket fehl (“too many open files”), typischerweise bei vielen gleichzeitigen Verbindungen.
Signale. Ein Signal ist eine kurze Nachricht des Kernels oder eines anderen Prozesses an einen Prozess. Drei musst du kennen (Nummern lokal mit signal.Signals(n).name ausgegeben):
| Nr. | Name | Bedeutung |
|---|---|---|
| 2 | SIGINT |
Strg+C, kann abgefangen werden |
| 15 | SIGTERM |
höfliche Bitte, sich zu beenden (so stoppt docker stop zuerst, bitte prüfen), kann abgefangen werden |
| 9 | SIGKILL |
sofortiges Ende, nicht abfangbar |
Wird ein Prozess mit SIGKILL beendet, meldet die Shell üblicherweise den Exit-Code 128 plus Signalnummer, hier 128 + 9 = 137 (allgemeines Fachwissen, die Summe ist lokal gerechnet). Das ist die Zahl, die du bei Containern wiedersiehst. Für sauberes Herunterfahren fängst du SIGTERM ab, beendest laufende Anfragen und schließt Verbindungen.
Umgebungsvariablen sind Schlüssel-Wert-Paare, die ein Prozess beim Start von seinem Elternprozess kopiert bekommt und an seine Kinder weitergibt. Darum eignen sie sich für Konfiguration (Datenbank-URL, Log-Level), und deshalb lässt sich ein Container ohne Neubau umkonfigurieren. Geheimnisse darin sind bequem, aber jeder Prozess und jedes Debug-Log, das sie ausgibt, kann sie leaken.
Berechtigungen (Unix permissions): Jede Datei hat einen Besitzer (owner), eine Gruppe (group) und einen Modus aus drei Zifferngruppen für Owner, Group und Other. Jede Ziffer ist eine Summe aus Lesen (4), Schreiben (2) und Ausführen (1). Aus 0o640 wird rw- für den Owner (4+2), r-- für die Gruppe (4) und --- für alle anderen:
Ausgabe:
0o754 -rwxr-xr--
0o640 -rw-r-----
0o600 -rw-------
Die Zugriffsentscheidung hat eine Reihenfolge, und die ist die Falle: Der Kernel prüft, in welche Klasse du fällst, und nimmt nur deren Bits. Bist du der Owner, zählen nur die Owner-Bits, auch wenn die Gruppe mehr darf. Bist du nicht der Owner, aber in der Gruppe, zählen die Group-Bits. Sonst die Other-Bits. Root (UID 0) darf in diesem Modell vereinfacht alles. Als Code (der Parameter gids ist die Menge der Gruppen des Nutzers):
Ausgabe:
True
True
False
False
True
False
True
Die vorletzte Zeile ist die interessante: Der Owner ist auch in der Gruppe, darf aber nicht schreiben, weil sein Owner-Eintrag (r--) greift und die Gruppen-Bits (rwx) gar nicht angeschaut werden.
Schritt 2: I/O-Modelle und warum ein Event Loop skaliert
Ein Server wartet fast die ganze Zeit: auf das Netzwerk, auf die Datenbank, auf die Platte. Die Frage ist, was in dieser Zeit mit dem Thread passiert.
| Modell | So funktioniert es | Kosten |
|---|---|---|
| blocking (ein Thread pro Verbindung) | read blockiert den Thread, bis Daten da sind |
Jeder Thread braucht Speicher für seinen Stack, dazu kommen viele Context Switches. Bei vielen tausend Verbindungen wird das teuer. |
non-blocking mit Bereitschaftsmeldung (select, poll, epoll) |
Ein Thread fragt den Kernel: “Welche meiner Sockets sind bereit?” und bearbeitet nur diese | Wartende Verbindungen kosten kaum etwas. epoll (Linux) liefert die bereiten direkt, kqueue (macOS und BSD) macht Ähnliches (allgemeines Fachwissen). |
Completion-basiert (io_uring, Linux) |
Aufträge kommen in eine gemeinsame Warteschlange mit dem Kernel, Ergebnisse kommen in einer zweiten Warteschlange zurück | Weniger Syscalls, weil viele Aufträge mit einem Aufruf abgegeben werden (allgemeines Fachwissen). |
Ein Event Loop nutzt das zweite Modell: Eine Schleife fragt “was ist bereit?”, ruft den passenden Handler auf, und der Handler gibt nach kurzer Arbeit die Kontrolle zurück. Er skaliert, weil Warten nichts kostet: 10.000 Verbindungen, von denen 50 gerade etwas wollen, sind für den Loop 50 kleine Aufgaben. Genau darauf bauen Node.js, nginx und asyncio auf.
Der Preis: Es gibt nur einen Thread, der rechnet. Ein Handler, der blockiert (synchrones Lesen, eine schwere Berechnung, ein riesiges JSON.parse, ein Regex mit Backtracking), hält alle anderen Verbindungen an. Einen echten Event Loop gibt es im Browser nicht, darum ein Simulator:
- Jede Anfrage ist ein Generator, der Befehle liefert (
yield). Jeder Befehl hat eine Art und eine Dauer in Ticks. ("io", n): Die Anfrage wartet n Ticks auf Netzwerk oder Platte. Der Loop ist frei und bedient solange andere (das ist das non-blocking-Warten).("cpu", n): Die Anfrage rechnet n Ticks im Loop. Solange kommt niemand sonst dran.("worker", n): Die Anfrage rechnet n Ticks in einem Worker (anderer Thread oder Prozess, in Nodeworker_threads). Der Loop ist frei. Der Simulator hat beliebig viele Worker.
So liest du den Simulator: Pro Tick schiebt der Loop jede Anfrage, die bereit ist, um einen Befehl weiter. Ein cpu-Befehl belegt den Loop (frei_ab), bis seine Ticks um sind, und in dieser Zeit rührt sich niemand. Ein io- oder worker-Befehl setzt nur die Anfrage selbst für n Ticks auf Pause. Die Latenz einer Anfrage ist der Tick ihres Endes minus ihre Ankunft.
Ein Handler für einen “Report-Endpunkt”, der eine schwere Berechnung im Loop macht, und 20 schnelle Anfragen, eine alle 3 Ticks. Der Report kommt bei Tick 5, holt 2 Ticks Daten und rechnet ab Tick 7 dann 30 Ticks im Loop, also bis Tick 37. In dieser Zeit kommt keine andere Anfrage dran:
Ausgabe:
Latenz der schnellen Anfragen: [3, 3, 32, 31, 28, 25, 22, 19, 16, 13, 10, 7, 4, 3, 3, 3, 3, 3, 3, 3]
Latenz des Reports: 33
Eine schnelle Anfrage braucht normalerweise 3 Ticks (2 plus 1). Die Anfragen 2 bis 12 brauchen bis zu 32 statt 3 Ticks, nur weil der Report 30 Ticks im Loop rechnet, obwohl sie selbst nichts Schweres tun. Je früher sie im Block ankamen, desto länger warteten sie (32, 31, 28, …). Das ist ein Tail-Latency-Ausreißer, ausgelöst durch eine Anfrage. Die Reparatur lautet: Die Rechenarbeit verlässt den Loop (worker). Der Report selbst wird dadurch nicht schneller (er braucht weiter 33 Ticks), aber er blockiert niemanden mehr.
Schritt 3: Container von innen
Ein Container ist kein kleiner Computer. Es ist ein normaler Prozess (oder eine Gruppe davon) auf dem Host-Kernel, dem der Kernel zwei Dinge beigebracht hat:
- Namespaces begrenzen, was der Prozess sieht. Jeder Namespace-Typ verbirgt einen Teil der Welt: PID (der Prozess sieht nur seine eigenen Prozesse und ist darin PID 1), Netzwerk (eigene Interfaces und Ports), Mount (eigenes Dateisystem), UTS (eigener Hostname), IPC und User (eigene Nutzer-IDs).
- cgroups (control groups) begrenzen, wie viel der Prozess verbraucht: CPU-Zeit, Arbeitsspeicher, I/O-Bandbreite, Anzahl Prozesse. Überschreitet ein Prozess sein Speicherlimit, beendet der Kernel ihn mit
SIGKILL(Exit-Code 137, siehe Schritt 1). Ohne CPU-Limit kann ein Container so viel Rechenzeit ziehen, wie der Host hergibt.
Merkhilfe: Namespaces = Sicht, cgroups = Menge.
Image-Layer. Ein Container-Image ist ein Stapel schreibgeschützter Schichten (layers): Basissystem, dann Abhängigkeiten, dann dein Code. Beim Start kommt eine dünne schreibbare Schicht oben drauf, die nur diesem Container gehört (ein Union-Dateisystem wie OverlayFS, bitte prüfen). Zehn Container vom selben Image teilen sich die schreibgeschützten Schichten, nur Änderungen landen in den zehn dünnen Schichten. Darum startet ein Container in Sekundenbruchteilen und braucht wenig Platz. Die Schichten sind auch der Grund, warum im Build die selten geänderten Dinge (Abhängigkeiten) vor den oft geänderten (dein Code) kommen: Ändert sich eine Schicht, werden alle darüber neu gebaut.
| Container | VM | |
|---|---|---|
| Kernel | gemeinsam mit dem Host | eigener Kernel pro VM |
| Start | Sekundenbruchteile (ein Prozess) | meist deutlich länger (ein ganzes Betriebssystem) |
| Overhead | gering | höher (eigener Kernel und Hardware-Emulation) |
| Isolation | schwächer, gemeinsame Angriffsfläche Kernel | stärker |
| Passt für | eigene Dienste, viele kleine Einheiten | fremder, nicht vertrauenswürdiger Code, harte Mandantentrennung |
Was ein Container nicht isoliert: den Kernel selbst. Eine Sicherheitslücke im Kernel, die ein Prozess im Container ausnutzt, trifft den Host und damit alle anderen Container. Alle teilen auch die Kernel-Version und die Hardware. Mit seccomp (ein Filter, der nur bestimmte Syscalls erlaubt) verkleinerst du die Angriffsfläche, ersetzt aber keinen eigenen Kernel. Und ohne cgroup-Limits stört ein Container seine Nachbarn (noisy neighbor).
Schritt 4: Performance-Denken
Latenz und Durchsatz. Latenz (latency) ist die Dauer einer Anfrage, in Millisekunden. Durchsatz (throughput) ist die Zahl der Anfragen pro Zeit. Beides hängt zusammen, ist aber nicht dasselbe: Mehrere Anfragen zu einem Paket zu bündeln (Batching) erhöht oft den Durchsatz und die Latenz der einzelnen Anfrage zugleich.
Der Durchschnitt lügt. 100 Anfragen: 98 dauern 20 ms, 2 dauern 2000 ms (z. B. ein Seitenfehler oder ein blockierter Event Loop). Der Mittelwert ist 59,6 ms, der Median (die mittlere Anfrage) 20 ms, der Wert, den 99 % der Anfragen unterbieten, ist 2000 ms (Nearest-Rank, siehe unten). Weder 59,6 noch 20 beschreibt irgendeine echte Anfrage. Die zwei Ausreißer sind bei 2 % der Nutzer die ganze Erfahrung.
Perzentile. Das p99 (99. Perzentil, percentile) ist der Wert, den 99 % der Messungen nicht überschreiten. p50 ist der Median, p95 und p99 beschreiben den “Schwanz” (tail) der Verteilung. Es gibt mehrere Berechnungsverfahren. Wir legen das einfachste fest, das Nearest-Rank-Verfahren (Rang-Verfahren):
- Sortiere die n Messungen aufsteigend.
- Der Rang ist
p * n / 100, aufgerundet, mindestens 1. Das Ergebnis ist der Wert an diesem Rang (der Rang zählt ab 1).
Beispiel mit 10 Messwerten in ms: [12, 15, 11, 40, 13, 14, 12, 95, 16, 13]. Sortiert: [11, 12, 12, 13, 13, 14, 15, 16, 40, 95], der Mittelwert ist 24,1. Die Ränge, ausgeführt:
| p | p mal n durch 100 | Rang | Wert |
|---|---|---|---|
| 0 | 0 | 1 (Festlegung: mindestens 1) | 11 |
| 50 | 5 | 5 | 13 |
| 90 | 9 | 9 | 40 |
| 99 | 9,9 | 10 | 95 |
| 100 | 10 | 10 | 95 |
Andere Verfahren (z. B. lineare Interpolation) liefern bei kleinen Listen andere Werte: Für [10, 20, 30, 40] ergibt statistics.median 25,0, Nearest-Rank für p50 aber 20. Bei vielen tausend Messungen verschwindet der Unterschied praktisch. Wichtig ist, dass dein Team ein Verfahren festlegt.
Falle beim Aufrunden. Aufrunden mit Fließkommazahlen kann knapp danebenliegen: 28 / 100 * 25 ist mathematisch genau 7, in Python aber 7.000000000000001, und math.ceil macht daraus 8. Das ist ein Rang zu viel. Rechne darum mit Ganzzahlen. Aufrunden von a / b geht ganzzahlig als -(-a // b) (aus 7 durch 2 wird 4, aus 8 durch 2 bleibt 4).
Wie entsteht Tail Latency? Aus drei Quellen, die sich verstärken:
- Einzelne langsame Ereignisse (allgemeines Fachwissen): Seitenfehler, Garbage-Collection-Pausen, ein blockierter Event Loop (Schritt 2), ein verlorenes Netzwerkpaket, ein Nachbar ohne cgroup-Limit, eine kalte Cache-Zeile, ein Retry.
- Warteschlangen (queueing): siehe unten. Die Wartezeit steigt nicht linear mit der Auslastung.
- Fan-out: Eine Anfrage ruft viele Dienste parallel auf und wartet auf den langsamsten.
Fan-out. Angenommen, jede Teilanfrage ist mit Wahrscheinlichkeit p langsam, unabhängig von den anderen. Die Wahrscheinlichkeit, dass alle N schnell sind, ist (1 - p) ** N. Dass mindestens eine langsam ist, ist das Gegenteil: 1 - (1 - p) ** N. Mit p = 0,01 (genau die 1 % Anfragen oberhalb des p99 jedes Dienstes):
Ausgabe:
1 parallele Teilanfragen: 1.0 % der Seiten sind langsam
10 parallele Teilanfragen: 9.6 % der Seiten sind langsam
100 parallele Teilanfragen: 63.4 % der Seiten sind langsam
500 parallele Teilanfragen: 99.3 % der Seiten sind langsam
Das ist der Kern der Prüfstein-Antwort: Bei 100 Teilanfragen ist das p99 jedes einzelnen Dienstes die typische Erfahrung der Nutzer, nicht mehr der seltene Ausreißer. Wer nur den Durchschnitt oder den Median der Dienste beobachtet, sieht das nie.
Queueing. Ein Server bearbeitet eine Anfrage nach der anderen, jede braucht eine feste Dienstzeit (service time, hier 10 ms). Die Anfragen kommen zufällig, im Mittel alle dienstzeit / auslastung ms (bei 80 % Auslastung also alle 12,5 ms). Die Wartezeit ist die Zeit in der Schlange, ohne die Bearbeitung. Die Antwortzeit ist Wartezeit plus Dienstzeit. Die Rekursion von Lindley rechnet sie aus: Die Wartezeit der nächsten Anfrage ist die Wartezeit der aktuellen plus deren Dienstzeit minus die Pause bis zur Ankunft der nächsten, aber nie unter 0.
Ausgabe:
Auslastung 0.50: mittlere Wartezeit 5.0 ms
Auslastung 0.70: mittlere Wartezeit 11.7 ms
Auslastung 0.80: mittlere Wartezeit 20.0 ms
Auslastung 0.90: mittlere Wartezeit 44.4 ms
Auslastung 0.95: mittlere Wartezeit 93.9 ms
Lies die Zahlen nach: Von 50 % auf 80 % Auslastung (1,6-fache Last) wächst die Wartezeit auf das Vierfache. Von 80 % auf 95 % (nur 1,19-fache Last) wächst sie nochmal auf fast das Fünffache (20,0 auf 93,9 ms). Die Kurve ist ein Hockeyschläger. Der Grund: Bei hoher Auslastung räumt der Server eine Schlange nur langsam ab, weil im Mittel kaum freie Zeit übrig bleibt, und jede zufällige Häufung von Ankünften baut einen Stau auf, der lange nachwirkt. Gemessen in derselben Simulation ist das p99 der Wartezeit bei 95 % Auslastung 422,4 ms (Nearest-Rank), bei 50 % 33,5 ms. Der Schwanz wächst noch schneller als der Mittelwert. Die Lehre für den Betrieb: Fahre Dienste nicht an der Kapazitätsgrenze, und plane Reserve ein (die oft genannte Faustregel von etwa 70 % ist Fachwissen, bitte prüfen).
Profiling und Flame Graphs. Bevor du optimierst, miss, wo die Zeit hingeht. Ein Profiler (in Python z. B. cProfile) zeichnet auf, in welchen Funktionen wie viel Zeit verbraucht wird. Ein Flame Graph zeigt das als gestapelte Balken: Unten steht der Einstiegspunkt, darüber, was er aufruft. Die Breite eines Balkens ist der Anteil an der Gesamtzeit (nicht der zeitliche Verlauf). Du suchst breite Balken oben im Stapel: Dort wird tatsächlich gerechnet oder gewartet. Ein schmaler Balken lohnt sich nicht zu optimieren, auch wenn er schlecht aussieht.
Falle
- Durchschnittliche Latenz als Ziel. Ein Mittelwert versteckt den Schwanz. Setze SLOs auf Perzentile (siehe konzepte/09b).
- Perzentile mitteln. Das p99 von drei Instanzen gemittelt ist nicht das p99 des Gesamtsystems. Dafür braucht man die Rohdaten oder Histogramme, die man zusammenführen kann (allgemeines Fachwissen).
- Etwas Schweres in den Event Loop legen. Synchrones Dateilesen,
JSON.parsegroßer Bodies, Bildverarbeitung, Hashing und Regex mit Backtracking blockieren alle Verbindungen. Auslagern an Worker, oder Streaming statt Komplettlesen. - Container für eine kleine VM halten. Ein Container teilt den Kernel. Für fremden, nicht vertrauenswürdigen Code reicht er allein nicht.
- Container ohne Limits. Ohne cgroup-Limits für CPU und Speicher reißt ein Container seine Nachbarn mit. Mit Speicherlimit und Leck wird er ohne Vorwarnung mit
SIGKILLbeendet. - Auf 95 % Auslastung planen. Die Rechnung oben zeigt, warum das keine Reserve ist.
Übungen
Übung 1: Perzentil selbst schreiben (ca. 10 Min.)
Schreibe perzentil(werte, p) nach dem Nearest-Rank-Verfahren aus Schritt 4. Regeln:
pist eine ganze Zahl von 0 bis 100. Rang =p * n / 100, aufgerundet, mindestens 1, der Wert an diesem Rang der sortierten Liste (Rang zählt ab 1).p = 0liefert den kleinsten Wert,p = 100den größten.- Eine leere Liste oder ein
paußerhalb von 0 bis 100 löstValueErroraus. - Die übergebene Liste darf nicht verändert werden.
Beispiele: perzentil([5, 1, 3], 50) ist 3, perzentil([5, 1, 3], 100) ist 5, perzentil([7], 99) ist 7.
Erst prüfen, dann sortieren (auf einer Kopie), dann den Rang bestimmen. Wie rundest du p * n / 100 auf, ohne Fließkommazahlen zu benutzen, und wie wird aus dem Rang ein Index in einer Python-Liste?
def perzentil(werte, p):
if not werte or not 0 <= p <= 100:
raise ValueError("leere Liste oder p außerhalb von 0 bis 100")
s = sorted(werte) # Kopie, die Eingabe bleibt unverändert
rang = max(1, -(-p * len(s) // 100)) # aufgerundet, mindestens 1
return s[rang - 1] # Rang zählt ab 1
perzentilGanzzahlig aufgerundet wird mit -(-a // b). sorted erzeugt eine neue Liste, werte.sort() würde die Eingabe verändern. Der Rang zählt ab 1, der Listenindex ab 0.
Übung 2: Tail-Latency-Fan-out vorhersagen (ca. 6 Min.)
Du hast in Schritt 4 1 - (1 - p) ** N für p = 0,01 gesehen. Hier drei neue Fälle. Trage ein Tupel (a, b, c) aus ganzen Zahlen ein. Prozentwerte rundest du mit round() auf ganze Prozent. Du darfst in der Zelle rechnen.
a: Eine Produktseite ruft 25 Dienste parallel auf. Jeder ist mit Wahrscheinlichkeit 0,02 langsam. Wie viel Prozent der Seiten haben mindestens eine langsame Teilanfrage?b: Eine Suche fragt 100 Shards parallel ab. Jeder ist mit Wahrscheinlichkeit 0,002 langsam. Wie viel Prozent der Suchen sind betroffen?c: Mit p = 0,005: Bei wie vielen parallelen Teilanfragen N ist es zum ersten Mal mindestens so wahrscheinlich wie ein Münzwurf (mindestens 50 %), dass mindestens eine langsam ist? Gesucht ist das kleinste solche N.
Berechne zuerst die Wahrscheinlichkeit, dass keine Teilanfrage langsam ist, und leite daraus das Gegenteil ab. Für Teil c: Wie findest du das kleinste N, ohne es zu raten? Zähle es hoch.
a = round(100 * (1 - (1 - 0.02) ** 25))
b = round(100 * (1 - (1 - 0.002) ** 100))
n = 1
while 1 - (1 - 0.005) ** n < 0.5:
n += 1
antwort = (a, b, n)
antwortDie Lösung ist (40, 18, 139). Teil a: 1 - 0.98 ** 25 ist etwa 0,397, also 40 %. Teil b: 1 - 0.998 ** 100 ist etwa 0,181, also 18 %. Teil c: Bei 138 Teilanfragen liegt die Wahrscheinlichkeit noch knapp unter 50 %, bei 139 knapp darüber. Selbst ein Dienst, der nur in 0,5 % der Fälle langsam ist, erzeugt bei 139 parallelen Aufrufen mehr langsame als schnelle Gesamtanfragen.
Übung 3: Queueing vorhersagen (ca. 8 Min.)
Die Simulation warteschlange(auslastung, dienstzeit=10.0) aus Schritt 4 ist geladen und liefert die Liste der Wartezeiten. Zwei Teile, trage ein Tupel (Buchstabe, Auslastung) ein.
Teil 1. Ein Server hat eine Dienstzeit von 10 ms und bekommt 80 Anfragen pro Sekunde (Auslastung 0,8). Das Team kauft einen Server, der doppelt so schnell ist (Dienstzeit 5 ms). Die Anfragerate bleibt gleich. Was passiert mit der mittleren Wartezeit (ohne Bearbeitungszeit)?
- A Sie sinkt auf unter ein Achtel, weil bei niedriger Auslastung kaum eine Schlange entsteht.
- B Sie halbiert sich, weil jede einzelne Bearbeitung nur noch halb so lange dauert.
- C Sie sinkt um etwa ein Viertel, weil die Wartezeit nur einen Teil der gesamten Antwortzeit ausmacht.
- D Sie bleibt gleich, weil pro Sekunde dieselbe Zahl von Anfragen ankommt.
Teil 2. Bei welcher dieser Auslastungen erreicht die mittlere Wartezeit (Dienstzeit 10 ms) zum ersten Mal mindestens 50 ms? Zur Auswahl stehen 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95.
Teil 1: Welche Auslastung hast du vorher und welche nachher? Ein Server mit halber Dienstzeit bei gleicher Rate hat die halbe Auslastung. Schau in die Tabelle aus Schritt 4, wie stark die Wartezeit von der Auslastung abhängt. Du kannst beide Fälle mit warteschlange auch selbst messen. Teil 2: Mittelwert aus der Liste bilden und die Kandidaten der Reihe nach probieren.
antwort = ("A", 0.95)
antwortTeil 1, A. Mit der halben Dienstzeit sinkt die Auslastung von 0,8 auf 0,4. In der Simulation fällt die mittlere Wartezeit von etwa 20 ms auf etwa 1,7 ms, also um mehr als den Faktor 8. Die Wartezeit hängt nicht linear von der Dienstzeit ab, sondern vor allem von der Auslastung. B unterschätzt den Effekt, C verwechselt Wartezeit und Antwortzeit, D übersieht, dass der Server die Anfragen schneller abräumt.
Teil 2, 0.95. Bei 0,9 liegt die mittlere Wartezeit bei etwa 44 ms, bei 0,95 bei etwa 94 ms.
Übung 4: Den Event Loop reparieren (ca. 6 Min.)
Ein Bilder-Dienst läuft auf einem Event Loop (der Simulator aus Schritt 2 ist geladen). Jede Anfrage liest zuerst den Upload (I/O, 1 Tick) und speichert am Ende das Ergebnis (I/O, 2 Ticks). Für die Art "bild" kommen dazwischen zwei Rechenschritte: dekodieren (10 Ticks) und ein Thumbnail berechnen (15 Ticks). Der Handler rechnet bisher im Loop. Dadurch verlieren schnelle Anfragen (Art "schnell", nur I/O) viel Zeit, sobald ein Bild kommt.
Repariere den Handler so, dass:
- die Rechenarbeit der Bilder (insgesamt mindestens 25 Ticks) nicht mehr im Loop läuft,
- die schnellen Anfragen unverändert bleiben (Befehle
("io", 1)und("io", 2)) und höchstens 3 Ticks Latenz haben, - die Bild-Anfragen weiterhin den Upload lesen, rechnen und speichern (ihre Latenz bleibt bei 28 Ticks, sie werden durch die Auslagerung nicht schneller).
Rechenarbeit gehört in den Worker, nicht in io: Auch wenn die Simulation beides gleich behandelt, ist io in der Realität Warten und worker Rechnen.
Welche der drei Befehlsarten lässt den Loop frei und rechnet trotzdem? Beachte, dass beide Rechenschritte der Bilder den Loop blockieren, nicht nur einer.
def handler(req):
yield ("io", 1)
if req["art"] == "bild":
yield ("worker", 10) # dekodieren im Worker
yield ("worker", 15) # Thumbnail im Worker
yield ("io", 2)
handlerBeide Rechenschritte wandern in den Worker. Der Loop wartet während dieser 25 Ticks nicht, sondern bedient die schnellen Anfragen. Die Bild-Anfragen selbst brauchen weiter 1 + 25 + 2 = 28 Ticks, weil die Arbeit nicht verschwindet, sondern nur woanders läuft.
Übung 5: Container-Isolation als Trade-off (ca. 6 Min.)
Zwei unabhängige Fälle. Trage ein Tupel mit zwei Buchstaben ein, z. B. ("A", "B").
Fall 1. Ein Anbieter lässt Kunden eigenen, nicht vertrauenswürdigen Code ausführen, mehrere Kunden pro physischem Server. Eine Kernel-Sicherheitslücke, die ein Kunde ausnutzt, darf nicht auf andere Kunden durchschlagen. Was ist die direkteste Antwort auf diese Randbedingung?
- A Jeder Kunde bekommt einen Container mit allen verfügbaren Namespaces, damit ein Kunde die Prozesse und Netzwerke der anderen nicht einmal sieht.
- B Jeder Kunde bekommt eine eigene VM mit eigenem Kernel, damit eine Kernel-Lücke an der Grenze der VM endet.
- C Jeder Kunde bekommt einen Container mit strengen cgroup-Limits, damit ein Angreifer nicht genug Ressourcen zum Ausbruch hat.
- D Jeder Kunde bekommt einen Container und eine Firewall am Host, damit Netzwerkregeln Kernel-Lücken blockieren.
Fall 2. Auf einem Host laufen die Container X und Y. Dank Namespaces sieht jeder nur seine eigenen Prozesse und sein eigenes Netzwerk-Interface. Trotzdem rechnet ein Job in X so, dass fast alle CPU-Kerne ausgelastet sind, und Y wird träge. Was fehlt?
- A Ein zusätzlicher Namespace für die CPU, damit X die Kerne von Y nicht mehr sehen kann.
- B Ein seccomp-Profil für X, damit gefährliche Systemaufrufe verboten sind und so Rechenzeit gespart wird.
- C Eine eigene VM für Y, denn nur ein eigener Kernel bekommt überhaupt Rechenzeit zugeteilt.
- D Ein CPU-Limit per cgroup für X, damit der Kernel ihm nur einen Anteil der Rechenzeit gibt.
Fall 1: Was teilen sich alle Container eines Hosts, und welche Option bringt etwas Eigenes an dieser Stelle? Fall 2: Welcher Mechanismus bestimmt, was ein Prozess sieht, und welcher, wie viel er verbraucht?
antwort = ("B", "D")
antwortFall 1, B (VM). Alle Container eines Hosts teilen einen Kernel. Eine Lücke im Kernel trifft sie alle. Nur eine VM bringt einen eigenen Kernel mit und trennt so die Angriffsfläche. Namespaces (A) verbergen nur die Sicht, cgroup-Limits (C) begrenzen nur die Menge, und eine Host-Firewall (D) filtert Netzwerkverkehr, nicht Fehler im Kernel.
Fall 2, D (cgroup). Namespaces bestimmen die Sicht, cgroups die Menge. Ohne CPU-Limit darf X so viel Rechenzeit ziehen, wie der Host hergibt. Einen CPU-Namespace gibt es hier nicht als Lösung (A), seccomp (B) filtert Systemaufrufe und begrenzt keine Rechenzeit, und eine VM für Y (C) ist eine schwere Lösung für ein Problem, das ein Limit löst.
Merksatz
Der Durchschnitt lügt, der Schwanz (p99) ist die Erfahrung deiner Nutzer: Er entsteht aus Warteschlangen, blockierten Event Loops und Fan-out, und ein Container ist nur ein Prozess auf einem gemeinsamen Kernel, dem Namespaces die Sicht und cgroups die Menge begrenzen.
Prüfstein
- Warum ist p99 oft wichtiger als der Durchschnitt, und wie entsteht Tail Latency?
- Was macht ein Container auf Kernel-Ebene, und was isoliert er nicht?
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “4. Betriebssystem und Netzwerk” (OS: Prozesse, Dateisystem, Berechtigungen, Umgebungsvariablen, stdin/stdout, Signale); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 4 “Betriebssystem, Netzwerk und Performance” (Kernel-Grundlagen, I/O-Modelle, Container von innen, Performance-Denken; die beiden Prüfsteine zu p99 und Container).
Über die Quelle hinaus (allgemeines Fachwissen): die Erklärungen zu Syscalls, Seitenfehlern, Context Switch und Dateideskriptoren, Signalnummern und der Exit-Code-Konvention 128 plus Signal, die Berechtigungslogik (Owner vor Group vor Other, Root-Sonderfall), die Beschreibung von epoll, kqueue und io_uring, die Tabelle VM gegen Container, das Union-Dateisystem für Image-Layer, seccomp, die Beschreibung von Flame Graphs, die Gründe für Tail Latency, die Faustregel zur Auslastung (bitte prüfen), die Beschreibung des Nearest-Rank-Verfahrens und der Lindley-Rekursion und die Aussage, dass Perzentile mehrerer Instanzen nicht gemittelt werden dürfen. Alle Zahlen im Text stammen aus dem Ausführen des Codes dieser Lektion (Simulationen mit festem Seed), nicht aus Messungen realer Systeme. Die Seitengröße von 4 KiB ist architekturabhängig (bitte prüfen).