GoSuda

Povestea Cilium: Îmbunătățiri remarcabile ale stabilității rețelei rezultate dintr-o mică modificare de cod

By iwanhae
views ...

Introducere

Recent, am examinat un PR al unui fost coleg privind proiectul Cilium.

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

Cantitatea de modificări (excluzând codul de testare) este minimă, constând în adăugarea unui singur bloc if. Cu toate acestea, impactul acestei modificări este considerabil, iar faptul că o idee simplă poate contribui semnificativ la stabilitatea sistemului mi s-a părut personal fascinant. Prin urmare, am decis să prezint acest caz într-un mod accesibil chiar și celor fără expertiză în domeniul rețelelor.

Cunoștințe de bază

Dacă există un obiect indispensabil pentru omul modern, la fel de important ca smartphone-ul, acesta ar fi probabil routerul Wi-Fi. Un router Wi-Fi comunică cu dispozitivele prin standardul de comunicație Wi-Fi și are rolul de a partaja adresa IP publică pe care o deține, astfel încât să poată fi utilizată de mai multe dispozitive. Particularitatea tehnică care apare aici este: cum se realizează această "partajare"?

Tehnologia utilizată în acest scop este Network Address Translation (NAT). NAT este o tehnologie care permite comunicarea cu exteriorul prin maparea unei comunicații interne, formată din IP privat:Port, la o IP publică:Port neutilizată, având în vedere că o comunicare TCP sau UDP este compusă dintr-o combinație de adresă IP și informații despre port.

NAT

Atunci când un dispozitiv intern încearcă să acceseze internetul extern, echipamentul NAT transformă combinația de adresă IP privată și număr de port a dispozitivului respectiv în adresa sa IP publică și un număr de port arbitrar neutilizat. Această informație de transformare este înregistrată într-o tabelă NAT în cadrul echipamentului NAT.

De exemplu, să presupunem că un smartphone din casă (IP privat: a.a.a.a, port: 50000) încearcă să se conecteze la un server web (IP public: c.c.c.c, port: 80).

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

Când routerul primește cererea de la smartphone, va vedea următorul pachet TCP:

1# Pachet TCP primit 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       |

Dacă acest pachet ar fi trimis direct serverului web (c.c.c.c), răspunsul nu ar ajunge la smartphone-ul cu adresa IP privată (a.a.a.a). Prin urmare, routerul găsește mai întâi un port arbitrar care nu este implicat în comunicarea curentă (ex: 60000) și îl înregistrează în tabela NAT internă.

1# Tabela NAT internă a routerului
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

După înregistrarea unei noi intrări în tabela NAT, routerul modifică adresa IP sursă și numărul portului sursă ale pachetului TCP primit de la smartphone în adresa sa IP publică (b.b.b.b) și noul port alocat (60000), apoi îl transmite serverului web.

1# Pachet TCP trimis de router, router => server web
2# Efectuează SNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Acum, serverul web (c.c.c.c) recunoaște cererea ca venind de la portul 60000 al routerului (b.b.b.b) și trimite pachetul de răspuns către router, după cum urmează:

1# Pachet TCP primit de router, server web => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Când routerul primește acest pachet de răspuns, caută în tabela NAT adresa IP privată originală (a.a.a.a) și numărul portului (50000) corespunzătoare adresei IP destinație (b.b.b.b) și numărului portului (60000) și modifică destinația pachetului către smartphone.

1# Pachet TCP trimis de router, router => smartphone
2# Efectuează DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Prin acest proces, smartphone-ul are impresia că comunică direct cu serverul web, având o adresă IP publică proprie. Datorită NAT, mai multe dispozitive interne pot utiliza internetul simultan cu o singură adresă IP publică.

Kubernetes

Kubernetes are una dintre cele mai sofisticate și complexe structuri de rețea dintre tehnologiile recente. Și, desigur, NAT-ul menționat anterior este utilizat în diverse locuri. Două exemple reprezentative sunt următoarele:

Când un Pod comunică cu exteriorul clusterului

