GoSuda

Cilium Story: Wie eine kleine Codeänderung zu einer erstaunlichen Verbesserung der Netzwerkstabilität führte

By iwanhae
views ...

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

Abgesehen vom Testcode ist die Änderungsmenge selbst gering und beschränkt sich auf das Hinzufügen eines if-Statements-Blocks. Die Auswirkungen dieser Änderung sind jedoch enorm, und die Tatsache, dass eine einfache Idee einen enormen Beitrag zur Systemstabilität leisten kann, finde ich persönlich faszinierend. Daher möchte ich diese Geschichte so erzählen, dass sie auch für Personen ohne Fachkenntnisse im Netzwerkbereich leicht verständlich ist.

Hintergrundwissen

Wenn es ein ebenso wichtiges Utensil für den modernen Menschen gibt wie das Smartphone, dann ist es wahrscheinlich der WLAN-Router. Ein WLAN-Router kommuniziert mit Geräten über den WLAN-Kommunikationsstandard und teilt seine öffentliche IP-Adresse mehreren Geräten mit. Die technische Besonderheit, die sich hieraus ergibt, ist die Frage, wie diese „Teilung“ erfolgt.

Die hier verwendete Technologie ist die Network Address Translation (NAT). NAT ist eine Technologie, die es ermöglicht, interne Kommunikationen, die aus privater IP:Port bestehen, auf eine derzeit ungenutzte öffentliche IP:Port abzubilden, um eine Kommunikation mit der Außenwelt zu ermöglichen, da die TCP- oder UDP-Kommunikation aus einer Kombination von IP-Adresse und Port-Informationen besteht.

NAT

Wenn ein internes Gerät versucht, auf das externe Internet zuzugreifen, wandelt das NAT-Gerät die Kombination aus der privaten IP-Adresse und der Portnummer dieses 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.

Angenommen, 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, sieht er das folgende TCP-Paket:

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, würde keine Antwort an das Smartphone (a.a.a.a) mit seiner privaten IP-Adresse zurückkommen. Daher sucht der Router zuerst einen beliebigen Port (z.B. 60000), der derzeit nicht in der Kommunikation involviert ist, und speichert ihn in seiner 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 die 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# 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 von Port 60000 des Routers (b.b.b.b) kommend und sendet das Antwortpaket wie folgt an den Router:

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    |

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# 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 Vorgang hat das Smartphone das Gefühl, direkt mit dem Webserver zu kommunizieren, als ob es eine eigene öffentliche IP-Adresse hätte. 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 bereits erwähnte NAT auch an verschiedenen Stellen eingesetzt. Zwei typische Beispiele sind die folgenden:

Kommunikation vom Pod-Inneren nach außerhalb des Clusters

Pods innerhalb eines Kubernetes-Clusters erhalten in der Regel private IP-Adressen, die nur innerhalb des Cluster-Netzwerks kommunizieren können. Daher ist NAT erforderlich, wenn ein Pod mit dem externen Internet kommunizieren muss, um den Traffic nach außerhalb des Clusters zu leiten. In diesem Fall wird NAT hauptsächlich auf dem Kubernetes-Knoten (jedem Server im Cluster), auf dem der Pod läuft, durchgeführt. Wenn ein Pod ein Paket nach außen sendet, wird dieses Paket zuerst an den Knoten weitergeleitet, zu dem der Pod gehört. Der Knoten ändert die Quell-IP-Adresse dieses Pakets (die private IP des Pods) in seine eigene öffentliche IP-Adresse und passt die Quellportnummer entsprechend an, bevor er es nach außen weiterleitet. Dieser Vorgang ähnelt dem zuvor beschriebenen NAT-Vorgang bei WLAN-Routern.

Angenommen, ein Pod innerhalb eines Kubernetes-Clusters (10.0.1.10, Port: 40000) greift auf einen externen API-Server (203.0.113.45, Port: 443) zu, dann empfängt der Kubernetes-Knoten die folgenden Pakete vom Pod:

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 dann die folgenden Informationen 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       |

Anschließend führt er SNAT durch und sendet das Paket nach außen, wie folgt:

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 dann dem Beispiel mit dem Smartphone und dem Router.

Kommunikation von außerhalb des Clusters mit einem Pod über NodePort

Eine Methode, 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 Knoten innerhalb des Clusters und leitet den über diesen Port eingehenden Traffic an die zum Dienst gehörenden Pods weiter. Externe Benutzer können über die IP-Adresse des Cluster-Knotens 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 Traffic von außen auf den NodePort eines bestimmten Knotens trifft, muss das Kubernetes-Netzwerk diesen Traffic letztendlich an den Pod weiterleiten, der den Dienst bereitstellt. In diesem Prozess erfolgt zunächst DNAT, wodurch die Ziel-IP-Adresse und die Portnummer des Pakets in die IP-Adresse und Portnummer des Pods geändert werden.

