сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Сети

Балансировка L4 и L7, proxy_pass

Балансировка и прокси

Поднял стенд: три одинаковых бэкенда за одним балансировщиком. Бэкенд - это копия приложения, а балансировщик - посредник, который распределяет запросы между такими копиями. Отправил 60 запросов - получил ровно 20, 20 и 20. Дальше выключил один бэкенд, и стенд с настройками по умолчанию превратился в тыкву: 10 запросов заняли 18 секунд, три из них клиент не дождался вовсе. Тот же стенд с пределом на установку соединения в 1 секунду и переходом к следующему бэкенду отдал 30 ответов из 30 за 2 секунды.

Стержень: раздача запросов - это только половина дела, вторая половина в том, как быстро балансировщик замечает мёртвого и что он делает дальше.

// Формулировки: «чем L4 отличается от L7?», «почему бэкенд получил запрос не по тому пути?», «как получить настоящий адрес клиента?»

Раздача, падение и привязка

По умолчанию запросы раздаются по кругу: первый первому, второй второму, третий третьему, четвёртый снова первому. На 60 запросах это дало идеальные 20 на каждый из трёх. Ровно так же считаются и веса, если бэкенды разной мощности.

Интереснее, что происходит при падении. Я удалил один бэкенд из трёх. С настройками по умолчанию балансировщик готов ждать установки соединения очень долго, поэтому попавшие на мёртвого клиенты просто висели: из 10 запросов три не дождались ответа, а весь прогон занял 18 142 миллисекунды. Тот же стенд, где я поставил предел на установку соединения 1 секунду и разрешил при неудаче идти к следующему бэкенду, отдал все 30 ответов успешно за 2039 миллисекунд - запросы разошлись по двум живым, 14 и 16.

// Третий режим - привязка клиента к бэкенду по его адресу. Проверил: 12 запросов с одного адреса ушли на один и тот же бэкенд. Это спасает приложения, которые держат данные о вошедшем пользователе у себя в памяти, но взамен ломает равномерность и делает обновление версии болезненным: у каждой копии свои привязанные клиенты. Правильнее вынести эти данные в общее хранилище, а привязку оставить как временную подпорку.

раздача по кругу
каждый следующий запрос уходит следующему бэкенду
переход к следующему
при неудаче повторить запрос на другом бэкенде
привязка по адресу
запросы одного клиента всегда идут на один бэкенд

Что видит бэкенд: путь и адрес клиента

В nginx форма записи адреса бэкенда решает судьбу пути, и это ловит всех. Правило целиком: если в proxy_pass указан путь, хотя бы одиночный слэш, то совпавшая с правилом часть заменяется на него. Если пути нет, адрес считается «как есть» и путь уходит целиком. Проверил на стенде: правило /rr/ с адресом, оканчивающимся слэшем, превратило запрос /rr/echo/users/42 в /echo/users/42 на бэкенде. Правило /noslash без пути отдало бэкенду /noslash/echo/users/42, вместе с префиксом.

Заодно видно, что бэкенду уезжает имя из настройки прокси, а не то, что писал клиент. То есть приложение, которое строит ссылки по полученному имени, за прокси начнёт выдавать внутренние адреса, если ему это имя не передать явно.

// Адрес клиента за прокси приложение тоже само не увидит: до него доходит адрес прокси. Настоящий передают отдельным заголовком, и вот тут ловушка. Отправил запрос с заголовком X-Forwarded-For, где написал 1.2.3.4 - бэкенд увидел «1.2.3.4, 172.19.0.1». Прокси ДОПИСАЛ настоящий адрес в конец, а не заменил список. Значит, доверять можно только тем значениям, которые дописали твои прокси, то есть читать список с конца и ровно столько шагов, сколько у тебя своих прокси. Наивное чтение первого значения означает, что любой клиент назначает себе любой адрес - а на этом висят ограничения по частоте, блокировки и геолокация.

proxy_pass
куда проксировать; наличие пути в нём меняет путь запроса
X-Forwarded-For
цепочка адресов клиента и прокси; каждый прокси дописывает в конец
доверенные прокси
свои прокси, чьим записям в цепочке можно верить

L4, L7 и чего стоит проверка здоровья

Балансировщик уровня соединений (его называют L4) разводит соединения по адресам и портам. Он быстрый, дешёвый и работает с любым протоколом, но внутрь не смотрит. Балансировщик уровня приложения (L7) разбирает сам запрос: умеет маршрутизировать по пути и заголовкам, повторять безопасные запросы на другом бэкенде, пускать часть трафика на новую версию, считать коды ответов. Платит он за это процессором и лишним звеном в цепочке.

Отсюда практический выбор: L4 хорош на входе и для протоколов, которые не разбираются, L7 нужен там, где решения принимаются по содержимому запроса. Часто ставят оба: снаружи L4 раскидывает по зонам, внутри L7 маршрутизирует по сервисам.

// И главное про проверки здоровья. Проверка на уровне соединения подтверждает ровно одно: порт открыт. Я поднял бэкенд, который на каждый запрос отвечает пятисотым кодом, - порт у него открыт, соединения принимаются, из раздачи он не выпадает, и клиенты продолжают получать ошибки. Значит, проверка должна быть на уровне приложения и на отдельном адресе. Но и тут есть край: если такая проверка ходит в базу, то падение базы выведет из раздачи все бэкенды разом, и сервис умрёт целиком вместо частичной деградации.

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

Как отвечать: «Запрос уходит на бэкенд с префиксом, хотя ждали без. Что в конфиге?»

