
В первой части мы разобрались с принципами работы Ingress-nginx контроллера. Теперь пришло время углубиться в то, как в Ingress-nginx устроен механизм обновления бэкендов и как реализована балансировка нагрузки на примере sticky sessions. Готовы узнать больше? Поехали!
Напомню, если вдруг забыли, эта статья написана по мотивам выступления на DevOpsConf’25, а меня зовут Алексей Колосков, я Lead DevOps из Hilbert Team.
Итак, рассмотрим, как реализовано обновление бэкендов в самом контроллере.
Реализация обновления бэкендов
Как вы узнали из предыдущей части статьи, обновление содержимого блока upstream
происходит без перечитывания конфигурационного файла nginx
.
Реализовано это следующим образом:
Контроллер получает эндпоинты сервисов,
Затем отправляет этот список LUA-обработчику через POST-запрос на порт
10246
.
На картинке ниже представлен кусок отрендеренного конфига нашего nginx:

Здесь мы видим блок server
, который слушает порт 10246
.
В этом блоке server есть location /configuration
, который все запросы на /configuration
передаёт LUA-обработчику, код которого расположен в файле /etc/nginx/lua/nginx/ngx_conf_configuration.lua
в контейнере с ingress-nginx контроллером.
Заглянем в этот файл.

Как видим, здесь подгружается LUA модуль configuration
и выполняется функция call
.
Так как мы хотим понять, как именно происходит обновление бэкендов, давайте найдём блок кода, который отвечает за обработку запросов к configuration/backends
.

В этом блоке мы видим, что вызывается функция handle_backends
.

Эта функция получает из тела POST-запроса список бэкендов и далее сохраняет этот список в configuration_data
для дальнейшей работы с ним из других модулей.

Как часто может обновляться такой список? У Ingress-контроллера есть параметр sync-rate-limit
, определяющий предел частоты таких обновлений, это 0,3 секунды.
Итак, мы определили список бэкендов, куда балансировать запросы. Дальше давайте разберемся непосредственно с самой балансировкой.
Балансировка
Чтобы выяснить, как именно работает балансировка, возьмём пример со sticky-сессиями.
Напомню, sticky-сессия в простейшем виде — это когда запросы от одного и того же клиента балансируются на один и тот же эндпоинт (под).
Включить sticky-сессии можно задеплоив ресурс Ingress
со следующими аннотациями:

В примере в Specification Rules добавлен host name
(sticky.dc25.example.corp
) и заданы аннотации, которые как раз и отвечают за включение sticky-сессий. Это affinity: cookie
и affinity-mode: persistent
.
В результате в конфиге nginx будет сгенерирован следующий блок server
:

В этом блоке задан server_name
sticky.dc25.example.corp
. В location
запрос будет перенаправлен на upstream, который называется upstream_balancer
. Имя upstream_balancer
никак не привязано к имени какого-либо сервиса, давайте разберем почему.
Посмотрим, что внутри данного блока upstream
:

Здесь видим блок, который передает запрос LUA-обработчику в файле /etc/nginx/lua/nginx/ngx_conf_balancer.lua
. Таким же образом в этот блок upstream
будут передаваться все HTTP-запросы, поступающие на инстанс ingress-nginx контроллера.
Посмотрим, как именно обрабатываются эти запросы, заглянув в файл /etc/nginx/lua/nginx/ngx_conf_balancer.lua

В нем мы видим, что подгружается модуль balancer
и вызывается функцию balance()
.
Посмотрим, что делает эта функция:

Здесь часть кода скрыта, но ключевые моменты подсвечу.
Сначала обработчик определяет какой алгоритм балансировки использовать. Для этого используется функция get_balancer()
, которая получает экземпляр балансировщика.

Она получает этот экземпляр так:

Берёт название бэкенда из имени upstream
, что было указано в параметре server_name
блока server
в nginx.conf (local backend_name = ngx.var.proxy_upstream_name
). Далее экземпляр balancer
выбирается в зависимости от имени бэкенда из списка балансировщиков (local balancer = balancers[backend_name]
). Этот список формируется с помощью функции sync_backend
.

Обработчик получает конкретную имплементацию балансировщика. Самое интересное, как он это делает.
Но сначала немного теории: ingress-nginx поддерживает два режима балансировки для sticky-cессий. Это balanced и persistent. Разница между ними в том, что в случае с sticky_balanced
, при добавлении новых эндпоинтов, происходит ребалансировка. То есть уже существующие клиенты перераспределяются между всеми эндпоинтами. В случае же sticky_persistent
, на новые эндпоинты отправляются только новые клиенты.
Теперь давайте посмотрим в код функции get_implementation()
, которая получает тип балансироваки из конфигурации ресурса Ingress.

