GoSuda

Historia Cilium: Zaskakujące ulepszenia stabilności sieci dzięki małej zmianie w kodzie

By iwanhae
views ...

Wstęp

Niedawno miałem okazję zapoznać się z PR dotyczącym projektu Cilium, autorstwa mojego byłego kolegi z pracy.

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

Zakres zmian (pomijając kod testowy) jest niewielki – sprowadza się do dodania bloku if. Jednakże, wpływ tej modyfikacji jest ogromny, a fakt, że prosty pomysł może wnieść tak znaczący wkład w stabilność systemu, osobiście wydał mi się fascynujący. Postanowiłem więc opowiedzieć o tym przypadku w sposób zrozumiały dla osób bez specjalistycznej wiedzy w dziedzinie sieci.

Wiedza podstawowa

Jeśli istnieje jakiś niezbędny element współczesnego życia, równie ważny jak smartfon, to prawdopodobnie jest nim router Wi-Fi. Router Wi-Fi komunikuje się z urządzeniami za pomocą protokołu Wi-Fi i pełni funkcję udostępniania swojego publicznego adresu IP wielu urządzeniom. Rodzi się tu techniczna specyfika: jak odbywa się to „udostępnianie”?

Technologia wykorzystywana w tym celu to Network Address Translation (NAT). NAT to technologia, która umożliwia komunikację zewnętrzną poprzez mapowanie wewnętrznej komunikacji opartej na prywatnym IP:Port na aktualnie niewykorzystywany publiczny IP:Port, biorąc pod uwagę, że komunikacja TCP lub UDP opiera się na kombinacji adresu IP i informacji o porcie.

NAT

Gdy urządzenie wewnętrzne próbuje uzyskać dostęp do Internetu zewnętrznego, urządzenie NAT tłumaczy kombinację prywatnego adresu IP i numeru portu tego urządzenia na swój publiczny adres IP i dowolny, nieużywany numer portu. Informacje o tym tłumaczeniu są zapisywane w tabeli NAT wewnątrz urządzenia NAT.

Na przykład, załóżmy, że smartfon w domu (prywatny IP: a.a.a.a, port: 50000) próbuje połączyć się z serwerem WWW (publiczny IP: c.c.c.c, port: 80).

1Smartfon (a.a.a.a:50000) ==> Router (b.b.b.b) ==> Serwer WWW (c.c.c.c:80)

Gdy router otrzyma żądanie ze smartfona, zobaczy następujący pakiet TCP:

1# Pakiet TCP odebrany przez router, smartfon => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| a.a.a.a | 50000    | c.c.c.c | 80       |

Gdyby ten pakiet został wysłany bezpośrednio do serwera WWW (c.c.c.c), odpowiedź nie wróciłaby do smartfona (a.a.a.a), który ma prywatny adres IP. Dlatego router najpierw znajduje dowolny port, który nie jest aktualnie używany do komunikacji (np. 60000) i zapisuje go w wewnętrznej tabeli NAT.

1# Wewnętrzna tabela NAT routera
2| local ip  | local port | global ip  | global port |
3-----------------------------------------------------
4| a.a.a.a   | 50000      | b.b.b.b    | 60000       |

Po zapisaniu nowego wpisu w tabeli NAT, router zmienia źródłowy adres IP i numer portu pakietu TCP otrzymanego ze smartfona na swój publiczny adres IP (b.b.b.b) oraz nowo przydzielony numer portu (60000) i przesyła go do serwera WWW.

1# Pakiet TCP wysłany przez router, router => serwer WWW
2# Wykonano SNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| b.b.b.b | 60000    | c.c.c.c | 80       |

Teraz serwer WWW (c.c.c.c) rozpoznaje żądanie jako pochodzące z portu 60000 routera (b.b.b.b) i wysyła pakiet odpowiedzi do routera w następujący sposób:

1# Pakiet TCP odebrany przez router, serwer WWW => router
2| src ip  | src port | dst ip  | dst port |
3-------------------------------------------
4| c.c.c.c | 80       | b.b.b.b | 60000    |

Po otrzymaniu tego pakietu odpowiedzi, router wyszukuje w tabeli NAT oryginalny prywatny adres IP (a.a.a.a) i numer portu (50000) odpowiadające docelowemu adresowi IP (b.b.b.b) i numerowi portu (60000) i zmienia docelowy adres pakietu na smartfon.

1# Pakiet TCP wysłany przez router, router => smartfon
2# Wykonano DNAT
3| src ip  | src port | dst ip  | dst port |
4-------------------------------------------
5| c.c.c.c | 80       | a.a.a.a | 50000    |

Dzięki temu procesowi smartfon ma wrażenie, że komunikuje się z serwerem WWW bezpośrednio, używając własnego publicznego adresu IP. Dzięki NAT, wiele urządzeń wewnętrznych może jednocześnie korzystać z Internetu, używając jednego publicznego adresu IP.

