Cilium-berättelsen: Små kodändringar som ledde till anmärkningsvärda förbättringar av nätverksstabiliteten
Introduktion
För en tid sedan granskade jag en PR för ett Cilium-projekt av en tidigare kollega.
bpf:nat: Restore ORG NAT entry if it's not found
Mängden ändringar (exklusive testkoden) var liten, ungefär som att lägga till ett if-satsblock. Men effekten av denna ändring är enorm, och jag tyckte personligen att det var intressant hur en enkel idé kan bidra så mycket till systemets stabilitet. Därför vill jag förklara detta fall på ett sätt som även personer utan specialistkunskap inom nätverk kan förstå.
Bakgrundskunskap
Om det finns en lika viktig nödvändighet för moderna människor som smarttelefonen, så är det nog WiFi-routern. En WiFi-router kommunicerar med enheter via WiFi-standarden och delar sin publika IP-adress så att flera enheter kan använda den. Den tekniska egenheten som uppstår här är "hur" den delar.
Tekniken som används här är Network Address Translation (NAT). NAT är en teknik som möjliggör kommunikation med externa nätverk genom att mappa intern kommunikation, som består av privat IP:Port, till en för närvarande oanvänd publik IP:Port, med tanke på att TCP- eller UDP-kommunikation består av en kombination av IP-adress och portinformation.
NAT
När en intern enhet försöker ansluta till det externa internet, omvandlar NAT-enheten den privata IP-adressen och portnummerkombinationen för den enheten till sin egen publika IP-adress och ett slumpmässigt, oanvänt portnummer. Denna översättningsinformation registreras i en NAT-tabell inuti NAT-enheten.
Låt oss till exempel anta att en smartphone i hemmet (privat IP: a.a.a.a, port: 50000) försöker ansluta till en webbserver (publik IP: c.c.c.c, port: 80).
1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Webbserver (c.c.c.c:80)
När routern tar emot förfrågan från smarttelefonen, kommer den att se följande TCP-paket.
1# TCP-paket mottaget av routern, smartphone => router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Om detta paket skickas direkt till webbservern (c.c.c.c), kommer inget svar att returneras till smarttelefonen (a.a.a.a) som har en privat IP-adress. Därför söker routern först efter en slumpmässig port (t.ex. 60000) som för närvarande inte används för kommunikation och registrerar den i den interna NAT-tabellen.
1# Routerns interna NAT-tabell
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
Efter att routern har registrerat en ny post i NAT-tabellen, ändrar den käll-IP-adressen och portnumret för TCP-paketet som mottogs från smarttelefonen till sin egen publika IP-adress (b.b.b.b) och det nyligen tilldelade portnumret (60000), och skickar det till webbservern.
1# TCP-paket skickat av routern, router => webbserver
2# SNAT utförs
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Nu kommer webbservern (c.c.c.c) att uppfatta förfrågan som kommande från routerns (b.b.b.b) port 60000, och skickar svarspaketet till routern enligt följande.
1# TCP-paket mottaget av routern, webbserver => router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
När routern tar emot detta svarspaket, hittar den den ursprungliga privata IP-adressen (a.a.a.a) och portnumret (50000) som motsvarar destinations-IP-adressen (b.b.b.b) och portnumret (60000) i NAT-tabellen, och ändrar paketets destination till smarttelefonen.
1# TCP-paket skickat av routern, router => smartphone
2# DNAT utförs
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Genom denna process upplever smarttelefonen det som om den kommunicerar direkt med webbservern med sin egen publika IP-adress. Tack vare NAT kan flera interna enheter samtidigt använda internet med en enda publik IP-adress.
Kubernetes
Kubernetes har en av de mest sofistikerade och komplexa nätverksstrukturerna bland de senaste teknikerna. Och naturligtvis används NAT, som nämnts tidigare, på olika ställen. De två representativa fallen är följande.
När en Pod kommunicerar med omvärlden utanför klustret
Pods inom ett Kubernetes-kluster tilldelas vanligtvis privata IP-adresser som endast kan kommunicera inom klusternätverket. Därför krävs NAT för trafik som går ut från klustret när en pod kommunicerar med det externa internet. I detta fall utförs NAT huvudsakligen på den Kubernetes-nod (varje server i klustret) där podden körs. När en pod skickar ett utgående paket, levereras paketet först till noden som podden tillhör. Noden ändrar käll-IP-adressen (podens privata IP) för detta paket till sin egen publika IP-adress och justerar även källporten innan det skickas ut. Denna process liknar NAT-processen som beskrevs tidigare för WiFi-routrar.
Antag till exempel att en pod i ett Kubernetes-kluster (10.0.1.10, port: 40000) ansluter till en extern API-server (203.0.113.45, port: 443). Kubernetes-noden kommer då att ta emot följande paket från podden:
1# TCP-paket mottaget av noden, pod => nod
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Noden registrerar sedan följande information:
1# Nodens interna NAT-tabell (exempel)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
Och efter att ha utfört SNAT enligt följande, skickar den paketet utåt.
1# TCP-paket skickat av noden, nod => API-server
2# SNAT utförs
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Därefter följer processen samma steg som i fallet med smarttelefonroutern.
När ett externt kluster kommunicerar med en Pod via NodePort
Ett sätt att exponera en tjänst externt i Kubernetes är att använda en NodePort-tjänst. En NodePort-tjänst öppnar en specifik port (NodePort) på alla noder i klustret och vidarebefordrar trafik som kommer in på denna port till de poddar som tillhör tjänsten. Externa användare kan komma åt tjänsten via nodens IP-adress och NodePort i klustret.
I detta fall spelar NAT en viktig roll, och särskilt DNAT (Destination NAT) och SNAT (Source NAT) sker samtidigt. När trafik kommer in från utsidan till en specifik nods NodePort, måste Kubernetes-nätverket slutligen leverera denna trafik till den pod som tillhandahåller tjänsten. I denna process sker först DNAT, där paketets destinations-IP-adress och portnummer ändras till podens IP-adress och portnummer.
Antag till exempel att en extern användare (203.0.113.10, port: 30000) försöker komma åt en tjänst via NodePort (30001) på en Kubernetes-nod (192.168.1.5) i klustret. Vi antar att denna tjänst internt pekar på en pod med IP-adress 10.0.2.15 och port 8080.
1Extern användare (203.0.113.10:30000) ==> Kubernetes-nod (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes-pod (10.0.2.15:8080)
I detta fall har Kubernetes-noden både den externt tillgängliga IP-adressen 192.168.1.5 och den internt giltiga IP-adressen 10.0.1.1 i Kubernetes-nätverket. (Policyn för detta varierar beroende på vilken typ av CNI som används, men i denna artikel förklaras det baserat på Cilium.)
När en extern användares förfrågan anländer till noden, måste noden vidarebefordra denna förfrågan till podden som ska hantera den. Då tillämpar noden följande DNAT-regel för att ändra paketets destinations-IP-adress och portnummer.
1# TCP-paket som noden förbereder att skicka till podden
2# Efter tillämpning av DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Det viktiga här är att när podden skickar ett svar på denna förfrågan, är käll-IP-adressen dess egen IP-adress (10.0.2.15) och destinations-IP-adressen är IP-adressen för den externa användaren som skickade förfrågan (203.0.113.10). I detta fall kommer den externa användaren att ta emot ett svar från en IP-adress som inte existerar och som användaren aldrig har begärt, och kommer helt enkelt att DROPPA paketet. Därför utför Kubernetes-noden ytterligare SNAT när podden skickar ett svarspaket externt, och ändrar paketets käll-IP-adress till nodens IP-adress (192.168.1.5 eller den interna nätverks-IP:n 10.0.1.1, i detta fall 10.0.1.1).
1# TCP-paket som noden förbereder att skicka till podden
2# Efter tillämpning av DNAT, SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Nu, när podden tar emot paketet, svarar den till noden som ursprungligen tog emot förfrågan via NodePort, och noden tillämpar samma DNAT- och SNAT-processer i omvänd ordning för att returnera informationen till den externa användaren. Under denna process kommer varje nod att lagra följande information.
1# Nodens interna DNAT-tabell
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Nodens interna SNAT-tabell
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Huvuddel
Normalt hanteras och utförs dessa NAT-processer i Linux via iptables med hjälp av conntrack-undersystemet. Faktum är att andra CNI-projekt som Flannel och Calico använder detta för att hantera ovanstående problem. Problemet är dock att Cilium, genom att använda tekniken eBPF, helt ignorerar denna traditionella Linux-nätverksstack. 🤣

Som ett resultat har Cilium valt att direkt implementera endast de funktioner som krävs i Kubernetes-situationen bland de uppgifter som den befintliga Linux-nätverksstacken utförde, som visas i bilden ovan. Därför hanterar Cilium SNAT-tabellen direkt i form av en LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) för den tidigare nämnda SNAT-processen.
1# Cilium SNAT-tabell
2# !Exempel för enkel förklaring. Faktisk definition finns på: 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 | protokoll, conntrack, etc. metadata
4----------------------------------------------
5| | | | |
Och eftersom det är en Hash Table, för snabb sökning, används en kombination av src ip, src port, dst ip, dst port som nyckelvärde här.
Problemidentifiering
Fenomen - 1: Uppslagning
Ett problem som uppstår är att ett paket som passerar eBPF måste utföra en uppslagning i ovanstående Hash Table för att verifiera om det behöver utföra SNAT eller DNAT under NAT-processen. Som vi har sett tidigare finns det två typer av paket i SNAT-processen: 1. Paket som går ut från det interna nätverket till det externa, och 2. Svarspaket som kommer in från det externa nätverket till det interna. Dessa två paket kräver NAT-översättning och kännetecknas av att src ip, port och dst ip, port-värdena är omvända.
Därför, för snabb sökning, måste man antingen lägga till ett extra värde i Hash Table med omvända src och dst som nyckel, eller utföra samma Hash Table-sökning två gånger för alla paket, även de som inte är relaterade till SNAT. Naturligtvis har Cilium, för bättre prestanda, valt att infoga samma data två gånger under namnet RevSNAT.
Fenomen - 2: LRU
Och oberoende av ovanstående problem kan oändliga resurser inte existera i all hårdvara, och särskilt i hårdvarulogik som kräver hög prestanda och inte kan använda dynamiska datastrukturer, är det nödvändigt att avlägsna befintliga data när resurserna är knappa. Cilium löste detta genom att använda LRU Hash Map, en grundläggande datastruktur som tillhandahålls som standard i Linux.
Fenomen 1 + Fenomen 2 = Anslutningsförlust
https://github.com/cilium/cilium/issues/31643
Det vill säga, för en SNAT-ansluten TCP- (eller UDP-) anslutning:
- I en Hash Table registreras samma data två gånger för utgående och inkommande paket.
- På grund av LRU-logiken kan en av dessa data när som helst gå förlorad.
Om en av NAT-posterna (nedan kallad entry) för utgående eller inkommande paket försvinner på grund av LRU, kan NAT inte utföras korrekt, vilket kan leda till en fullständig förlust av anslutningen.
Lösning
Här kommer de tidigare nämnda PR:erna in.
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Tidigare, när ett paket passerade eBPF, försökte det slå upp med en nyckel bestående av src ip, src port, dst ip, dst port i SNAT-tabellen. Om nyckeln inte fanns, skapades ny NAT-information enligt SNAT-reglerna och registrerades i tabellen. Om det var en ny anslutning skulle detta leda till normal kommunikation. Men om nyckeln oavsiktligt hade tagits bort av LRU, skulle en ny NAT utföras med en annan port än den som användes för den befintliga kommunikationen, vilket skulle resultera i att mottagaren av paketet skulle vägra att ta emot det och anslutningen skulle avslutas med ett RST-paket.
Det tillvägagångssätt som denna PR har valt är enkelt.
När ett paket observeras i någon riktning, uppdatera även posten för motsatt riktning.
När kommunikation observeras i någon riktning uppdateras båda posterna, vilket minskar deras prioritet för evakuering i LRU-logiken och därmed minskar risken för att endast en post tas bort och hela kommunikationen kollapsar.
Detta är ett mycket enkelt tillvägagångssätt och kan verka som en enkel idé, men genom detta tillvägagångssätt har problemet med att NAT-informationen för svarspaket löper ut först och därmed bryter anslutningen, effektivt lösts och systemets stabilitet har avsevärt förbättrats. Det kan också sägas vara en viktig förbättring som har uppnått följande resultat när det gäller nätverksstabilitet.

