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:Port, да бъде съпоставена с неизползван в момента публичен IP:Port, така че да е възможна комуникация с външния свят, тъй като при TCP или UDP комуникацията се формира от комбинация от IP адрес и port информация.
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), отговорът няма да се върне към смартфона с частния IP адрес (a.a.a.a), затова рутерът първо намира свободен port, който в момента не участва в комуникацията (например 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 адрес (b.b.b.b) и номера на порт (60000) оригиналния частен IP адрес (a.a.a.a) и номер на порт (50000) и променя целта на пакета към смартфона.
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 комуникира с външно за клъстера
Pod-овете в Kubernetes клъстера обикновено получават частни IP адреси, които позволяват комуникация само в рамките на мрежата на клъстера. Затова, ако Pod иска да комуникира с външния интернет, е необходим NAT за трафика, излизащ извън клъстера. NAT обикновено се изпълнява на Kubernetes възела (всеки сървър в клъстера), на който работи съответният Pod. Когато Pod изпрати пакет към външен адрес, пакетът първо се предава към възела, на който се намира Pod-ът. Възелът заменя изходящия IP адрес на пакета (частния IP на Pod-а) със собствения си публичен IP адрес и също така подходящо променя изходящия порт, след което препраща пакета навън. Този процес е сходен с NAT процеса, описан по-горе при Wi-Fi рутера.
Например, да предположим, че Pod в Kubernetes клъстера (10.0.1.10, порт: 40000) се свързва с външен API сървър (203.0.113.45, порт: 443). Тогава Kubernetes възелът получава от Pod-а следния пакет
1# TCP пакет, получен от възела, Pod => възел
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 |
След това процесът протича по същия начин като при случая със смартфона и Wi-Fi рутера.
Когато външен клиент комуникира с Pod чрез NodePort
Един от начините за излагане на услуга навън в Kubernetes е използването на NodePort услуга. NodePort услугата отваря определен порт (NodePort) на всички възли в клъстера и препраща трафика, който пристига на този порт, към Pod-овете, принадлежащи към услугата. Външните потребители могат да достъпват услугата чрез IP адреса на възел от клъстера и NodePort.
В този случай NAT играе важна роля, като особено DNAT (Destination NAT) и SNAT (Source NAT) се извършват едновременно. Когато трафик от външния свят пристигне на NodePort на определен възел, мрежата на Kubernetes трябва да препрати този трафик към Pod-а, който предоставя съответната услуга. В този процес първо се извършва DNAT, при което целевият IP адрес и номер на порт на пакета се променят на IP адреса и номера на порт на Pod-а.
Например, да предположим, че външен потребител (203.0.113.10, порт: 30000) достъпва услугата през NodePort (30001) на възел от Kubernetes клъстера (192.168.1.5). Нека приемем, че услугата вътрешно сочи към Pod с IP адрес 10.0.2.15 и порт 8080.
1Външен потребител (203.0.113.10:30000) ==> Kubernetes възел (външен:192.168.1.5:30001 / вътрешен: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)
Тук Kubernetes възелът има два адреса: външно достъпния IP адрес 192.168.1.5 и IP адреса 10.0.1.1, който е валиден във вътрешната мрежа на Kubernetes. (В зависимост от използвания CNI вида политиките по този въпрос могат да варират, но в тази статия обяснението е базирано на Cilium.)
Когато заявката от външния потребител пристигне на възела, възелът трябва да я препрати към Pod-а, който ще я обработи. За целта възелът прилага следното DNAT правило, с което променя целевия IP адрес и номер на порт на пакета.
1# TCP пакет, който възелът се готви да изпрати към Pod-а
2# След прилагане на DNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
Важно е, че когато Pod-ът изпраща отговор на тази заявка, изходящият IP адрес е неговият собствен IP адрес (10.0.2.15), а целевият IP адрес е на външния потребител, изпратил заявката (203.0.113.10). В този случай външният потребител получава отговор от несъществуващ за него IP адрес, който не е искал, и просто отхвърля пакета. Затова, когато Pod-ът изпраща отговор навън, възелът извършва допълнителен SNAT, при който изходящият IP адрес на пакета се променя на IP адреса на възела (192.168.1.5 или вътрешния мрежов IP 10.0.1.1; в този случай се използва 10.0.1.1).
1# TCP пакет, който възелът се готви да изпрати към Pod-а
2# След прилагане на DNAT и SNAT
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Сега Pod-ът, получил този пакет, отговаря на възела, от който първоначално е получил заявката през 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 процеси се управляват и изпълняват чрез iptables с помощта на подсистема, наречена conntrack. Всъщност проекти като flannel и calico, които са други CNI проекти, използват именно този механизъм, за да решат подобни проблеми. Проблемът обаче е, че Cilium използва технологията eBPF и напълно игнорира този традиционен Linux мрежов стек. 🤣

В резултат Cilium е избрал да реализира сам само онези функции от традиционния Linux мрежов стек, които са необходими в Kubernetes среда. Затова, за 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| | | | |
Тъй като това е Hash Table, за бързо търсене се използва ключ, като комбинацията от src ip, src port, dst ip и dst port се използва като ключ.
Идентифициране на проблема
Проблем 1: Търсене
Възниква следният проблем: при преминаване на пакет през eBPF трябва да се провери дали за пакета е необходимо SNAT или DNAT преобразуване, като за целта се търси в горната Hash Table. Както видяхме, при SNAT има два вида пакети: 1. пакети, които излизат от вътрешната мрежа навън, и 2. техните отговори, които влизат отвътре от външния свят. И двата пакета изискват NAT преобразуване, но характерното за тях е, че стойностите на src ip, port и dst ip, port са разменени.
Затова, за бързо търсене, трябва или да се добави още един запис в Hash Table, с ключ, в който src и dst са разменени, или всеки пакет, дори и такъв, който няма нищо общо със SNAT, да се търси два пъти в една и съща Hash Table. Разбира се, за по-добра производителност Cilium е избрал да записва същите данни два пъти, в механизъм, наречен RevSNAT.
Проблем 2: LRU
Освен това, всички хардуерни ресурси са ограничени, а особено в логиката на ниво хардуер, където се изисква бърза производителност, не може да се използват динамични структури от данни. Когато ресурсите са недостатъчни, трябва да се освободи място чрез изтриване (eviction) на съществуващи данни. Cilium решава това, като използва LRU Hash Map, основна структура от данни, предоставена по подразбиране в Linux.
Проблем 1 + Проблем 2 = Загуба на връзка
https://github.com/cilium/cilium/issues/31643
Т.е. за една SNAT TCP (или UDP) връзка:
- В една Hash Table за изходящия и входящия пакет са записани два пъти едни и същи данни, и
- Според LRU логиката по всяко време един от двата записа може да бъде загубен,
ако някой от NAT записите (наричани по-нататък entry) за изходящия или входящия пакет бъде премахнат от LRU, NAT преобразуването няма да може да се извърши правилно, което може да доведе до загуба на цялата връзка.
Решение
Тук се появяват PR-овете, които споменахме по-рано.
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, се създаваше ключ от комбинацията src ip, src port, dst ip и dst port и се търсеше в SNAT таблицата. Ако ключът не съществуваше, се създаваше нова NAT информация според SNAT правилата и се записваше в таблицата. При нова връзка това ще доведе до нормална комуникация, но ако ключът е бил премахнат непреднамерено от LRU, връзката ще продължи с различен порт от този, използван досега, и приемащата страна ще отхвърли пакетите, като връзката ще бъде прекратена с RST пакет.
Подходът, който предлага горният PR, е прост:
Когато пакет бъде наблюдаван в която и да е посока, нека също обновим entry за обратната посока.
Когато комуникация бъде наблюдавана в която и да е посока, и двата entry се обновяват, като по този начин се отдалечават от приоритета за изтриване в LRU логиката. Така се намалява вероятността само един от записите да бъде изтрит и цялата комуникация да се срине.
Макар подходът да е много прост и идеята да изглежда елементарна, именно чрез него се решава ефективно проблемът, при който NAT информацията за отговорния пакет изтича първа и връзката се прекъсва, и системната стабилност се подобрява значително. Освен това това е важно подобрение с впечатляващи резултати по отношение на мрежовата стабилност.

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