Kubernetes

Kubernetes posiada jedną z najbardziej wyrafinowanych i złożonych struktur sieciowych spośród ostatnio opracowanych technologii. Oczywiście, wspomniany wcześniej NAT jest wykorzystywany w wielu miejscach. Dwa główne przykłady to:

Kiedy Pod wewnątrz klastra komunikuje się z zewnętrznym światem

Pody w klastrze Kubernetes zazwyczaj otrzymują prywatne adresy IP, które umożliwiają komunikację tylko wewnątrz sieci klastra. Dlatego, aby pod mógł komunikować się z zewnętrznym Internetem, wymagany jest NAT dla ruchu wychodzącego z klastra. W tym przypadku NAT jest zazwyczaj wykonywany na węźle Kubernetes (każdy serwer w klastrze), na którym działa dany pod. Gdy pod wysyła pakiet na zewnątrz, pakiet ten jest najpierw przekazywany do węzła, do którego należy pod. Węzeł zmienia źródłowy adres IP tego pakietu (prywatny IP poda) na swój publiczny adres IP, odpowiednio zmienia również port źródłowy i przesyła go na zewnątrz. Proces ten jest podobny do procesu NAT opisanego wcześniej w przypadku routera Wi-Fi.

Na przykład, załóżmy, że Pod w klastrze Kubernetes (10.0.1.10, port: 40000) łączy się z zewnętrznym serwerem API (203.0.113.45, port: 443). Węzeł Kubernetes otrzyma następujący pakiet od Podu:

1# Pakiet TCP odebrany przez węzeł, Pod => węzeł
2| src ip    | src port | dst ip        | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000    | 203.0.113.45  | 443      |

Węzeł zapisze następujące informacje:

1# Wewnętrzna tabela NAT węzła (przykład)
2| local ip    | local port | global ip     | global port |
3---------------------------------------------------------
4| 10.0.1.10   | 40000      | 192.168.1.5   | 50000       |

Następnie wykona SNAT i wyśle pakiet na zewnątrz w następujący sposób:

1# Pakiet TCP wysłany przez węzeł, węzeł => serwer API
2# Wykonano SNAT
3| src ip      | src port | dst ip        | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000    | 203.0.113.45  | 443      |

Dalszy proces przebiega podobnie jak w przypadku smartfona z routerem.

Kiedy komunikacja z Podem odbywa się z zewnątrz klastra poprzez NodePort

Jednym ze sposobów na udostępnienie usługi na zewnątrz w Kubernetes jest użycie usługi NodePort. Usługa NodePort otwiera określony port (NodePort) na wszystkich węzłach w klastrze i przekazuje ruch przychodzący na ten port do Podów należących do usługi. Użytkownicy zewnętrzni mogą uzyskać dostęp do usługi za pośrednictwem adresu IP węzła klastra i NodePort.

W tym przypadku NAT odgrywa ważną rolę, a w szczególności DNAT (Destination NAT) i SNAT (Source NAT) występują jednocześnie. Gdy ruch przychodzi z zewnątrz na NodePort określonego węzła, sieć Kubernetes musi ostatecznie przekazać ten ruch do Podu świadczącego daną usługę. W tym procesie najpierw następuje DNAT, zmieniając docelowy adres IP i numer portu pakietu na adres IP i numer portu Podu.

Na przykład, załóżmy, że użytkownik zewnętrzny (203.0.113.10, port: 30000) uzyskuje dostęp do usługi za pośrednictwem NodePort (30001) węzła Kubernetes (192.168.1.5). Załóżmy, że ta usługa wewnętrznie wskazuje na Pod o adresie IP 10.0.2.15 i porcie 8080.

1Użytkownik zewnętrzny (203.0.113.10:30000) ==> Węzeł Kubernetes (zewnętrzny:192.168.1.5:30001 / wewnętrzny: 10.0.1.1:42132) ==> Pod Kubernetes (10.0.2.15:8080)

W tym przypadku węzeł Kubernetes posiada zarówno adres IP 192.168.1.5, dostępny z zewnątrz, jak i adres IP 10.0.1.1, ważny w wewnętrznej sieci Kubernetes. (Polityka dotycząca tego może się różnić w zależności od używanego typu CNI, ale w tym artykule wyjaśnienie opiera się na Cilium.)

Gdy żądanie od użytkownika zewnętrznego dociera do węzła, węzeł musi przekazać to żądanie do Podu, który ma je obsłużyć. W tym celu węzeł stosuje następującą regułę DNAT, zmieniając docelowy adres IP i numer portu pakietu:

1# Pakiet TCP przygotowywany przez węzeł do wysłania do Podu
2# Po zastosowaniu DNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 203.0.113.10  | 30000    | 10.0.2.15 | 8080     |

