GoSuda

Opowieść o Cilium: Niewielka zmiana w kodzie, która znacząco poprawiła stabilność sieci

By iwanhae
views ...

Wstęp

Niedawno miałem okazję zapoznać się z PR-em kolegi z poprzedniej pracy dotyczącym projektu Cilium.

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

(Z wyłączeniem kodu testowego) sama zmiana jest niewielka, sprowadzając się do dodania bloku instrukcji 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ę intrygujący. Dlatego postanowiłem opowiedzieć tę historię w sposób zrozumiały nawet dla osób bez specjalistycznej wiedzy w dziedzinie sieci.

Wiedza podstawowa

Jeśli istnieje niezbędny element wyposażenia współczesnego człowieka, równie ważny jak smartfon, to prawdopodobnie jest nim router Wi-Fi. Router Wi-Fi komunikuje się z urządzeniami za pośrednictwem standardu komunikacyjnego Wi-Fi i pełni rolę udostępniania swojego publicznego adresu IP wielu urządzeniom. Techniczną osobliwością, która się tutaj pojawia, jest pytanie: jak dokładnie odbywa się to „udostępnianie”?

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

NAT

Urządzenie NAT, gdy urządzenie wewnętrzne próbuje uzyskać dostęp do zewnętrznego Internetu, przekształca 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 przekształceniu 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) z prywatnym adresem IP. Dlatego router najpierw znajduje dowolny port, który nie jest aktualnie używany w 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 w pakiecie TCP otrzymanym ze smartfona na swój publiczny adres IP (b.b.b.b) i nowo przydzielony numer portu (60000), a następnie 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 to jako żądanie 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    |

Gdy router otrzyma ten pakiet odpowiedzi, odnajduje w tabeli NAT odpowiadający pierwotny prywatny adres IP (a.a.a.a) i numer portu (50000) dla docelowego adresu IP (b.b.b.b) i numeru portu (60000), a następnie zmienia cel 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, jakby komunikował się z serwerem WWW bezpośrednio, posiadając publiczny adres IP. Dzięki NAT wiele wewnętrznych urządzeń może jednocześnie korzystać z Internetu, używając jednego publicznego adresu IP.

Kubernetes

Kubernetes posiada jedną z najbardziej wyrafinowanych, a jednocześnie złożonych struktur sieciowych spośród technologii, które pojawiły się w ostatnim czasie. Oczywiście, wspomniany wcześniej NAT jest również wykorzystywany w wielu miejscach. Poniżej przedstawiono dwa typowe przykłady.

Gdy Pod komunikuje się z zewnętrznym klastrem

Pody w klastrze Kubernetes zazwyczaj otrzymują prywatne adresy IP, które umożliwiają komunikację tylko w ramach sieci klastra. Dlatego, aby pod mógł komunikować się z zewnętrznym Internetem, potrzebny jest NAT dla ruchu wychodzącego poza klaster. W tym przypadku NAT jest zazwyczaj wykonywany na węźle Kubernetes (każdym serwerze w klastrze), na którym działa dany pod. Gdy pod wysyła pakiet na zewnątrz, pakiet ten najpierw trafia do węzła, do którego należy pod. Węzeł zmienia źródłowy adres IP pakietu (prywatny IP poda) na swój publiczny adres IP i odpowiednio zmienia port źródłowy, a następnie przesyła go na zewnątrz. Proces ten jest podobny do procesu NAT opisanego wcześniej dla 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 od poda następujący pakiet:

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ł zapisuje 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 wykonuje SNAT w następujący sposób i wysyła pakiet na zewnątrz.

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 jest taki sam jak w przypadku routera smartfona.

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

Jednym ze sposobów udostępniania usług na zewnątrz w Kubernetes jest użycie usług 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 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 poda świadczącego daną usługę. W tym procesie najpierw następuje DNAT, który zmienia docelowy adres IP i numer portu pakietu na adres IP i numer portu poda.

Na przykład, załóżmy, że zewnętrzny użytkownik (203.0.113.10, port: 30000) uzyskuje dostęp do usługi poprzez NodePort (30001) jednego z węzłów 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 węzła dostępny z zewnątrz, 192.168.1.5, jak i adres IP ważny w wewnętrznej sieci Kubernetes, 10.0.1.1. (W zależności od rodzaju używanego CNI, polityki z tym związane są różne, ale w tym artykule skupimy się na Cilium.)

Gdy żądanie użytkownika zewnętrznego dotrze do węzła, węzeł musi przekazać to żądanie do poda, który je obsłuży. W tym momencie węzeł stosuje następującą regułę DNAT, aby zmienić docelowy adres IP i numer portu pakietu.

1# Pakiet TCP przygotowywany przez węzeł do wysłania do poda
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, ż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 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 poda
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 poprzez 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ść

Zazwyczaj w systemach Linux procesy NAT są zarządzane i realizowane przez podsystem conntrack za pośrednictwem iptables. Rzeczywiście, inne projekty CNI, takie jak flannel czy calico, wykorzystują ten mechanizm do rozwiązywania wspomnianych 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 zdecydowało się na bezpośrednie zaimplementowanie tylko tych funkcji, które są niezbędne w środowisku Kubernetes, spośród zadań, które wcześniej były realizowane przez tradycyjny stos sieciowy Linuksa, jak pokazano na powyższym schemacie. Dlatego też, w odniesieniu do wspomnianego wcześniej procesu SNAT, Cilium bezpośrednio zarządza tabelą SNAT w formie LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

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, który wykorzystuje kombinację src ip, src port, dst ip, dst port jako wartość klucza.

