GoSuda

Cilium története: egy apró kódmódosítás figyelemre méltó hálózati stabilitásjavulást eredményez

By iwanhae views ...

Bevezetés

Nemrég megnéztem egy korábbi munkatársam PR-ját a Cilium projektben.

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

A módosítás mennyisége (a tesztkódot nem számítva) önmagában csekély, mindössze egy if blokk hozzáadásáról van szó. Azonban ennek a módosításnak a hatása óriási, és személy szerint érdekesnek találom, hogy egyetlen egyszerű ötlet is rendkívül nagy mértékben hozzájárulhat a rendszer stabilitásához. Ezért úgy döntöttem, hogy elmesélem ezt a történetet, hogy azok is könnyen megérthessék ezt az esetet, akik nem rendelkeznek szakmai ismeretekkel a hálózatok terén.

Háttérismeretek

Ha van a modern emberek számára a okostelefonhoz hasonlóan fontos nélkülözhetetlen eszköz, az talán a Wi-Fi router. A Wi-Fi router a Wi-Fi nevű kommunikációs szabvány segítségével kommunikál az eszközökkel, és a birtokában lévő nyilvános IP-címet megosztja, hogy azt több eszköz is használhassa. Itt felmerül a technikai kérdés: hogyan történik ez a „megosztás”?

Az itt alkalmazott technológia a Network Address Translation (NAT). A NAT abban a tekintetben, hogy a TCP és UDP kommunikáció IP-cím és port információk kombinációjából áll, a belső, privát IP:Port alapú kommunikációt leképezi egy jelenleg nem használt nyilvános IP:Port címre, így lehetővé téve a külvilággal való kommunikációt.

NAT

A NAT eszköz akkor, amikor egy belső eszköz külső internetes kapcsolatot szeretne létesíteni, az adott eszköz privát IP-címét és portszámának kombinációját átalakítja a saját nyilvános IP-címére és egy nem használt, tetszőleges portszámra. Ezt az átalakítási információt a NAT eszköz belsejében lévő NAT táblázatban rögzíti.

Tegyük fel például, hogy az otthoni okostelefon (privát IP: a.a.a.a, port: 50000) egy webszerverhez (nyilvános IP: c.c.c.c, port: 80) szeretne csatlakozni.

1okostelefon (a.a.a.a:50000) ==> router (b.b.b.b) ==> webszerver (c.c.c.c:80)

Amikor a router megkapja az okostelefon kérését, a következő TCP csomagot fogja látni.

1# A routerhez érkezett TCP csomag, okostelefon => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Ha ezt a csomagot változtatás nélkül továbbítaná a webszerver (c.c.c.c) felé, a válasz nem térne vissza a privát IP-címmel rendelkező okostelefonhoz (a.a.a.a), ezért a router először megkeres egy olyan szabad portot (például 60000), amely jelenleg nem vesz részt a kommunikációban, és rögzíti azt a belső NAT táblázatban.

1# A router belső NAT táblázata
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Miután a router új bejegyzést írt a NAT táblázatba, az okostelefontól kapott TCP csomag forrás IP-címét és portszámát megváltoztatja a saját nyilvános IP-címére (b.b.b.b) és az újonnan hozzárendelt portszámra (60000), majd így küldi tovább a webszervernek.

1# A router által küldött TCP csomag, router => webszerver
2# SNAT végrehajtása
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Most a webszerver (c.c.c.c) a router (b.b.b.b) 60000-es portjáról érkezett kérésként ismeri fel a csomagot, és a választ a következő módon küldi vissza a routernek.

1# A routerhez érkezett TCP csomag, webszerver => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Amikor a router megkapja ezt a válaszcsomagot, a NAT táblázatban megkeresi a célIP-címnek (b.b.b.b) és portszámnak (60000) megfelelő eredeti privát IP-címet (a.a.a.a) és portszámot (50000), majd a csomag célját az okostelefonra módosítja.

1# A router által küldött TCP csomag, router => okostelefon
2# DNAT végrehajtása
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Ennek a folyamatnak köszönhetően az okostelefon úgy érzi, mintha közvetlenül egy nyilvános IP-címmel rendelkezne, és úgy kommunikálna a webszerverrel. A NAT révén egyetlen nyilvános IP-cím segítségével több belső eszköz is egyidejűleg használhatja az internetet.

Kubernetes

A Kubernetes a közelmúltban megjelent technológiák között az egyik legkifinomultabb és legösszetettebb hálózati struktúrával rendelkezik. Természetesen a korábban említett NAT is számos helyen alkalmazásra kerül. A két legjellemzőbb példa a következő.