Ważne jest tutaj, że gdy Pod wysyła odpowiedź na to żądanie, jego źródłowy adres IP to jego własny adres IP (10.0.2.15), a docelowy adres IP to adres IP użytkownika zewnętrznego, który wysłał żądanie (203.0.113.10). W takim przypadku użytkownik zewnętrzny otrzyma odpowiedź z nieistniejącego adresu IP, o który nigdy nie prosił, i po prostu odrzuci ten pakiet. Dlatego węzeł Kubernetes dodatkowo wykonuje SNAT, gdy Pod wysyła pakiet odpowiedzi na zewnątrz, zmieniając źródłowy adres IP pakietu na adres IP węzła (192.168.1.5 lub wewnętrzny adres sieciowy 10.0.1.1, w tym przypadku 10.0.1.1).

1# Pakiet TCP przygotowywany przez węzeł do wysłania do Podu
2# Po zastosowaniu DNAT, SNAT
3| src ip        | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1      | 40021    | 10.0.2.15 | 8080     |

Teraz Pod, który otrzymał ten pakiet, odpowiada węzłowi, który pierwotnie otrzymał żądanie NodePort, a węzeł odwraca proces DNAT i SNAT, aby zwrócić informacje użytkownikowi zewnętrznemu. W tym procesie każdy węzeł będzie przechowywał następujące informacje:

1# Wewnętrzna tabela DNAT węzła
2| original ip     | original port | destination ip  | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5     | 30001         | 10.0.2.15       | 8080             |
5
6# Wewnętrzna tabela SNAT węzła
7| original ip     | original port | destination ip  | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10    | 30000         | 10.0.1.1        | 42132            |

Treść główna

Zazwyczaj w systemie Linux procesy NAT są zarządzane i obsługiwane przez podsystem conntrack za pośrednictwem iptables. W rzeczywistości inne projekty CNI, takie jak flannel czy calico, wykorzystują to do rozwiązywania powyższych problemów. Problem polega jednak na tym, że Cilium, używając technologii eBPF, całkowicie ignoruje ten tradycyjny stos sieciowy Linuksa. 🤣

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

W rezultacie Cilium wybrał ścieżkę bezpośredniego implementowania tylko tych funkcji, które są niezbędne w środowisku Kubernetes, z zadań, które tradycyjny stos sieciowy Linuksa wykonywał, jak pokazano na powyższym schemacie. Dlatego Cilium bezpośrednio zarządza tabelą SNAT w postaci LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) dla wspomnianego wcześniej procesu SNAT.

1# Tabela SNAT Cilium
2# !Przykład dla łatwiejszego zrozumienia. Rzeczywista definicja: 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 inne metadane
4----------------------------------------------
5|            |          |         |          |

Ponieważ jest to Hash Table, w celu szybkiego wyszukiwania istnieje klucz, a jako klucz używana jest kombinacja src ip, src port, dst ip, dst port.

Identyfikacja problemu

Zjawisko - 1: Wyszukiwanie

Z tego powodu pojawia się jeden problem: pakiet przechodzący przez eBPF musi sprawdzić, czy wymaga on procesu SNAT lub DNAT, aby to zrobić, musi przeszukać powyższą Hash Table. Jak widzieliśmy wcześniej, istnieją dwa typy pakietów w procesie SNAT: 1. Pakiety wychodzące z wewnątrz na zewnątrz, oraz 2. Pakiety przychodzące z zewnątrz do wewnątrz jako odpowiedź. Te dwa pakiety wymagają transformacji w procesie NAT, a ich wartości src ip, port i dst ip, port są zamienione.

Dlatego, w celu szybkiego wyszukiwania, konieczne jest dodanie kolejnego wpisu do Hash Table z kluczem, w którym src i dst są zamienione, albo dwukrotne przeszukanie tej samej Hash Table dla wszystkich pakietów, nawet tych niezwiązanych z SNAT. Oczywiście, Cilium, w celu uzyskania lepszej wydajności, zastosował metodę wprowadzania tych samych danych dwukrotnie pod nazwą RevSNAT.

Zjawisko - 2: LRU

Niezależnie od powyższego problemu, żadne urządzenie sprzętowe nie może posiadać nieograniczonych zasobów. Zwłaszcza w przypadku logiki na poziomie sprzętowym, gdzie wymagana jest wysoka wydajność i nie można używać dynamicznych struktur danych, konieczne jest usuwanie istniejących danych, gdy zasoby są niewystarczające. Cilium rozwiązał ten problem, używając LRU Hash Map, podstawowej struktury danych dostarczanej domyślnie w systemie Linux.

Zjawisko 1 + Zjawisko 2 = Utrata połączenia

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