Angenommen, ein externer Benutzer (203.0.113.10, Port: 30000) greift über den NodePort (30001) eines Knotens (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-Knoten (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes-Pod (10.0.2.15:8080)

Hier hat der Kubernetes-Knoten sowohl die von außen zugängliche IP-Adresse des Knotens 192.168.1.5 als auch die innerhalb des Kubernetes-Netzwerks gültige IP-Adresse 10.0.1.1. (Die Richtlinien hierzu variieren je nach Art des verwendeten CNI, aber in diesem Artikel wird die Erklärung auf Cilium basieren.)

Wenn die Anfrage des externen Benutzers am Knoten ankommt, muss der Knoten diese an den Pod weiterleiten, der die Anfrage verarbeitet. Dabei wendet der Knoten die folgende DNAT-Regel an, um die Ziel-IP-Adresse und die Portnummer des Pakets zu ändern:

1# TCP-Paket, das der Knoten an den Pod senden will
2# Nach DNAT-Anwendung
3| src ip      | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080     |

Wichtig ist hier, dass, wenn der Pod auf diese Anfrage antwortet, die Quell-IP-Adresse seine eigene IP-Adresse (10.0.2.15) und die Ziel-IP-Adresse die IP-Adresse des externen Benutzers (203.0.113.10) ist, der die Anfrage gesendet hat. In diesem Fall würde der externe Benutzer eine Antwort von einer nicht existierenden IP-Adresse erhalten, die er nie angefordert hat, und das entsprechende Paket einfach DROPpen. Daher führt der Kubernetes-Knoten zusätzlich SNAT durch, wenn der Pod Antwortpakete nach außen sendet, um die Quell-IP-Adresse des Pakets in die IP-Adresse des Knotens (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 Knoten 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     |

Der Pod, der das Paket empfängt, antwortet nun an den Knoten, von dem er ursprünglich die NodePort-Anfrage erhalten hat. Der Knoten wendet denselben DNAT- und SNAT-Prozess invers an, um die Informationen an den externen Benutzer zurückzugeben. Während dieses Prozesses 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

Im Allgemeinen werden diese NAT-Prozesse in Linux über iptables vom Subsystem conntrack verwaltet und ausgeführt. Tatsächlich verwenden andere CNI-Projekte wie flannel oder calico dies, um die oben genannten Probleme zu lösen. Das Problem ist jedoch, dass Cilium die eBPF-Technologie verwendet und dabei diesen traditionellen Linux-Netzwerk-Stack vollständig ignoriert. 🤣

https://cilium.io/blog/2021/05/11/cni-benchmark/

Infolgedessen hat Cilium den Weg gewählt, nur die für Kubernetes-Szenarien erforderlichen Funktionen, die der traditionelle Linux-Netzwerk-Stack in der Abbildung bereitstellte, direkt zu implementieren. Daher verwaltet Cilium für den zuvor erwähnten SNAT-Prozess eine SNAT-Tabelle direkt in Form einer LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Cilium SNAT Tabelle
2# !Beispiel zur Vereinfachung der Erklärung. Die eigentliche 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 da es sich um eine Hash-Tabelle handelt, existiert ein Schlüssel für eine schnelle Abfrage, wobei die Kombination aus src ip, src port, dst ip und dst port als Schlüssel verwendet wird.

Problemidentifikation

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 einen SNAT- oder DNAT-Prozess für NAT benötigt. Wie bereits erwähnt, gibt es bei SNAT zwei Arten von Paketen: 1. Pakete, die von innen nach außen gehen, und 2. Antwortpakete, die von außen nach innen kommen. Diese beiden Pakete erfordern eine NAT-Transformation und zeichnen sich dadurch aus, dass die Werte von src ip, port und dst ip, port vertauscht sind.

Für eine schnelle Abfrage ist es daher erforderlich, entweder einen weiteren Wert in die Hash-Tabelle mit dem als Schlüssel vertauschten src und dst einzufügen, oder die gleiche Hash-Tabelle zweimal für alle Pakete abzufragen, selbst wenn sie nicht mit SNAT zusammenhängen. Natürlich hat Cilium für eine bessere Performance unter dem Namen RevSNAT die Methode gewählt, dieselben Daten zweimal einzufügen.

Phänomen - 2: LRU

Unabhängig vom obigen Problem gibt es auf keiner Hardware unendliche Ressourcen, und insbesondere in einer Hardware-Logik, die schnelle Leistung erfordert, wo dynamische Datenstrukturen nicht verwendet werden können, ist es notwendig, vorhandene Daten zu evicten, wenn Ressourcen knapp werden. Cilium hat dies gelöst, indem es die von Linux standardmäßig bereitgestellte Datenstruktur, die LRU Hash Map, verwendet.

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:

  1. In einer Hash-Tabelle dieselben Daten zweimal für ausgehende und eingehende Pakete gespeichert sind, und
  2. Aufgrund der LRU-Logik jederzeit einer der beiden Datensätze verloren gehen kann.

Wenn auch nur eine der NAT-Informationen (im Folgenden Entry genannt) für ein ausgehendes oder eingehendes Paket durch LRU entfernt wird, kann die NAT nicht ordnungsgemäß durchgeführt werden, 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, die SNAT-Tabelle mit einer Kombination aus src ip, src port, dst ip und dst port als Schlüssel abzufragen. Wenn der Schlüssel nicht existiert, wird gemäß der SNAT-Regel eine neue NAT-Information generiert und in die Tabelle geschrieben. Im Falle einer neuen Verbindung führt dies zu einer normalen Kommunikation, und wenn der Schlüssel unbeabsichtigt durch LRU entfernt wurde, wird die NAT mit einem anderen Port als dem für die bestehende Kommunikation verwendeten Port neu durchgeführt, woraufhin der Empfänger des Pakets den Empfang verweigert und die Verbindung mit einem RST-Paket beendet wird.

Der Ansatz des obigen PR ist dabei einfach.

Wenn ein Paket in einer Richtung beobachtet wird, soll auch der Eintrag für die entgegengesetzte Richtung neu aktualisiert werden.

Wird eine Kommunikation in irgendeiner Richtung beobachtet, werden beide Einträge neu aktualisiert, wodurch sie aus der Eviction-Priorität der LRU-Logik entfernt werden und die Wahrscheinlichkeit sinkt, dass nur ein Eintrag gelöscht wird, was zu einem Zusammenbruch der gesamten Kommunikation führen könnte.

Dies ist ein sehr einfacher Ansatz und mag wie eine einfache Idee erscheinen, aber durch diesen Ansatz konnte das Problem, dass die NAT-Informationen für Antwortpakete zuerst ablaufen und die Verbindung unterbrochen wird, effektiv gelöst und die Systemstabilität erheblich verbessert werden. Es ist auch eine wichtige Verbesserung, die im Hinblick auf die Netzwerkstabilität folgende Ergebnisse erzielt hat.

benchmark

Fazit

Ich halte diesen PR für ein hervorragendes Beispiel dafür, wie eine einfache Idee selbst in komplexen Systemen große Veränderungen bewirken kann, angefangen bei grundlegenden CS-Kenntnissen über die Funktionsweise von NAT.

Ich habe Ihnen in diesem Artikel zwar keine direkten Beispiele für komplexe Systeme gezeigt. Aber um diesen PR richtig zu verstehen, habe ich fast 3 Stunden lang DeepSeek V3 0324 mit dem Wort Please angebettelt, und das Ergebnis war ein Wissenszuwachs über Cilium +1 und die folgende Abbildung. 😇

diagram

Und während ich die Issues und PRs las, schrieb ich diesen Artikel, um meine Kompensationsgefühle für die bösen Vorahnungen zu verarbeiten, dass etwas, das ich zuvor erstellt hatte, möglicherweise zu einem Issue geführt haben könnte.

Nachwort - 1

Übrigens, für dieses Problem gibt es eine sehr effektive Umgehung. Da die Ursache des Problems ein Mangel an NAT-Tabellenplatz ist, kann man einfach die Größe der NAT-Tabelle erhöhen. :-D

Während jemand anderes, der auf dasselbe Problem gestoßen wäre, es wahrscheinlich ohne Meldung gelöst und dann die NAT-Tabellengröße erhöht hätte, 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 ihn nicht direkt betraf.

Dies war der Anlass, diesen Artikel zu schreiben.

Nachwort - 2

Diese Geschichte passt eigentlich nicht direkt zum Thema Gosuda, das sich auf die Go-Sprache spezialisiert hat. Da die Go-Sprache und das Cloud-Ökosystem jedoch eng miteinander verbunden sind und die Cilium-Beitragenden über ein gewisses Wissen in der Go-Sprache verfügen, habe ich beschlossen, Inhalte, die ich in meinem persönlichen Blog veröffentlichen könnte, einmal auf Gosuda zu präsentieren.

Da ich (als einer der Administratoren) die Erlaubnis dazu hatte, sollte es wohl in Ordnung sein.

Falls Sie der Meinung sind, dass es nicht in Ordnung ist, speichern Sie es am besten sofort als PDF, da es jederzeit gelöscht werden könnte. ;)

Nachwort - 3

Bei der Erstellung dieses Artikels habe ich große Unterstützung von Cline und Llama 4 Maveric erhalten. 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.