Cilium-Geschichte: Wie eine kleine Codeänderung eine bemerkenswerte Verbesserung der Netzwerkstabilität bewirkte

Einleitung
Vor einiger Zeit habe ich mir die PR eines ehemaligen Kollegen zum Cilium-Projekt angesehen.
bpf:nat: Restore ORG NAT entry if it's not found
Der eigentliche Umfang der Änderung ist (ohne Testcode) gering und beschränkt sich auf das Hinzufügen eines einzelnen if-Blocks. Die Wirkung dieser Änderung ist jedoch enorm, und dass eine einfache Idee einen außerordentlich großen Beitrag zur Stabilität eines Systems leisten kann, finde ich persönlich interessant. Deshalb möchte ich versuchen, die Geschichte so aufzubereiten, dass auch Menschen ohne Fachwissen im Bereich Netzwerke dieses Beispiel leicht nachvollziehen können.
Hintergrundwissen
Wenn es ein unverzichtbares Alltagsgerät gibt, das für moderne Menschen fast genauso wichtig ist wie das Smartphone, dann ist es wohl der WLAN-Router. Der WLAN-Router kommuniziert mit Geräten über den WLAN-Kommunikationsstandard und übernimmt die Aufgabe, seine öffentliche IP-Adresse für mehrere Geräte nutzbar zu machen. Der technisch interessante Punkt dabei ist die Frage: Wie erfolgt diese "Freigabe"?
Die hierfür verwendete Technik ist Network Address Translation (NAT). NAT ist eine Technik, die ausgehend von der Tatsache, dass TCP- oder UDP-Kommunikation aus der Kombination von IP-Adresse und Portinformation besteht, die interne Kommunikation im Format private IP:Port auf einen aktuell nicht verwendeten öffentlichen IP:Port abbildet, sodass die Kommunikation mit der Außenwelt möglich wird.
NAT
Wenn ein internes Gerät eine Verbindung zum externen Internet aufbauen möchte, wandelt das NAT-Gerät die Kombination aus privater IP-Adresse und Portnummer des Geräts in seine eigene öffentliche IP-Adresse und eine ungenutzte beliebige Portnummer um. Diese Umwandlungsinformation wird in einer als NAT-Tabelle bezeichneten Struktur innerhalb des NAT-Geräts gespeichert.
Angenommen, ein Smartphone im Haushalt (private IP: a.a.a.a, Port: 50000) möchte auf einen Webserver (öffentliche IP: c.c.c.c, Port: 80) zugreifen.
1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Webserver (c.c.c.c:80)
Wenn der Router die Anfrage des Smartphones erhält, sieht er ein TCP-Paket wie das folgende.
1# Vom Router empfangenes TCP-Paket, 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, käme keine Antwort beim Smartphone mit der privaten IP-Adresse (a.a.a.a) an. Daher sucht der Router zunächst einen beliebigen Port (z. B. 60000), der an der aktuellen Kommunikation nicht beteiligt ist, und trägt ihn in die interne NAT-Tabelle ein.
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 der NAT-Tabelle angelegt hat, ändert er die Quell-IP-Adresse und den Quellport des vom Smartphone empfangenen TCP-Pakets in seine eigene öffentliche IP-Adresse (b.b.b.b) und den neu zugewiesenen Portnummer (60000) und sendet es an den Webserver.
1# Vom Router gesendetes TCP-Paket, Router => Webserver
2# SNAT durchgeführt
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Der Webserver (c.c.c.c) erkennt die Anfrage nun als eine vom Port 60000 des Routers (b.b.b.b) stammende Anfrage und sendet das Antwortpaket wie folgt an den Router zurück.
1# Vom Router empfangenes TCP-Paket, Webserver => Router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Erhält der Router dieses Antwortpaket, sucht er in der NAT-Tabelle anhand der Ziel-IP-Adresse (b.b.b.b) und der Portnummer (60000) die ursprüngliche private IP-Adresse (a.a.a.a) und Portnummer (50000) heraus und ändert das Ziel des Pakets auf das Smartphone.
1# Vom Router gesendetes TCP-Paket, Router => Smartphone
2# DNAT durchgeführt
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Durch diesen Ablauf fühlt sich das Smartphone so an, als würde es selbst mit einer öffentlichen IP-Adresse direkt mit dem Webserver kommunizieren. Dank NAT können mehrere interne Geräte über eine einzige öffentliche IP-Adresse gleichzeitig das Internet nutzen.
Kubernetes
Kubernetes besitzt unter den in jüngerer Zeit entstandenen Technologien eine der anspruchsvollsten und komplexesten Netzwerkstrukturen. Selbstverständlich wird auch das zuvor erwähnte NAT an vielen Stellen eingesetzt. Zwei repräsentative Beispiele sind die folgenden.
Wenn ein Pod mit einem Ziel außerhalb des Clusters kommuniziert
Pods innerhalb eines Kubernetes-Clusters erhalten in der Regel private IP-Adressen, die nur innerhalb des Cluster-Netzwerks kommunizieren können. Damit ein Pod mit dem externen Internet kommunizieren kann, ist daher NAT für den Datenverkehr erforderlich, der den Cluster verlässt. Dieses NAT wird typischerweise auf dem Kubernetes-Knoten (dem jeweiligen Server im Cluster) durchgeführt, auf dem der Pod läuft. Sendet ein Pod ein Paket nach außen, wird dieses Paket zunächst an den Knoten weitergeleitet, zu dem der Pod gehört. Der Knoten ersetzt die Quell-IP-Adresse des Pakets (die private IP des Pods) durch seine eigene öffentliche IP-Adresse, passt auch den Quellport entsprechend an und leitet das Paket nach außen weiter. Dieser Vorgang ähnelt dem zuvor am WLAN-Router beschriebenen NAT-Prozess.
Angenommen, ein Pod innerhalb des Kubernetes-Clusters (10.0.1.10, Port: 40000) greift auf eine externe API-Server-Adresse (203.0.113.45, Port: 443) zu, dann erhält der Kubernetes-Knoten ein Paket von dem Pod wie folgt
1# Vom Knoten empfangenes TCP-Paket, Pod => Knoten
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Der Knoten zeichnet den folgenden Inhalt auf
1# Interne NAT-Tabelle des Knotens (Beispiel)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
und sendet danach das Paket nach Durchführung von SNAT nach außen.
1# Vom Knoten gesendetes TCP-Paket, Knoten => API-Server
2# SNAT durchgeführt
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Der weitere Ablauf entspricht danach dem zuvor beschriebenen Beispiel mit dem Smartphone und dem Router.
Wenn die Kommunikation von außerhalb des Clusters über einen NodePort mit einem Pod erfolgt
Eine Möglichkeit, Dienste in Kubernetes nach außen verfügbar zu machen, ist die Verwendung eines NodePort-Dienstes. Ein NodePort-Dienst öffnet an allen Knoten des Clusters einen bestimmten Port (NodePort) und leitet den über diesen Port eingehenden Datenverkehr an die Pods weiter, die zum Dienst gehören. Externe Nutzer können über die IP-Adresse eines Clusterknotens und den NodePort auf den Dienst zugreifen.
Dabei spielt NAT eine wichtige Rolle, wobei insbesondere DNAT (Destination NAT) und SNAT (Source NAT) gleichzeitig auftreten. Wenn Datenverkehr von außen auf den NodePort eines bestimmten Knotens trifft, muss das Kubernetes-Netzwerk diesen Datenverkehr letztlich an den Pod weiterleiten, der den Dienst bereitstellt. Dabei findet zunächst DNAT statt, sodass die Ziel-IP-Adresse und Portnummer des Pakets durch die IP-Adresse und Portnummer des Pods ersetzt werden.
Angenommen, ein externer Nutzer (203.0.113.10, Port: 30000) greift über den NodePort (30001) eines Knotens des Kubernetes-Clusters (192.168.1.5) auf den Dienst zu. Dieser Dienst verweist intern auf einen Pod mit der IP-Adresse 10.0.2.15 und dem Port 8080.
1Externer Nutzer (203.0.113.10:30000) ==> Kubernetes-Knoten (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes-Pod (10.0.2.15:8080)
Der Kubernetes-Knoten besitzt in diesem Fall sowohl die von außen erreichbare IP-Adresse 192.168.1.5 als auch die innerhalb des Kubernetes-Netzwerks gültige IP-Adresse 10.0.1.1. (Je nach verwendetem CNI-Plugin variieren die entsprechenden Richtlinien, in diesem Beitrag wird jedoch auf Grundlage von Cilium erklärt.)
Sobald die Anfrage des externen Nutzers den Knoten erreicht, muss der Knoten sie an den verarbeitenden Pod weiterleiten. Dabei wendet der Knoten die folgende DNAT-Regel an, um die Ziel-IP-Adresse und Portnummer des Pakets zu ändern.
1# TCP-Paket, das der Knoten 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 Antwort auf diese Anfrage als Quell-IP-Adresse seine eigene IP-Adresse (10.0.2.15) und als Ziel-IP-Adresse die IP-Adresse des anfragenden externen Nutzers (203.0.113.10) verwendet. In diesem Fall erhält der externe Nutzer eine Antwort von einer IP-Adresse, die er nie angefragt hat und die nicht existiert, und verwirft das Paket einfach. Deshalb führt der Kubernetes-Knoten beim Versand der Antwort des Pods nach außen zusätzlich SNAT durch und ersetzt die Quell-IP-Adresse des Pakets durch die IP-Adresse des Knotens (192.168.1.5 oder die interne Netzwerk-IP 10.0.1.1; in diesem Fall wird mit 10.0.1.1 fortgefahren).
1# TCP-Paket, das der Knoten an den Pod senden will
2# Nach Anwendung von DNAT und SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Der Pod, der diese Anfrage erhalten hat, antwortet nun an den Knoten, der die NodePort-Anfrage ursprünglich entgegengenommen hat, und dieser führt den gleichen DNAT- und SNAT-Vorgang in umgekehrter Richtung aus, sodass die Informationen an den externen Nutzer zurückgegeben werden. Dabei speichert jeder Knoten die folgenden Informationen.
1# Interne DNAT-Tabelle des Knotens
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 Knotens
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Hauptteil
Unter Linux werden diese NAT-Vorgänge üblicherweise über iptables gesteuert und durch ein Subsystem namens conntrack verwaltet und ausgeführt. Tatsächlich lösen andere CNI-Projekte wie Flannel oder Calico das beschriebene Problem auf diese Weise. Das Problem ist jedoch, dass Cilium mit der Verwendung der Technologie eBPF den traditionellen Linux-Netzwerkstack vollständig übergeht. 🤣

Daher hat Cilium in der obigen Abbildung den Weg gewählt, unter den Aufgaben, die der traditionelle Linux-Netzwerkstack übernahm, nur die für Kubernetes notwendigen Funktionen selbst zu implementieren. Deshalb verwaltet Cilium die SNAT-Tabelle für den zuvor beschriebenen SNAT-Vorgang direkt als LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Cilium SNAT-Tabelle
2# !Vereinfachtes Beispiel zur Veranschaulichung. Die tatsächliche Definition finden Sie unter: 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 usw. Metadaten
4----------------------------------------------
5| | | | |
Da es sich um eine Hash-Tabelle handelt, existiert für schnelle Abfragen ein Schlüsselwert, und als Schlüssel wird die Kombination aus src ip, src port, dst ip und dst port verwendet.
Problemerkennung
Phänomen 1: Abfrage
Daraus ergibt sich ein Problem: Ein Paket, das durch eBPF läuft, muss diese Hash-Tabelle abfragen, um festzustellen, ob es für SNAT- oder DNAT-Vorgänge relevant ist. Wie bereits gezeigt, gibt es beim SNAT-Vorgang zwei Arten von Paketen: 1. Pakete, die vom internen Netz nach außen gehen, und 2. die zugehörigen Antwortpakete, die von außen wieder ins interne Netz gelangen. Beide Pakettypen benötigen eine NAT-Umwandlung, zeichnen sich jedoch dadurch aus, dass sich die Werte von src ip und src port sowie dst ip und dst port gegenseitig vertauschen.
Um schnell abfragen zu können, müsste man daher entweder einen weiteren Eintrag in der Hash-Tabelle mit dem vertauschten src- und dst-Wert als Schlüssel anlegen oder jedes Paket, auch wenn es nichts mit SNAT zu tun hat, zweimal in derselben Hash-Tabelle abfragen. Cilium hat sich aus Gründen der besseren Leistung dafür entschieden, dieselben Daten zweimal einzutragen, und zwar unter dem Namen RevSNAT.
Phänomen 2: LRU
Unabhängig vom vorherigen Problem gilt: Hardware verfügt nicht über unbegrenzte Ressourcen, und insbesondere bei hardwarenaher Logik, die auf hohe Geschwindigkeit angewiesen ist, können keine dynamischen Datenstrukturen verwendet werden. Wenn die Ressourcen knapp werden, müssen bestehende Daten verdrängt werden (evict). Cilium löst dies, indem es die Standarddatenstruktur von Linux verwendet, nämlich die LRU Hash Map.
Phänomen 1 + Phänomen 2 = Verbindungsverlust
https://github.com/cilium/cilium/issues/31643
Das bedeutet, dass für eine einzelne SNAT-verarbeitete TCP- (oder UDP-) Verbindung
- in einer Hash-Tabelle für ausgehende und eingehende Pakete dieselben Daten zweimal gespeichert sind und
- je nach LRU-Logik jederzeit einer dieser beiden Einträge verloren gehen kann,
es dazu kommen kann, dass die gesamte Verbindung abbricht, sobald auch nur einer der NAT-Einträge (im Folgenden Entry) für ausgehende oder eingehende Pakete durch LRU entfernt wird, da dann keine ordnungsgemäße NAT-Verarbeitung mehr möglich ist.
Lösung
An dieser Stelle kommen die zuvor 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 wurde beim Durchlaufen eines Pakets durch eBPF ein Schlüssel aus der Kombination von src ip, src port, dst ip und dst port gebildet und in der SNAT-Tabelle abgefragt. Existiert der Schlüssel nicht, wird gemäß den SNAT-Regeln ein neuer NAT-Eintrag erzeugt und in der Tabelle gespeichert. Handelt es sich um eine neue Verbindung, führt dies zu einer regulären Kommunikation. Wurde der Schlüssel jedoch unbeabsichtigt durch LRU entfernt, wird NAT mit einem anderen Port als dem für die bestehende Kommunikation verwendeten durchgeführt. Der Empfänger lehnt das Paket dann ab, und die Verbindung wird mit einem RST-Paket beendet.
Der Ansatz der oben genannten PR ist hier einfach:
Sobald ein Paket in einer beliebigen Richtung beobachtet wird, sollte auch der Entry für die Gegenrichtung neu aktualisiert werden.
Wird Kommunikation in einer beliebigen Richtung beobachtet, werden beide Entries neu aktualisiert. Dadurch rücken sie von der Priorität für die Eviction durch die LRU-Logik weg, und das Szenario, in dem nur ein Entry gelöscht wird und die gesamte Kommunikation zusammenbricht, wird unwahrscheinlicher.
Dieser Ansatz ist sehr schlicht, und auf den ersten Blick mag es wie eine einfache Idee wirken. Dennoch wird durch diesen Ansatz das Problem, dass der NAT-Eintrag für Antwortpakete zuerst abläuft und die Verbindung dadurch abreißt, wirksam gelöst, und die Stabilität des Systems konnte deutlich verbessert werden. Zudem handelt es sich in Bezug auf die Netzwerkstabilität um eine bedeutende Verbesserung mit den folgenden Ergebnissen.

Fazit
Ich halte diese PR für ein sehr gutes Beispiel dafür, wie viel eine einfache Idee selbst in einem komplexen System bewirken kann, und das beginnt bei den grundlegenden Informatikkenntnissen darüber, wie NAT funktioniert.
Natürlich habe ich in diesem Beitrag kein komplexes System direkt gezeigt. Um diese PR wirklich zu verstehen, habe ich jedoch fast drei Stunden lang DeepSeek V3 0324 sogar mit dem Wort Please angebettelt, und als Ergebnis konnte ich mein Wissen über Cilium um eins erweitern und das folgende Diagramm erhalten. 😇
Außerdem habe ich diesen Beitrag aus einer Art Ausgleichsbedürfnis heraus verfasst, nachdem ich die Issues und PRs gelesen hatte und das ungute Gefühl bekam, dass Issues durch etwas entstanden sein könnten, das ich früher selbst geschrieben habe.
Nachtrag 1
Übrigens gibt es für dieses Issue eine sehr wirksame Umgehungsmöglichkeit. Die eigentliche Ursache des Issues ist der Mangel an NAT-Tabellenkapazität, daher muss man nur die Größe der NAT-Tabelle erhöhen. :-D
Ich habe den Einsatz von gyutaeb bewundert und respektiert: Jemand hätte bei demselben Issue einfach die Größe der NAT-Tabelle erhöht und wäre gegangen, ohne das Issue auch nur zu dokumentieren. Stattdessen hat er, obwohl das Issue nicht direkt mit ihm zusammenhing, gründlich analysiert, verstanden und mit objektiven Datenbelegen zum Cilium-Ökosystem beigetragen.
Das war der Anlass, der mich zum Entschluss gebracht hat, diesen Beitrag zu schreiben.
Nachtrag 2
Dieses Thema passt eigentlich nicht wirklich zu Gosuda, einer Gruppe, die sich professionell mit der Programmiersprache Go beschäftigt. Da aber Go und das Cloud-Ökosystem eng miteinander verbunden sind und die Beitragenden zu Cilium ein gewisses Grundverständnis von Go mitbringen, habe ich den Inhalt, der auch in einem persönlichen Blog veröffentlicht werden könnte, einmal nach Gosuda gebracht.
Da einer der Administratoren (ich selbst) seine Zustimmung gegeben hat, sollte es wohl in Ordnung sein.
Falls Sie der Meinung sind, dass es nicht in Ordnung ist, wird der Beitrag möglicherweise jederzeit gelöscht, also speichern Sie ihn am besten rasch als PDF. ;)
Nachtrag 3
Bei der Erstellung dieses Beitrags habe ich stark von Cline und Llama 4 Maverick profitiert. Zwar habe ich die Analyse mit Gemini begonnen und DeepSeek um Hilfe gebeten, tatsächliche Unterstützung kam aber von Llama 4. Llama 4 ist gut. Probieren Sie es unbedingt aus.