GoSuda

Het verhaal van Cilium: Opmerkelijke verbeteringen in netwerkstabiliteit door kleine codewijzigingen

By iwanhae
views ...

Inleiding

Onlangs heb ik een Pull Request (PR) van een voormalige collega voor het Cilium-project bekeken.

bpf:nat: Herstel ORG NAT-entry als deze niet wordt gevonden

De omvang van de wijziging (exclusief de testcode) is gering, slechts de toevoeging van één if-statementblok. Echter, de impact van deze wijziging is aanzienlijk, en het persoonlijke aspect dat een eenvoudig idee zo'n grote bijdrage kan leveren aan de systeemstabiliteit, vond ik interessant. Daarom wil ik dit verhaal uiteenzetten op een wijze die gemakkelijk te begrijpen is voor mensen zonder diepgaande kennis van netwerken.

Achtergrondinformatie

Als er één essentieel item is voor de moderne mens, net zo belangrijk als de smartphone, dan is het waarschijnlijk de wifi-router. Een wifi-router communiceert met apparaten via de wifi-communicatiestandaard en deelt zijn publieke IP-adres zodat meerdere apparaten dit kunnen gebruiken. De technische bijzonderheid die hieruit voortvloeit, is hoe dit "delen" precies gebeurt.

De technologie die hiervoor wordt gebruikt, is Network Address Translation (NAT). NAT is een techniek die interne communicatie, bestaande uit een combinatie van een privaat IP:Poort, toewijst aan een publiek IP:Poort dat momenteel niet in gebruik is, waardoor communicatie met de buitenwereld mogelijk wordt, aangezien TCP- of UDP-communicatie eveneens uit een combinatie van IP-adres en poortinformatie bestaat.

NAT

Wanneer een intern apparaat verbinding probeert te maken met het externe internet, converteert een NAT-apparaat de combinatie van het private IP-adres en 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 het NAT-apparaat.

Stel bijvoorbeeld dat een smartphone in huis (privaat 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 rechtstreeks naar de webserver (c.c.c.c) zou worden verzonden, zou er geen antwoord terugkeren naar de smartphone (a.a.a.a) met een privaat IP-adres. Daarom zoekt de router eerst een willekeurige poort (bijvoorbeeld 60000) die momenteel niet bij de communicatie betrokken is 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 nieuwe entry in de NAT-tabel heeft vastgelegd, wijzigt deze 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), waarna het pakket naar de webserver wordt verzonden.

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 dit nu als een verzoek 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 deze in de NAT-tabel naar het oorspronkelijke private IP-adres (a.a.a.a) en poortnummer (50000) die overeenkomen met het doel-IP-adres (b.b.b.b) en poortnummer (60000), waarna het doel van het pakket wordt gewijzigd 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    |

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

Kubernetes

Kubernetes beschikt over een van de meest geavanceerde en complexe netwerkstructuren van recente technologieën. En uiteraard wordt de eerder genoemde NAT op diverse plaatsen toegepast. De volgende twee voorbeelden zijn representatief:

Communicatie vanuit een Pod naar de buitenwereld van het cluster

Pods binnen een Kubernetes-cluster krijgen doorgaans private IP-adressen toegewezen die alleen binnen het clusternetwerk kunnen communiceren. Daarom is NAT noodzakelijk voor uitgaand verkeer wanneer een Pod met het externe internet moet communiceren. In dergelijke gevallen wordt NAT voornamelijk uitgevoerd op de Kubernetes-node (elke server in het cluster) waar de Pod draait. Wanneer een Pod een pakket naar buiten stuurt, wordt dit pakket eerst naar de node gestuurd waartoe de Pod behoort. De node wijzigt vervolgens het bron-IP-adres van dit pakket (het private IP van de Pod) naar zijn eigen publieke IP-adres en past ook de bronpoort dienovereenkomstig aan, waarna het pakket naar buiten wordt verzonden. Dit proces is vergelijkbaar met het eerder beschreven NAT-proces bij een 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). De Kubernetes-node ontvangt dan het volgende pakket van de Pod:

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

De node legt dan de volgende informatie vast:

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

Vervolgens voert het SNAT uit en verzendt het pakket naar buiten, zoals hieronder weergegeven:

1# TCP-pakket verzonden door de 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 verloopt dan op dezelfde wijze als in het voorbeeld met de smartphone-router.

Communicatie van buiten het cluster met een Pod via NodePort

