Cilium-berättelsen: Hur en liten kodändring gav en anmärkningsvärd förbättring av nätverkets stabilitet

Inledning
För en tid sedan tittade jag på en pull request i Cilium-projektet från en tidigare kollega.
bpf:nat: Restore ORG NAT entry if it's not found
Till sin omfattning är själva ändringen (exklusive testkoden) liten, eftersom den i huvudsak lägger till ett enda if-block. Men den påverkan som denna ändring fått är betydande, och det faktum att en enkel idé kan bidra enormt till systemets stabilitet har för mig personligen varit intressant. Därför vill jag försöka berätta om detta fall på ett sätt som även personer utan specialistkunskaper inom nätverk lätt kan förstå.
Bakgrundskunskap
Om det finns något som är lika oumbärligt för moderna människor som smartphones, så är det kanske Wi-Fi-routern. Wi-Fi-routern kommunicerar med enheter via standarden för trådlös kommunikation Wi-Fi och har till uppgift att dela den publika IP-adress den besitter så att den kan användas av flera enheter. Den tekniska frågan som uppstår här är: hur sker denna "delning"?
Tekniken som används här är Network Address Translation (NAT). NAT är en teknik som, med tanke på att TCP- eller UDP-kommunikation består av kombinationen av IP-adress och portinformation, mappar den interna kommunikationen i formen privat IP:Port till en publik IP:Port som för närvarande inte används, så att kommunikation med omvärlden kan utföras.
NAT
När en intern enhet försöker ansluta till internet, omvandlar NAT-enheten kombinationen av den enhetens privata IP-adress och portnummer till sin egen publika IP-adress tillsammans med ett oanvänt, godtyckligt portnummer. Denna omvandlingsinformation registreras i en tabell inuti NAT-enheten som kallas NAT-tabellen.
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 begäran från smartphonen kommer den att se ett TCP-paket enligt följande.
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 vidare till webbservern (c.c.c.c) i oförändrat skick kommer svaret inte att nå smartphonen med den privata IP-adressen (a.a.a.a). Därför letar routern först upp en godtycklig port som inte används i den aktuella kommunikationen (t.ex. 60000) 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 den nya posten i NAT-tabellen ändrar den käll-IP-adressen och portnumret i TCP-paketet från smartphonen till sin egen publika IP-adress (b.b.b.b) och den nytt tilldelade portnumret (60000), och skickar sedan paketet till webbservern.
1# TCP-paket skickat av routern, router => webbserver
2# SNAT utförd
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Nu uppfattar webbservern (c.c.c.c) begäran som kommande från port 60000 på routern (b.b.b.b) 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 svarspaketet söker den i NAT-tabellen efter den ursprungliga privata IP-adressen (a.a.a.a) och portnumret (50000) som motsvarar destinations-IP-adressen (b.b.b.b) och portnumret (60000), och ändrar paketets destination till smartphonen.
1# TCP-paket skickat av routern, router => smartphone
2# DNAT utförd
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Genom denna process känns det för smartphonen som om den själv hade en publik IP-adress och kommunicerade direkt med webbservern. Tack vare NAT kan flera interna enheter samtidigt använda internet med en enda publik IP-adress.
Kubernetes
Kubernetes har ett av de mest sofistikerade men också komplexa nätverksstrukturerna bland de senaste teknikerna. Och naturligtvis används även NAT, som nämnts ovan, på många ställen. De två mest representativa fallen är följande.
När en Pod kommunicerar med utsidan av klustret
Pods inne i ett Kubernetes-kluster tilldelas i allmänhet privata IP-adresser som endast kan användas för kommunikation inom klusternätverket. Därför kräver det NAT för trafik som lämnar klustret när en Pod ska kommunicera med internet. NAT utförs då huvudsakligen på den Kubernetes-nod (varje server i klustret) där Pod:en körs. När en Pod skickar ett paket mot utsidan, levereras paketet först till noden som Pod:en tillhör. Noden byter ut paketets käll-IP-adress (Pod:ens privata IP) mot sin egen publika IP-adress, ändrar även källporten på lämpligt sätt och vidarebefordrar paketet till utsidan. Denna process liknar den NAT-process som beskrevs tidigare för Wi-Fi-routern.
Låt oss till exempel anta 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). Då får Kubernetes-noden ett paket från Pod:en enligt följande.
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 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 utför därefter SNAT enligt följande innan paketet skickas vidare till utsidan.
1# TCP-paket skickat av noden, nod => API-server
2# SNAT utförd
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Därefter genomgår processen samma steg som i det tidigare exemplet med smartphonen och routern.
När kommunikation sker från utsidan av klustret till 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 håller en specifik port (NodePort) öppen på alla noder i klustret och vidarebefordrar trafik som kommer till denna port till de Pods som tillhör tjänsten. Externa användare kan nå tjänsten via klustrets nods IP-adress och NodePort.
I detta fall spelar NAT en viktig roll, och särskilt sker DNAT (Destination NAT) och SNAT (Source NAT) samtidigt. När trafik från utsidan når en nods NodePort måste Kubernetes-nätverket slutligen vidarebefordra denna trafik till den Pod som tillhandahåller tjänsten. I denna process sker först DNAT, vilket ändrar paketets destinations-IP-adress och portnummer till Pod:ens IP-adress och portnummer.
Låt oss till exempel anta att en extern användare (203.0.113.10, port: 30000) når en tjänst via NodePort (30001) på en nod i Kubernetes-klustret (192.168.1.5). Vi antar att denna tjänst internt pekar på en Pod med IP-adressen 10.0.2.15 och porten 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)
Här har Kubernetes-noden både en IP-adress som är åtkomlig utifrån (192.168.1.5) och en IP-adress som är giltig inom det interna Kubernetes-nätverket (10.0.1.1). (Beroende på vilken CNI som används varierar policyerna kring detta, men i den här artikeln förklarar jag utifrån Cilium.)
När den externa användarens begäran når noden måste noden vidarebefordra den till den Pod som ska hantera den. Noden tillämpar då följande DNAT-regel och ändrar paketets destinations-IP-adress och portnummer.
1# TCP-paket som noden förbereder för att skicka till Pod:en
2# Efter 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 Pod:en skickar svarspaketet, blir käll-IP-adressen dess egen IP-adress (10.0.2.15) och destinations-IP-adressen den externa användarens IP-adress (203.0.113.10) som skickade begäran. I detta fall skulle den externa användaren få svar från en okänd IP-adress som den aldrig begärt något från, och skulle därför helt enkelt släppa paketet. Därför utför Kubernetes-noden ytterligare SNAT när Pod:en skickar svarspaketet utåt, och ändrar paketets käll-IP-adress till nodens IP-adress (192.168.1.5 eller den interna nätverks-IP-adressen 10.0.1.1; i detta fall används 10.0.1.1).
1# TCP-paket som noden förbereder för att skicka till Pod:en
2# Efter DNAT och SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Pod:en som tar emot detta paket svarar nu till den nod som ursprungligen tog emot begäran via NodePort, och noden tillämpar samma DNAT- och SNAT-process i omvänd ordning och returnerar informationen till den externa användaren. Under denna process kommer varje nod att spara 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
Generellt sett hanteras dessa NAT-processer i Linux via iptables och drivs av ett delsystem som kallas conntrack. Faktum är att andra CNI-projekt som flannel och calico använder detta för att hantera problem av det här slaget. Problemet är dock att Cilium använder en teknik som kallas eBPF och i praktiken helt ignorerar det traditionella Linux-nätverksstacken. 🤣

