История 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 — это технология, которая, исходя из того, что связь по TCP или UDP состоит из комбинации IP-адреса и информации о порте, позволяет внутренней связи, состоящей из частного IP:порта
, отображаться на неиспользуемый в данный момент публичный 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 |
Когда маршрутизатор получает этот ответный пакет, он находит соответствующий исходный частный IP-адрес (a.a.a.a
) и номер порта (50000) в NAT-таблице по 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 осуществляет связь с внешней частью кластера изнутри 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
Один из способов exposing-а сервисов вовне в Kubernetes — использование сервиса NodePort. Сервис NodePort открывает определенный порт (NodePort) на всех узлах кластера и передает трафик, поступающий на этот порт, подам, принадлежащим сервису. Внешние пользователи могут получить доступ к сервису через 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, как показано на изображении выше. Поэтому Cilium напрямую управляет SNAT-таблицей для вышеупомянутого процесса SNAT в форме LRU Hash Map (BPF_MAP_TYPE_LRU_HASH).
1# SNAT-таблица Cilium
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 поменяны местами, либо выполнять поиск в одной и той же Hash Table дважды для всех пакетов, даже если они не имеют отношения к SNAT. Естественно, Cilium для лучшей производительности выбрал метод добавления одних и тех же данных дважды под названием RevSNAT.
Явление - 2: LRU
И, независимо от вышеуказанной проблемы, на любом оборудовании не может быть бесконечных ресурсов, особенно в условиях, когда требуется высокая производительность аппаратной логики, и динамические структуры данных не могут использоваться. В такой ситуации возникает необходимость вытеснения существующих данных при нехватке ресурсов. 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, он пытался выполнить поиск в SNAT-таблице, используя комбинацию src ip, src port, dst ip, dst port в качестве ключа. Если ключ не существовал, создавалась новая информация NAT в соответствии с правилами SNAT и записывалась в таблицу. В случае нового соединения это привело бы к нормальной связи, но если ключ был непреднамеренно удален LRU, то NAT выполнялся бы по-новому, используя другой порт, отличный от того, который использовался для существующей связи, что привело бы к отказу в получении пакета принимающей стороной и завершению соединения с пакетом RST.
Подход, использованный в вышеупомянутом PR, прост:
Если пакет наблюдается в любом направлении, то запись для обратного направления также должна быть обновлена.
Когда связь наблюдается в любом направлении, обе записи обновляются, что снижает их приоритет вытеснения в логике LRU. Это уменьшает вероятность сценария, при котором удаление только одной записи приводит к полному разрыву связи.
Это может показаться очень простым подходом и незамысловатой идеей, однако такой подход эффективно решил проблему разрыва соединения из-за истечения срока действия информации NAT для ответных пакетов и значительно повысил стабильность системы. Кроме того, это является важным улучшением, которое привело к следующим результатам с точки зрения стабильности сети:
Заключение
Я считаю, что этот PR является отличным примером, демонстрирующим, как одна простая идея может принести огромные изменения даже в сложной системе, начиная с базовых знаний CS о том, как работает NAT.
О, конечно, в этой статье я не приводил примеры сложных систем напрямую. Однако, чтобы правильно понять этот PR, я почти 3 часа умолял DeepSeek V3 0324
, даже добавляя слово Please
, и в результате получил знание о Cilium +1 и следующую диаграмму. 😇
И, читая issues и PR, я пишу эту статью из чувства компенсации за зловещие предчувствия, что какая-то проблема могла возникнуть из-за чего-то, что я сделал раньше.
Послесловие - 1
Кстати, для этой проблемы существует очень эффективный способ избежать ее. Основная причина проблемы заключается в нехватке места в NAT-таблице, поэтому достаточно увеличить размер NAT-таблицы. :-D
Пока кто-то другой, столкнувшись с той же проблемой, просто увеличил размер NAT-таблицы и сбежал, не оставив ни одного issue, я восхищаюсь и уважаю страсть gyutaeb, который, несмотря на то, что проблема не была напрямую связана с ним, тщательно проанализировал и понял ее, а также внес свой вклад в экосистему Cilium с объективными данными.
Это стало причиной, по которой я решил написать эту статью.
Послесловие - 2
Эта история, по сути, не совсем соответствует темам, которые обычно обсуждаются в Gosuda, специализирующемся на языке Go. Однако язык Go и облачная экосистема тесно связаны, и участники Cilium в некоторой степени разбираются в Go, поэтому я решил перенести сюда содержимое, которое обычно публиковал бы в личном блоге.
Поскольку один из администраторов (я сам) дал разрешение, думаю, все будет в порядке.
Если вы считаете, что это не так, то сохраните это в PDF как можно скорее, пока оно не было удалено. ;)
Послесловие - 3
При написании этой статьи мне очень помогли Cline и Llama 4 Maveric. Хотя я начал анализ с Gemini и умолял DeepSeek, на самом деле помощь пришла от Llama 4. Llama 4 — это хорошо. Обязательно попробуйте.