Pod-urile din interiorul unui cluster Kubernetes primesc, în general, adrese IP private care pot comunica doar în cadrul rețelei clusterului. Prin urmare, dacă un Pod trebuie să comunice cu internetul extern, este necesar NAT pentru traficul care iese din cluster. În acest caz, NAT este efectuat în principal pe nodul Kubernetes pe care rulează Pod-ul (fiecare server din cluster). Când un Pod trimite un pachet către exterior, acel pachet este mai întâi transmis nodului căruia îi aparține Pod-ul. Nodul schimbă adresa IP sursă a acestui pachet (IP-ul privat al Pod-ului) cu adresa sa IP publică și modifică portul sursă în mod corespunzător, apoi îl transmite către exterior. Acest proces este similar cu procesul NAT explicat anterior pentru routerul Wi-Fi.

De exemplu, să presupunem că un Pod din clusterul Kubernetes (10.0.1.10, port: 40000) se conectează la un server API extern (203.0.113.45, port: 443). Nodul Kubernetes va primi următorul pachet de la Pod:

1# Pachet TCP primit de nod, Pod => nod
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Nodul va înregistra următoarele informații:

1# Tabela NAT internă a nodului (exemplu)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Apoi, va efectua SNAT și va trimite pachetul către exterior, după cum urmează:

1# Pachet TCP trimis de nod, nod => server API
2# Efectuează SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Procesul ulterior este același ca în cazul routerului smartphone.

Când se comunică cu un Pod prin NodePort din exteriorul clusterului

Una dintre modalitățile de expunere a unui serviciu în Kubernetes către exterior este utilizarea serviciului NodePort. Un serviciu NodePort deschide un port specific (NodePort) pe toate nodurile din cluster și transmite traficul care intră pe acest port către Pod-urile asociate serviciului. Utilizatorii externi pot accesa serviciul prin adresa IP a nodului clusterului și NodePort-ul.

În acest caz, NAT joacă un rol important, iar în special DNAT (Destination NAT) și SNAT (Source NAT) apar simultan. Când traficul intră pe NodePort-ul unui anumit nod din exterior, rețeaua Kubernetes trebuie să transmită acest trafic către Pod-ul care furnizează serviciul respectiv. În acest proces, mai întâi apare DNAT, prin care adresa IP destinație și numărul portului pachetului sunt modificate în adresa IP și numărul portului Pod-ului.

De exemplu, să presupunem că un utilizator extern (203.0.113.10, port: 30000) accesează un serviciu prin NodePort-ul (30001) unui nod din clusterul Kubernetes (192.168.1.5). Acest serviciu indică intern un Pod cu adresa IP 10.0.2.15 și portul 8080.

1Utilizator extern (203.0.113.10:30000) ==> Nod Kubernetes (extern:192.168.1.5:30001 / intern: 10.0.1.1:42132) ==> Pod Kubernetes (10.0.2.15:8080)

În acest caz, nodul Kubernetes are atât adresa IP 192.168.1.5, accesibilă din exterior, cât și adresa IP 10.0.1.1, valabilă în rețeaua internă Kubernetes. (Politica legată de aceasta variază în funcție de tipul CNI utilizat, dar în acest articol, explicația se bazează pe Cilium.)

Când cererea utilizatorului extern ajunge la nod, nodul trebuie să o transmită Pod-ului care va procesa cererea. În acest moment, nodul aplică următoarele reguli DNAT pentru a modifica adresa IP destinație și numărul portului pachetului.

1# Pachet TCP pregătit de nod pentru a fi trimis către Pod
2# După aplicarea DNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10  | 30000    | 10.0.2.15 | 8080     |

Punctul crucial aici este că, atunci când Pod-ul trimite un răspuns la această cerere, adresa IP sursă este propria sa adresă IP (10.0.2.15), iar adresa IP destinație este adresa IP a utilizatorului extern care a trimis cererea (203.0.113.10). În acest caz, utilizatorul extern va primi un răspuns de la o adresă IP inexistentă, pe care nu a solicitat-o, și va renunța pur și simplu la acel pachet. Prin urmare, nodul Kubernetes efectuează SNAT suplimentar atunci când Pod-ul trimite pachete de răspuns către exterior, modificând adresa IP sursă a pachetului la adresa IP a nodului (192.168.1.5 sau IP-ul rețelei interne 10.0.1.1, în acest caz 10.0.1.1).