Som ett resultat har Cilium valt att direkt implementera endast de funktioner bland de uppgifter som det traditionella Linux-nätverksstacken utför som krävs i en Kubernetes-miljö. Därför hanterar Cilium för SNAT-processen ovan sin SNAT-tabell direkt i form av en LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Cilium SNAT-tabell
2# !Exempel för enkel förklaring. Den faktiska definitionen 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 | protocol, conntrack m.m. metadata
4----------------------------------------------
5| | | | |
Eftersom det är en hashtabell används dessutom en nyckel för snabb sökning, och kombinationen src ip, src port, dst ip, dst port används som nyckel.
Problemidentifiering
Fenomen 1: Sökning
Det uppstår ett problem här: för att avgöra om ett paket som passerar eBPF behöver genomgå SNAT- eller DNAT-process, måste hashtabellen sökas. Som vi sett tidigare finns det två typer av paket i SNAT-processen: 1. paket som går från insidan till utsidan, och 2. de svarspaket som kommer från utsidan till insidan. Dessa två typer av paket kräver NAT-omvandling, men kännetecknas av att värdena för src ip, port och dst ip, port är omkastade.
För att möjliggöra snabb sökning måste man antingen lägga till ytterligare en post i hashtabellen med src och dst omkastade som nyckel, eller utföra två sökningar i samma hashtabell för alla paket, även för paket som inte har något att göra med SNAT. Cilium har naturligtvis, för att uppnå bättre prestanda, valt att lägga in samma data två gånger, under namnet RevSNAT.
Fenomen 2: LRU
Oberoende av ovanstående problem kan inte hårdvara ha oändliga resurser, och särskilt i hårdvarunära logik där hög prestanda krävs, går det inte att använda dynamiska datastrukturer. När resurserna tar slut måste befintlig data evakueras. Cilium har löst detta genom att använda LRU Hash Map, som är en grundläggande datastruktur i Linux.
Fenomen 1 + Fenomen 2 = Förlorad anslutning
https://github.com/cilium/cilium/issues/31643
Det vill säga, för en SNAT-översatt TCP- (eller UDP-) anslutning:
- Samma data finns lagrad två gånger i en och samma hashtabell, för utgående och inkommande paket, och
- beroende på LRU-logiken kan ena eller andra datan när som helst gå förlorad,
så kan det leda till att hela anslutningen förloras om någon av NAT-informationen (nedan kallad entry) för utgående eller inkommande paket försvinner genom LRU, eftersom NAT då inte kan utföras korrekt.
Lösning
Här dyker de PR:ar upp som nämndes tidigare:
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, skapades en nyckel av kombinationen src ip, src port, dst ip, dst port i SNAT-tabellen och sedan utfördes en sökning. Om nyckeln inte fanns skapades en ny NAT-information enligt SNAT-reglerna och registrerades i tabellen. Vid en ny anslutning leder detta till normal kommunikation, men om nyckeln oavsiktligt tagits bort av LRU kommer NAT att utföras med en annan port än den som användes för den pågående kommunikationen, vilket får den mottagande sidan att vägra ta emot paketet, och anslutningen avslutas med ett RST-paket.
Den metod som denna PR använder är enkel:
Så snart ett paket i någon riktning observerats, uppdatera även entryn för dess motsatta riktning.
När kommunikation observeras i någon riktning uppdateras båda entryna på nytt, vilket gör att de hamnar längre från LRU-logikens prioritet för eviction, och därigenom minskas risken att endast ett av entryna raderas och hela kommunikationen kollapsar.
Detta är ett mycket enkelt tillvägagångssätt, och även om det kan verka som en enkel idé, har det gjort det möjligt att effektivt lösa problemet där NAT-informationen för svarspaket förfaller först och anslutningen bryts, och avsevärt förbättra systemets stabilitet. Det kan också betraktas som en viktig förbättring med följande resultat för nätverksstabiliteten.

