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-адреса и порта. NAT сопоставляет внутреннюю связь вида приватный IP:Port с неиспользуемой в данный момент парой публичный 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). Поэтому роутер сначала находит произвольный порт, не участвующий в текущей связи (например, 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 взаимодействует с внешней стороной кластера

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 패킷, 파드 => 노드
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) ==> 쿠버네티스 노드 (외부:192.168.1.5:30001 / 내부: 10.0.1.1:42132) ==> 쿠버네티스 파드 (10.0.2.15:8080)

Здесь узел Kubernetes имеет как внешний IP-адрес 192.168.1.5, доступный извне, так и IP-адрес 10.0.1.1, действительный во внутренней сети Kubernetes. (В зависимости от используемого типа CNI соответствующие политики могут различаться, но в этой статье объяснение ведётся на примере Cilium.)

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

Важно отметить, что когда Pod отправляет ответ на этот запрос, IP-адрес отправителя — это его собственный IP-адрес (10.0.2.15), а IP-адрес получателя — IP-адрес внешнего пользователя, отправившего запрос (203.0.113.10). В таком случае внешний пользователь получает ответ с несуществующего для него IP-адреса, который он никогда не запрашивал, и просто отбрасывает этот пакет (DROP). Поэтому узел Kubernetes при отправке ответного пакета Pod'ом наружу дополнительно выполняет 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     |

Теперь 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. Фактически в других CNI-проектах, таких как flannel и calico, эта возможность используется для решения подобных задач. Однако проблема в том, что Cilium использует технологию eBPF и полностью игнорирует традиционный сетевой стек Linux. 🤣

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

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

Поскольку это хеш-таблица, для быстрого поиска используется ключ, и в качестве ключа применяется комбинация src ip, src port, dst ip, dst port.

Постановка проблемы

Проблема 1: поиск

Возникает такая проблема: для того чтобы eBPF определил, нужно ли пакету выполнять SNAT или DNAT, ему необходимо обратиться к описанной выше хеш-таблице. При этом, как мы видели выше, в процессе SNAT существуют два вида пакетов: 1. пакеты, идущие изнутри наружу, и 2. пакеты, приходящие извне внутрь как ответ на них. Оба этих пакета требуют NAT-преобразования, но у них значения src ip, port и dst ip, port меняются местами.

Поэтому для быстрого поиска придётся либо добавить в хеш-таблицу ещё одну запись, использующую в качестве ключа значения с переставленными src и dst, либо выполнять поиск в одной и той же хеш-таблице дважды для каждого пакета, даже если он не связан с SNAT. Разумеется, для повышения производительности Cilium выбрал подход, при котором одни и те же данные записываются дважды, под названием RevSNAT.

Проблема 2: LRU

Кроме того, в любом аппаратном обеспечении ресурсы не бесконечны, а в особенности в логике аппаратного уровня, где требуется высокая производительность, нельзя использовать динамические структуры данных; поэтому при нехватке ресурсов необходимо вытеснять (evict) существующие данные. Cilium решает эту задачу, используя LRU Hash Map, стандартную структуру данных, предоставляемую Linux.

Проблема 1 + Проблема 2 = потеря соединения

https://github.com/cilium/cilium/issues/31643

Таким образом, для одного SNAT-преобразованного TCP (или UDP) соединения:

  1. в одной хеш-таблице для исходящего и входящего пакетов одинаковые данные записаны дважды, и
  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 система формировала ключ из комбинации src ip, src port, dst ip, dst port и выполняла поиск в SNAT-таблице. Если ключ не найден, создавалась новая NAT-запись по правилам SNAT и записывалась в таблицу. В случае нового соединения это приводит к нормальной связи; но если ключ был удалён LRU непреднамеренно, то NAT выполняется с портом, отличным от того, который использовался в существующем соединении, и принимающая сторона отвергает пакет, после чего соединение завершается с RST-пакетом.

Подход, предложенный в упомянутом PR, прост:

Если пакет замечен в одном направлении, то обновим и entry для обратного направления.

Если связь замечена в любом направлении, обновляются обе записи, благодаря чему они удаляются из приоритета вытеснения LRU, и становится возможным снизить вероятность того, что будет удалена только одна из записей и вся связь разрушится.

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

benchmark

Заключение

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

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

diagram

Кроме того, читая issue и PR, я испытал компенсаторное желание написать эту статью в качестве награды за тревожные предчувствия о том, что проблема могла возникнуть из-за чего-то, что я когда-то создал.

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

Кстати, у этой проблемы есть очень эффективный обходной путь. Поскольку причина возникновения проблемы — нехватка места в NAT-таблице, достаточно увеличить её размер. :-D

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

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

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

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

Я думаю, что всё в порядке, так как было получено разрешение одного из администраторов (от меня самого).

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

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

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