Identyfikacja problemu

Zjawisko - 1: Wyszukiwanie

Z tego powodu pojawia się jeden problem: pakiet przechodzący przez eBPF musi sprawdzić, czy wymaga wykonania procesu SNAT lub DNAT w odniesieniu do procesu NAT, poprzez odpytanie powyższej Hash Table. Jak widzieliśmy wcześniej, w procesie SNAT istnieją dwa rodzaje pakietów: 1. pakiet wychodzący z wnętrza na zewnątrz oraz 2. pakiet przychodzący z zewnątrz do wnętrza jako odpowiedź. Oba te 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 do Hash Table kolejnej wartości z kluczem, w którym src i dst są zamienione, lub przeszukanie tej samej Hash Table dwukrotnie dla wszystkich pakietów, nawet tych niezwiązanych z SNAT. Oczywiście, Cilium, w celu uzyskania lepszej wydajności, przyjęło metodę wstawiania tych samych danych dwukrotnie pod nazwą RevSNAT.

Zjawisko - 2: LRU

Niezależnie od powyższego problemu, w żadnym sprzęcie nie ma nieskończonych zasobów, a zwłaszcza w logice sprzętowej wymagającej wysokiej wydajności, gdzie nie można używać dynamicznych struktur danych, konieczne jest usuwanie istniejących danych, gdy zasoby są niewystarczające. Cilium rozwiązało 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 pojedynczego połączenia TCP (lub UDP) z SNAT:

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

Jeśli którykolwiek z wpisów NAT (zwanych dalej entry) dla pakietu wychodzącego lub przychodzącego zostanie usunięty przez LRU, to prawidłowe wykonanie NAT nie będzie możliwe, co może prowadzić do utraty całego 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ło to do normalnej komunikacji. Jeśli klucz został przypadkowo usunięty przez LRU, SNAT był wykonywany ponownie z innym portem niż ten używany w istniejącej komunikacji, co powodowało odrzucenie odbioru pakietu przez odbiorcę i zakończenie połączenia za pomocą pakietu RST.

Podejście przyjęte w powyższym PR jest proste.

Gdy pakiet zostanie zaobserwowany w dowolnym kierunku, należy również odnowić wpis dla kierunku odwrotnego.

Gdy komunikacja zostanie zaobserwowana w dowolnym kierunku, oba wpisy są odświeżane, co zmniejsza prawdopodobieństwo, że jeden z wpisów zostanie usunięty z powodu logiki LRU, co mogłoby prowadzić do całkowitego zerwania komunikacji.

Może to wydawać się bardzo prostym podejściem i nieskomplikowanym pomysłem, ale dzięki temu podejściu skutecznie rozwiązano problem zerwania połączenia spowodowany wcześniejszym wygaśnięciem informacji NAT dla pakietu odpowiedzi, znacznie zwiększając stabilność systemu. Można to również uznać za ważne ulepszenie, które przyniosło następujące rezultaty w zakresie stabilności sieci.

benchmark

Podsumowanie

Uważam, że ten PR jest doskonałym przykładem, który pokazuje, jak prosta idea może przynieść ogromne zmiany w złożonym systemie, począwszy od podstawowej wiedzy CS na temat działania NAT.

Ach, oczywiście, w tym artykule nie przedstawiłem bezpośrednio przykładów złożonych systemów. Jednakże, aby w pełni zrozumieć ten PR, błagałem DeepSeek V3 0324 przez prawie 3 godziny, nawet dodając słowo „Please”, a w rezultacie zdobyłem wiedzę o Cilium +1 i otrzymałem poniższy schemat. 😇

diagram

Czytając te problemy i PR, postanowiłem napisać ten artykuł, kierując się pragnieniem zadośćuczynienia za niepokojące przeczucia, że coś, co stworzyłem w przeszłości, mogło spowodować te problemy.

Po fakcie - 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ć jej rozmiar. :-D

Podziwiam i szanuję pasję gyutaeb, który, w sytuacji, gdy ktoś inny, napotkawszy ten sam problem, mógłby po prostu zwiększyć rozmiar tabeli NAT i uciec, nie pozostawiając żadnego zgłoszenia, on sam, mimo że problem nie dotyczył go bezpośrednio, dokładnie go przeanalizował, zrozumiał i wniósł wkład w ekosystem Cilium, dostarczając obiektywne dane dowodowe.

To właśnie to skłoniło mnie do napisania tego artykułu.

Po fakcie - 2

Ta historia, co prawda, nie jest bezpośrednio związana z Gosudą, 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 posiadają pewne doświadczenie w Go, dlatego postanowiłem przenieść treść, którą mógłbym umieścić na osobistym blogu, do Gosudy.

Zezwolenie od jednego z administratorów (mnie samego) zostało udzielone, więc prawdopodobnie będzie w porządku.

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

Po fakcie - 3

Podczas pisania tego artykułu otrzymałem znaczącą pomoc od Cline i Llama 4 Maveric. Chociaż analizę rozpocząłem z Gemini i błagałem DeepSeek, to jednak Llama 4 najbardziej mi pomogła. Llama 4 jest świetna. Zdecydowanie polecam ją wypróbować.