Напомню, что мы создали аннотацию с affinity: cookie
и affinity-mode: persistent
. По коду видно, что мы проверяем значение affinity-mode
только на наличии значения persistent
. Если в affinity-mode
не указан persistent
, то по умолчанию будет выбран тип балансировки sticky_balanced
.
Определились с именем типа балансировки, и дальше из списка реализаций выбираем по этому имени соответствующий LUA-модуль.
Так выглядит полный список из шести доступных реализаций, каждая из которых расположена в отдельном файле:

Итак, экземпляр балансировщика мы получили.
Теперь посмотрим как реализовано получение эндпоинта, на который балансировать трафик. Вернемся к функции balance()
модуля balancer.lua
. Выбор эндпоинта реализован вызовом функции balancer:balance()
.

Ранее мы выяснили, что в нашем случае будет использоваться тип балансировки sticky_persistent
(код с реализацией расположен в файле sticky_persistent.lua
). Общие функции для sticky_persistent
и sticky_balanced
расположены в файле sticky.lua
, в том числе и реализация функции balance()
. Посмотрим что она делает:
Первым делом выполняется попытка получить из HTTP-запроса cookie и извлечь из этого cookie эндпоинт, на который отправлять данный запрос.

Но если извлечь cookie не удалось, то будет выбран новый эндпоинт и сохранён в новом cookie.

Посмотрим, как cookie создаётся, из чего состоит, и как мы можем влиять на то, к какому эндпоинту на оброаботку попадёт наш запрос.
Создаётся он в функции set_cookie()
, в которой мы можем найти структуру данных нашей cookie.

Она состоит из трех элементов:
value
– хэш эндпоинта, который выбран для балансировки трафика.Разделитель – это вертикальная черта. Задано константой:
local COOKIE_VALUE_DELIMITER = "|"
.backend_key
— хэш от имени бэкенда, который формируется на основе имени сервиса.
Посмотрим, как выглядит сам cookie в реальной жизни:
"INGRESSCOOKIE":"1732977967.415.26.789579|83d0b1558537830b973a46ddb80557ff
"
По умолчанию имя cookie — INGRESSCOOKIE
. При желании его можно изменить. Первая часть cookie – хэш эндпоинта (выглядит почти как IP-адрес, но это не он 🙂).
Затем следует разделитель.
И вторая часть cookie — ключ бэкенда (backend_key
).
От себя хочу добавить, что пробовал экспериментировать с подменой cookie. На какой под реально уйдёт в итоге ваш запрос, влияет только первая часть cookie, вторая часть, даже если её изменить, — нет.
Мы узнали, как ingress-nginx определяет, на какой эндпоинт балансировать трафик. На этом с этой функцией закончили.
Посмотрим, что происходит с запросом дальше.

А дальше LUA-обработчик передает запрос обратно nginx, где тот уже самостоятельно отправляет его по адресу эндпоинта.
С балансировкой разобрались. А теперь, для тех, кто добрался до этого момента, в качестве вишенки на торте — бонус про сниппеты.
Сниппеты
Сниппеты — кусочки конфигурации nginx, которые мы можем задать с помощью аннотаций при деплое ресурса Ingress. Определить, в какое место конфигурационного файла nginx.conf попадет сниппет, зависит от того с помощью какой аннотации он задан.
Давайте разберем подробнее:
Блок:
location { ... }
Аннотация:nginx.ingress.kubernetes.io/configuration-snippet
Блок:
server { ... }
Аннотация:nginx.ingress.kubernetes.io/server-snippet
Блок:
stream { ... }
Аннотация:nginx.ingress.kubernetes.io/stream-snippet
Блок:
modsecurity { ... }
Аннотация:nginx.ingress.kubernetes.io/modsecurity-snippet
Однако по умолчанию сниппеты отключены. В некоторых компаниях они вообще запрещены политикой безопасности. И не спроста.
Так, например, в репозитории k8s есть достаточно старый issue от 2021 года, в котором вы найдете одну из причин, почему сниппеты лучше не использовать. Но знаю, не все пойдут читать issue, поэтому покажу на примере.
Чтобы получать список ресурсов Ingress, nginx-контроллер использует сервисный аккаунт (k8s ServiceAccount). Для определения прав доступа сервисного аккаунта к ресурсам кластера, создается ClusterRole
. Данная ClusterRole
, помимо прочего, позволяет просматривать список всех ресурсов, которые в ней определены, и которые контроллер использует для формирования конфигурации nginx.
Среди ресурсов, которые использует контроллер, есть и секреты (secrets
). При этом среди разрешенных типов доступа к ресурсам присутствует право list
, которое разрешает не только просмотр списка ресурсов, но и их содержимого. То есть потенциально мы можем получить доступ ко всем секретам кластера, к которым контроллер может обращаться согласно данной ClusterRole
.

