Историята на 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:порт
, да се мапира към неизползван публичен 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| източник ip | източник порт | дестинация ip | дестинация порт |
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| локален ip | локален порт | глобален ip | глобален порт |
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| източник ip | източник порт | дестинация ip | дестинация порт |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
Сега уеб сървърът (c.c.c.c
) разпознава заявката като идваща от порт 60000 на рутера (b.b.b.b
) и изпраща пакета с отговор до рутера, както следва:
1# TCP пакет, получен от рутера, уеб сървър => рутер
2| източник ip | източник порт | дестинация ip | дестинация порт |
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| източник ip | източник порт | дестинация ip | дестинация порт |
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| източник ip | източник порт | дестинация ip | дестинация порт |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
Възелът записва следната информация:
1# Вътрешна NAT таблица на възела (пример)
2| локален ip | локален порт | глобален ip | глобален порт |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
След това изпълнява SNAT, както следва, и изпраща пакета навън:
1# TCP пакет, изпратен от възела, възел => API сървър
2# Извършване на SNAT
3| източник ip | източник порт | дестинация ip | дестинация порт |
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) ==> Kubernetes Pod (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| източник ip | източник порт | дестинация ip | дестинация порт |
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 адрес, който не е поискал, и просто ще DROP-не пакета. Ето защо възелът на Kubernetes допълнително извършва SNAT, когато подът изпраща пакети с отговор навън, променяйки IP адреса на източника на пакета на IP адреса на възела (192.168.1.5 или вътрешния мрежов IP адрес 10.0.1.1, в този случай 10.0.1.1).
1# TCP пакет, подготвен от възела за изпращане до пода
2# След прилагане на DNAT, SNAT
3| източник ip | източник порт | дестинация ip | дестинация порт |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
Сега подът, получил този пакет, ще отговори на възела, който първоначално е получил заявката чрез NodePort, и възелът ще приложи обратния процес на DNAT и SNAT, за да върне информацията на външния потребител. В този процес всеки възел ще съхранява следната информация:
1# Вътрешна DNAT таблица на възела
2| оригинален ip | оригинален порт | дестинация ip | дестинация порт |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# Вътрешна SNAT таблица на възела
7| оригинален ip | оригинален порт | дестинация ip | дестинация порт |
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| източник ip | източник порт | дестинация ip | дестинация порт | протокол, 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, е възможно една от двете данни да бъде загубена.
Ако дори един запис (entry) за 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, той се опитваше да търси в SNAT таблицата, като създаваше ключ от комбинацията от src ip, src port, dst ip, dst port. Ако ключът не съществуваше, нова NAT информация се генерираше според правилата на SNAT и се записваше в таблицата. Ако това беше нова връзка, това щеше да доведе до нормална комуникация, но ако ключът беше неволно премахнат от LRU, нова NAT щеше да бъде извършена с различен порт от този, използван за съществуващата комуникация, което щеше да доведе до отказ за приемане от страна на получателя на пакета и прекъсване на връзката с RST пакет.
Подходът, възприет от горепосочения PR, е прост:
Ако пакет бъде наблюдаван в която и да е посока, актуализирайте и записа за обратната посока.
Когато комуникацията се наблюдава в която и да е посока, и двата записа се актуализират, отдалечавайки ги от приоритета за изхвърляне на LRU логиката. Това намалява вероятността от сценарий, при който само един запис се изтрива, което води до срив на цялата комуникация.
Това е много прост подход и може да изглежда като проста идея, но чрез този подход беше възможно ефективно да се реши проблемът с прекъсването на връзката поради изтичане на NAT информацията за пакетите с отговор и значително да се подобри стабилността на системата. Това е и важно подобрение, което постигна следните резултати по отношение на мрежовата стабилност:
Заключение
Смятам, че този PR е отличен пример, който показва как една проста идея може да донесе огромна промяна дори в сложни системи, като се започне от основните CS познания за това как работи NAT.
Разбира се, в тази статия не показах директно примери за сложни системи. Но за да разбера правилно този PR, аз молих DeepSeek V3 0324
близо 3 часа, дори с думата „моля“, и в резултат на това получих +1 знание за Cilium и следната диаграма. 😇
И докато четях проблемите и PR-ите, написах тази статия като компенсация за зловещите предчувствия, че нещо, което съм направил преди, може да е причинило проблема.
Следговор - 1
Между другото, има много ефективен начин да се избегне този проблем. Тъй като основната причина за проблема е недостатъчното място в NAT таблицата, просто увеличете размера на NAT таблицата. :-D
Възхищавам се и уважавам страстта на [gyutaeb], който, въпреки че проблемът не е пряко свързан с него, старателно го анализира, разбира го и допринесе за екосистемата на Cilium с обективни данни, докато някой друг, който се е сблъскал със същия проблем, просто е увеличил размера на NAT таблицата и е избягал, без да остави информация за проблема.
Това беше причината да реша да напиша тази статия.
Следговор - 2
Тази история всъщност не е пряко свързана с Gosuda, която е специализирана в езика Go. Но тъй като езикът Go и облачната екосистема са тясно свързани и сътрудниците на Cilium имат известно познание по Go, реших да пренеса съдържание, което може да бъде публикувано в личен блог, в Gosuda.
Тъй като един от администраторите (аз самият) даде разрешение, предполагам, че е добре.
Ако смятате, че не е добре, запазете го като PDF бързо, тъй като не е известно кога ще бъде изтрито. ;)
Следговор - 3
При написването на тази статия получих значителна помощ от Cline и Llama 4 Maveric. Въпреки че започнах анализа с Gemini и молих DeepSeek, всъщност получих помощ от Llama 4. Llama 4 е страхотна. Трябва да я изпробвате.