GoSuda

Povestea Cilium: Cum o mică modificare de cod a adus o îmbunătățire remarcabilă a stabilității rețelei

By iwanhae views ...

Introducere

Nu cu mult timp în urmă am parcurs PR-ul unui fost coleg de muncă referitor la proiectul Cilium.

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

Volumul modificării propriu-zise (excluzând codul de test) este redus, fiind vorba doar de adăugarea unui bloc if. Totuși, impactul acestei modificări este considerabil, iar faptul că o idee simplă poate aduce o contribuție enormă la stabilitatea unui sistem mi s-a părut personal interesant. De aceea, am decis să prezint acest caz într-un mod care să poată fi înțeles cu ușurință chiar și de persoanele fără cunoștințe specializate în domeniul rețelelor.

Cunoștințe de bază

Dacă există un echipament la fel de important pentru oamenii moderni ca smartphone-ul, acesta este probabil routerul Wi-Fi. Routerul Wi-Fi comunică cu dispozitivele printr-un standard de comunicație denumit Wi-Fi și îndeplinește rolul de a partaja adresa IP publică pe care o deține, astfel încât aceasta să poată fi utilizată de mai multe dispozitive. Aici apare o problemă tehnică esențială: cum realizează acesta „partajarea”?

Tehnologia utilizată în acest scop este Network Address Translation (NAT). Întrucât comunicarea TCP sau UDP se realizează prin combinația dintre adresa IP și informațiile de port, NAT mapează comunicarea internă, realizată cu IP privat:Port, către IP public:Port, care nu este utilizat în prezent, permițând astfel comunicarea cu exteriorul.

NAT

Atunci când un dispozitiv intern dorește să acceseze internetul extern, echipamentul NAT transformă combinația dintre adresa IP privată și numărul de port al dispozitivului în adresa IP publică proprie și într-un număr de port arbitrar, neutilizat. Informațiile acestei transformări sunt înregistrate într-un loc din interiorul echipamentului NAT, denumit tabel 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)

Atunci când primește cererea de la smartphone, routerul va observa un pachet TCP de forma următoare.

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 astfel, direct către serverul web (c.c.c.c), răspunsul nu s-ar întoarce către smartphone-ul cu adresa IP privată (a.a.a.a). Prin urmare, routerul identifică mai întâi un port arbitrar care nu este implicat în comunicarea curentă (de exemplu, 60000) și îl înregistrează în tabelul NAT intern.

1# Tabel 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 portul sursă ale pachetului TCP primit de la smartphone, înlocuindu-le cu adresa IP publică proprie (b.b.b.b) și cu portul nou alocat (60000), apoi îl transmite către serverul web.

1# Pachet TCP trimis de router, router => server web
2# SNAT aplicat
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 fiind venită 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    |

La primirea acestui pachet de răspuns, routerul caută în tabelul NAT adresa IP de destinație (b.b.b.b) și portul de destinație (60000), identifică adresa IP privată originală (a.a.a.a) și portul original (50000) și modifică destinația pachetului către smartphone.

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

Kubernetes

Kubernetes dispune de o structură de rețea extrem de sofisticată și complexă, fiind una dintre cele mai avansate tehnologii apărute recent. Desigur, NAT, despre care am vorbit anterior, este utilizat în multiple contexte. Cele mai reprezentative exemple sunt următoarele două.

Atunci când un Pod comunică cu exteriorul clusterului

Pod-urile din interiorul unui cluster Kubernetes primesc, în mod normal, adrese IP private care permit comunicarea doar în cadrul rețelei clusterului. Prin urmare, pentru ca un Pod să comunice cu internetul extern, este necesar NAT pentru traficul care iese din cluster. În acest caz, NAT este realizat, în principal, pe nodul Kubernetes (fiecare server din cluster) pe care rulează Pod-ul respectiv. Atunci când Pod-ul trimite un pachet către exterior, acesta ajunge mai întâi la nodul de care aparține Pod-ul. Nodul modifică adresa IP sursă a pachetului (IP-ul privat al Pod-ului) cu propria adresă IP publică, ajustează adecvat și portul sursă, apoi transmite pachetul către exterior. Acest proces este similar cu procesul NAT descris anterior în cazul routerului Wi-Fi.

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

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 înregistrează următoarele informații

1# Tabel 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       |

după care efectuează SNAT și trimite pachetul către exterior, astfel:

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

Ulterior, procesul urmează aceiași pași ca în cazul prezentat anterior, al smartphone-ului și al routerului.

Atunci când comunicarea cu un Pod se realizează din exteriorul clusterului prin NodePort

