Příběh Ciliumu: Jak malá změna kódu vedla k úžasnému zlepšení stability sítě
Úvod
Nedávno jsem se podíval na PR (Pull Request) k projektu Cilium od mého bývalého kolegy.
bpf:nat: Restore ORG NAT entry if it's not found
Množství změn (s výjimkou testovacího kódu) je malé, přidává se pouze jeden blok if
příkazu. Nicméně dopad této úpravy je obrovský a je osobně fascinující, jak prostý nápad může významně přispět ke stabilitě systému. Proto bych se chtěl pokusit vysvětlit tento případ tak, aby mu snadno porozuměli i lidé bez odborných znalostí v oblasti sítí.
Základní znalosti
Pokud existuje nějaká nezbytnost pro moderního člověka, která je stejně důležitá jako chytrý telefon, pak je to pravděpodobně Wi-Fi router. Wi-Fi router komunikuje se zařízeními prostřednictvím komunikačního standardu Wi-Fi a slouží k sdílení své veřejné IP adresy, aby ji mohlo používat více zařízení. Technická zvláštnost, která zde vyvstává, je, jak se toto „sdílení“ provádí.
Technologie, která se zde používá, je Network Address Translation (NAT). NAT je technologie, která umožňuje interní komunikaci složenou z kombinace privátní IP:Port
s vnější komunikací tím, že ji mapuje na nepoužívanou kombinaci veřejná IP:Port
, jelikož TCP nebo UDP komunikace je tvořena kombinací IP adresy a informací o portu.
NAT
Když se interní zařízení pokusí připojit k externímu internetu, zařízení NAT převede kombinaci privátní IP adresy a portu tohoto zařízení na svou vlastní veřejnou IP adresu a volné náhodné číslo portu. Tato informace o převodu je zaznamenána do NAT tabulky uvnitř zařízení NAT.
Předpokládejme například, že chytrý telefon (privátní IP: a.a.a.a
, port: 50000) v domě se pokouší připojit k webovému serveru (veřejná IP: c.c.c.c
, port: 80).
1Chytrý telefon (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Webový server (c.c.c.c:80)
Když router obdrží požadavek od chytrého telefonu, uvidí následující TCP Packet:
1# TCP paket přijatý routerem, chytrý telefon => router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Pokud by tento paket byl odeslán přímo na webový server (c.c.c.c
), odpověď by se nevrátila chytrému telefonu (a.a.a.a
) s privátní IP adresou. Proto router nejprve najde náhodný port, který se v dané komunikaci nepoužívá (např. 60000), a zaznamená jej do interní NAT tabulky.
1# Interní NAT tabulka routeru
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
Poté, co router zaznamená novou položku do NAT tabulky, změní zdrojovou IP adresu a číslo portu TCP paketu přijatého od chytrého telefonu na svou vlastní veřejnou IP adresu (b.b.b.b
) a nově přidělené číslo portu (60000) a odešle jej webovému serveru.
1# TCP paket odeslaný routerem, router => webový server
2# Provedení SNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Nyní webový server (c.c.c.c
) rozpozná požadavek jako přicházející z portu 60000 routeru (b.b.b.b
) a odešle odpovědní paket routeru následovně:
1# TCP paket přijatý routerem, webový server => router
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Když router obdrží tento odpovědní paket, vyhledá v NAT tabulce původní privátní IP adresu (a.a.a.a
) a číslo portu (50000) odpovídající cílové IP adrese (b.b.b.b
) a číslu portu (60000) a změní cíl paketu na chytrý telefon.
1# TCP paket odeslaný routerem, router => chytrý telefon
2# Provedení DNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Díky tomuto procesu má chytrý telefon pocit, jako by přímo komunikoval s webovým serverem pomocí své vlastní veřejné IP adresy. Díky NATu může jedna veřejná IP adresa umožnit více interním zařízením současně používat internet.
Kubernetes
Kubernetes má jednu z nejdůmyslnějších a nejsložitějších síťových struktur mezi nedávno vyvinutými technologiemi. A samozřejmě, výše zmíněný NAT je využíván na různých místech. Dva reprezentativní příklady jsou následující:
Když Pod uvnitř clusteru komunikuje s externím prostředím clusteru
Pody uvnitř Kubernetes clusteru obvykle dostávají privátní IP adresy, které umožňují komunikaci pouze v rámci sítě clusteru. Proto, pokud má Pod komunikovat s externím internetem, je pro odchozí provoz z clusteru vyžadován NAT. V tomto případě se NAT provádí primárně na uzlu Kubernetes (každý server v clusteru), kde je Pod spuštěn. Když Pod odešle paket směřující ven, tento paket je nejprve doručen uzlu, ke kterému Pod patří. Uzel změní zdrojovou IP adresu paketu (privátní IP Podu) na svou vlastní veřejnou IP adresu a také vhodně změní zdrojový port před odesláním ven. Tento proces je podobný procesu NAT popsanému dříve u Wi-Fi routeru.
Předpokládejme například, že Pod v Kubernetes clusteru (10.0.1.10
, port: 40000) se připojuje k externímu API serveru (203.0.113.45
, port: 443). Kubernetes uzel obdrží následující paket od Podu:
1# TCP paket přijatý uzlem, Pod => Uzel
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Uzel poté zaznamená následující informace:
1# Interní NAT tabulka uzlu (příklad)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
Následně provede SNAT a odešle paket ven:
1# TCP paket odeslaný uzlem, uzel => API server
2# Provedení SNAT
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
Zbytek procesu je stejný jako v případě chytrého telefonu a routeru.
Když externí klient komunikuje s Podem přes NodePort
V Kubernetes je jedním ze způsobů vystavení služeb externímu světu použití služby NodePort. Služba NodePort otevře specifický port (NodePort) na všech uzlech v clusteru a předává provoz přicházející na tento port Podům patřícím k této službě. Externí uživatelé mohou přistupovat ke službě pomocí IP adresy uzlu clusteru a NodePortu.
V tomto případě hraje NAT důležitou roli, přičemž DNAT (Destination NAT) a SNAT (Source NAT) probíhají současně. Když provoz přichází zvenčí na NodePort konkrétního uzlu, síť Kubernetes musí tento provoz nakonec předat Podu poskytujícímu danou službu. V tomto procesu nejprve probíhá DNAT, který změní cílovou IP adresu a číslo portu paketu na IP adresu a číslo portu Podu.
Předpokládejme například, že externí uživatel (203.0.113.10
, port: 30000) přistupuje ke službě přes NodePort (30001
) jednoho z uzlů Kubernetes clusteru (192.168.1.5
). Předpokládáme, že tato služba interně odkazuje na Pod s IP adresou 10.0.2.15
a portem 8080
.
1Externí uživatel (203.0.113.10:30000) ==> Kubernetes uzel (externí:192.168.1.5:30001 / interní: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)
V tomto případě má Kubernetes uzel jak IP adresu uzlu přístupnou zvenčí (192.168.1.5), tak IP adresu platnou v interní síti Kubernetes (10.0.1.1). (V závislosti na typu CNI se zásady s tím související liší, ale v tomto článku se zaměříme na Cilium.)
Když požadavek externího uživatele dorazí na uzel, uzel musí tento požadavek předat Podu, který jej zpracuje. V tomto okamžiku uzel použije následující pravidlo DNAT k úpravě cílové IP adresy a čísla portu paketu.
1# TCP paket připravený uzlem k odeslání Podu
2# Po aplikaci DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Zde je důležité, že když Pod odešle odpověď na tento požadavek, zdrojová IP adresa bude jeho vlastní IP adresa (10.0.2.15
) a cílová IP adresa bude IP adresa externího uživatele, který požadavek odeslal (203.0.113.10
). V takovém případě externí uživatel obdrží odpověď z neexistující IP adresy, o kterou si nikdy nepožádal, a tento paket jednoduše DROPne. Proto, když Kubernetes uzel odešle odpovědní paket ven, provede dodatečně SNAT, aby změnil zdrojovou IP adresu paketu na IP adresu uzlu (192.168.1.5 nebo interní síťovou IP adresu 10.0.1.1, v tomto případě 10.0.1.1).
1# TCP paket připravený uzlem k odeslání Podu
2# Po aplikaci DNAT, SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Nyní Pod, který obdržel tento paket, odpoví uzlu, který původně přijal požadavek NodePort. Uzel provede reverzní proces DNAT a SNAT a vrátí informace externímu uživateli. V průběhu tohoto procesu si každý uzel uloží následující informace:
1# Interní DNAT tabulka uzlu
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Interní SNAT tabulka uzlu
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Jádro věci
V Linuxu jsou tyto procesy NAT obecně řízeny a prováděny subsystémem conntrack prostřednictvím iptables. Projekty CNI jako flannel nebo calico ve skutečnosti využívají tuto funkcionalitu k řešení výše uvedených problémů. Problémem však je, že Cilium používá technologii eBPF a zcela ignoruje tento tradiční síťový stack Linuxu. 🤣
V důsledku toho si Cilium zvolilo cestu přímé implementace pouze těch funkcí, které jsou potřebné v prostředí Kubernetes, z úloh, které dříve zajišťoval tradiční síťový stack Linuxu, jak je znázorněno na obrázku výše. Proto Cilium přímo spravuje tabulku SNAT pro výše zmíněný proces SNAT ve formě LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Cilium SNAT tabulka
2# !Příklad pro snadné vysvětlení. Skutečná definice: 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 atd. metadata
4----------------------------------------------
5| | | | |
A jelikož se jedná o Hash Table, pro rychlé vyhledávání se používá kombinace src ip
, src port
, dst ip
, dst port
jako klíč https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/common.h#L909-L922.
Rozpoznání problému
Fenomén - 1: Vyhledávání
V důsledku toho vzniká jeden problém: paket procházející eBPF musí zkontrolovat, zda potřebuje provést proces SNAT nebo DNAT pro proces NAT, a to provedením vyhledávání v Hash Table. Jak jsme viděli dříve, existují dva typy paketů pro proces SNAT: 1. Pakety směřující zevnitř ven, a 2. Odpovědní pakety přicházející zvenčí dovnitř. Tyto dva pakety vyžadují transformaci NAT a vyznačují se výměnou hodnot src ip, port a dst ip, port.
Pro rychlé vyhledávání je proto nutné přidat do Hash Table další hodnotu s prohozenými src a dst, nebo je nutné provést dvakrát vyhledávání ve stejné Hash Table pro všechny pakety, i když s SNAT nesouvisí. Cilium samozřejmě zvolilo metodu vkládání stejných dat dvakrát pod názvem RevSNAT pro lepší výkon.
Fenomén - 2: LRU
Nezávisle na výše uvedeném problému, nekonečné zdroje nemohou existovat na žádném hardwaru, a zejména v případě logiky na úrovni hardwaru, která vyžaduje vysoký výkon, kde dynamické datové struktury nemohou být použity, je nutné vyřadit stávající data, když dojde k nedostatku zdrojů. Cilium to řeší pomocí LRU Hash Map, základní datové struktury poskytované Linuxem.
Fenomén 1 + Fenomén 2 = Ztráta spojení
https://github.com/cilium/cilium/issues/31643
To znamená, že pro jedno SNAT TCP (nebo UDP) spojení:
- Stejná data jsou zaznamenána dvakrát v jedné Hash Table pro odchozí a příchozí pakety.
- Vzhledem k logice LRU může kdykoli dojít ke ztrátě jednoho z těchto dat.
Pokud se jedna z informací NAT (dále jen "entry") pro odchozí nebo příchozí paket ztratí kvůli LRU, nemůže být NAT proveden správně, což může vést k úplné ztrátě spojení.
Řešení
Zde se objevují výše zmíněné PR:
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Dříve, když paket prošel eBPF, pokusil se vyhledat v tabulce SNAT pomocí klíče vytvořeného kombinací src ip, src port, dst ip a dst port. Pokud klíč neexistoval, byla vytvořena nová informace NAT podle pravidla SNAT a zaznamenána do tabulky. V případě nového spojení by to vedlo k normální komunikaci. Pokud by však byl klíč neúmyslně odstraněn LRU, NAT by byl proveden nově s jiným portem než ten, který byl použit pro stávající komunikaci, což by způsobilo, že by příjemce paketu odmítl příjem a spojení by bylo ukončeno s RST paketem.
Zde je přístup PR jednoduchý:
Pokud je paket pozorován v jednom směru, obnovte i záznam pro opačný směr.
Když je komunikace pozorována v jakémkoli směru, oba záznamy jsou aktualizovány, čímž se vzdalují od priority vyřazení v logice LRU. Tím se snižuje pravděpodobnost scénáře, kdy by se vymazáním pouze jednoho záznamu zhroutila celá komunikace.
Jedná se o velmi jednoduchý přístup a může se zdát jako prostý nápad, ale díky tomuto přístupu bylo možné efektivně vyřešit problém přerušení spojení, kdy informace NAT pro odpovědní pakety vypršela dříve, a výrazně se zlepšila stabilita systému. Může být také považováno za důležité zlepšení, které dosáhlo následujících výsledků z hlediska stability sítě.
Závěr
Domnívám se, že tento PR je vynikajícím příkladem toho, jak jednoduchý nápad může přinést obrovskou změnu i v komplexním systému, počínaje základními znalostmi CS o tom, jak funguje NAT.
Ach, samozřejmě, v tomto článku jsem přímo neukázal příklad složitého systému. Ale abych správně porozuměl tomuto PR, musel jsem prosit DeepSeek V3 0324
téměř 3 hodiny, dokonce slovem Please
, a výsledkem bylo získání znalostí o Cilium +1 a následujícího obrázku. 😇
A při čtení issues a PR jsem pociťoval zlou předtuchu, že nějaký problém mohl vzniknout kvůli něčemu, co jsem kdysi vytvořil, a tak jsem se rozhodl napsat tento článek jako kompenzaci.
Poznámka - 1
Mimochodem, pro tento problém existuje velmi efektivní způsob, jak se mu vyhnout. Základní příčinou problému je nedostatek místa v tabulce NAT, takže stačí zvětšit velikost tabulky NAT. :-D
Zatímco se někdo jiný, kdo se s tímto problémem setkal, vyhnul vytvoření issue a jen zvětšil velikost tabulky NAT a "utekl", obdivuji a respektuji vášeň pana gyutaeb, který i přesto, že se ho problém netýkal, provedl důkladnou analýzu, pochopení a přispěl do ekosystému Cilium s objektivními daty.
To byl důvod, proč jsem se rozhodl napsat tento článek.
Poznámka - 2
Tento příběh se ve skutečnosti přímo nehodí k Gosudě, která se specializuje na jazyk Go. Nicméně jazyk Go a cloudový ekosystém jsou úzce propojeny a přispěvatelé do Cilium mají do jisté míry znalosti Go, takže jsem se rozhodl přinést obsah, který by mohl být zveřejněn na osobním blogu, na Gosudu.
Jelikož jsem dostal povolení od jednoho z administrátorů (sebe sama), myslím, že by to mělo být v pořádku.
Pokud si myslíte, že to v pořádku není, raději si to rychle uložte jako PDF, protože nikdo neví, kdy to bude smazáno. ;)
Poznámka - 3
Při psaní tohoto článku mi velmi pomohli Cline a Llama 4 Maveric. Ačkoli jsem začal analýzu s Gemini a prosil DeepSeek, nakonec mi pomohla Llama 4. Llama 4 je skvělá. Určitě ji vyzkoušejte.