Cilium-Geschichte: Wie eine kleine Code-Änderung die Netzwerkstabilität bemerkenswert verbesserte
Einleitung
Vor Kurzem habe ich mir einen PR eines ehemaligen Kollegen zum Cilium-Projekt angesehen.
bpf:nat: Restore ORG NAT entry if it's not found
Die Menge der Änderungen (abgesehen vom Testcode) ist gering und beschränkt sich auf das Hinzufügen eines if-Anweisungsblocks. Dennoch sind die Auswirkungen dieser Änderung enorm, und die Tatsache, dass eine einfache Idee einen immensen Beitrag zur Systemstabilität leisten kann, fand ich persönlich sehr interessant. Daher möchte ich diese Geschichte so erzählen, dass auch Personen ohne Fachkenntnisse im Netzwerkbereich sie leicht verstehen können.
Hintergrundwissen
Wenn es neben dem Smartphone einen weiteren unverzichtbaren Gegenstand für den modernen Menschen gibt, dann ist es wohl der WLAN-Router. Ein WLAN-Router kommuniziert mit Geräten über den WLAN-Kommunikationsstandard und teilt seine öffentliche IP-Adresse, damit sie von mehreren Geräten genutzt werden kann. Die technische Besonderheit, die sich hieraus ergibt, ist die Frage, wie diese „Teilung“ erfolgt.
Die hierbei verwendete Technologie ist Network Address Translation (NAT). NAT ist eine Technologie, die es ermöglicht, externe Kommunikation durchzuführen, indem interne Kommunikation, die aus privater IP:Port besteht, auf eine aktuell nicht verwendete öffentliche IP:Port-Kombination abgebildet wird, basierend auf der Tatsache, dass TCP- oder UDP-Kommunikation aus einer Kombination von IP-Adresse und Portinformationen besteht.
NAT
Wenn ein internes Gerät versucht, auf das externe Internet zuzugreifen, wandelt das NAT-Gerät die private IP-Adresse und Portnummernkombination des Geräts in seine eigene öffentliche IP-Adresse und eine beliebige, nicht verwendete Portnummer um. Diese Übersetzungsinformationen werden in einer NAT-Tabelle im NAT-Gerät gespeichert.
Nehmen wir zum Beispiel an, ein Smartphone im Haus (private IP: a.a.a.a, Port: 50000) versucht, auf einen Webserver (öffentliche IP: c.c.c.c, Port: 80) zuzugreifen.
1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Webserver (c.c.c.c:80)
Wenn der Router die Anfrage des Smartphones empfängt, wird er das folgende TCP-Paket sehen:
1# TCP-Paket, das der Router empfangen hat, Smartphone => Router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Würde dieses Paket unverändert an den Webserver (c.c.c.c) gesendet, würde keine Antwort an das Smartphone (a.a.a.a) mit seiner privaten IP-Adresse zurückkehren. Daher sucht der Router zunächst einen beliebigen Port (z.B. 60000), der derzeit nicht in Gebrauch ist, und speichert ihn in der internen NAT-Tabelle.
1# Interne NAT-Tabelle des Routers
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
Nachdem der Router einen neuen Eintrag in die NAT-Tabelle geschrieben hat, ändert er die Quell-IP-Adresse und Portnummer des vom Smartphone empfangenen TCP-Pakets in seine eigene öffentliche IP-Adresse (b.b.b.b) und die neu zugewiesene Portnummer (60000) und sendet es an den Webserver.
1# TCP-Paket, das der Router gesendet hat, Router => Webserver
2# SNAT ausgeführt
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Nun erkennt der Webserver (c.c.c.c) die Anfrage als von Port 60000 des Routers (b.b.b.b) kommend und sendet das Antwortpaket wie folgt an den Router:
1# TCP-Paket, das der Router empfangen hat, Webserver => Router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Wenn der Router dieses Antwortpaket empfängt, sucht er in der NAT-Tabelle die ursprüngliche private IP-Adresse (a.a.a.a) und Portnummer (50000), die der Ziel-IP-Adresse (b.b.b.b) und Portnummer (60000) entsprechen, und ändert das Ziel des Pakets auf das Smartphone.
1# TCP-Paket, das der Router gesendet hat, Router => Smartphone
2# DNAT ausgeführt
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Durch diesen Prozess hat das Smartphone das Gefühl, direkt mit dem Webserver über eine öffentliche IP-Adresse zu kommunizieren. Dank NAT können mehrere interne Geräte gleichzeitig das Internet über eine einzige öffentliche IP-Adresse nutzen.
Kubernetes
Kubernetes verfügt über eine der anspruchsvollsten und komplexesten Netzwerkstrukturen unter den jüngsten Technologien. Und natürlich wird das oben erwähnte NAT an verschiedenen Stellen eingesetzt. Die folgenden zwei Fälle sind typische Beispiele.
Wenn ein Pod innerhalb des Clusters mit der Außenwelt kommuniziert
Pods innerhalb eines Kubernetes-Clusters erhalten in der Regel private IP-Adressen, die nur innerhalb des Cluster-Netzwerks kommunizieren können. Daher ist NAT für den Traffic, der den Cluster verlässt, erforderlich, wenn ein Pod mit dem externen Internet kommunizieren soll. In diesem Fall wird NAT hauptsächlich auf dem Kubernetes-Node (jedem Server im Cluster) durchgeführt, auf dem der Pod läuft. Wenn ein Pod ein ausgehendes Paket sendet, wird dieses Paket zuerst an den Node übermittelt, zu dem der Pod gehört. Der Node ändert die Quell-IP-Adresse dieses Pakets (die private IP des Pods) in seine eigene öffentliche IP-Adresse und passt auch den Quellport entsprechend an, bevor er es nach außen weiterleitet. Dieser Prozess ähnelt dem zuvor beschriebenen NAT-Prozess beim WLAN-Router.
Nehmen wir zum Beispiel an, ein Pod in einem Kubernetes-Cluster (10.0.1.10, Port: 40000) greift auf einen externen API-Server (203.0.113.45, Port: 443) zu. Der Kubernetes-Node empfängt dann das folgende Paket vom Pod:
1# TCP-Paket, das der Node empfangen hat, Pod => Node
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Der Node zeichnet dann die folgenden Informationen auf:
1# Interne NAT-Tabelle des Nodes (Beispiel)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
Anschließend führt er SNAT durch und sendet das Paket nach außen:
1# TCP-Paket, das der Node gesendet hat, Node => API-Server
2# SNAT ausgeführt
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Danach durchläuft der Prozess denselben Ablauf wie im Smartphone-Router-Beispiel.
Wenn von außerhalb des Clusters über NodePort mit Pods kommuniziert wird
Eine Möglichkeit, Dienste in Kubernetes nach außen verfügbar zu machen, ist die Verwendung von NodePort-Diensten. Ein NodePort-Dienst öffnet einen bestimmten Port (NodePort) auf allen Nodes innerhalb des Clusters und leitet den über diesen Port eingehenden Traffic an die Pods weiter, die zum Dienst gehören. Externe Benutzer können über die IP-Adresse des Nodes im Cluster und den NodePort auf den Dienst zugreifen.
In diesem Fall spielt NAT eine wichtige Rolle, wobei insbesondere DNAT (Destination NAT) und SNAT (Source NAT) gleichzeitig auftreten. Wenn Traffic von außen über den NodePort eines bestimmten Nodes eingeht, muss das Kubernetes-Netzwerk diesen Traffic letztendlich an den Pod weiterleiten, der den Dienst bereitstellt. In diesem Prozess tritt zuerst DNAT auf, wodurch die Ziel-IP-Adresse und Portnummer des Pakets in die IP-Adresse und Portnummer des Pods geändert werden.
Nehmen wir zum Beispiel an, ein externer Benutzer (203.0.113.10, Port: 30000) greift über den NodePort (30001) eines Nodes (192.168.1.5) im Kubernetes-Cluster auf einen Dienst zu. Dieser Dienst verweist intern auf einen Pod mit der IP-Adresse 10.0.2.15 und Port 8080.
1Externer Benutzer (203.0.113.10:30000) ==> Kubernetes Node (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)
Im Fall des Kubernetes-Nodes verfügt dieser sowohl über die von außen erreichbare IP-Adresse 192.168.1.5 als auch über die innerhalb des Kubernetes-Netzwerks gültige IP-Adresse 10.0.1.1. (Je nach verwendetem CNI-Typ variieren die diesbezüglichen Richtlinien, aber in diesem Artikel wird die Erklärung auf Cilium basieren.)
Wenn die Anfrage des externen Benutzers am Node ankommt, muss der Node diese Anfrage an den Pod weiterleiten, der sie bearbeiten soll. Dabei wendet der Node die folgende DNAT-Regel an, um die Ziel-IP-Adresse und Portnummer des Pakets zu ändern:
1# TCP-Paket, das der Node an den Pod senden will
2# Nach Anwendung von DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Wichtig ist hierbei, dass der Pod bei der Beantwortung dieser Anfrage seine eigene IP-Adresse (10.0.2.15) als Quell-IP-Adresse und die IP-Adresse des externen Benutzers (203.0.113.10), der die Anfrage gesendet hat, als Ziel-IP-Adresse verwendet. In diesem Fall würde der externe Benutzer eine Antwort von einer nicht existierenden IP-Adresse erhalten, die er nie angefordert hat, und das Paket einfach verwerfen. Daher führt der Kubernetes-Node zusätzlich SNAT durch, wenn der Pod Antwortpakete nach außen sendet, um die Quell-IP-Adresse des Pakets in die IP-Adresse des Nodes (192.168.1.5 oder die interne Netzwerk-IP 10.0.1.1, in diesem Fall 10.0.1.1) zu ändern.
1# TCP-Paket, das der Node an den Pod senden will
2# Nach Anwendung von DNAT, SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Nachdem der Pod dieses Paket empfangen hat, antwortet er an den Node, der ursprünglich die NodePort-Anfrage erhalten hat. Der Node führt dann den umgekehrten DNAT- und SNAT-Prozess durch, um die Informationen an den externen Benutzer zurückzugeben. Während dieses Prozesses speichert jeder Node die folgenden Informationen:
1# Interne DNAT-Tabelle des Nodes
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Interne SNAT-Tabelle des Nodes
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Hauptteil
Im Allgemeinen werden diese NAT-Prozesse unter Linux über iptables durch das Subsystem conntrack verwaltet und ausgeführt. Tatsächlich nutzen andere CNI-Projekte wie flannel oder calico dies, um die oben genannten Probleme zu lösen. Das Problem ist jedoch, dass Cilium die Technologie eBPF verwendet und diesen traditionellen Linux-Netzwerk-Stack vollständig ignoriert. 🤣

