Историята на Cilium: Как малка промяна в кода доведе до забележително подобрение в мрежовата стабилност
Въведение
Неотдавна разгледах PR на бивш колега относно проекта Cilium.
bpf:nat: Restore ORG NAT entry if it's not found
Обемът на промените (с изключение на тестовия код) е малък, свеждайки се до добавяне на един блок if израз. Въпреки това, въздействието на тази промяна е огромно и лично аз намирам за интересно как една проста идея може да допринесе значително за стабилността на системата. Затова ще се опитам да разкажа тази история по начин, който е лесно разбираем дори за хора без специализирани познания в областта на мрежите.
Основна информация
Ако има един основен елемент от съвременния живот, който е също толкова важен, колкото и смартфонът, то това вероятно е Wi-Fi рутерът. Wi-Fi рутерът комуникира с устройства чрез стандарта за комуникация Wi-Fi и споделя своя публичен IP адрес, така че множество устройства да могат да го използват. Техническата особеност, която възниква тук, е как точно се осъществява това "споделяне".
Технологията, използвана тук, е Network Address Translation (NAT). NAT е техника, която позволява вътрешна комуникация, състояща се от частен IP:порт, да бъде картографирана към неизползван публичен IP:порт, за да може да комуникира с външния свят, като се има предвид, че TCP или UDP комуникацията се състои от комбинация от IP адрес и информация за порт.
NAT
Когато вътрешно устройство се опита да осъществи достъп до външния интернет, NAT устройството преобразува комбинацията от частен IP адрес и номер на порт на това устройство в собствения си публичен IP адрес и произволен неизползван номер на порт. Тази информация за преобразуване се записва в NAT таблица в рамките на NAT устройството.
Например, да предположим, че смартфон в дома (частен IP: a.a.a.a, порт: 50000) се опитва да осъществи достъп до уеб сървър (публичен IP: c.c.c.c, порт: 80).
1Смартфон (a.a.a.a:50000) ==> Рутер (b.b.b.b) ==> Уеб сървър (c.c.c.c:80)
Когато рутерът получи заявката от смартфона, той ще види следния TCP пакет:
1# TCP пакет, получен от рутера, смартфон => рутер
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
Ако този пакет бъде изпратен директно до уеб сървъра (c.c.c.c), отговорът няма да се върне към смартфона (a.a.a.a), който има частен IP адрес. Затова рутерът първо намира произволен порт, който не е зает в момента (напр. 60000), и го записва във вътрешната си NAT таблица.
1# Вътрешна NAT таблица на рутера
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
След като запише нов запис в NAT таблицата, рутерът променя изходния IP адрес и номера на порта на TCP пакета, получен от смартфона, на своя публичен IP адрес (b.b.b.b) и новоназначения номер на порта (60000) и го изпраща към уеб сървъра.
1# TCP пакет, изпратен от рутера, рутер => уеб сървър
2# Извършване на SNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Сега уеб сървърът (c.c.c.c) разпознава заявката като идваща от порт 60000 на рутера (b.b.b.b) и изпраща пакета за отговор обратно към рутера, както следва:
1# TCP пакет, получен от рутера, уеб сървър => рутер
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
Когато рутерът получи този пакет за отговор, той търси в NAT таблицата оригиналния частен IP адрес (a.a.a.a) и номера на порта (50000), съответстващи на дестинационния IP адрес (b.b.b.b) и номера на порта (60000), и променя дестинацията на пакета към смартфона.
1# TCP пакет, изпратен от рутера, рутер => смартфон
2# Извършване на DNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
Чрез този процес смартфонът усеща, че комуникира директно с уеб сървъра, сякаш сам притежава публичен IP адрес. Благодарение на NAT, множество вътрешни устройства могат да използват интернет едновременно с един публичен IP адрес.
Kubernetes
Kubernetes има една от най-сложните и сложни мрежови структури сред скорошните технологии. И, разбира се, споменатият по-горе NAT се използва в различни области. Два типични примера са следните:
Когато Pod инициира комуникация извън клъстера
Подовете в клъстер на Kubernetes обикновено получават частни IP адреси, които им позволяват да комуникират само в рамките на мрежата на клъстера. Следователно, ако един под трябва да комуникира с външния интернет, е необходим NAT за изходящия трафик. В този случай NAT обикновено се извършва на възела на Kubernetes (всеки сървър в клъстера), на който работи подът. Когато подът изпрати пакет навън, този пакет първо се предава на възела, към който принадлежи подът. Възелът променя изходния IP адрес на пакета (частния IP на пода) на собствения си публичен IP адрес и също така променя изходния порт, преди да го изпрати навън. Този процес е подобен на процеса на NAT, описан по-горе за Wi-Fi рутерите.
Например, ако под в клъстер на Kubernetes (10.0.1.10, порт: 40000) се свързва с външен API сървър (203.0.113.45, порт: 443), възелът на Kubernetes ще получи следния пакет от пода:
1# TCP пакет, получен от възела, под => възел
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Възелът ще запише следното съдържание:
1# Вътрешна NAT таблица на възела (пример)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
И след това ще извърши SNAT, както следва, преди да изпрати пакета навън:
1# TCP пакет, изпратен от възела, възел => API сървър
2# Извършване на SNAT
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
След това процесът следва същите стъпки като при примера с рутера на смартфона.
Когато комуникирате с Pod от външен за клъстера чрез NodePort
Един от начините за излагане на услуга в Kubernetes на външния свят е използването на NodePort услуга. Услугата NodePort отваря определен порт (NodePort) на всички възли в клъстера и препраща трафика, идващ през този порт, към подовeте, принадлежащи към услугата. Външните потребители могат да осъществят достъп до услугата чрез IP адреса на възела на клъстера и NodePort.
В този случай NAT играе важна роля, като DNAT (Destination NAT) и SNAT (Source NAT) се извършват едновременно. Когато трафикът пристигне на NodePort на определен възел отвън, мрежата на Kubernetes трябва в крайна сметка да препрати този трафик към пода, който предоставя услугата. В този процес първо се извършва DNAT, като дестинационният IP адрес и номерът на порта на пакета се променят на IP адреса и номера на порта на пода.
Например, да предположим, че външен потребител (203.0.113.10, порт: 30000) осъществява достъп до услуга чрез NodePort (30001) на възел на Kubernetes (192.168.1.5). Тази услуга вътрешно сочи към под с IP адрес 10.0.2.15 и порт 8080.
1Външен потребител (203.0.113.10:30000) ==> Възел на Kubernetes (външен:192.168.1.5:30001 / вътрешен: 10.0.1.1:42132) ==> Pod на Kubernetes (10.0.2.15:8080)
В този случай възелът на Kubernetes има както IP адрес 192.168.1.5, достъпен отвън, така и IP адрес 10.0.1.1, валиден във вътрешната мрежа на Kubernetes. (В зависимост от използвания CNI тип, правилата, свързани с това, варират, но в тази статия обяснението се основава на Cilium.)
Когато заявката на външния потребител пристигне на възела, възелът трябва да препрати тази заявка към пода, който ще я обработи. В този момент възелът прилага следните правила за DNAT, за да промени IP адреса и номера на порта на дестинацията на пакета:
1# TCP пакет, който възелът се готви да изпрати към пода
2# След прилагане на DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Важното тук е, че когато подът изпраща отговор на тази заявка, изходният IP адрес е неговият собствен IP адрес (10.0.2.15), а дестинационният IP адрес е IP адресът на външния потребител (203.0.113.10), който е изпратил заявката. В този случай външният потребител ще получи отговор от несъществуващ IP адрес, който той никога не е заявявал, и ще отхвърли пакета. Затова възелът на Kubernetes допълнително извършва SNAT, когато подът изпраща пакет за отговор навън, като променя изходния IP адрес на пакета на IP адреса на възела (192.168.1.5 или IP адреса на вътрешната мрежа 10.0.1.1, в този случай 10.0.1.1).
1# TCP пакет, който възелът се готви да изпрати към пода
2# След прилагане на DNAT, SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Сега подът, получил този пакет, ще отговори на възела, който първоначално е получил заявката чрез NodePort, и възелът ще приложи обратните процеси на DNAT и SNAT, за да върне информацията на външния потребител. По време на този процес всеки възел ще съхранява следната информация:
1# Вътрешна DNAT таблица на възела
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Вътрешна SNAT таблица на възела
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
Основен текст
Обикновено в Linux тези NAT процеси се управляват и функционират от подсистема, наречена conntrack, чрез iptables. Всъщност други CNI проекти като flannel и calico използват това за решаване на горните проблеми. Проблемът обаче е, че Cilium използва технологията eBPF и напълно игнорира този традиционен мрежов стек на Linux. 🤣