Предположим, вы — бесстрашный человек, которому всё же очень нужны сниппеты. И вы решили их включить.
Тогда, во-первых, вы должны задать allowSnippetAnnotations: "true"
, чтобы разрешить использовать аннотации со сниппетами. Однако этого будет недостаточно. Есть также параметр annotations-risk-level
, который по умолчанию имеет значение high, что не разрешает использование сниппетов. Поэтому нужно задать для него значение critical
.

Здесь явно указано, что это критично, и лучше этого не делать. Но мы очень хотим, и включаем сниппеты, ведь у нас нет страха, только слабоумие и отвага.
Давайте представим. Условный джун задал условному ChatGPT вопрос, как фичу какую-нибудь настроить, например сайт опубликовать. Условный ChatGPT ему ответил: «Такой сниппет добавь, и у тебя будет всё хорошо».

Джун добавляет этот сниппет. В результате в nginx.conf добавится новый location /snippet-hole/
:

В этом location выполняется LUA-код, который:
Возьмёт из HTTP-заголовка cmd команду, которую мы можем передать в HTTP-запросе,
Выполнит её от имени nginx, в поде с ingress-контроллером,
Результат сохранит в переменную
rsfile
,Прочитает ее значение как строку и сохранит в другую переменную (
rschar
),Вернет результат выполнения нашей команды в HTTP-ответе на запрос.
С помощью такого сниппета мы можем, например, получить токен сервисного аккаунта, который использует ingress-nginx для получения доступа к ресурсам кластера. Сделать это можно, выполнив:
curl http://sticky.dc25.corp/snippet-hole/ \
-H 'cmd: cat /var/run/secrets/kubernetes.io/serviceaccount/token'
«Курлим» наш host_name, location, передаём в заголовке cmd команду cat и путь к файлу с секретом с токеном сервисного аккаунта.
И вот, готово: токен получили.

С помощью этого токена можно, например, получить все секреты из namespace kube-system (или любого другого). Для этого достаточно выполнить простой скрипт:
export APISERVER="https://XX.XX.XX.XX"
export TOKEN="$(curl -s http://sticky.dc25.corp/snippet-hole/ -H 'cmd: cat /var/run/secrets/kubernetes.io/serviceaccount/token')"
curl -k -s $APISERVER/api/v1/namespaces/kube-system/secrets/ --header "Authorization: Bearer $TOKEN" | jq -rM '.items[].metadata.name'
Где XX.XX.XX.XX
- это ip-адрес k8s api.
В скрипте мы:
получаем токен сервисного аккаунта в переменную
TOKEN
Используя
curl
выполняем запрос к API k8s, авторизуемся с использованием этого токена, и получаем все секреты в нэймспейсеkube-system
.
На выходе получим примерно следующую структуру данных:

В примере видна закрытая часть SSL сертификата. Что дальше с этим сделать, придумайте сами 🙃
Сниппеты: как защититься?
Так что же делать? Самое верное решение — не использовать сниппеты. Но если без сниппетов никак, то вот несколько способов снизить риски от их использования:
Параметр annotation-value-word-blocklist
Значение по умолчанию пустое:
default: ""
Однако в документации есть пример, который позволит вам его хоть как-то заполнить:
suggested: "load_module,lua_package,_by_lua,location,root,proxy_pass,serviceaccount,{,},',\""
Namespaced ingress-nginx
Можно использовать namespaced ingress-nginx, чтобы доступ у контроллера был только в рамках одного namespace. Но не всем подходит такая конфигурация — она может быть неудобна или неприменима в вашей инфраструктуре.
Ревью кода
Очень полезно всё деплоить как код и пропускать через ревью. Это позволит отсеять творчество условных джунов (а также позволит немного снизить bus factor).
Policy engine
Использовать policy engine типа kyverno и самостоятельно реализовать политики для валидации аннотаций.
Выводы
Итак, мы погрузились в детали работы Ingress-nginx контроллера, изучив его внутреннюю кухню.
Мы выяснили, как в Ingress-nginx динамически обновляется блок upstream без перезагрузки конфигурации Nginx.
Затем мы детально разобрали механизм балансировки нагрузки на примере sticky-сессий.
А также мы рассмотрели сниппеты — мощный, но крайне опасный инструмент. Мы увидели, как некорректное использование сниппетов может привести к серьезным уязвимостям, позволяя получить доступ к конфиденциальным данным кластера.
Спасибо, что дочитали мою статью до конца, но если вам хочется больше эмоций и вы лучше воспринимаете информацию на слух, то вот ссылка на мое выступление на DevOpsConf 2025, по мотивам которого была написана эта серия статей. Не пропустите новый сезон конференции, следите за обновлениями!