Una dintre metodele de expunere a unui serviciu către exterior în Kubernetes este utilizarea unui serviciu de tip NodePort. Serviciul NodePort deschide un anumit port (NodePort) pe toate nodurile din cluster și transmite traficul sosit pe acest port către Pod-urile care aparțin serviciului. Utilizatorii externi pot accesa serviciul prin adresa IP a unui nod din cluster și prin NodePort.

În acest caz, NAT joacă un rol important, iar DNAT (Destination NAT) și SNAT (Source NAT) apar simultan. Atunci când traficul extern sosește la NodePort-ul unui anumit nod, rețeaua Kubernetes trebuie să transmită acest trafic, în final, către Pod-ul care oferă serviciul respectiv. În acest proces, DNAT are loc mai întâi, iar adresa IP de destinație și portul de destinație ale pachetului sunt modificate cu adresa IP și portul Pod-ului.

De exemplu, să presupunem că un utilizator extern al clusterului (203.0.113.10, port: 30000) accesează serviciul prin NodePort-ul (30001) al unui nod din clusterul Kubernetes (192.168.1.5). Se presupune că serviciul 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 deține 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. (În funcție de tipul CNI utilizat, politicile referitoare la acest aspect pot varia, însă în acest articol explicația se bazează pe Cilium.)

Atunci când cererea utilizatorului extern ajunge la nod, acesta trebuie să o transmită Pod-ului care o va procesa. În acest scop, nodul aplică următoarea regulă DNAT, modificând adresa IP de destinație și portul de destinație ale pachetului:

1# Pachet TCP pe care nodul se pregătește să îl trimită 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    |

Aici, aspectul important este că, atunci când Pod-ul trimite răspunsul la această cerere, adresa IP sursă este propria sa adresă IP (10.0.2.15), iar adresa IP de destinație este adresa IP a utilizatorului extern care a trimis cererea (203.0.113.10). În acest caz, utilizatorul extern primește un răspuns de la o adresă IP pe care nu a solicitat-o și care, în realitate, nu există pentru el, astfel încât pachetul este pur și simplu eliminat (DROP). Prin urmare, atunci când Pod-ul trimite pachete de răspuns către exterior, nodul Kubernetes aplică suplimentar SNAT, modificând adresa IP sursă a pachetului cu adresa IP a nodului (192.168.1.5 sau adresa IP din rețeaua internă 10.0.1.1; în acest caz, se utilizează 10.0.1.1):

1# Pachet TCP pe care nodul se pregătește să îl trimită către Pod
2# După aplicarea DNAT și 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 acest pachet trimite răspunsul către nodul care a primit inițial cererea prin NodePort, iar nodul aplică din nou aceleași procese DNAT și SNAT, dar în sens invers, returnând informațiile utilizatorului extern. În acest proces, fiecare nod stochează informațiile următoare:

1# Tabel 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# Tabel 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            |

Partea principală

În general, în Linux, aceste procese NAT sunt gestionate și executate prin iptables, de către un subsistem numit conntrack. De altfel, alte proiecte CNI, precum flannel sau calico, utilizează acest mecanism pentru a rezolva problemele de acest tip. Totuși, problema este că Cilium, folosind tehnologia numită 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 cele pe care stiva tradițională de rețea Linux le realiza în figura de mai sus. Astfel, în ceea ce privește SNAT menționat anterior, Cilium gestionează direct tabelul SNAT sub forma unui LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabel SNAT Cilium
2# Exemplu pentru explicație simplificată. Definiția reală se află la: 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|            |          |         |          |

Deoarece este vorba de un Hash Table, pentru o căutare rapidă se utilizează o cheie, iar combinația src ip, src port, dst ip, dst port este folosită ca cheie.

Identificarea problemei

Fenomenul 1: căutarea

Astfel apare o problemă: pentru a verifica dacă un pachet procesat de eBPF necesită o transformare SNAT sau DNAT, trebuie efectuată o căutare în acest Hash Table. Așa cum s-a văzut anterior, există două tipuri de pachete în cadrul SNAT: 1. pachetele care ies din interior către exterior și 2. pachetele de răspuns care sosesc din exterior către interior. Aceste două tipuri de pachete necesită transformare NAT, dar prezintă particularitatea că valorile src ip, port și dst ip, port sunt inversate.

Prin urmare, pentru o căutare rapidă, ar trebui fie să se adauge încă o intrare în Hash Table, folosind ca cheie valorile src și dst inversate, fie să se efectueze căutarea în același Hash Table de două ori pentru fiecare pachet, inclusiv pentru cele care nu au legătură cu SNAT. Firește, pentru a obține performanțe mai bune, Cilium a adoptat metoda de a introduce aceleași date de două ori, denumită RevSNAT.

Fenomenul 2: LRU