В резултат на това Cilium избра да внедри директно само функциите, необходими за Kubernetes, от задачите, които традиционният мрежов стек на Linux изпълняваше, както е показано на горната диаграма. Следователно, за SNAT процеса, споменат по-горе, Cilium директно управлява SNAT таблицата под формата на LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# Cilium SNAT таблица
2# !Пример за по-лесно обяснение. Действителната дефиниция е: 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 и други метаданни
4----------------------------------------------
5| | | | |
И тъй като това е хеш таблица, за бързо търсене съществува ключ, който използва комбинацията от src ip, src port, dst ip, dst port като ключ.
Идентифициране на проблема
Феномен - 1: Търсене
В резултат на това възниква един проблем: когато пакетът преминава през eBPF, за да се провери дали пакетът изисква SNAT или DNAT процес за NAT, трябва да се извърши търсене в горната хеш таблица. Както видяхме по-рано, има два вида пакети в процеса на SNAT: 1. Пакети, излизащи от вътрешната мрежа навън, и 2. Пакети за отговор, идващи отвън към вътрешната мрежа. Тези два пакета изискват преобразуване чрез NAT процес и се характеризират с размяна на стойностите на src ip, port и dst ip, port.
Следователно, за бързо търсене, трябва или да се добави още една стойност към хеш таблицата с обърнати src и dst като ключ, или да се извърши търсене в същата хеш таблица два пъти за всички пакети, дори и тези, които не са свързани със SNAT. Естествено, Cilium, за по-добра производителност, е приел метода за вмъкване на едни и същи данни два пъти под името RevSNAT.
Феномен - 2: LRU
Независимо от горния проблем, никоя хардуерна система не може да има безкрайни ресурси, и особено в логиката на хардуерно ниво, където се изисква висока производителност, и динамични структури от данни не могат да бъдат използвани, е необходимо да се изхвърлят съществуващи данни, когато ресурсите са недостатъчни. Cilium решава това, като използва LRU Hash Map, основна структура от данни, предоставяна по подразбиране в Linux.
Феномен 1 + Феномен 2 = Загуба на връзка
https://github.com/cilium/cilium/issues/31643
Тоест, за една SNAT TCP (или UDP) връзка,
- Едни и същи данни се записват два пъти в една хеш таблица за изходящи и входящи пакети.
- Всяка от двете данни може да бъде загубена по всяко време поради LRU логиката.
Ако дори един запис на NAT информация (наричан по-долу entry) за изходящ или входящ пакет бъде изтрит от LRU, NAT няма да може да се извърши правилно, което може да доведе до пълна загуба на връзката.
Решение
Тук идват гореспоменатите PRs:
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
Преди това, когато пакет преминаваше през eBPF, той се опитваше да търси в SNAT таблицата, като създаваше ключ от комбинацията от src ip, src port, dst ip, dst port. Ако ключът не съществуваше, се генерираше нова NAT информация съгласно правилата на SNAT и се записваше в таблицата. В случай на нова връзка, това би довело до нормална комуникация, но ако ключът е бил неволно премахнат от LRU, ще се извърши нов NAT с различен порт от този, използван за съществуващата комуникация, и приемащата страна ще отхвърли пакета, което ще доведе до прекратяване на връзката с RST пакет.
Подходът, възприет от гореспоменатите PRs, е прост:
Ако пакет бъде наблюдаван в която и да е посока, обновете и записа за обратната посока.
Когато комуникацията се наблюдава в която и да е посока, и двата записа се обновяват, отдалечавайки се от приоритета за изключване на LRU логиката, като по този начин се намалява вероятността от сценарий, при който само един запис се изтрива и цялата комуникация се срива.
Това може да изглежда като много прост подход и проста идея, но чрез този подход ефективно се решава проблемът с прекъсването на връзките поради изтичане на NAT информацията за пакетите за отговор, като значително се подобрява стабилността на системата. Това е важно подобрение, което е довело до следните постижения по отношение на мрежовата стабилност:

Заключение
Считам, че този PR е отличен пример за това как една проста идея може да донесе голяма промяна в сложна система, като се започне от основните CS познания за това как работи NAT.
Разбира се, не съм показал директно примери за сложни системи в тази статия. Въпреки това, за да разбера напълно този PR, аз молих DeepSeek V3 0324 близо 3 часа, дори добавяйки думата Please, и в резултат на това получих знания за Cilium +1 и следната диаграма. 😇
И докато четях проблемите и PR-ите, изпитах предчувствие, че нещо, което бях създал преди, може да е причинило проблема, и като компенсация за това чувство, написах тази статия.
Следговор - 1
Като справка, за този проблем съществува много ефективен метод за избягване. Тъй като основната причина за проблема е липсата на място в NAT таблицата, решението е да се увеличи размерът на NAT таблицата. :-D
В ситуация, в която някой друг можеше просто да увеличи размера на NAT таблицата и да си тръгне, без да остави проблем, аз се възхищавам и уважавам страстта на gyutaeb, който щателно анализира, разбра и допринесе за екосистемата на Cilium с обективни данни, въпреки че проблемът не беше пряко свързан с него.
Това беше причината да реша да напиша тази статия.
Следговор - 2
Тази история всъщност не е пряко свързана с Gosuda, която е специализирана в езика Go. Въпреки това, езикът Go и облачната екосистема са тясно свързани и сътрудниците на Cilium имат известни познания за Go, така че реших да публикувам съдържание, което бих могъл да публикувам в личния си блог, в Gosuda.
Тъй като един от администраторите (аз самият) даде разрешение, вероятно е добре.
Ако смятате, че не е, запазете го като PDF веднага, тъй като не е сигурно кога ще бъде изтрит. ;)
Следговор - 3
За написването на тази статия получих значителна помощ от Cline и Llama 4 Maveric. Въпреки че започнах анализа с Gemini и молих DeepSeek, всъщност получих помощ от Llama 4. Llama 4 е страхотна. Определено трябва да я изпробвате.