Eén manier om services in Kubernetes extern te exponeren, is door middel van NodePort-services. Een NodePort-service opent een specifieke poort (NodePort) op alle nodes binnen het cluster en routeert inkomend verkeer op deze poort naar de Pods die bij de service horen. Externe gebruikers kunnen via het IP-adres van de node en de NodePort toegang krijgen tot de service.

In dit proces speelt NAT een cruciale rol, waarbij met name DNAT (Destination NAT) en SNAT (Source NAT) gelijktijdig optreden. Wanneer extern verkeer via de NodePort een specifieke node bereikt, moet het Kubernetes-netwerk dit verkeer uiteindelijk doorsturen naar de Pod die de betreffende service levert. Hierbij 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 Kubernetes-node (192.168.1.5). We nemen aan 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 de Kubernetes-node zowel het extern toegankelijke IP-adres 192.168.1.5 als het interne Kubernetes-netwerk geldige IP-adres 10.0.1.1. (Afhankelijk van het gebruikte CNI-type kunnen de gerelateerde beleidsregels variëren, maar in dit artikel wordt de uitleg gebaseerd op Cilium.)

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

1# TCP-pakket dat de 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     |

Het cruciale punt hierbij is dat wanneer de Pod een antwoord op dit verzoek verzendt, 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 verzonden (203.0.113.10). In dit geval zal de externe gebruiker een antwoord ontvangen van een niet-bestaand IP-adres waarvan hij geen verzoek heeft ingediend, en zal hij dat pakket eenvoudigweg DROPPEN. Daarom voert de Kubernetes-node aanvullend SNAT uit wanneer de Pod antwoordpakketten naar buiten stuurt, om het bron-IP-adres van het pakket te wijzigen naar het IP-adres van de 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 de 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 zal de Pod, die het pakket heeft ontvangen, reageren op de node die oorspronkelijk het NodePort-verzoek heeft ontvangen. De node zal hetzelfde DNAT- en SNAT-proces in omgekeerde volgorde toepassen om de informatie terug te sturen naar de externe gebruiker. Tijdens dit proces zal elke node de volgende informatie opslaan:

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

Kern van de zaak

Doorgaans worden deze NAT-processen in Linux beheerd en uitgevoerd via iptables, onder het conntrack-subsysteem. Inderdaad, andere CNI-projecten zoals flannel en calico gebruiken dit om de bovengenoemde problemen aan te pakken. Het probleem is echter dat Cilium, door gebruik te maken van 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-omgeving, van de taken die de bestaande Linux-netwerkstack in de bovenstaande afbeelding uitvoerde, direct te implementeren. Daarom beheert Cilium de SNAT-tabel voor het eerder genoemde SNAT-proces zelf 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, etc. metadata
4----------------------------------------------
5|            |          |         |          |

En als Hash Table, voor snelle opzoekingen, bestaat er een sleutelwaarde, waarbij de combinatie van src ip, src port, dst ip, dst port als sleutelwaarde wordt gebruikt.

Probleemherkenning

Fenomeen - 1: Opzoeken

Hierdoor ontstaat één probleem: om te controleren of een pakket dat eBPF passeert, NAT (SNAT of DNAT) moet uitvoeren, 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. Beide pakketten vereisen een transformatie via het NAT-proces, en kenmerkend is dat de waarden voor src ip, port en dst ip, port worden omgewisseld.

Om een snelle opzoeking mogelijk te maken, moet ofwel een extra waarde aan de Hash Table worden toegevoegd met de omgekeerde src en dst als sleutel, ofwel moet de Hash Table twee keer worden geraadpleegd voor alle pakketten, zelfs die niet gerelateerd zijn aan SNAT. Uiteraard heeft Cilium, voor betere prestaties, gekozen voor de methode om dezelfde gegevens twee keer in te voeren onder de naam RevSNAT.

Fenomeen - 2: LRU

Naast het bovengenoemde probleem kunnen er geen oneindige middelen op hardware bestaan, en aangezien het een hardware-logica betreft die snelle prestaties vereist, is het ook niet mogelijk om dynamische datastructuren te gebruiken. Daarom is het noodzakelijk om bestaande gegevens te verwijderen (evict) wanneer de middelen ontoereikend zijn. Cilium heeft dit opgelost door gebruik te maken van de LRU Hash Map, een fundamentele datastructuur die standaard in Linux wordt aangeboden.

Fenomeen 1 + Fenomeen 2 = Verbinding verloren

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