Pod belső kommunikációja a klaszter külső környezetével

A Kubernetes klaszter belsejében lévő podok általában olyan privát IP-címet kapnak, amellyel csak a klaszterhálózaton belül tudnak kommunikálni. Ezért ahhoz, hogy egy pod külső internettel kommunikálhasson, NAT-ra van szükség a klaszteren kívülre irányuló forgalomhoz. Ez a NAT általában azon Kubernetes csomóponton (a klaszter egyes szerverein) történik, amelyen az adott pod fut. Amikor a pod külső irányba küld csomagot, a csomag először a pod által használt csomóponthoz kerül. A csomópont a csomag forrás IP-címét (a pod privát IP-címét) a saját nyilvános IP-címére cseréli, a forrásportot pedig megfelelően módosítva továbbítja kifelé. Ez a folyamat hasonló ahhoz a NAT folyamathoz, amelyet korábban a Wi-Fi routernél ismertettünk.

Tegyük fel például, hogy egy Kubernetes klaszter belsejében lévő pod (10.0.1.10, port: 40000) egy külső API szerverhez (203.0.113.45, port: 443) csatlakozik. Ebben az esetben a Kubernetes csomópont a podtól a következő csomagot kapja.

1# A csomópont által kapott TCP csomag, pod => csomópont
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

A csomópont a következő információt rögzíti.

1# A csomópont belső NAT táblázata (példa)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Ezután a következő módon végrehajtja az SNAT műveletet, és elküldi a csomagot kifelé.

1# A csomópont által küldött TCP csomag, csomópont => API szerver
2# SNAT végrehajtása
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Ezt követően a folyamat ugyanúgy zajlik, mint a korábbi okostelefonos, routeres példában.

Külső kliens kommunikációja a podokkal NodePort használatával

A szolgáltatások külső elérhetővé tételének egyik módja a Kubernetesben a NodePort típusú szolgáltatás használata. A NodePort szolgáltatás a klaszter összes csomópontján megnyit egy meghatározott portot (NodePort), és az erre a portra érkező forgalmat a szolgáltatáshoz tartozó podokhoz továbbítja. A külső felhasználók a klaszter csomópontjainak IP-címe és a NodePort segítségével érhetik el a szolgáltatást.

Ebben a folyamatban a NAT kulcsszerepet tölt be, különösen a DNAT (Destination NAT) és az SNAT (Source NAT) egyidejűleg következik be. Amikor a külső forgalom egy adott csomópont NodePortjára érkezik, a Kubernetes hálózatnak ezt a forgalmat végül a szolgáltatást kiszolgáló podhoz kell eljuttatnia. Ennek során először DNAT történik, aminek következtében a csomag céljának IP-címe és portszáma a pod IP-címére és portszámára változik.

Tegyük fel például, hogy a klaszteren kívüli felhasználó (203.0.113.10, port: 30000) a Kubernetes klaszter egyik csomópontján (192.168.1.5) keresztül, annak NodePortján (30001) fér hozzá a szolgáltatáshoz. Feltételezzük, hogy ez a szolgáltatás belsőleg a 10.0.2.15 IP-címmel és 8080 porttal rendelkező podra mutat.

1külső felhasználó (203.0.113.10:30000) ==> Kubernetes csomópont (külső:192.168.1.5:30001 / belső: 10.0.1.1:42132) ==> Kubernetes pod (10.0.2.15:8080)

Itt a Kubernetes csomópont egyszerre rendelkezik a külvilágból elérhető csomópont IP-címével, a 192.168.1.5 címmel, és a belső Kubernetes hálózatban érvényes IP-címmel, a 10.0.1.1 címmel. (A használt CNI típusától függően ez a szabályozás eltérő lehet, azonban ebben a cikkben a Cilium alapján ismertetjük a folyamatot.)

Amikor a külső felhasználó kérése megérkezik a csomópontra, a csomópontnak továbbítania kell azt a kérést kiszolgáló podhoz. Ehhez a csomópont a következő DNAT szabályt alkalmazza, amely módosítja a csomag cél IP-címét és portszámát.

1# A csomópont által pod felé küldeni készülő TCP csomag
2# DNAT alkalmazása után
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000    | 10.0.2.15 | 8080    |

