Kein Restart, kein Disconnect: Kubernetes-Pods live migrieren mit Paguro

Inhalt

Worum geht’s

Wer Kubernetes betreibt, kennt den Moment: Ein Node muss weg, etwa weil eine Spot-Instanz reclaimed wird oder ein Kernel-Patch ansteht. Kubernetes beendet dann den Pod und startet ihn woanders neu. Für einen Gameserver mit fünfzig Spielern heißt das: Alle fliegen raus.

Paguro ändert das. Es migriert laufende Pods live auf einen anderen Node, mit Memory, Volumes und sogar offenen TCP- und UDP-Connections. Die Anwendung steht für einen Augenblick still und läuft dann einfach weiter.

In einfachen Worten

Stell dir ein volles Restaurant vor, das in ein anderes Gebäude umziehen muss. Kubernetes setzt alle Gäste vor die Tür, eröffnet woanders neu und hofft, dass sie wiederkommen. Paguro zieht das Restaurant samt Gästen um: Jeder sitzt am selben Tisch, das Essen steht noch da, das Gespräch geht weiter. Für einen Wimpernschlag flackert das Licht. Mehr merkt niemand.

0,6 sFreeze eines Gameservers mit IP-Erhalt auf Cilium
0Disconnects: Minecraft mit acht Bots auf fünf Clustern
55 → 0,6 sFreeze-Optimierung beim Umzug der Pod-IP
0,1 sRestore von 1 GiB Memory dank Lazy Pages

Erst die Demos

Erst beim Abspielen wird das Video von YouTube geladen (Google). Datenschutz · Auf YouTube ansehen

Amazon EKS, acht Bots spielen weiter: 0,4 s Freeze, 0 Disconnects, dieselbe TCP-Connection wie vorher.

Erst beim Abspielen wird das Video von YouTube geladen (Google). Datenschutz · Auf YouTube ansehen

Ein Arena-Shooter auf Amazon EKS: 0,3 s Freeze, das Match läuft einfach weiter, weil der Serverprozess samt Memory umzieht.

So einfach wird migriert:

# einen einzelnen Pod gezielt migrieren
kubectl paguro migrate <pod> -n <namespace> --to <node> --wait

# oder den Node drainen: migrierbare Pods werden migriert statt neu gestartet
kubectl drain <node> --ignore-daemonsets

Wie Paguro funktioniert

Der Trick: Der Pod auf dem Target-Node ist ein ganz normaler Pod. Ein 2,7 MB kleiner Wrapper um runc macht aus seinem runc create ein runc restore. kubelet und containerd merken davon nichts, am Runtime-Stack des Nodes ändert sich nichts.

Ablauf einer Live-Migration: Pre-Copy, während die Anwendung weiterläuft, dann ein kurzer Freeze mit finalem Dump, Commit und Restore auf dem Target-Node
Ablauf einer Live-Migration. Für die Anwendung spürbar ist nur der orange Freeze.
  1. Pre-Copy: Die Anwendung läuft weiter, während das Memory in Runden kopiert wird, ab der zweiten Runde nur noch die geänderten Pages.
  2. Freeze und Commit: Der Container wird pausiert, der letzte Rest übertragen. Bis zum Commit endet jeder Fehler in einem Rollback.
  3. Restore: CRIU startet den Prozess auf dem Target, fehlende Pages kommen per userfaultfd nach.

Die fünf härtesten Probleme

Die Idee ist einfach, die Umsetzung nicht. An diesen fünf Problemen scheitert Live-Migration normalerweise.

1. Ein einziges Paket kann alles zerstören

Trifft eine TCP-Retransmission den Target-Pod, bevor sein Socket wiederhergestellt ist, antwortet der Kernel mit einem RST, und ein einziger RST beendet die Connection endgültig. Paguros RST-Shield setzt deshalb in jeden neuen Network Namespace eine nftables-Drop-Rule, noch bevor die CNI läuft. Selbst ein Race von wenigen Millisekunden beim Anlegen der Namespaces ist abgefangen: 0 Fehler bei 500 Versuchen.

2. Die IP mitnehmen: von 55 auf 0,6 Sekunden

