GoSuda

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

By iwanhae
views ...

Introducere

Nu cu mult timp în urmă, am avut ocazia să analizez un PR referitor la proiectul Cilium, realizat de un fost coleg.

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

Cantitatea de modificări (cu excepția codului 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 interesant. De aceea, doresc să expun această situație într-un mod accesibil, chiar și pentru persoanele fără expertiză în domeniul rețelelor.

Cunoștințe de bază

Dacă există un obiect indispensabil pentru oamenii moderni, la fel de important ca smartphone-ul, acela este probabil routerul Wi-Fi. Routerul 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. O particularitate tehnică care apare aici este: cum se realizează această „partajare”?

Tehnologia utilizată aici este Network Address Translation (NAT). NAT este o tehnologie care permite comunicarea cu exteriorul prin maparea comunicației interne, realizată prin IP privat:Port, la o IP publică:Port neutilizată în prezent, având în vedere că o comunicare TCP sau UDP este formată 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 adresei IP private și a numărului de port al dispozitivului respectiv în adresa sa IP publică și un număr de port aleatoriu neutilizat. Aceste informații de transformare sunt înregistrate în interiorul echipamentului NAT, într-o zonă numită tabelul 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 aleatoriu care nu este implicat în comunicarea curentă (de exemplu: 60000) și îl înregistrează în tabelul NAT intern.

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

După ce înregistrează o nouă intrare în tabelul NAT, routerul modifică adresa IP sursă și numărul portului pachetului TCP primit de la smartphone în adresa sa IP publică (b.b.b.b) și în numărul portului nou alocat (60000), apoi îl transmite serverului web.

1# Pachet TCP trimis de router, router => server web
2# SNAT efectuat
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 tabelul NAT adresa IP publică (b.b.b.b) și numărul portului (60000) corespunzătoare adresei IP private originale (a.a.a.a) și numărului portului (50000), apoi modifică destinația pachetului către smartphone.

1# Pachet TCP trimis de router, router => smartphone
2# DNAT efectuat
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 senzația că el însuși comunică direct cu serverul web, având o adresă IP publică. Datorită NAT, mai multe dispozitive interne pot utiliza internetul simultan, având o singură adresă IP publică.

Kubernetes

Kubernetes, printre tehnologiile recente, posedă una dintre cele mai sofisticate și complexe structuri de rețea. Desigur, NAT-ul menționat anterior este utilizat pe scară largă în diverse contexte. 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 cu 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 de ieșire din cluster. În acest caz, NAT-ul este efectuat, în principal, pe nodul Kubernetes (fiecare server din cluster) pe care rulează Pod-ul respectiv. 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 pachetului (IP-ul privat al Pod-ului) cu propria adresă IP publică și modifică portul sursă în mod corespunzător, apoi îl transmite către exterior. Acest proces este similar cu procesul NAT descris anterior pentru routerul Wi-Fi.

De exemplu, presupunând 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ătoarele pachete 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# Tabelul NAT intern al 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# SNAT efectuat
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 din exteriorul clusterului prin NodePort

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

În acest context, NAT joacă un rol crucial, iar DNAT (Destination NAT) și SNAT (Source NAT) apar simultan. Când traficul sosește la NodePort-ul unui nod specific din exterior, rețeaua Kubernetes trebuie să transmită acest trafic către Pod-ul care furnizează, în cele din urmă, serviciul. În acest proces, DNAT are loc mai întâi, modificând adresa IP de destinație și numărul portului pachetului la 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 printr-un NodePort (30001) al unui nod Kubernetes (192.168.1.5). Să presupunem că 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, validă în rețeaua internă Kubernetes. (Politicile legate de acest aspect 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 o va procesa. În acest moment, nodul aplică următoarea regulă DNAT pentru a modifica adresa IP de 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    |

Un aspect 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 această situație, utilizatorul extern va primi un răspuns de la o adresă IP inexistentă, pe care nu a solicitat-o niciodată, și va pur și simplu ignora pachetul. Prin urmare, nodul Kubernetes efectuează un SNAT suplimentar atunci când Pod-ul trimite pachetul de răspuns către exterior, modificând adresa IP sursă a pachetului la adresa IP a nodului (192.168.1.5 sau adresa IP a 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 NodePort, iar nodul va inversa procesul DNAT și SNAT pentru a returna informația utilizatorului extern. În acest proces, fiecare nod va stoca următoarele informații:

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

Subiectul principal

În general, în Linux, aceste procese NAT sunt gestionate și operate de subsistemul conntrack prin iptables. De fapt, alte proiecte CNI, cum ar fi flannel sau calico, utilizează acest mecanism pentru a rezolva problemele menționate anterior. Totuși, problema este că Cilium, utilizând tehnologia eBPF, ignoră complet această stivă tradițională de rețea Linux. 🤣

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 le îndeplinea stiva de rețea Linux existentă, așa cum se vede în figura de mai sus. Prin urmare, pentru procesul SNAT menționat anterior, Cilium gestionează direct un tabel SNAT sub forma unui LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabelul SNAT Cilium
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 un Hash Table, pentru o căutare rapidă, există o cheie care utilizează o combinație 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ă, pentru a verifica dacă un pachet care trece prin eBPF necesită un proces SNAT sau DNAT în cadrul procesului NAT, trebuie efectuată o căutare în Hash Table-ul menționat anterior. După cum am văzut, există două tipuri de pachete în procesul SNAT: 1. pachete care ies din interior spre exterior și 2. pachete de răspuns care vin din exterior spre interior. Aceste două pachete necesită transformare NAT și au caracteristica că valorile src ip, src port și dst ip, dst port sunt inversate.

Prin urmare, pentru o căutare rapidă, fie se adaugă o intrare suplimentară în Hash Table cu cheia inversată (src și dst schimbate), fie se efectuează de două ori căutarea în același Hash Table 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 menționată anterior, nu pot exista resurse infinite pe niciun hardware, iar în special în logica hardware care necesită performanțe rapide și unde 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 LRU Hash Map, 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 conexiune TCP (sau UDP) SNAT-ată:

  1. Aceleași date sunt înregistrate de două ori într-un singur Hash Table pentru pachetele de ieșire și de intrare.
  2. Din cauza logicii LRU, oricare dintre cele două date poate fi pierdută în orice moment.

Dacă oricare dintre informațiile NAT (denumite în continuare „entry”) pentru pachetele care ies sau intră din exterior este eliminată de LRU, atunci NAT nu poate fi efectuat corect, ceea ce poate duce la pierderea întregii conexiuni.

Soluție

Aici intervin următoarele PR-uri 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 tabelul 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 noi conexiuni, acest lucru ar duce la o comunicare normală. Însă, dacă cheia era eliminată neintenționat de LRU, s-ar efectua un nou NAT cu un port diferit de cel utilizat anterior pentru comunicare, ceea ce ar face ca partea receptoare să refuze recepția pachetului, iar conexiunea s-ar încheia cu un pachet RST.

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

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

Oricare ar fi direcția în care este observată o comunicare, ambele intrări sunt reînnoite, ceea ce le îndepărtează de a fi prioritare pentru eliminarea prin logica LRU. Prin aceasta, se reduce probabilitatea ca doar una dintre intrări să fie ștearsă, ducând la colapsul întregii comunicații.

Aceasta este o abordare foarte simplă și poate părea o idee banală, dar prin această abordare s-a putut rezolva eficient problema întreruperii conexiunilor din cauza expirării anticipate a informațiilor NAT pentru pachetele de răspuns, îmbunătățind semnificativ stabilitatea 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ă, pornind de la cunoștințe CS fundamentale despre funcționarea NAT, chiar și în cadrul unor sisteme complexe.

Ah, desigur, nu v-am prezentat direct un exemplu de sistem complex în acest articol. Totuși, pentru a înțelege pe deplin acest PR, am petrecut aproape 3 ore implorându-l pe DeepSeek V3 0324, inclusiv cu cuvântul Please, și, ca rezultat, am obținut cunoștințe suplimentare despre Cilium și următoarea diagramă. 😇

diagram

Și, citind problemele și PR-urile, scriu acest articol ca o compensare pentru presimțirile nefaste că ceva ce am creat eu în trecut ar fi putut cauza o problemă.

Postfață - 1

Apropo, există o modalitate foarte eficientă de a evita această problemă. Cauza fundamentală a problemei este lipsa spațiului în tabelul NAT, deci soluția este să măriți dimensiunea tabelului NAT. :-D

În situația în care cineva s-ar fi confruntat cu aceeași problemă și ar fi fugit după ce ar fi mărit dimensiunea tabelului NAT fără a lăsa o problemă înregistrată, admir și respect pasiunea lui gyutaeb care, deși problema nu era direct legată de el, a analizat-o și a înțeles-o în detaliu, contribuind la ecosistemul Cilium cu date obiective și dovezi.

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

Postfață - 2

Această poveste nu este, de fapt, un subiect direct potrivit pentru Gosuda, care se ocupă în special de limbajul Go. Totuși, limbajul Go și ecosistemul cloud sunt strâns legate, iar contribuitorii la Cilium au o anumită înțelegere a limbajului Go, așa că am adus un conținut care ar putea fi publicat pe un blog personal pe Gosuda.

Am avut permisiunea unuia dintre administratori (eu însumi), așa că probabil este în regulă.

Dacă credeți că nu este în regulă, salvați rapid în format PDF, căci nu se știe când ar putea fi șters. ;)

Postfață - 3

La scrierea acestui articol am beneficiat mult de ajutorul Cline și Llama 4 Maveric. Deși am început analiza cu Gemini și am implorat DeepSeek, în realitate am primit ajutor de la Llama 4. Llama 4 este excelent. Nu uitați să-l încercați.