Oznacza to, że dla jednego połączenia TCP (lub UDP) z SNAT:

  1. W jednej Hash Table te same dane są zapisane dwukrotnie dla pakietów wychodzących i przychodzących.
  2. Zgodnie z logiką LRU, w każdej chwili jedna z tych dwóch danych może zostać usunięta.

Jeśli jakakolwiek informacja NAT (zwana dalej wpisem) dla pakietu wychodzącego lub przychodzącego zostanie usunięta przez LRU, komunikacja NAT nie będzie mogła zostać prawidłowo wykonana, co może prowadzić do całkowitej utraty połączenia.

Rozwiązanie

W tym miejscu pojawiają się wspomniane wcześniej PR-y.

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

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

Wcześniej, gdy pakiet przechodził przez eBPF, podejmowano próbę wyszukania w tabeli SNAT, tworząc klucz z kombinacji src ip, src port, dst ip, dst port. Jeśli klucz nie istniał, tworzono nową informację NAT zgodnie z regułą SNAT i zapisywano ją w tabeli. W przypadku nowego połączenia, prowadziłoby to do normalnej komunikacji. Jednakże, jeśli klucz został przypadkowo usunięty przez LRU, NAT zostałby ponownie wykonany na nowym porcie, innym niż ten używany do istniejącej komunikacji, co spowodowałoby odrzucenie pakietu przez odbiorcę i zakończenie połączenia pakietem RST.

Metoda zastosowana w powyższym PR jest prosta:

Jeśli pakiet zostanie zaobserwowany w dowolnym kierunku, należy zaktualizować również wpis dla kierunku odwrotnego.

Gdy komunikacja zostanie zaobserwowana w dowolnym kierunku, oba wpisy są aktualizowane, co zmniejsza ich priorytet ewakuacji z logiki LRU. W ten sposób zmniejsza się prawdopodobieństwo, że tylko jeden wpis zostanie usunięty, co mogłoby doprowadzić do załamania całej komunikacji.

Jest to bardzo proste podejście i może wydawać się to prostym pomysłem, ale dzięki niemu skutecznie rozwiązano problem utraty połączeń z powodu wcześniejszego wygaśnięcia informacji NAT dla pakietów odpowiedzi, co znacznie poprawiło stabilność systemu. Można to również uznać za ważną poprawę, która przyniosła następujące korzyści w zakresie stabilności sieci:

benchmark

Wniosek

Uważam, że ten PR jest doskonałym przykładem, który pokazuje, jak działa NAT, od podstawowej wiedzy o CS, po to, jak prosty pomysł może przynieść ogromne zmiany w złożonym systemie.

Oczywiście, w tym artykule nie przedstawiłem bezpośrednio przykładu złożonego systemu. Jednakże, aby w pełni zrozumieć ten PR, przez prawie 3 godziny błagałem DeepSeek V3 0324, używając nawet słowa „Please”, a w rezultacie zdobyłem wiedzę o Cilium +1 i poniższy schemat. 😇

diagram

Czytając te problemy i PR-y, napisałem ten artykuł jako formę rekompensaty za złe przeczucia, że coś, co stworzyłem w przeszłości, mogło spowodować te problemy.

Posłowie - 1

Nawiasem mówiąc, istnieje bardzo skuteczny sposób na uniknięcie tego problemu. Ponieważ podstawową przyczyną problemu jest brak miejsca w tabeli NAT, wystarczy zwiększyć rozmiar tabeli NAT. :-D

Kiedy ktoś inny napotkał ten sam problem i uciekł, zwiększając rozmiar tabeli NAT bez zgłaszania problemu, byłem pod wrażeniem i pełen szacunku dla pasji pana gyutaeb, który dokładnie przeanalizował i zrozumiał problem, mimo że nie był on bezpośrednio z nim związany, i wniósł wkład w ekosystem Cilium, dostarczając obiektywne dane dowodowe.

Był to powód, dla którego postanowiłem napisać ten artykuł.

Posłowie - 2

Właściwie, ten temat nie do końca pasuje do Gosudy, która specjalizuje się w języku Go. Jednakże język Go i ekosystem chmury są ze sobą ściśle powiązane, a współtwórcy Cilium mają pewną znajomość języka Go, więc postanowiłem przenieść treść z mojego osobistego bloga do Gosudy.

Ponieważ jeden z administratorów (ja sam) wyraził zgodę, zakładam, że będzie to w porządku.

Jeśli uważasz, że nie jest to w porządku, zapisz to szybko w formacie PDF, ponieważ nie wiadomo, kiedy zostanie usunięte. ;)

Posłowie - 3

W pisaniu tego artykułu bardzo pomogły mi Cline i Llama 4 Maveric. Chociaż analizę rozpocząłem z Gemini i błagałem DeepSeek, to jednak Llama 4 okazała się najbardziej pomocna. Llama 4 jest świetna. Koniecznie spróbujcie.