Balkendiagramm: Der Freeze mit IP-Erhalt auf Cilium sinkt über acht Optimierungsschritte von 55,2 Sekunden auf 0,55 bis 0,73 Sekunden
Freeze pro Migration mit IP-Erhalt auf Cilium, Schritt für Schritt.

Hinter jedem Balken steckt ein Mechanismus aus Cilium oder kubelet, den ich erst im Quellcode gefunden habe. Ein Beispiel: kubelet versucht eine fehlgeschlagene Sandbox nur einmal pro Sekunde erneut. Meine erste Idee war, den Freeze genau auf diesen Takt abzustimmen. Klang logisch, war im A/B-Test aber langsamer: 1.731 statt 1.510 ms. Also flog sie raus. Funktioniert hat ein anderer Weg: Paguro bindet den Ersatz-Pod erst im exakt richtigen Moment an den Node, denn der allererste Sync eines Pods wartet nicht auf diesen Takt. So lief das ganze Projekt: Jede Idee wird gemessen, und was die Zahlen nicht besser macht, kommt nicht rein.

Warum jede Millisekunde zählt: TCP wiederholt verlorene Pakete nach etwa 0,2, 0,6, 1,4 und 3,0 s. Ein Freeze von 1,12 s kostete den Client deshalb 1,45 s, einer von 1,60 s schon 3,16 s.

3. Speicher, der sich schneller ändert, als man ihn kopieren kann

Pre-Copy kopiert das Memory, während die Anwendung weiterschreibt. Bei einem Pod mit 8 GiB schrumpften die Runden von 8.194 MiB auf 4 MiB, der Freeze lag bei 1,59 s für 8 GiB. Beim Restore war CRIU der Engpass: 1,87 s für 1 GiB. Mit einem 13-Zeilen-Patch für CRIU und Lazy Pages sind es jetzt 104–146 ms.

4. CPUs, die nicht gleich sind

Ein Python-Prozess, auf einer neueren CPU gestartet und auf eine ältere migriert, crashte direkt nach dem Restore: glibc hatte beim Start Instruktionen gewählt, die es auf der älteren CPU nicht gibt. Paguro gibt jedem migrierbaren Pod deshalb eine CPU-Baseline, auf die glibc, Go, die JVM und Co. von Anfang an begrenzt sind. Seitdem migriert derselbe Prozess in 326 ms, in beide Richtungen.

5. Phantom: neue Adresse, gleiche Verbindung

Normalerweise nimmt ein Pod bei Paguro seine IP-Adresse mit, so wie man beim Umzug seine Telefonnummer behält. Das hat zwei Haken: Die Übergabe der Adresse kostet Zeit, und zwar genau im Freeze. Und viele Netzwerke, etwa Flannel, Antrea oder das AWS VPC CNI, können Adressen gar nicht zwischen Nodes verschieben.

Phantom löst beides. Der Pod bekommt am neuen Ort eine neue Adresse, und Paguro sorgt dafür, dass seine Gesprächspartner davon nichts merken. Das funktioniert wie ein Nachsendeauftrag bei der Post, nur in Echtzeit und in beide Richtungen: Wer weiter an die alte Adresse schreibt, erreicht den Pod an der neuen, und seine Antworten tragen weiter den alten Absender. Die alte Adresse gehört niemandem mehr, sie lebt nur noch als Phantom weiter. Daher der Name.

Phantom: Ein Client schreibt weiter an die alte IP 10.0.1.5, die niemandem mehr gehört. eBPF schreibt jedes Paket auf die neue IP 10.0.2.9 des migrierten Pods um, die Antworten kommen mit 10.0.1.5 als Absender zurück.
Phantom: Laufende Verbindungen sprechen weiter mit der alten Adresse, eBPF leitet sie unsichtbar zur neuen um.

Technisch übernehmen das kleine eBPF-Programme im Network Namespace des Pods. Sie schreiben jedes Paket einer laufenden Connection zwischen alter und neuer IP um, exakt pro Verbindung, während neue Verbindungen direkt an die neue Adresse gehen. Weil der neue Pod schon vor dem Freeze komplett bereitsteht, sinkt der Freeze auf 0,3–0,9 s. Damit funktioniert Live-Migration auch auf Netzwerken, die IPs gar nicht verschieben können.

Vier CNIs, ein Ergebnis