Fontos megjegyezni, hogy amikor a pod válaszol erre a kérésre, a forrás IP-címe a saját IP-címe (10.0.2.15), a célIP-cím pedig a kérést küldő külső felhasználó IP-címe (203.0.113.10) lesz. Ebben az esetben a külső felhasználó olyan, nem létező IP-címről kap választ, amelyről soha nem kért semmit, és a csomagot egyszerűen eldobja. Ezért a Kubernetes csomópont a podtól kifelé irányuló válaszcsomag küldésekor további SNAT műveletet hajt végre, és a csomag forrás IP-címét a csomópont IP-címére módosítja (vagy a 192.168.1.5 külső címre, vagy a belső hálózati címre, a 10.0.1.1 címre; ebben az esetben a 10.0.1.1 címmel folytatjuk).

1# A csomópont által pod felé küldeni készülő TCP csomag
2# DNAT, SNAT alkalmazása után
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

Most a csomagot kapó pod arra a csomópontra küldi a választ, amelyen a NodePort kérés eredetileg megérkezett, a csomópont pedig ugyanazokat a DNAT és SNAT lépéseket fordítva alkalmazza, és visszaküldi az információt a külső felhasználónak. Ennek során minden csomópont a következő információkat tárolja.

1# Csomópont belső DNAT táblázata
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Csomópont belső SNAT táblázata
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Főrész

Linuxon ezeket a NAT folyamatokat általában az iptables segítségével, a conntrack nevű alrendszer kezeli és működteti. Valójában a flannel vagy a calico más CNI projektek is ezt használják a fent leírt probléma kezelésére. A probléma azonban az, hogy a Cilium az eBPF technológiát használva gyakorlatilag figyelmen kívül hagyja a hagyományos Linux hálózati vermet. 🤣

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

Ennek eredményeként a Cilium a fenti ábrán látható, a hagyományos Linux hálózati verem által végzett feladatok közül csak azokat a funkciókat valósította meg közvetlenül, amelyekre a Kubernetes környezetben szükség van. Így a korábban említett SNAT folyamathoz a Cilium az SNAT táblázatot LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) formájában kezeli közvetlenül.

1# Cilium SNAT táblázat
2# !Egyszerűsített példa a könnyebb érthetőség kedvéért. A tényleges definíció: 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 és egyéb metaadatok
4----------------------------------------------
5|            |          |         |          |

Mivel hash táblázatról van szó, a gyors lekérdezés érdekében kulcs is létezik, és a kulcsként a src ip, src port, dst ip, dst port kombinációját használja.

Problémafelismerés

Jelenség 1: keresés

Ebből egy probléma adódik: az eBPF-en áthaladó csomagnak meg kell vizsgálnia, hogy szükséges-e SNAT vagy DNAT műveletet végrehajtani rajta, ehhez pedig le kell kérdeznie a fenti hash táblázatot. Ahogy korábban láttuk, az SNAT folyamatban kétféle csomag létezik: 1. a belsőből a külső felé távozó csomag, és 2. a erre adott válaszként a külsőből a belső felé érkező csomag. Mindkét csomagnak szüksége van NAT-átalakításra, ugyanakkor az src ip, port és a dst ip, port értékei felcserélődnek.

Ezért a gyors keresés érdekében vagy egy további bejegyzést kell felvenni a hash táblázatba, amelynek kulcsa az src és dst felcserélt értékeiből áll, vagy minden csomagnál két alkalommal kell lekérdezni ugyanazt a hash táblázatot, még akkor is, ha az SNAT-hoz nincs köze. Természetesen a Cilium a jobb teljesítmény érdekében a RevSNAT nevű megoldást választotta, amely ugyanazokat az adatokat kétszer helyezi el a táblázatban.

Jelenség 2: LRU

Emellett, a fenti problémától függetlenül, egyetlen hardveren sem állnak rendelkezésre korlátlan erőforrások, és különösen egy gyors teljesítményt igénylő, hardver szintű logikánál, ahol dinamikus adatstruktúrák sem használhatók, erőforrás-hiány esetén szükség van a meglévő adatok kiürítésére. A Cilium ezt a problémát azzal oldotta meg, hogy a Linux alapértelmezett adatstruktúráját, az LRU Hash Map-et használja.

1. jelenség + 2. jelenség = kapcsolatvesztés

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

Vagyis egy SNAT-olt TCP (vagy UDP) kapcsolat esetén:

  1. a hash táblázatban a kimenő és a bejövő csomagokhoz tartozóan ugyanaz az adat kétszer szerepel, és
  2. az LRU logika következtében a két adat közül bármelyik elveszhet,

ha a külső irányba távozó vagy a külsőből érkező csomagokhoz tartozó NAT információk (a továbbiakban: entry) bármelyike az LRU miatt törlődik, akkor a NAT nem hajtható végre megfelelően, ami a teljes kapcsolat megszakadásához vezethet.