Als Ergebnis hat Cilium den Weg gewählt, nur die im Kubernetes-Kontext benötigten Funktionen, die der bestehende Linux-Netzwerk-Stack in der obigen Abbildung bereitstellte, direkt zu implementieren. Daher verwaltet Cilium den zuvor erwähnten SNAT-Prozess direkt in Form einer LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) als SNAT-Tabelle.
1# Cilium SNAT-Tabelle
2# !Beispiel zur einfachen Erklärung. Die tatsächliche Definition ist: https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip | src port | dst ip | dst port | protocol, conntrack und andere Metadaten
4----------------------------------------------
5| | | | |
Und als Hash-Tabelle existiert ein Schlüssel für eine schnelle Suche, wobei die Kombination aus src ip, src port, dst ip, dst port als Schlüssel verwendet wird.
Problemerkennung
Phänomen – 1: Abfrage
Daraus ergibt sich ein Problem: Ein Paket, das eBPF durchläuft, muss die Hash-Tabelle abfragen, um zu überprüfen, ob es für den NAT-Prozess SNAT oder DNAT ausführen muss. Wie bereits erwähnt, gibt es zwei Arten von Paketen im SNAT-Prozess: 1. Pakete, die von innen nach außen gehen, und 2. Pakete, die als Antwort von außen nach innen kommen. Diese beiden Pakete erfordern eine Transformation im NAT-Prozess und zeichnen sich dadurch aus, dass die src ip, port und dst ip, port Werte vertauscht werden.
Für eine schnelle Abfrage ist es daher erforderlich, entweder einen weiteren Wert in die Hash-Tabelle als Schlüssel mit den vertauschten src- und dst-Werten einzufügen oder alle Pakete, auch solche, die nichts mit SNAT zu tun haben, zweimal in derselben Hash-Tabelle abzufragen. Natürlich hat Cilium für eine bessere Leistung den Ansatz gewählt, dieselben Daten zweimal unter dem Namen RevSNAT einzufügen.
Phänomen – 2: LRU
Unabhängig vom oben genannten Problem gibt es keine unbegrenzten Ressourcen für jede Hardware, und da es sich um eine Hardware-Logik handelt, die eine schnelle Leistung erfordert und keine dynamischen Datenstrukturen verwendet werden können, müssen vorhandene Daten bei Ressourcenknappheit entfernt werden. Cilium löste dies, indem es die LRU Hash Map, eine standardmäßige Datenstruktur, die von Linux bereitgestellt wird, verwendete.
Phänomen 1 + Phänomen 2 = Verbindungsverlust
https://github.com/cilium/cilium/issues/31643
Das bedeutet, dass für eine SNAT-ed TCP- (oder UDP-) Verbindung:
- In einer Hash-Tabelle dieselben Daten zweimal für ausgehende und eingehende Pakete gespeichert sind, und
- Aufgrund der LRU-Logik jederzeit eines der beiden Datensätze verloren gehen kann.
Wenn auch nur ein Eintrag der NAT-Informationen (im Folgenden: Eintrag) für ein ausgehendes oder eingehendes Paket durch LRU entfernt wird, kann die NAT-Ausführung nicht korrekt erfolgen, was zu einem vollständigen Verbindungsverlust führen kann.
Lösung
Hier kommen die bereits erwähnten PRs ins Spiel.
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Bisher versucht ein Paket, wenn es eBPF durchläuft, eine Abfrage in der SNAT-Tabelle, indem es einen Schlüssel aus der Kombination von Quell-IP, Quell-Port, Ziel-IP und Ziel-Port erstellt. Wenn der Schlüssel nicht existiert, werden neue NAT-Informationen gemäß den SNAT-Regeln erstellt und in der Tabelle gespeichert. Bei einer neuen Verbindung würde dies zu einer normalen Kommunikation führen. Wenn jedoch ein Schlüssel unbeabsichtigt durch LRU entfernt wurde, würde eine neue NAT-Ausführung mit einem anderen Port als dem ursprünglich für die Kommunikation verwendeten Port erfolgen, was dazu führen würde, dass der Empfänger des Pakets den Empfang verweigert und die Verbindung mit einem RST-Paket beendet wird.
Der Ansatz, den dieser PR verfolgt, ist einfach:
Wenn ein Paket in irgendeiner Richtung beobachtet wird, aktualisieren wir den Eintrag für die umgekehrte Richtung ebenfalls.
Wird die Kommunikation in irgendeiner Richtung beobachtet, werden beide Einträge neu aktualisiert, wodurch sie von der Eviktionspriorität der LRU-Logik wegbewegen. Dies reduziert die Wahrscheinlichkeit eines Szenarios, bei dem nur ein Eintrag gelöscht wird und die gesamte Kommunikation zusammenbricht.
Dies mag ein sehr einfacher Ansatz und eine einfache Idee erscheinen, aber durch diesen Ansatz konnte das Problem, dass die Verbindung aufgrund des vorzeitigen Ablaufs der NAT-Informationen für Antwortpakete unterbrochen wird, effektiv gelöst und die Systemstabilität erheblich verbessert werden. Es kann auch als eine wichtige Verbesserung bezeichnet werden, die folgende Ergebnisse in Bezug auf die Netzwerkstabilität erzielt hat.