1# Pachet TCP pregătit de nod pentru a fi trimis către Pod
2# După aplicarea DNAT, SNAT
3| src ip      | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1    | 40021    | 10.0.2.15 | 8080     |

Acum, Pod-ul care a primit pachetul va răspunde nodului care a primit inițial cererea prin NodePort, iar nodul va aplica invers aceleași procese DNAT și SNAT pentru a returna informațiile utilizatorului extern. În acest proces, fiecare nod va stoca următoarele informații:

1# Tabela DNAT internă a nodului
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Tabela SNAT internă a nodului
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Corpul principal

În mod obișnuit, în Linux, aceste procese NAT sunt gestionate și operate prin iptables, folosind subsistemul conntrack. De fapt, alte proiecte CNI precum flannel sau calico utilizează acest mecanism pentru a rezolva problemele menționate mai sus. Însă, problema este că Cilium utilizează tehnologia eBPF și ignoră complet stiva de rețea tradițională a Linux-ului. 🤣

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

Ca urmare, Cilium a ales să implementeze direct doar funcționalitățile necesare în contextul Kubernetes, dintre sarcinile pe care stiva de rețea Linux existentă le-ar fi îndeplinit, așa cum se arată în diagrama de mai sus. Prin urmare, pentru procesul SNAT menționat anterior, Cilium gestionează direct tabela SNAT sub forma unei hărți hash LRU (BPF_MAP_TYPE_LRU_HASH).

1# Tabela Cilium SNAT
2# !Exemplu pentru o explicație simplificată. Definiția reală este: 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 și alte metadate
4----------------------------------------------
5|            |          |         |          |

Și, fiind o tabelă Hash, pentru o căutare rapidă, există o cheie care utilizează combinația de src ip, src port, dst ip, dst port ca valoare a cheii.

Identificarea problemei

Fenomenul - 1: Căutare

O problemă care apare este că pachetele care trec prin eBPF trebuie să verifice dacă este necesar să efectueze un proces SNAT sau DNAT pentru NAT, prin căutarea în tabela Hash menționată. Așa cum am văzut anterior, există două tipuri de pachete în procesul SNAT: 1. pachete care ies din interior spre exterior și 2. pachete care intră din exterior spre interior ca răspuns. Aceste două pachete necesită o transformare pentru procesul NAT și au particularitatea că valorile IP-ului și portului sursă și destinație sunt inversate.

Prin urmare, pentru o căutare rapidă, este necesar fie să se adauge o intrare suplimentară în tabela Hash cu cheia inversată pentru src și dst, fie să se efectueze două căutări în aceeași tabelă Hash pentru toate pachetele, chiar și pentru cele care nu au legătură cu SNAT. Desigur, Cilium, pentru o performanță mai bună, a adoptat metoda de a introduce aceleași date de două ori, sub numele de RevSNAT.

Fenomenul - 2: LRU

Pe lângă problema de mai sus, deoarece nu pot exista resurse infinite pe niciun hardware, și în special în logica la nivel hardware unde este necesară o performanță rapidă, iar structurile de date dinamice nu pot fi utilizate, este necesar să se elimine datele existente atunci când resursele sunt insuficiente. Cilium a rezolvat această problemă utilizând o hartă hash LRU, o structură de date de bază furnizată implicit de Linux.

Fenomenul 1 + Fenomenul 2 = Pierderea conexiunii

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

Adică, pentru o singură conexiune TCP (sau UDP) SNAT-ată:

  1. Aceleași date sunt înregistrate de două ori într-o singură tabelă Hash pentru pachetele de ieșire și de intrare, și
  2. În conformitate cu logica LRU, oricare dintre cele două date poate fi pierdută oricând,

Dacă oricare dintre informațiile NAT (denumite în continuare „entry”) pentru un pachet de intrare sau de ieșire este eliminată de LRU, NAT-ul nu poate fi efectuat corect, ceea ce poate duce la pierderea întregii conexiuni.

