GoSuda

Opowieść o Cilium: zadziwiająca poprawa stabilności sieci dzięki drobnej zmianie w kodzie

By iwanhae views ...

Wprowadzenie

Niedawno przejrzałem PR w projekcie Cilium, który napisał mój były współpracownik.

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

Sama zmiana (nie licząc kodu testowego) polega na dodaniu zaledwie jednego bloku if. Jednak skutki tej poprawki są ogromne, i osobiście uważam za interesujące, że pojedynczy prosty pomysł może znacząco przyczynić się do stabilności systemu. Dlatego postanowiłem opowiedzieć tę historię tak, aby mogli ją łatwo zrozumieć także osoby bez specjalistycznej wiedzy z zakresu sieci.

Wiedza podstawowa

Jeśli istnieje rzecz, która dla współczesnych ludzi jest równie ważna jak smartfon, to chyba jest nią router Wi-Fi. Router komunikuje się z urządzeniami za pomocą standardu Wi-Fi i pełni rolę udostępniania jednego publicznego adresu IP wielu urządzeniom. Pojawia się tu osobliwy punkt techniczny: w jaki sposób odbywa się to "udostępnianie"?

Technologią używaną w tym celu jest Network Address Translation (NAT). NAT jest technologią, która odwzorowuje wewnętrzną komunikację prywatny IP:Port, ponieważ komunikacja TCP lub UDP odbywa się w oparciu o kombinację adresu IP i portu, na nieużywane obecnie publiczny IP:Port, dzięki czemu możliwa jest komunikacja z zewnętrzną siecią.

NAT

Urządzenie NAT, gdy urządzenie wewnętrzne próbuje połączyć się z Internetem zewnętrznym, przekształca kombinację prywatnego adresu IP i numeru portu tego urządzenia na swój publiczny adres IP oraz dowolny nieużywany numer portu. Informacje o tym przekształceniu są zapisywane w miejscu wewnątrz urządzenia NAT zwanym tabelą NAT.

Załóżmy na przykład, ż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 od smartfona, zobaczy pakiet TCP taki jak poniższy.

1# Pakiet TCP otrzymany 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       |

Jeśli ten pakiet zostanie wysłany dalej bez zmian do serwera WWW (c.c.c.c), odpowiedź nie wróci do smartfona o prywatnym adresie IP (a.a.a.a). Dlatego router najpierw znajduje dowolny port (np. 60000), który nie uczestniczy obecnie w komunikacji, i zapisuje to 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 adres IP źródła i numer portu z pakietu TCP otrzymanego od smartfona na swój publiczny adres IP (b.b.b.b) oraz nowo przydzielony numer portu (60000), a następnie wysyła pakiet 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 otrzymany 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, 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 miejsce docelowe 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 odczuwa tak, jakby sam posiadał publiczny adres IP i komunikował się bezpośrednio z serwerem WWW. Dzięki NAT wiele wewnętrznych urządzeń może jednocześnie korzystać z Internetu przy użyciu jednego publicznego adresu IP.

Kubernetes

Kubernetes ma jedną z najbardziej zaawansowanych i jednocześnie złożonych struktur sieciowych spośród ostatnio pojawiających się technologii. Oczywiście wspomniany wcześniej NAT jest również wykorzystywany w wielu miejscach. Reprezentatywne przypadki to dwa poniższe.

Gdy Pod komunikuje się z zewnętrznym środowiskiem klastra

Pody w klastrze Kubernetes zazwyczaj otrzymują prywatne adresy IP, które umożliwiają komunikację wyłącznie w obrębie sieci klastra. Dlatego, aby pod mógł komunikować się z zewnętrznym Internetem, wymagany jest NAT dla ruchu wychodzącego poza klaster. W tym przypadku NAT jest zazwyczaj wykonywany na węźle Kubernetes (każdy serwer klastra), 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ł zamienia adres IP źródła pakietu (prywatny IP poda) na swój publiczny adres IP, odpowiednio zmienia też port źródłowy i przekazuje pakiet na zewnątrz. Proces ten jest podobny do procesu NAT opisanego wcześniej dla routera Wi-Fi.

Załóżmy na przykład, że pod w klastrze Kubernetes (10.0.1.10, port: 40000) łączy się z zewnętrznym serwerem API (203.0.113.45, port: 443). Wtedy węzeł Kubernetes otrzymuje od poda pakiet w następującej postaci.

1# Pakiet TCP otrzymany 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 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 przebieg jest taki sam jak w opisanym wcześniej przypadku routera Wi-Fi ze smartfonem.

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

Jednym ze sposobów udostępniania 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. Zewnętrzni użytkownicy mogą uzyskać dostęp do usługi za pomocą adresu IP węzła klastra oraz NodePort.

W tym miejscu NAT odgrywa ważną rolę, a w szczególności DNAT (Destination NAT) oraz SNAT (Source NAT) występują jednocześnie. Gdy ruch z zewnątrz trafia na NodePort określonego węzła, sieć Kubernetes musi ostatecznie przekazać ten ruch do poda obsługującego daną usługę. W tym procesie najpierw zachodzi DNAT, w wyniku którego adres IP docelowy i numer portu docelowego pakietu są zmieniane na adres IP i numer portu poda.