Megoldás

Itt lépnek színre a korábban említett PR-ok.

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

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

Korábban, amikor a csomag áthaladt az eBPF-en, a rendszer az SNAT táblázatban a src ip, src port, dst ip, dst port kombinációjából készített kulccsal próbált keresni. Ha a kulcs nem létezett, az SNAT szabályai szerint új NAT információt hozott létre, és rögzítette a táblázatban. Új kapcsolat esetén ez normál kommunikációhoz vezet, ha viszont az LRU miatt a kulcs véletlenül törlődött, akkor a korábbi kapcsolat során használt porttól eltérő porton hajtódik végre új NAT, aminek következtében a fogadó oldal visszautasítja a csomagot, és a kapcsolat RST csomaggal megszakad.

A fenti PR-ok megközelítése itt egyszerű:

Ha bármelyik irányban megfigyelünk egy csomagot, frissítsük az ellenkező irányhoz tartozó entry-t is.

Ha bármelyik irányban megfigyelünk kommunikációt, mindkét irány entry-je frissül, így kikerülnek az LRU logika kiürítési prioritási listájáról, és ezáltal csökken annak az esélye, hogy csak az egyik entry törlődjön, és emiatt az egész kommunikáció összeomoljon.

Bár ez nagyon egyszerű megközelítésnek tűnhet, és egyszerű ötletnek látszik, ezzel a módszerrel hatékonyan megoldható az a probléma, hogy a válaszcsomag NAT információja előbb jár le, és emiatt megszakad a kapcsolat, valamint jelentősen javítható a rendszer stabilitása. Emellett a hálózati stabilitás szempontjából ez egy fontos fejlesztésnek tekinthető, amely a következő eredményeket hozta.

benchmark

Következtetés

Úgy gondolom, hogy ez a PR nagyszerű példa arra, hogy a NAT működésének alapvető informatikai ismereteitől kiindulva bemutatja, hogy egy összetett rendszeren belül is mekkora változást hozhat egyetlen egyszerű ötlet.

Természetesen ebben a cikkben nem mutattam be közvetlenül egy összetett rendszer példáját. Ugyanakkor ahhoz, hogy megértsem ezt a PR-t, több mint három órán át könyörögtem a DeepSeek V3 0324 modellnek, még a „Please” szót is hozzáadva, aminek eredményeként a Cilium ismereteim egy egységgel gyarapodtak, és megszereztem az alábbi ábrát. 😇

diagram

Az issue-kat és a PR-okat olvasva pedig egy korábbi, általam megírt valamitől származó problémák miatti rossz érzés kompenzálására írom ezt a cikket.

Utószó - 1

Megjegyzendő, hogy erre az issue-ra létezik egy nagyon hatékony megkerülő megoldás. Mivel a probléma gyökere a NAT táblázat helyhiánya, elegendő megnövelni a NAT táblázat méretét. :-D

Azt a személyt, aki ugyanezzel a problémával találkozott, de az issue-t sem jelentette be, csupán megnövelte a NAT táblázat méretét, majd továbbállt, szemben azzal, hogy gyutaeb úr, akinek a problémához közvetlen köze sem volt, alaposan elemezte és megértette azt, objektív bizonyítékokkal támasztotta alá, és hozzájárult a Cilium ökoszisztémához, az lenyűgözött és tisztelem.

Ez volt az oka annak, hogy elhatároztam ennek a cikknek a megírását.

Utószó - 2

Ez a történet valójában nem illeszkedik szorosan a Go nyelvre specializálódott Gosuda témájához. Ugyanakkor a Go nyelv és a felhőkörnyezet szorosan összefügg, a Cilium közreműködői pedig rendelkeznek bizonyos Go nyelvi jártassággal, ezért a személyes blogon közzétehető tartalmat áthoztam a Gosudára.

Mivel az egyik adminisztrátor (maga én) engedélyt adott, azt gondolom, hogy ez valószínűleg rendben van.

Ha úgy gondolja, hogy ez nincs rendben, kérem, mentse el PDF formátumban, mert bármikor törölhető lehet. ;)

Utószó - 3

Ennek a cikknek az elkészítésében nagy segítséget kaptam a Cline-tól és a Llama 4 Maverictől. Bár az elemzést Gemini-vel kezdtem, és DeepSeek-et könyörögtem meg, a valódi segítséget a Llama 4-től kaptam. A Llama 4 jó, feltétlenül próbálja ki.