Slutsats
Jag anser att denna PR är ett utmärkt exempel som visar hur en enkel idé kan åstadkomma stor förändring, även inom komplexa system, med utgångspunkt från grundläggande CS-kunskaper om hur NAT fungerar.
Ja, jag har förstås inte direkt visat exempel på komplexa system i denna artikel. Men för att korrekt förstå denna PR har jag tiggt DeepSeek V3 0324 i nästan tre timmar, till och med med ordet "Please", och som ett resultat har jag fått kunskap om Cilium +1 och följande bild. 😇
Och medan jag läste igenom problemen och PR:erna, fick jag en dålig känsla av att ett problem kan ha uppstått på grund av något jag skapat tidigare, och som en kompensation för detta skriver jag den här artikeln.
Recension - 1
Förresten, det finns ett mycket effektivt sätt att undvika detta problem. Eftersom grundorsaken till problemet är brist på utrymme i NAT-tabellen, är det bara att öka storleken på NAT-tabellen. :-D
Medan någon annan kanske hade stött på samma problem, inte lämnat någon rapport och bara ökat NAT-tabellens storlek och försvunnit, är jag imponerad av och respekterar gyutaebs passion. Han har noggrant analyserat, förstått och bidragit till Cilium-ekosystemet med objektiva data, trots att problemet inte var direkt relaterat till honom.
Detta var anledningen till att jag bestämde mig för att skriva den här artikeln.
Recension - 2
Denna berättelse är faktiskt inte direkt relevant för Gosuda, som är specialiserat på Go-språket. Men Go-språket och molnekosystemet är nära besläktade, och bidragsgivarna till Cilium har en viss kunskap om Go-språket, så jag har tagit med innehåll som jag annars skulle ha publicerat på min personliga blogg till Gosuda.
Eftersom en av administratörerna (jag själv) har gett tillstånd, antar jag att det är okej.
Om du inte tycker det är okej, spara det snabbt som en PDF, för man vet aldrig när det kan raderas. ;)
Recension - 3
Jag fick stor hjälp av Cline och Llama 4 Maveric vid skrivandet av denna artikel. Även om jag började analysen med Gemini och tiggde DeepSeek, fick jag faktiskt hjälp av Llama 4. Llama 4 är bra. Prova det definitivt.