В proxy_pass не указан путь. Правило такое: если в proxy_pass есть путь, хотя бы одиночный слэш, nginx заменяет им ту часть, что совпала с правилом, поэтому /rr/echo/users/42 уходит как /echo/users/42. Если пути нет, адрес считается как есть и путь передаётся целиком, с префиксом. Я это проверял на стенде: одна и та же пара правил, разница только в слэше, и бэкенд получает разные пути. Поэтому добавление или удаление слэша меняет маршруты, и это первое, что я смотрю. Заодно проверяю, что бэкенду передано имя из запроса клиента, а не имя из настройки прокси, иначе приложение начнёт строить ссылки на внутренний адрес.

Ответ формулирует правило целиком, а не запоминалку про слэш, и добавляет соседнюю ошибку про имя, которая всплывает сразу после починки путей.

На чём валятся

  • Добавляют или убирают слэш в proxy_pass наугад и ломают маршруты.
  • Берут первое значение из цепочки адресов клиента как истину и получают подделку.
  • Считают открытый порт признаком здоровья сервиса.
  • Оставляют пределы ожидания по умолчанию: один мёртвый бэкенд подвешивает часть запросов надолго.
  • Ставят проверку здоровья, которая ходит в базу: падение базы выводит из раздачи всех сразу.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.

  1. #dvo_net_lb1 / 5
    За балансировщиком приложение видит один и тот же IP. Включаем разбор X-Forwarded-For. Чем это опасно?
    A)Заголовок режется прокси и до приложения доходит пустым
    B)Клиент подставит свой адрес, доверять надо прокси
    C)Разбор заголовка ломает keep-alive между прокси и приложением
    D)XFF заполняется лишь при HTTPS, на HTTP его не будет
    показать ответ и разбор
    +B)Клиент подставит свой адрес, доверять надо прокси

    // разбор: X-Forwarded-For — обычный заголовок: клиент вправе прислать свой, и прокси допишет реальный адрес в конец списка. Если приложение берёт первый элемент как есть, любой подделает себе адрес — а на этом висят лимиты, бан-листы и геолокация. Потому доверяют только адресам своих прокси (set_real_ip_from у nginx, trusted proxies у фреймворка) и читают список справа налево.

  2. #dvo_net_lb2 / 5
    Одна нода за балансировщиком умерла, но часть запросов всё равно летит на неё и падает. Чего не хватает в LB?
    A)Больше нод в пуле, тогда мёртвая просто затеряется среди живых
    B)Клиентских ретраев, чтобы упавший запрос ушёл повторно
    C)Health-check: LB должен сам выкидывать неотвечающую ноду из пула
    D)Sticky-сессий, чтобы клиент не попадал на мёртвую ноду
    показать ответ и разбор
    +C)Health-check: LB должен сам выкидывать неотвечающую ноду из пула

    // разбор: Балансировщику нужны health-check'и (активные и/или пассивные), чтобы обнаружить мёртвый бэкенд и убрать его из ротации; без них он продолжает слать свою долю на упавшую ноду. Ретраи и лишние ноды — полумеры, а sticky вообще прибьёт часть клиентов к мёртвой. Настраивают проверку (путь, интервал, пороги), чтобы LB сам выводил и позже возвращал бэкенды.

  3. #dvo_net_lb3 / 5
    Приложение отлично масштабируется, но после добавления реплик пользователей стало «разлогинивать» на части запросов. Причина?
    A)Реплики рассинхронили часы, из-за этого токены протухают
    B)Балансировщик шлёт слишком много запросов на одну реплику
    C)У реплик разные версии кода, часть не понимает сессию
    D)Сессия лежит в памяти одной реплики; нужен общий стор или sticky
    показать ответ и разбор
    +D)Сессия лежит в памяти одной реплики; нужен общий стор или sticky

    // разбор: Сессия в памяти живёт на одной реплике; round-robin отправляет следующий запрос на другую, где этой сессии нет, — «разлогинило». Лечится: вынести состояние сессии наружу (Redis/БД), чтобы её мог отдать любой инстанс (предпочтительно — приложение остаётся stateless), либо включить sticky-сессии (привязка к реплике) — проще, но хуже для ребаланса и отказоустойчивости.

  4. #dvo_net_lb4 / 5
    При каждом деплое часть живых запросов обрывается 502, хотя ноды выкатываются по одной. Что настроить?
    A)Просто поднять таймаут балансировщика на время деплоя
    B)Дренаж: вывести из пула, дать доиграть текущим запросам
    C)Выкатывать сразу все ноды, чтобы обрыв вышел короче
    D)Добавить клиентские ретраи и не обращать внимания на обрывы
    показать ответ и разбор
    +B)Дренаж: вывести из пула, дать доиграть текущим запросам

    // разбор: Резкое выведение бэкенда рвёт его in-flight запросы → 502. Дренаж (graceful removal): перестать слать НОВЫЕ запросы (провалить readiness / дерегистрировать), дать текущим завершиться в пределах drain-таймаута, затем гасить. В Kubernetes это readiness + preStop-хук + terminationGracePeriod; на LB — задержка дерегистрации. Ретраи и большие таймауты сам обрыв не убирают.

  5. #dvo_net_lb5 / 5
    Зачем перед несколькими одинаковыми серверами ставят балансировщик?
    A)Чтобы синхронизировать данные между серверами
    B)Чтобы объединить их диски в общее хранилище
    C)Чтобы распределять запросы между ними и обходить упавшие
    D)Чтобы серверы могли обращаться друг к другу по именам
    показать ответ и разбор
    +C)Чтобы распределять запросы между ними и обходить упавшие

    // разбор: Балансировщик даёт клиенту одну точку входа и раскидывает запросы по живым узлам, опираясь на проверки здоровья. Данные он не синхронизирует — за согласованность отвечают сама база и репликация.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.