Załóżmy na przykład, że zewnętrzny użytkownik (203.0.113.10, port: 30000) uzyskuje dostęp do usługi przez NodePort (30001) jednego z węzłów klastra Kubernetes (192.168.1.5). Załóżmy też, że usługa wewnętrznie wskazuje pod o adresie IP 10.0.2.15 i porcie 8080.

1Zewnętrzny użytkownik (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 zewnętrzny adres IP węzła (192.168.1.5), dostępny z zewnątrz, jak i adres IP (10.0.1.1), ważny w wewnętrznej sieci Kubernetes. (W zależności od używanego CNI polityki dotyczące tego mogą się różnić, jednak w tym artykule wyjaśnienia opierają się na Cilium.)

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

1# Pakiet TCP, który węzeł przygotowuje 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 tutaj, że gdy pod wysyła odpowiedź na to żądanie, adresem IP źródła jest jego własny adres IP (10.0.2.15), a adresem IP docelowego jest adres IP zewnętrznego użytkownika, który wysłał żądanie (203.0.113.10). W takim przypadku zewnętrzny użytkownik otrzymałby odpowiedź od nieistniejącego adresu IP, którego nigdy nie żądał, i po prostu odrzuciłby ten pakiet (DROP). Dlatego węzeł Kubernetes, gdy pod wysyła odpowiedź na zewnątrz, dodatkowo wykonuje SNAT i zmienia adres IP źródła pakietu na adres IP węzła (192.168.1.5 lub wewnętrzny adres IP 10.0.1.1; w tym przypadku przyjmujemy 10.0.1.1).

1# Pakiet TCP, który węzeł przygotowuje do wysłania do poda
2# Po zastosowaniu DNAT i SNAT
3| src ip       | src port | dst ip    | dst port |
4---------------------------------------------------
5| 10.0.1.1     | 40021    | 10.0.2.15 | 8080     |

Pod, który otrzymał ten pakiet, odpowiada teraz do węzła, który początkowo otrzymał żądanie przez NodePort, a węzeł, stosując dokładnie te same procesy DNAT i SNAT w odwrotnej kolejności, zwraca informacje zewnętrznemu użytkownikowi. W tym procesie każdy węzeł przechowuje 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            |

Część główna

Ogólnie w systemie Linux procesy NAT są zarządzane i wykonywane przez podsystem conntrack, za pośrednictwem iptables. Projekty CNI takie jak flannel czy calico rzeczywiście wykorzystują ten mechanizm do rozwiązania opisanego problemu. Problem polega jednak na tym, że Cilium, korzystając z technologii eBPF, całkowicie pomija tradycyjny stos sieciowy Linuksa. 🤣

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

W rezultacie Cilium wybrało drogę bezpośredniego implementowania tylko tych funkcji z zadań wykonywanych przez tradycyjny stos sieciowy Linuksa (pokazany na powyższym rysunku), które są potrzebne w środowisku Kubernetes. Dlatego w odniesieniu do wspomnianego wcześniej SNAT Cilium bezpośrednio zarządza tabelą SNAT w postaci LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).

1# Tabela SNAT Cilium
2# !Przykład dla łatwiejszego wyjaśnienia. 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 itp. metadane
4----------------------------------------------
5|            |          |         |          |

Ponieważ jest to tabela haszująca, aby zapewnić szybkie wyszukiwanie, używany jest klucz, a jako klucz wykorzystywana jest kombinacja src ip, src port, dst ip oraz dst port .

Rozpoznanie problemu

Zjawisko 1: wyszukiwanie

Z tego powodu pojawia się jeden problem. Pakiet przechodzący przez eBPF, aby sprawdzić, czy wymaga wykonania SNAT lub DNAT, musi przeszukać powyższą tabelę haszującą. Jak już wcześniej widzieliśmy, w procesie SNAT występują dwa rodzaje pakietów: 1. pakiety wychodzące z sieci wewnętrznej na zewnątrz oraz 2. pakiety odpowiedzi przychodzące z zewnątrz do wewnątrz. Oba te pakiety wymagają przekształcenia NAT, ale mają tę cechę, że wartości src ip, port oraz dst ip, port są zamienione.

Dlatego, aby zapewnić szybkie wyszukiwanie, trzeba albo dodatkowo wstawić do tabeli wpis z kluczem, w którym src i dst są zamienione, albo wykonywać dwa wyszukiwania w tej samej tabeli dla każdego pakietu, nawet jeśli nie dotyczy on SNAT. Oczywiście, aby uzyskać lepszą wydajność, Cilium przyjęło rozwiązanie o nazwie RevSNAT, wstawiając te same dane dwukrotnie.

Zjawisko 2: LRU