Independent de problema de mai sus, resursele hardware nu pot fi infinite. În special pentru logica de nivel hardware, care necesită performanță ridicată, nu se pot utiliza nici structuri de date dinamice. Prin urmare, atunci când resursele sunt insuficiente, trebuie eliminate intrările existente. Cilium a rezolvat această situație utilizând LRU Hash Map, o structură de date implicită în Linux.

Fenomenul 1 + Fenomenul 2 = pierderea conexiunii

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

Cu alte cuvinte, pentru o singură conexiune TCP (sau UDP) cu SNAT:

  1. în același Hash Table, datele pentru pachetele de ieșire și cele pentru pachetele de intrare sunt înregistrate de două ori, și
  2. în condițiile în care, conform logicii LRU, oricare dintre cele două date poate fi pierdută oricând,

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

Soluția

Aici intervin 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, atunci când un pachet trecea prin eBPF, se construia o cheie din combinația src ip, src port, dst ip, dst port și se încerca o căutare în tabelul SNAT. Dacă cheia nu exista, se generau informații NAT noi conform regulilor SNAT și se înregistrau în tabel. În cazul unei conexiuni noi, aceasta va continua ca o comunicare normală. Totuși, dacă cheia a fost eliminată neintenționat de LRU, NAT va fi realizat cu un alt port decât cel utilizat anterior pentru comunicare, iar partea receptoare va refuza recepția, conexiunea fiind astfel încheiată cu un pachet RST.

Abordarea adoptată de acest PR este simplă:

Ori de câte ori un pachet este observat într-o direcție, să actualizăm și entry-ul pentru direcția opusă.

Dacă o comunicare este observată într-o direcție, ambele entry-uri sunt actualizate, fiind astfel îndepărtate de prioritatea de eliminare a mecanismului LRU. Astfel, scade probabilitatea unui scenariu în care doar una dintre entry-uri este ștearsă, ducând la prăbușirea întregii comunicări.

Deși este o abordare foarte simplă și poate părea o idee elementară, ea a permis rezolvarea eficientă a problemei în care informațiile NAT pentru răspuns expirau mai devreme, provocând întreruperea conexiunii, și a îmbunătățit semnificativ stabilitatea sistemului. Din punctul de vedere al stabilității rețelei, este vorba de o îmbunătățire importantă care a adus următoarele rezultate.

benchmark

Concluzie

Consider că acest PR este un exemplu foarte bun al modului în care NAT funcționează, pornind de la cunoștințele fundamentale de informatică, și al felului în care o idee simplă poate aduce o schimbare majoră chiar și în interiorul unui sistem complex.

Desigur, în acest articol nu am prezentat direct un sistem complex. Totuși, pentru a înțelege cu adevărat acest PR, am cerut ajutor de aproape trei ore modelului DeepSeek V3 0324, adăugând chiar și cuvântul Please, iar rezultatul a fost o cunoaștere suplimentară despre Cilium și diagrama de mai jos. 😇

diagram

De asemenea, citind issue-urile și PR-urile, am avut diverse presimțiri neplăcute că unele lucruri create de mine în trecut ar fi putut cauza aceste probleme; acest articol este, în parte, o compensare psihologică pentru acele presimțiri.

Notă - 1

Pentru această problemă există o metodă foarte eficientă de ocolire. Cauza principală a problemei este lipsa spațiului din tabelul NAT, astfel că este suficient să se mărească dimensiunea acestuia. :-D

Un alt utilizator, care a întâlnit aceeași problemă, ar fi putut să mărească tabelul NAT fără să lase vreun issue și să plece. Însă gyutaeb, deși problema nu era direct legată de el, a analizat-o în detaliu, a înțeles-o și a contribuit la ecosistemul Cilium cu date obiective. Îi admir și îl respect pentru acest entuziasm.

Acesta a fost motivul care m-a determinat să mă hotărăsc să scriu acest articol.

Notă - 2

Acest subiect nu se potrivește cu adevărat cu Gosuda, care se ocupă în mod specializat de limbajul Go. Totuși, limbajul Go și ecosistemul cloud sunt strâns legate, iar contributorii Cilium au o pregătire oarecare în Go, așa că am adus acest conținut, care ar putea fi publicat pe un blog personal, pe Gosuda.

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

Dacă considerați că nu este în regulă, vă rugăm să salvați rapid articolul ca PDF, deoarece ar putea fi șters în orice moment. ;)

Notă - 3

La redactarea acestui articol am primit un ajutor important din partea Cline și a modelului Llama 4 Maveric. Deși analiza am început-o cu Gemini și am cerut ajutor de la DeepSeek, ajutorul real a venit de la Llama 4. Llama 4 este excelent. Vă recomand să îl încercați.