GoSuda

Het Cilium-verhaal: Hoe een kleine codewijziging leidde tot een opmerkelijke verbetering van de netwerkstabiliteit

By iwanhae
views ...

Inleiding

Onlangs kwam ik de PR tegen van een voormalige collega over het Cilium-project.

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

De omvang van de wijziging (exclusief de testcode) is gering, bestaande uit de toevoeging van slechts één if-blok. Echter, de impact van deze wijziging is aanzienlijk, en het feit dat een eenvoudig idee zo'n enorme bijdrage kan leveren aan de stabiliteit van een systeem vond ik persoonlijk fascinerend. Daarom wil ik dit verhaal delen op een wijze die ook voor personen zonder gespecialiseerde kennis van netwerken gemakkelijk te begrijpen is.

Achtergrondkennis

Als er naast de smartphone nog een essentieel item voor de moderne mens is, dan is het waarschijnlijk de wifi-router. Een wifi-router communiceert met apparaten via de wifi-standaard en deelt zijn publieke IP-adres zodat meerdere apparaten er gebruik van kunnen maken. De technische bijzonderheid die hieruit voortvloeit, is hoe dit "delen" precies gebeurt.

De technologie die hierbij wordt gebruikt, is Network Address Translation (NAT). NAT is een techniek die, gezien het feit dat TCP- of UDP-communicatie bestaat uit een combinatie van IP-adres en poortinformatie, interne communicatie via 'privé IP:Poort' in staat stelt om met de buitenwereld te communiceren door deze te mappen naar een momenteel niet-gebruikt 'publiek IP:Poort'.

NAT

Wanneer een intern apparaat verbinding probeert te maken met het externe internet, converteert de NAT-router de combinatie van het privé IP-adres en het poortnummer van dat apparaat naar zijn eigen publieke IP-adres en een willekeurig, ongebruikt poortnummer. Deze conversie-informatie wordt vastgelegd in een NAT-tabel binnen de NAT-router.

Stel bijvoorbeeld dat een smartphone in huis (privé IP: a.a.a.a, poort: 50000) verbinding probeert te maken met een webserver (publiek IP: c.c.c.c, poort: 80).