Niezależnie od powyższego problemu, żaden sprzęt nie ma nieskończonych zasobów, a zwłaszcza w logice sprzętowej, gdzie wymagana jest szybka wydajność i nie można używać dynamicznych struktur danych, konieczne jest usuwanie istniejących danych (eviction), gdy brakuje zasobów. Cilium rozwiązało to, używając LRU Hash Map, czyli 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) poddanego SNAT:

  1. W jednej tabeli haszującej te same dane są zapisane dwukrotnie: dla pakietu wychodzącego i dla pakietu przychodzącego, oraz
  2. zgodnie z logiką LRU, w dowolnym momencie jedna z tych dwóch wartości może zostać utracona,

jeśli choćby jeden z wpisów informacji NAT dla pakietów wychodzących na zewnątrz lub przychodzących z zewnątrz (dalej: entry) zostanie usunięty przez LRU, NAT nie może zostać poprawnie wykonany, co może prowadzić do utraty całego połączenia.

Rozwiązanie

Tutaj 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

Dotychczas, gdy pakiet przechodził przez eBPF, system próbował wyszukać wpis w tabeli SNAT, tworząc klucz z kombinacji src ip, src port, dst ip i dst port. Jeśli klucz nie istniał, zgodnie z regułami SNAT tworzone były nowe informacje NAT i zapisywane w tabeli. W przypadku nowego połączenia prowadzi to do normalnej komunikacji. Jeśli jednak klucz został usunięty niezamierzenie przez LRU, nowy NAT był wykonywany z innym portem niż ten, którego używało dotychczasowe połączenie, a strona odbierająca odrzucała pakiet, co kończyło połączenie wysłaniem pakietu RST.

Podejście, które przyjęło wspomniane PR, jest proste:

Jeśli pakiet zostanie zaobserwowany w którymkolwiek kierunku, odśwież także entry dla kierunku przeciwnego.

Gdy komunikacja zostanie zaobserwowana w dowolnym kierunku, oba entry są odświeżane, dzięki czemu oddalają się od priorytetu usuwania w logice LRU. Dzięki temu można zmniejszyć prawdopodobieństwo, że usunięty zostanie tylko jeden z wpisów i cała komunikacja się załamie.

Jest to bardzo prosty sposób i może wyglądać jak prosty pomysł, jednak dzięki temu podejściu udało się skutecznie rozwiązać problem, w którym wpis NAT dla pakietu odpowiedzi wygasa wcześniej i połączenie zostaje zerwane, a także znacznie poprawić stabilność systemu. Ponadto jest to istotna poprawka, która osiągnęła następujące rezultaty w zakresie stabilności sieci.

benchmark

Zakończenie

Uważam, że ten PR jest bardzo dobrym przykładem tego, jak od podstawowej wiedzy informatycznej o tym, jak działa NAT, aż po wnętrze złożonego systemu, pojedynczy prosty pomysł może przynieść ogromne zmiany.

Oczywiście w tym artykule nie pokazałem bezpośrednio złożonych przykładów systemów. Jednak aby dobrze zrozumieć ten PR, poprosiłem DeepSeek V3 0324, dodając słowo Please, przez prawie 3 godziny, i w rezultacie zdobyłem +1 do wiedzy o Cilium oraz poniższy diagram. 😇

diagram

Ponadto, czytając zgłoszenia i PR-y, miałem złe przeczucia, że coś, co wcześniej stworzyłem, mogło spowodować te problemy. Jako rekompensatę za te przeczucia napisałem ten artykuł.

Uwagi - 1

Warto wiedzieć, że na ten problem istnieje bardzo skuteczny sposób obejścia. Przyczyną problemu jest brak miejsca w tabeli NAT, więc wystarczy zwiększyć rozmiar tabeli NAT. :-D

Byłem pod wrażeniem i szanuję zaangażowanie gyutaeb, który, mimo że sprawa nie dotyczyła bezpośrednio jego samego, w sytuacji, gdy ktoś inny po napotkaniu tego problemu nie tylko nie zgłosił go, ale zwiększył rozmiar tabeli NAT i uciekł, dokładnie przeanalizował i zrozumiał problem, a następnie wniósł wkład w ekosystem Cilium wraz z obiektywnymi danymi.

To był powód, dla którego zdecydowałem się napisać ten artykuł.

Uwagi - 2

Ta historia właściwie nie pasuje zbytnio do Gosuda, które zajmuje się profesjonalnie językiem Go. Jednak Go i ekosystem chmurowy są ściśle powiązane, a współtwórcy Cilium posiadają pewną wiedzę o Go, więc postanowiłem przenieść treść, którą mogłem opublikować na blogu osobistym, na Gosuda.

Ponieważ zgodę wyraził jeden z administratorów (ja sam), myślę, że wszystko jest w porządku.

Jeśli uważasz, że to niewłaściwe, pamiętaj, że treść może zostać usunięta w dowolnym momencie, więc lepiej szybko zapisz ją jako PDF. ;)

Uwagi - 3

Przy pisaniu tego artykułu bardzo pomógł mi Cline oraz Llama 4 Maveric. Choć analizę zacząłem z Gemini i prosiłem DeepSeek, to w rzeczywistości największą pomoc otrzymałem od Llama 4. Llama 4 jest dobra, koniecznie jej spróbujcie.