Soluție

Aici apar PR-urile menționate anterior:

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

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

Anterior, când un pachet trecea prin eBPF, se încerca o căutare în tabela SNAT, creând o cheie din combinația src ip, src port, dst ip, dst port. Dacă cheia nu exista, se genera o nouă informație NAT conform regulii SNAT și se înregistra în tabelă. În cazul unei conexiuni noi, aceasta ar duce la o comunicare normală. Însă, dacă cheia era eliminată accidental de LRU, s-ar efectua un nou NAT cu un port diferit de cel folosit anterior, iar partea receptoare ar refuza pachetul, ducând la închiderea conexiunii cu un pachet RST.

Abordarea adoptată de PR-ul menționat este simplă:

Ori de câte ori un pachet este observat într-o direcție, actualizați și intrarea corespunzătoare pentru direcția inversă.

Oricând este observată o comunicare în orice direcție, ambele intrări sunt actualizate, îndepărtându-le de prioritatea de eliminare a logicii LRU. Acest lucru reduce posibilitatea ca doar o singură intrare să fie ștearsă, ducând la colapsul întregii comunicații.

Aceasta poate părea o abordare foarte simplă și o idee facilă, dar prin această metodă a fost posibilă rezolvarea eficientă a problemei întreruperii conexiunilor din cauza expirării anticipate a informațiilor NAT pentru pachetele de răspuns și îmbunătățirea semnificativă a stabilității sistemului. De asemenea, poate fi considerată o îmbunătățire importantă care a adus următoarele rezultate în ceea ce privește stabilitatea rețelei.

benchmark

Concluzie

Consider că acest PR este un exemplu excelent care ilustrează modul în care o idee simplă poate aduce o schimbare semnificativă chiar și într-un sistem complex, pornind de la cunoștințe CS fundamentale despre cum funcționează NAT.

Ah, desigur, nu am prezentat direct un exemplu de sistem complex în acest articol. Însă, pentru a înțelege pe deplin acest PR, am implorat DeepSeek V3 0324 timp de aproape 3 ore, adăugând chiar și cuvântul „Please”, și, ca rezultat, am obținut cunoștințe suplimentare despre Cilium și o diagramă ca cea de mai jos. 😇

diagram

Și, citind problemele și PR-urile, am scris acest articol ca o compensație pentru presimțirile nefaste că problemele ar fi putut apărea din cauza a ceva ce am creat eu în trecut.

Postfață - 1

Apropo, există o metodă foarte eficientă de evitare a acestei probleme. Deoarece cauza fundamentală a problemei este lipsa spațiului în tabela NAT, tot ce trebuie făcut este să se mărească dimensiunea tabelei NAT. :-D

Am fost impresionat și îl respect pe gyutaeb pentru pasiunea sa de a analiza și înțelege temeinic problema, de a oferi date obiective și de a contribui la ecosistemul Cilium, chiar dacă nu era o problemă direct legată de el, în timp ce cineva ar fi putut întâlni aceeași problemă, ar fi mărit dimensiunea tabelei NAT și ar fi plecat fără a înregistra o problemă.

Acesta a fost motivul pentru care am decis să scriu acest articol.

Postfață - 2

Acest subiect nu este direct legat de Gosuda, care se ocupă în mod special de limbajul Go. Cu toate acestea, limbajul Go și ecosistemul cloud sunt strâns legate, iar contribuitorii Cilium au o anumită competență în limbajul Go. Prin urmare, am adus un conținut care ar fi putut fi publicat pe un blog personal, aici, la Gosuda.

Cred că este în regulă, deoarece am avut permisiunea unuia dintre administratori (eu însumi).

Dacă nu credeți că este în regulă, ar trebui să-l salvați ca PDF rapid, deoarece nu știți când ar putea fi șters. ;)

Postfață - 3

Am beneficiat în mare măsură de asistența Cline și Llama 4 Maveric la redactarea acestui articol. Deși am început analiza cu Gemini și am implorat DeepSeek, am primit ajutorul propriu-zis de la Llama 4. Llama 4 este excelent. Vă recomand cu tărie să-l încercați.