GoSuda

История Cilium: Как небольшое изменение в коде привело к удивительному улучшению стабильности сети

By iwanhae
views ...

Введение

Недавно я ознакомился с 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. 🤣

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

В результате 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):

  1. В одной Hash Table дважды записываются одинаковые данные для исходящих и входящих пакетов.
  2. В соответствии с логикой 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 для ответных пакетов и значительно повысил стабильность системы. Кроме того, это является важным улучшением, которое привело к следующим результатам с точки зрения стабильности сети:

benchmark

Заключение

Я считаю, что этот PR является отличным примером, демонстрирующим, как одна простая идея может принести огромные изменения даже в сложной системе, начиная с базовых знаний CS о том, как работает NAT.

О, конечно, в этой статье я не приводил примеры сложных систем напрямую. Однако, чтобы правильно понять этот PR, я почти 3 часа умолял DeepSeek V3 0324, даже добавляя слово Please, и в результате получил знание о Cilium +1 и следующую диаграмму. 😇

diagram

И, читая 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 — это хорошо. Обязательно попробуйте.