Search
Write a publication
Pull to refresh
415.17
Конференции Олега Бунина (Онтико)
Конференции Олега Бунина

Как работает ingress-nginx: нырнем еще глубже. Часть 2 — балансировка

Level of difficultyMedium
Reading time8 min
Views2.4K

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

Tags:
Hubs:
+18
Comments0

Articles

Information

Website
www.ontico.ru
Registered
Founded
Employees
11–30 employees
Location
Россия