Fazit
Ich betrachte diesen PR als ein hervorragendes Beispiel, das zeigt, wie eine einfache Idee, ausgehend von grundlegendem CS-Wissen darüber, wie NAT funktioniert, selbst in komplexen Systemen eine große Veränderung bewirken kann.
Ah, natürlich habe ich in diesem Artikel keine komplexen Systembeispiele direkt gezeigt. Aber um diesen PR richtig zu verstehen, habe ich DeepSeek V3 0324 fast 3 Stunden lang angefleht, sogar mit dem Wort "Please", und als Ergebnis habe ich Wissen über Cilium +1 und ein Diagramm wie das untenstehende erhalten. 😇
Und während ich die Issues und PRs las, schrieb ich diesen Artikel als eine Art Entschädigung für meine unguten Vorahnungen, dass meine früheren Kreationen möglicherweise Probleme verursacht haben könnten.
Nachbemerkung – 1
Übrigens gibt es eine sehr effektive Methode, dieses Problem zu umgehen. Da die eigentliche Ursache des Problems ein Mangel an NAT-Tabellenspeicherplatz ist, kann man einfach die Größe der NAT-Tabelle erhöhen. :-D
Während jemand anderes in einer ähnlichen Situation das Problem einfach umgangen und die NAT-Tabellengröße erhöht hätte, ohne ein Issue zu hinterlassen, bewundere und respektiere ich die Leidenschaft von gyutaeb, der das Problem gründlich analysiert und verstanden hat und mit objektiven Daten zum Cilium-Ökosystem beigetragen hat, obwohl es sich nicht direkt um ein eigenes Problem handelte.
Das war der Anstoß, diesen Artikel zu schreiben.
Nachbemerkung – 2
Diese Geschichte passt eigentlich nicht direkt zum Thema Gosuda, das sich auf die Go-Sprache spezialisiert hat. Da jedoch die Go-Sprache und das Cloud-Ökosystem eng miteinander verbunden sind und die Cilium-Mitwirkenden über ein gewisses Maß an Go-Kenntnissen verfügen, habe ich den Inhalt, der auf einem persönlichen Blog veröffentlicht werden könnte, einmal zu Gosuda gebracht.
Da ich (ich selbst) als einer der Administratoren die Erlaubnis hatte, denke ich, dass es in Ordnung sein sollte.
Wenn Sie anderer Meinung sind, speichern Sie es schnell als PDF, bevor es möglicherweise gelöscht wird. ;)
Nachbemerkung – 3
Bei der Erstellung dieses Artikels wurde ich maßgeblich von Cline und Llama 4 Maveric unterstützt. Obwohl ich die Analyse mit Gemini begann und DeepSeek anflehte, erhielt ich die eigentliche Hilfe von Llama 4. Llama 4 ist großartig. Probieren Sie es unbedingt aus.