Jede CNI hatte ihre eigene Falle, von Ciliums eBPF-Load-Balancer bis zum Open vSwitch von Antrea. Nach den Fixes geht keine einzige Connection mehr verloren, egal ob der Client die Pod-IP, den Service oder einen NodePort von außen nutzt:

CNIVerlorene ConnectionsFreeze
Flannel0338–635 ms
Cilium0339–491 ms
Antrea0354–609 ms
Calico0356–904 ms

Die Königsdisziplin: Gameserver

Gameserver haben kein Failover: Spieler schicken über UDP 30 Inputs pro Sekunde, der Server hält ihre Sessions im Memory. UDP brachte vier eigene Fallen mit, die Paguro alle entschärft. Im Test mit fünf Migrationen direkt hintereinander gingen Inputs nur während des Freeze von 0,6 s verloren, es gab keinen Session-Reset, und alle 625 neu joinenden Spieler kamen rein.

Der Praxistest mit Minecraft Java (TCP) und Minecraft Bedrock (UDP), je acht Bots, die spielen und chatten. Auf allen fünf Clustern verlor kein einziger Spieler die Verbindung:

ClusterFreeze Java (TCP)Freeze Bedrock (UDP)
Cilium (Lab)1,1 / 1,75 s0,77 / 0,85 s
Calico (Lab)1,9–2,7 s1,45–1,57 s
Flannel (Lab)1,3 / 1,5 s0,48 / 0,51 s
Antrea (Lab)1,2 / 4,0 s0,53 / 0,53 s
Amazon EKS0,61 / 0,66 s0,44 / 0,47 s

Storage, Cloud und Chaos

Volumes ohne Datenverlust. Auch die Daten des Pods ziehen mit um. Paguro kopiert sie schon vorab, im Freeze kommt nur noch dazu, was sich zuletzt geändert hat. Im Härtetest zog ein 25-GiB-Volume um, auf das alle 100 ms geschrieben wurde: Kein einziger Eintrag fehlte.

Cloud, Karpenter und Spot. Wird ein Node geleert, etwa durch Karpenter oder eine Spot-Unterbrechung, migriert Paguro die Pods automatisch, statt sie neu zu starten:

DisruptionErgebnis
Drift (neuer Node zuerst, dann Drain)651 ms Freeze, 0 Disconnects
Drift von On-Demand auf Spot549 ms Freeze, 0 Disconnects
NodeClaim gelöscht531 ms Freeze, 0 Disconnects
Spot Interruption Warning0,53–0,70 s Freeze, fertig bevor AWS die Instanz beendet

Chaos-Tests. Ein Testskript killt Controller und Agents mitten in der Migration. Jeder Run endete entweder in einem sauberen Rollback oder in einer erfolgreichen Migration, nie mit einem doppelten oder eingefrorenen Pod. Sogar ein helm upgrade mitten in einer Minecraft-Migration lief ohne Disconnect durch.

Was Paguro heute kann

  • Live-Migration von Memory, Dateien und Volumes
  • IP-Erhalt auf Cilium und Calico, Phantom für alle Netzwerke, die IPs nicht verschieben können (Flannel, Antrea, AWS VPC CNI und weitere)
  • automatische Migration bei kubectl drain, Karpenter, Cluster Autoscaler und Spot-Unterbrechungen
  • Agones-Integration für Gameserver
  • verschlüsselte Übertragung zwischen den Nodes, Installation per Helm-Chart

Key Learnings

  1. Measure, don’t assume. Ich hielt das 1-GbE-Kabel für den Engpass. Ein 10G-Direktlink zeigte: Auch zwei VMs auf demselben Host schaffen nur 5–6 Gbit/s. Der Engpass war die Virtualisierung, nicht das Kabel.
  2. Optimiere auf das, was der User spürt. Nicht der Freeze zählt, sondern wie lange der Client wartet. Deshalb misst Paguro immer auch beim Client.
  3. Mach stille Fehler laut. Seit Go 1.24 öffnet jeder Go-Server MPTCP-Sockets, die CRIU nicht migrieren kann. Paguro erkennt das heute vor dem Freeze und sagt klar, was zu tun ist.

Du betreibst Gameserver, Spot-Flotten oder Jobs, die keinen Restart vertragen? Dann lass uns austauschen: david@picillo.de