GoSuda

Cilium-berättelsen: Små kodändringar ledde till enastående nätverksstabilitetsförbättringar

By iwanhae
views ...

Introduktion

För en tid sedan stötte jag på en PR för Cilium-projektet från en före detta kollega.

bpf:nat: Restore ORG NAT entry if it's not found

Mängden ändringar (exklusive testkoden) var liten, bara tillägget av ett if-block. Men effekten av denna ändring var enorm, och det var personligen intressant att se hur en enkel idé kan bidra stort till systemets stabilitet. Därför vill jag förklara detta fall på ett sätt som är lätt att förstå även för dem som inte har specialistkunskaper inom nätverk.

Bakgrundskunskap

Om det finns något som är lika viktigt som smarttelefonen för den moderna människan, 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 här är: hur sker denna "delning"?

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 enhetens privata IP-adress och portnummerkombination till sin egen publika IP-adress och ett oanvänt slumpmässigt portnummer. Denna omvandlingsinformation lagras i en NAT-tabell inuti NAT-enheten.

Anta till exempel att din smarttelefon hemma (privat IP: a.a.a.a, port: 50000) försöker ansluta till en webbserver (publik IP: c.c.c.c, port: 80).

1Smarttelefon (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 Packet:

1# TCP-paket mottaget av routern, smarttelefon => 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 skickades direkt till webbservern (c.c.c.c) skulle inget svar komma tillbaka till smarttelefonen (a.a.a.a) som har en privat IP-adress. Därför hittar routern först en slumpmässig port (t.ex. 60000) som för närvarande inte är inblandad i kommunikation och registrerar den i sin interna NAT-tabell.

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 i TCP-paketet som mottogs från smarttelefonen till sin egen publika IP-adress (b.b.b.b) och det nyallokerade portnumret (60000), och skickar det sedan till webbservern.

1# TCP-paket skickat av routern, router => webbserver
2# SNAT utfört
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Nu känner webbservern (c.c.c.c) igen 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, 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 sedan paketets destination till smarttelefonen.

1# TCP-paket skickat av routern, router => smarttelefon
2# DNAT utfört
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 direkt kommunicerar 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 teknologierna. Och naturligtvis används den tidigare nämnda NAT-funktionen på olika ställen. De två representativa fallen är följande:

När en Pod kommunicerar med extern kluster

Pod-ar inom ett Kubernetes-kluster tilldelas vanligtvis privata IP-adresser som endast kan kommunicera inom klusternätverket. Därför krävs NAT för utgående trafik när en Pod ska kommunicera med det externa internet. I detta fall utförs NAT oftast på den Kubernetes Node (varje server i klustret) där Pod-en körs. När en Pod skickar ett paket utåt, levereras paketet först till den Node som Pod-en tillhör. Noden ändrar sedan paketets käll-IP-adress (Pod-ens privata IP) till sin egen publika IP-adress, ändrar även källporten på lämpligt sätt och vidarebefordrar det utåt. Denna process liknar den NAT-process som beskrevs tidigare för WiFi-routern.

Anta till exempel att en Pod (10.0.1.10, port: 40000) i ett Kubernetes-kluster ansluter till en extern API-server (203.0.113.45, port: 443). Kubernetes Node kommer då att ta emot följande paket från Pod-en:

1# TCP-paket mottaget av noden, Pod => Node
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 utför sedan SNAT enligt följande innan den skickar paketet utåt:

1# TCP-paket skickat av noden, Node => API-server
2# SNAT utfört
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 extern kluster kommunicerar med 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 Pod-ar som tillhör tjänsten. Externa användare kan komma åt tjänsten via klustrets nod-IP-adress och NodePort.

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 NodePort på en specifik nod, måste Kubernetes-nätverket slutligen vidarebefordra denna trafik till Pod-en 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.

Anta till exempel att en extern användare (203.0.113.10, port: 30000) kommer åt en tjänst via NodePort (30001) på en nod (192.168.1.5) i Kubernetes-klustret. Anta 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 nodens IP-adress 192.168.1.5 och den internt giltiga IP-adressen 10.0.1.1 i Kubernetes-nätverket. (Beroende på vilken typ av CNI som används, varierar reglerna för detta, men i denna artikel utgår förklaringen från Cilium.)

När den externa användarens förfrågan anländer till noden, måste noden vidarebefordra denna förfrågan till den Pod som ska behandla den. Noden tillämpar då följande DNAT-regel för att ändra paketets destinations-IP-adress och portnummer:

1# TCP-paket som noden förbereder att skicka till Pod
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 Pod-en 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 den externa användarens IP-adress (203.0.113.10) som skickade förfrågan. I så fall kommer den externa användaren att få ett svar från en obefintlig IP-adress som den aldrig har begärt, och kommer helt enkelt att DROP-a paketet. Därför utför Kubernetes-noden ytterligare SNAT när Pod-en skickar svarspaket utåt, för att ändra 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 Pod
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 kommer Pod-en, som har mottagit paketet, att svara till noden som ursprungligen tog emot förfrågan via NodePort, och noden kommer att tillämpa 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            |

Huvuddelen

Normalt hanteras och utförs dessa NAT-processer i Linux via iptables av ett sub-system som kallas conntrack. 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 eBPF-teknik, helt ignorerar denna traditionella Linux-nätverksstack. 🤣

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

Som ett resultat har Cilium, som visas i bilden ovan, valt att direkt implementera endast de funktioner som krävs i Kubernetes-situationer, av de uppgifter som den befintliga Linux-nätverksstacken utförde. Därför hanterar Cilium SNAT-processen som nämnts tidigare genom att direkt hantera SNAT-tabellen i form av en LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Cilium SNAT-tabell
2# !Exempel för enkel förklaring. Faktisk definition: 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 nyckel, och kombinationen av src ip, src port, dst ip, dst port används som nyckel.

Problemupptäckt

Fenomen – 1: Sökning

Ett problem som uppstår är att när ett paket passerar genom eBPF, måste det kontrolleras om det behöver utföra SNAT eller DNAT under NAT-processen genom att söka i den ovanstående Hash Table. Som vi har sett tidigare finns det två typer av paket i SNAT-processen: 1. Paket som går ut från insidan till utsidan, och 2. Svarspaket som kommer in från utsidan till insidan. Dessa två paket kräver NAT-omvandling och kännetecknas av att src ip, port och dst ip, port-värdena är omvända.

För snabb sökning måste man antingen lägga till ytterligare en post i Hash Table med de omvända src och dst-värdena som nyckel, eller söka i samma Hash Table två gånger för alla paket, även om de inte är relaterade till SNAT. Cilium, för bättre prestanda, har naturligtvis valt att infoga samma data två gånger under namnet RevSNAT.

Fenomen – 2: LRU

Och separat från ovanstående problem kan inga hårdvaror ha oändliga resurser, och särskilt i hårdvarulogik som kräver snabb prestanda, där dynamiska datastrukturer inte kan användas, är det nödvändigt att avlägsna befintlig data när resurserna är knappa. Cilium har löst detta genom att använda LRU Hash Map, en grundläggande datastruktur som Linux tillhandahåller som standard.

Fenomen 1 + Fenomen 2 = Anslutningsförlust

https://github.com/cilium/cilium/issues/31643

Det vill säga, för en SNAT-ad TCP (eller UDP) anslutning:

  1. I en Hash Table registreras samma data två gånger för utgående och inkommande paket.
  2. Baserat på LRU-logiken kan en av dessa data när som helst försvinna.

Om så mycket som en enda NAT-information (hädanefter "entry") för utgående eller inkommande paket försvinner på grund av LRU, kan det leda till att hela anslutningen går förlorad eftersom NAT inte kan utföras korrekt.

Lösning

Här kommer de tidigare nämnda PR-erna in i bilden:

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 genom eBPF, försökte det slå upp i SNAT-tabellen med en nyckel skapad från kombinationen av src ip, src port, dst ip och dst port. 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. Om nyckeln däremot 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 leda till att mottagaren av paketet vägrade att ta emot det och anslutningen skulle avslutas med ett RST-paket.

Här är hur PR närmade sig problemet:

När kommunikation observeras i den ena riktningen, uppdatera även posten för den motsatta riktningen.

När kommunikation observeras i någon riktning, uppdateras båda posterna, vilket minskar deras prioritet för Eviction i LRU-logiken. Detta minskar risken för att endast en av posterna raderas och att hela kommunikationen kollapsar.

Detta är ett mycket enkelt tillvägagångssätt och kan verka som en trivial idé, men genom detta tillvägagångssätt har problemet med att NAT-informationen för svarspaket förfaller i förtid och avbryter anslutningen effektivt lösts, och systemets stabilitet har förbättrats avsevärt. Det är också en viktig förbättring som har uppnått följande resultat när det gäller nätverksstabilitet:

benchmark

Slutsats

Jag anser att denna PR är ett utmärkt exempel som visar hur en enkel idé kan åstadkomma en stor förändring, från grundläggande CS-kunskaper om hur NAT fungerar till komplexa systeminteriörer.

Åh, jag har naturligtvis inte direkt visat exempel på komplexa system i denna artikel. Men för att verkligen förstå denna PR, bad jag DeepSeek V3 0324 i nästan tre timmar, till och med med ordet "Please", och som ett resultat fick jag kunskap om Cilium +1 och följande bild. 😇

diagram

Och efter att ha läst igenom problemen och PR:erna kände jag en obehaglig föraning att något jag hade skapat tidigare kunde ha orsakat problemet, och som ett resultat av kompensationen för detta skriver jag denna artikel.

Efterord - 1

För övrigt finns det en mycket effektiv metod för att undvika detta problem. Eftersom grundorsaken till problemet är brist på NAT-tabellutrymme, är det bara att öka NAT-tabellens storlek. :-D

Jag beundrar och respekterar gyutaebs passion. Han analyserade och förstod problemet noggrant, även om det inte var direkt relaterat till honom, och bidrog till Cilium-ekosystemet med objektiv data, medan någon annan i samma situation troligen skulle ha ökat NAT-tabellens storlek utan att lämna en issue och sedan försvunnit.

Det var detta som fick mig att besluta mig för att skriva denna artikel.

Efterord - 2

Denna berättelse är egentligen inte direkt relevant för Gosuda, som specialiserar sig 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 bestämde mig för att ta med något som kunde publiceras på en personlig blogg till Gosuda.

Eftersom en av administratörerna (jag själv) gav sitt tillstånd, antar jag att det är okej.

Om du inte tycker det är okej, spara det som PDF snabbt innan det tas bort. ;)

Efterord - 3

Jag fick mycket hjälp av Cline och Llama 4 Maveric när jag skrev denna artikel. Även om jag började analysen med Gemini och tiggde av DeepSeek, fick jag faktiskt hjälp av Llama 4. Llama 4 är bra. Prova det definitivt.