Slutsats
Jag anser att denna PR är ett mycket bra exempel som visar hur en enkel idé, även inom ett komplext system, kan åstadkomma stora förändringar, med utgångspunkt i den grundläggande datavetenskapliga kunskapen om hur NAT fungerar.
Ja, naturligtvis har jag i denna artikel inte direkt visat exempel på komplexa system. Men för att verkligen förstå denna PR tiggde jag nästan i tre timmar hos DeepSeek V3 0324 och lade till ordet Please, och resultatet blev en extra kunskapspoäng om Cilium samt en bild enligt nedan. 😇
Och som kompensation för de olycksbådande förkänslor jag hade, om att något jag tidigare skapat kan ha orsakat problemet, när jag läste igenom issues och PR:ar, skriver jag denna artikel.
Efterord - 1
Förresten finns det ett mycket effektivt sätt att undvika detta issue. Eftersom den grundläggande orsaken till problemet är brist på utrymme i NAT-tabellen, kan man helt enkelt öka storleken på NAT-tabellen. :-D
Jag beundrar och respekterar gyutaeb, som, trots att problemet inte var direkt kopplat till honom, när någon stött på samma issue och lämnat det utan att rapportera det och bara ökat NAT-tabellens storlek, analyserade och förstod det grundligt, bidrog till Cilium-ekosystemet med objektiva underlag och data.
Detta var anledningen till att jag bestämde mig för att skriva den här artikeln.
Efterord - 2
Denna berättelse handlar egentligen inte direkt om ämnet för Gosuda, som är specialiserat på programmeringsspråket Go. Men Go-språket och molnekosystemet är nära relaterade, och eftersom Cilium-bidragsgivarna har en viss kompetens inom Go, har jag valt att ta med innehåll som kunde publicerats på min personliga blogg till Gosuda.
Eftersom en av administratörerna (jag själv) gav sitt tillstånd, tror jag att det är okej.
Om du anser att det inte är okej, kan det komma att raderas när som helst, så spara gärna en PDF-kopia snabbt. ;)
Efterord - 3
Vid skrivandet av denna artikel fick jag stor hjälp av Cline och Llama 4 Maveric. Även om jag började analysera med Gemini och tiggde hos DeepSeek, fick jag faktiskt hjälp främst av Llama 4. Llama 4 är bra. Testa den gärna.