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 — это технология, которая позволяет осуществлять внешнюю связь путем сопоставления внутренней связи, состоящей из частного 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.

Например, предположим, что Pod (10.0.1.10, порт: 40000) внутри кластера Kubernetes подключается к внешнему 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      |

Далее процесс будет аналогичен случаю со смартфоном и маршрутизатором.

Когда внешний кластер общается с 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). Предположим, что этот сервис внутренне указывает на Pod с 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, как показано на изображении выше. Поэтому для упомянутого ранее процесса 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 для NAT. Для этого выполняется поиск в вышеупомянутой 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

Я восхищаюсь страстью г-на gyutaeb, который, столкнувшись с той же проблемой, но не оставив ни одного сообщения о ней, а просто увеличив размер таблицы NAT, тщательно проанализировал и понял ее, предоставил объективные данные и даже внес свой вклад в экосистему Cilium, хотя это не было напрямую связано с ним. Я очень уважаю его.

Это стало причиной, по которой я решил написать эту статью.

Послесловие - 2

Эта история, по сути, не совсем подходит для Gosuda, который специализируется на языке Go. Однако язык Go и облачная экосистема тесно связаны, и поскольку участники Cilium в некоторой степени разбираются в языке Go, я решил принести на Gosuda контент, который мог бы разместить в личном блоге.

Поскольку один из администраторов (я сам) дал на это разрешение, я думаю, все будет в порядке.

Если вы считаете, что это не так, сохраните это в PDF как можно скорее, пока оно не было удалено. ;)

Послесловие - 3

При написании этой статьи мне очень помогли Cline и Llama 4 Maveric. Хотя я начал анализ с Gemini и обращался за помощью к DeepSeek, на самом деле я получил помощь от Llama 4. Llama 4 — отличный инструмент. Обязательно попробуйте.