1Smartphone (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Webserver (c.c.c.c:80)

Wanneer de router het verzoek van de smartphone ontvangt, zal deze het volgende TCP-pakket zien:

1# TCP-pakket ontvangen door de router, smartphone => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Als dit pakket direct naar de webserver (c.c.c.c) zou worden gestuurd, zou de smartphone (a.a.a.a) met een privé IP-adres geen antwoord ontvangen. Daarom zoekt de router eerst een willekeurige poort (bijv. 60000) die momenteel niet in gebruik is voor communicatie en legt deze vast in de interne NAT-tabel.

1# Interne NAT-tabel van de router
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Nadat de router een nieuw item in de NAT-tabel heeft vastgelegd, wijzigt hij het bron-IP-adres en poortnummer van het TCP-pakket dat van de smartphone is ontvangen naar zijn eigen publieke IP-adres (b.b.b.b) en het nieuw toegewezen poortnummer (60000), en verzendt het vervolgens naar de webserver.

1# TCP-pakket verzonden door de router, router => webserver
2# SNAT uitgevoerd
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

De webserver (c.c.c.c) herkent de aanvraag nu als afkomstig van poort 60000 van de router (b.b.b.b) en stuurt het antwoordpakket als volgt naar de router:

1# TCP-pakket ontvangen door de router, webserver => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Wanneer de router dit antwoordpakket ontvangt, zoekt hij in de NAT-tabel het oorspronkelijke privé IP-adres (a.a.a.a) en poortnummer (50000) op dat overeenkomt met het doel-IP-adres (b.b.b.b) en poortnummer (60000), en wijzigt vervolgens de bestemming van het pakket naar de smartphone.

1# TCP-pakket verzonden door de router, router => smartphone
2# DNAT uitgevoerd
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Door dit proces lijkt het voor de smartphone alsof deze direct communiceert met de webserver via zijn eigen publieke IP-adres. Dankzij NAT kunnen meerdere interne apparaten tegelijkertijd internet gebruiken met één enkel publiek IP-adres.

Kubernetes

Kubernetes heeft een van de meest geavanceerde en complexe netwerkstructuren van recente technologieën. En natuurlijk wordt de eerder genoemde NAT ook op diverse plaatsen toegepast. De twee meest representatieve gevallen zijn de volgende:

Wanneer een Pod communiceert met de buitenkant van het cluster

Pods binnen een Kubernetes-cluster krijgen doorgaans privé IP-adressen toegewezen die alleen communicatie binnen het clusternetwerk mogelijk maken. Daarom is NAT vereist voor verkeer dat een Pod naar het externe internet verzendt. In dit geval wordt NAT voornamelijk uitgevoerd op het Kubernetes Node (elke server in het cluster) waarop de Pod draait. Wanneer een Pod een pakket naar buiten stuurt, wordt dit pakket eerst doorgestuurd naar het Node waartoe de Pod behoort. Het Node wijzigt dan het bron-IP-adres van dit pakket (het privé IP van de Pod) naar zijn eigen publieke IP-adres en past ook het bronpoortnummer dienovereenkomstig aan alvorens het naar buiten te verzenden. Dit proces is vergelijkbaar met het NAT-proces dat eerder werd beschreven voor de wifi-router.

Stel bijvoorbeeld dat een Pod in een Kubernetes-cluster (10.0.1.10, poort: 40000) verbinding maakt met een externe API-server (203.0.113.45, poort: 443). Het Kubernetes-Node ontvangt dan het volgende pakket van de Pod:

1# TCP-pakket ontvangen door het Node, Pod => Node
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Het Node registreert dan de volgende informatie:

1# Interne NAT-tabel van het Node (voorbeeld)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

En vervolgens voert het SNAT uit en stuurt het pakket naar buiten:

1# TCP-pakket verzonden door het Node, Node => API-server
2# SNAT uitgevoerd
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Het verdere proces volgt dan hetzelfde pad als in het voorbeeld van de smartphone-router.

Wanneer er wordt gecommuniceerd met een Pod vanuit buiten het cluster via NodePort

Eén manier om services in Kubernetes extern te exposen, is door gebruik te maken van NodePort-services. Een NodePort-service opent een specifieke poort (NodePort) op alle Nodes binnen het cluster en stuurt inkomend verkeer op deze poort door naar de Pods die deel uitmaken van de service. Externe gebruikers kunnen de service benaderen via het IP-adres van het Node van het cluster en de NodePort.

In dit geval speelt NAT een belangrijke rol, en in het bijzonder vinden DNAT (Destination NAT) en SNAT (Source NAT) gelijktijdig plaats. Wanneer verkeer van buitenaf binnenkomt op de NodePort van een specifiek Node, moet het Kubernetes-netwerk dit verkeer uiteindelijk doorsturen naar de Pod die de betreffende service levert. In dit proces vindt eerst DNAT plaats, waarbij het doel-IP-adres en poortnummer van het pakket worden gewijzigd naar het IP-adres en poortnummer van de Pod.

Stel bijvoorbeeld dat een externe gebruiker (203.0.113.10, poort: 30000) toegang krijgt tot een service via de NodePort (30001) van een Node (192.168.1.5) in een Kubernetes-cluster. Stel dat deze service intern verwijst naar een Pod met IP-adres 10.0.2.15 en poort 8080.

1Externe gebruiker (203.0.113.10:30000) ==> Kubernetes Node (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)

In dit geval heeft het Kubernetes Node zowel het extern toegankelijke IP-adres 192.168.1.5 als het intern geldige IP-adres 10.0.1.1 binnen het Kubernetes-netwerk. (Afhankelijk van het type CNI dat wordt gebruikt, kunnen de beleidsregels hieromtrent variëren, maar in dit artikel wordt de uitleg gebaseerd op Cilium.)

Wanneer het verzoek van de externe gebruiker op het Node aankomt, moet het Node dit verzoek doorsturen naar de Pod die het moet verwerken. Hierbij past het Node de volgende DNAT-regel toe om het doel-IP-adres en poortnummer van het pakket te wijzigen:

1# TCP-pakket dat het Node voorbereidt om naar de Pod te sturen
2# Na toepassing van DNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10  | 30000    | 10.0.2.15 | 8080     |

Belangrijk hierbij is dat wanneer de Pod een antwoord op dit verzoek stuurt, het bron-IP-adres zijn eigen IP-adres (10.0.2.15) is en het doel-IP-adres het IP-adres van de externe gebruiker die het verzoek heeft gestuurd (203.0.113.10). In dit geval ontvangt de externe gebruiker een antwoord van een niet-bestaand IP-adres dat hij nooit heeft aangevraagd, en zal hij het betreffende pakket simpelweg DROPpen. Daarom voert het Kubernetes Node een aanvullende SNAT uit wanneer de Pod antwoordpakketten naar buiten stuurt, waarbij het bron-IP-adres van het pakket wordt gewijzigd naar het IP-adres van het Node (192.168.1.5 of het interne netwerk-IP 10.0.1.1; in dit geval 10.0.1.1).

1# TCP-pakket dat het Node voorbereidt om naar de Pod te sturen
2# Na toepassing van DNAT, SNAT
3| src ip    | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1  | 40021    | 10.0.2.15 | 8080     |

Nu stuurt de Pod, na ontvangst van dit pakket, een antwoord naar het Node dat oorspronkelijk de NodePort-aanvraag ontving. Het Node past dan omgekeerd dezelfde DNAT- en SNAT-processen toe om de informatie terug te sturen naar de externe gebruiker. Tijdens dit proces zal elk Node de volgende informatie opslaan:

1# Interne DNAT-tabel van het Node
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-tabel van het Node
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Hoofdtekst

Doorgaans worden dergelijke NAT-processen in Linux beheerd en uitgevoerd door het conntrack-subsysteem via iptables. Andere CNI-projecten zoals flannel of calico maken hier daadwerkelijk gebruik van om de bovengenoemde problemen op te lossen. Het probleem is echter dat Cilium, door gebruik te maken van de eBPF-technologie, deze traditionele Linux-netwerkstack volledig negeert. 🤣

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

Als gevolg hiervan heeft Cilium ervoor gekozen om alleen de functionaliteiten die nodig zijn in een Kubernetes-context, rechtstreeks te implementeren, in plaats van de taken die de bestaande Linux-netwerkstack in de bovenstaande afbeelding uitvoerde. Daarom beheert Cilium de eerder genoemde SNAT-processen rechtstreeks via een SNAT-tabel in de vorm van een LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Cilium SNAT-tabel
2# !Voorbeeld ter vereenvoudiging. De daadwerkelijke definitie is: 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 en andere metadata
4----------------------------------------------
5|            |          |         |          |

En aangezien het een Hash Table is, wordt voor snelle lookup een sleutelwaarde gebruikt, waarbij de combinatie van src ip, src port, dst ip, dst port als sleutelwaarde wordt gebruikt.

Probleemherkenning

Fenomeen - 1: Lookup

Hieruit vloeit één probleem voort: om te controleren of een pakket dat eBPF passeert, SNAT- of DNAT-bewerkingen nodig heeft, moet de bovengenoemde Hash Table worden geraadpleegd. Zoals eerder gezien, zijn er twee soorten pakketten betrokken bij het SNAT-proces: 1. pakketten die van binnen naar buiten gaan, en 2. antwoordpakketten die van buiten naar binnen komen. Deze twee pakketten vereisen een transformatie voor het NAT-proces en kenmerken zich door het feit dat de src ip, port en dst ip, port-waarden worden omgedraaid.

Daarom is voor snelle lookup het nodig om ofwel een extra waarde in de Hash Table toe te voegen met de omgedraaide src- en dst-waarden als sleutel, ofwel twee keer dezelfde Hash Table te raadplegen voor alle pakketten, zelfs als deze geen betrekking hebben op SNAT. Cilium heeft uiteraard, voor betere prestaties, de methode gekozen om dezelfde gegevens twee keer in te voeren onder de naam RevSNAT.

Fenomeen - 2: LRU

En los van het bovengenoemde probleem, is er geen onbeperkte hoeveelheid middelen beschikbaar op enige hardware, en in het bijzonder in een hardware-logica die snelle prestaties vereist, waar dynamische datastructuren niet kunnen worden gebruikt, is het noodzakelijk om bestaande gegevens te evicten wanneer er een tekort aan middelen is. Cilium heeft dit opgelost door gebruik te maken van de LRU Hash Map, een basisdatastructuur die standaard in Linux wordt geleverd.

Fenomeen 1 + Fenomeen 2 = Verlies van verbinding

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

Dat betekent dat voor een gesnattede TCP- (of UDP-)verbinding:

  1. Dezelfde gegevens tweemaal zijn vastgelegd in één Hash Table voor uitgaande en inkomende pakketten.
  2. Afhankelijk van de LRU-logica kan een van de twee gegevens op elk moment verloren gaan.

Als zelfs maar één NAT-informatie (hierna 'entry') voor een uitgaand of inkomend pakket door LRU wordt verwijderd, kan dit leiden tot het niet correct kunnen uitvoeren van NAT, wat resulteert in het verlies van de gehele verbinding.

Oplossing

Hier komen de eerder genoemde PR's in beeld.

bpf:nat: restore a NAT entry if its REV NAT is not found

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

Voorheen, wanneer een pakket eBPF passeerde, probeerde het een lookup uit te voeren in de SNAT-tabel met behulp van een sleutel die was samengesteld uit de combinatie van src ip, src port, dst ip en dst port. Als de sleutel niet bestond, werd een nieuwe NAT-informatie gegenereerd volgens de SNAT-regels en vastgelegd in de tabel. In het geval van een nieuwe verbinding zou dit leiden tot normale communicatie. Als de sleutel echter onbedoeld was verwijderd door LRU, zou een nieuwe NAT worden uitgevoerd met een andere poort dan de poort die voor de bestaande communicatie werd gebruikt, waardoor de ontvangende partij het pakket zou weigeren en de verbinding zou worden beëindigd met een RST-pakket.

De aanpak die deze PR hanteert, is eenvoudig:

Als een pakket in welke richting dan ook wordt waargenomen, moet de entry voor de omgekeerde richting ook opnieuw worden bijgewerkt.

Wanneer communicatie in welke richting dan ook wordt waargenomen, worden beide entries opnieuw bijgewerkt, waardoor ze minder snel in aanmerking komen voor Eviction door de LRU-logica. Dit verkleint de kans dat slechts één entry wordt verwijderd, wat zou leiden tot een volledige verstoring van de communicatie.

Dit is een zeer eenvoudige benadering en mag dan een simpel idee lijken, maar door deze aanpak is het mogelijk geworden om het probleem van verbroken verbindingen, veroorzaakt door het voortijdig verlopen van NAT-informatie voor antwoordpakketten, effectief op te lossen en de systeemstabiliteit aanzienlijk te verbeteren. Bovendien kan dit worden beschouwd als een belangrijke verbetering die de volgende resultaten heeft opgeleverd op het gebied van netwerkstabiliteit:

benchmark

Conclusie

Ik ben van mening dat deze PR een uitstekend voorbeeld is van hoe een eenvoudig idee, beginnend bij fundamentele CS-kennis over de werking van NAT, een aanzienlijke impact kan hebben, zelfs binnen complexe systemen.

Oh, en ik heb in dit artikel niet direct voorbeelden van complexe systemen laten zien. Echter, om deze PR correct te begrijpen, heb ik bijna 3 uur lang gesmeekt bij DeepSeek V3 0324, zelfs met het woord Please, en als resultaat heb ik Cilium-kennis +1 en de volgende afbeelding verkregen. 😇

diagram

En tijdens het lezen van de issues en PR's bekroop me een onheilspellend voorgevoel dat er problemen waren ontstaan door iets dat ik eerder had gecreëerd, en als compensatie voor dat gevoel heb ik dit artikel geschreven.

Naschrift - 1

Ter referentie, er bestaat een zeer effectieve manier om dit probleem te omzeilen. Aangezien de hoofdoorzaak van het probleem het gebrek aan ruimte in de NAT-tabel is, hoeft men alleen de grootte van de NAT-tabel te vergroten. :-D

Ik bewonder en respecteer de passie van gyutaeb, die, in een situatie waarin iemand anders hetzelfde probleem zou zijn tegengekomen en zonder een issue te loggen de NAT-tabelgrootte zou hebben vergroot en vervolgens zou zijn weggelopen, dit probleem grondig heeft geanalyseerd, begrepen en zelfs een bijdrage heeft geleverd aan het Cilium-ecosysteem met objectieve onderbouwde gegevens, hoewel het geen probleem was dat direct met hem verband hield.

Dit was de aanleiding om dit artikel te schrijven.

Naschrift - 2

Dit verhaal is eigenlijk niet direct relevant voor Gosuda, dat gespecialiseerd is in de Go-taal. Echter, aangezien de Go-taal en het cloud-ecosysteem nauw met elkaar verbonden zijn en de bijdragers aan Cilium een zekere mate van kennis van de Go-taal hebben, heb ik besloten om content die ik normaal op mijn persoonlijke blog zou plaatsen, nu eens naar Gosuda te brengen.

Aangezien één van de beheerders (ikzelf) toestemming heeft gegeven, denk ik dat het wel in orde is.

Mocht u van mening zijn dat het niet in orde is, dan weet u maar nooit wanneer het wordt verwijderd, dus sla het snel op als PDF. ;)

Naschrift - 3

Bij het schrijven van dit artikel heb ik veel hulp gekregen van Cline en Llama 4 Maveric. Hoewel ik de analyse begon met Gemini en bij DeepSeek smeekte, kreeg ik uiteindelijk hulp van Llama 4. Llama 4 is uitstekend. Probeer het zeker eens.