Dat wil zeggen, voor één SNAT-TCP- (of UDP-) verbinding:

  1. In één Hash Table worden dezelfde gegevens twee keer vastgelegd voor uitgaande en inkomende pakketten.
  2. In een situatie waarin één van de twee gegevens te allen tijde kan verdwijnen volgens de LRU-logica.

Als slechts één van de NAT-informatie (hierna entry genoemd) voor uitgaande of inkomende pakketten door LRU wordt verwijderd, kan de NAT niet correct worden uitgevoerd, wat kan leiden tot volledig verlies van de verbinding.

Oplossing

Hier komen de eerder genoemde PR's aan bod:

bpf:nat: herstel een NAT-entry als de REV NAT niet wordt gevonden

bpf:nat: Herstel ORG NAT-entry als deze niet wordt gevonden

Voorheen, wanneer een pakket eBPF passeerde, probeerde het een opzoeking uit te voeren in de SNAT-tabel met een sleutel die was samengesteld uit src ip, src port, dst ip en dst port. Als de sleutel niet bestond, genereerde het nieuwe NAT-informatie volgens de SNAT-regels en legde deze vast in de tabel. Bij een nieuwe verbinding zou dit leiden tot normale communicatie. Echter, als de sleutel onbedoeld door LRU was verwijderd, zou een nieuwe NAT worden uitgevoerd met een andere poort dan de poort die eerder voor de communicatie werd gebruikt. De ontvangende partij zou dan de ontvangst van het pakket weigeren, en de verbinding zou worden beëindigd met een RST-pakket.

De aanpak die de bovenstaande PR hanteerde, is eenvoudig:

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

Wanneer communicatie in welke richting dan ook wordt waargenomen, worden beide entries nieuw 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 het instorten van de gehele communicatie.

Dit lijkt misschien een zeer eenvoudige benadering en een simpel idee, maar via deze aanpak is het probleem van het verbreken van verbindingen door het voortijdig verlopen van NAT-informatie voor antwoordpakketten effectief opgelost, en is de systeemstabiliteit aanzienlijk verbeterd. Het kan ook worden beschouwd als een belangrijke verbetering die de volgende resultaten heeft opgeleverd op het gebied van netwerkstabiliteit:

benchmark

Conclusie

Ik beschouw deze PR als een uitstekend voorbeeld dat niet alleen de fundamentele CS-kennis over de werking van NAT illustreert, maar ook aantoont hoe een eenvoudig idee binnen een complex systeem een aanzienlijke verandering teweeg kan brengen.

Ah, natuurlijk heb ik in dit artikel geen directe voorbeelden van complexe systemen gegeven. Maar om deze PR goed te begrijpen, heb ik bijna 3 uur lang "Please" gesmeekt aan DeepSeek V3 0324, en als resultaat heb ik kennis van Cilium +1 en de volgende afbeelding verkregen. 😇

diagram

En tijdens het lezen van de issues en PR's bekroop mij een onheilspellend voorgevoel dat er problemen zouden zijn ontstaan door iets wat ik in het verleden had gemaakt. Als compensatie daarvoor schrijf ik dit artikel.

Naschrift - 1

Ter referentie, voor dit probleem bestaat een zeer effectieve omzeiling. Aangezien de fundamentele oorzaak van het probleem onvoldoende ruimte in de NAT-tabel is, volstaat het om de grootte van de NAT-tabel te vergroten. :-D

In een situatie waarin iemand anders, geconfronteerd met hetzelfde probleem, geen issue zou hebben gemeld en simpelweg de NAT-tabelgrootte zou hebben vergroot en vervolgens zou zijn verdwenen, bewonder en respecteer ik de passie van gyutaeb die, ondanks dat het geen direct gerelateerd probleem was, het grondig analyseerde, begreep en zelfs bijdroeg aan het Cilium-ecosysteem met objectieve onderbouwde gegevens.

Dit was de aanleiding voor het schrijven van dit artikel.

Naschrift - 2

Dit verhaal is eigenlijk niet direct gerelateerd aan Gosuda, dat zich specialiseert in de Go-taal. Echter, aangezien de Go-taal en het cloud-ecosysteem nauw met elkaar verbonden zijn en de bijdragers van Cilium tot op zekere hoogte bedreven zijn in de Go-taal, heb ik besloten om content die ik normaal op mijn persoonlijke blog zou plaatsen, naar Gosuda te halen.

Aangezien één van de beheerders (ikzelf) toestemming heeft gegeven, zal het waarschijnlijk in orde zijn.

Als u denkt dat het niet in orde is, sla het dan snel op als PDF, want het kan op elk moment worden verwijderd. ;)

Naschrift - 3

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