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

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

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

балансировщик · 900 клиентов на три экземпляра все три в ротации app-1 300 клиентов app-2 300 клиентов app-3 300 клиентов app-2 молчит на /health ×3 app-2 вне ротации app-1 450 клиентов app-3 450 клиентов состояние в Redis — сервис stateless запрос ушёл на app-1 или app-3 — корзина там же ни один из 900 не заметил корзина в памяти app-2 — sticky клиенты попали на app-1 и app-3, но их не узнали 300 клиентов из 900 без корзины

Отказ экземпляра балансировщик разруливает сам: вывел app-2 из ротации, оставшиеся двое приняли по 450 вместо 300. Заметят ли это клиенты — решает только одно: лежит их состояние в памяти экземпляра или снаружи.

Обязательно

Зачем нужен балансировщик

Представьте вход в большой магазин с десятью кассами. Если бы каждый покупатель сам выбирал кассу, кто-то встал бы в очередь из двадцати человек, а соседняя касса простаивала бы. Поэтому ставят администратора, который направляет людей: «вы — на третью, вы — на седьмую». Он видит все кассы сразу и раскидывает поток равномерно.

Балансировщик — это тот самый администратор для сетевых запросов. Клиенты (браузеры, мобильные приложения, соседние сервисы) обращаются по одному адресу, а балансировщик за ним прячет пул одинаковых экземпляров и распределяет запросы между ними. Это даёт сразу три вещи: масштабирование (добавили экземпляров — выросла пропускная способность), отказоустойчивость (одна копия упала — трафик уходит на живые) и бесшовные обновления (выкатываем новую версию по одному экземпляру, не роняя сервис).

Клиент Балансировщик HTTPS app-1 HTTP, /health ok app-2 HTTP, /health ok app-3 молчит на /health все заняты очередь на входе отказ по таймауту

Шифрование заканчивается на балансировщике и внутрь идёт обычный HTTP, экземпляр без ответа на /health выпадает из ротации, а когда свободных нет, запросы ждут в очереди на входе.

Обратный прокси и балансировщик — в чём разница

Часто эти два слова используют почти как синонимы, и на практике это нередко одна и та же программа. Разница в акценте.

Обратный прокси (reverse proxy) — это посредник, который стоит перед сервисами и принимает запросы от имени клиента, а потом переправляет их внутрь. «Обратный» — потому что он работает на стороне сервера, в отличие от обычного (прямого) прокси, который стоит на стороне клиента. Обратный прокси умеет много всего помимо распределения: терминировать шифрование, отдавать кэш, сжимать ответы, роутить запросы к разным сервисам по адресу.

Балансировщик нагрузки — это функция «раскидать запросы по нескольким одинаковым экземплярам». Обычно её выполняет тот же обратный прокси. Скажем, популярный nginx — это и обратный прокси, и балансировщик одновременно: одна программа принимает трафик, распределяет его по бэкендам и попутно занимается кэшем и шифрованием.

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

Как клиент находит сам балансировщик

Балансировщик снимает проблему «какой экземпляр выбрать» и создаёт новую: он сам становится точкой отказа. Четыре способа с этим жить.

DNS-имя с несколькими адресами. У api.example.com заведены два-три адреса, и разрешатель отдаёт их в разном порядке. Просто, работает с любым клиентом, но: проверок работоспособности нет (мёртвый адрес продолжает выдаваться), а кэш на стороне клиента держит старый ответ до истечения срока жизни записи.

Плавающий адрес. Два балансировщика, один адрес: активный держит его на своём интерфейсе, а при отказе резервный забирает адрес себе (по протоколу вроде VRRP или скриптом в облаке). Клиент ничего не знает и ничего не меняет — переключение занимает секунды. Это самый распространённый способ в своей инфраструктуре.

Пара «активный — резервный» с внешним управлением. То же, но переключением занимается сторонний наблюдатель (кластерное ПО, облачная служба), и он же решает, кто жив.

Anycast. Один адрес объявляется из многих точек присутствия, а сеть сама везёт пакет в ближайшую. Так работают публичные разрешатели имён и сети доставки; в своей инфраструктуре встречается редко, потому что требует управления маршрутизацией.

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

Таймауты и повторы на балансировщике

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

Отсюда классические симптомы. Таймаут балансировщика меньше времени работы вашей ручки — клиент получает ошибку шлюза, хотя приложение спокойно досчитывает и отвечает в пустоту (и в журнале приложения запрос успешный). Таймаут простоя меньше времени жизни соединения в пуле клиента — клиент пишет в закрытую линию и получает обрывы «ни с чего». Правило согласования то же, что везде: клиент сдаётся раньше балансировщика, балансировщик — раньше приложения, а время жизни соединения в пуле меньше таймаута простоя на балансировщике.

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

Вывод из ротации при выкате

«Graceful shutdown» на практике — это два разных шага, и порядок между ними важнее самих шагов.

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

Дальше — дожить старые: уже принятые запросы обрабатываются до конца, а долгоживущие соединения (постоянные соединения, поток событий, веб-сокеты) закрываются с уведомлением, чтобы клиент переподключился к другому экземпляру. Это и называют выводом из ротации с додержкой (connection draining), и у него есть предел по времени — обычно десятки секунд, после которых соединения рвутся принудительно.

Главная ловушка в промежутке: между «пометил себя неготовым» и «балансировщик это увидел» проходит время — интервал проверок плюс число неудачных проверок. Если экземпляр начинает останавливаться сразу, часть запросов прилетит в уже умирающий процесс. Поэтому в конфигурации всегда есть пауза перед остановкой (в Kubernetes — задержка перед сигналом остановки или ожидание в обработчике), и она должна быть больше периода обнаружения балансировщиком.

Соединения против запросов при долгих соединениях

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

Два следствия, о которые натыкаются.

Перекос. Клиент с постоянным соединением (или один экземпляр HTTP/2, или поток событий) прибит к одному узлу на всё время жизни соединения. Добавили новый экземпляр приложения — на него не придёт ни одного уже подключённого клиента; он получит только новые подключения. Значит, после выката или расширения нагрузка распределена неравномерно, и выравнивается она только по мере переподключений.

Балансировка запросов требует понимания протокола. Чтобы раскладывать запросы внутри одного соединения, балансировщик должен его разбирать — то есть работать на уровне приложения (HTTP/2, gRPC). Тогда он открывает свои соединения к экземплярам и распределяет запросы; цена — он расшифровывает трафик и становится участником, а не трубой.

Практический вывод: для веб-сокетов и потока событий планируют не «сколько запросов в секунду», а сколько соединений на экземпляр и как они перераспределятся при выкате. Как это выглядит и сколько стоит — в статье про WebSocket и поток событий.

L4 против L7

Помните слои сети из моделей OSI и TCP/IP? Балансировщики бывают двух типов ровно по этим слоям — и это принципиальное различие.

L4 - транспорт TCP-соединение адрес и порт бэкенд на соединение L7 - приложение HTTP-запрос путь и заголовки бэкенд на запрос

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

L4-балансировщик работает на транспортном слое. Он оперирует TCP-соединениями и не заглядывает внутрь: видит только IP-адреса и порты, но не знает, HTTP там внутри или что-то ещё. Его задача — взять входящее соединение и целиком перебросить на один из бэкендов. Быстро и дёшево, потому что разбирать содержимое не надо. Но и умеет он немного: раскидать соединения — и всё.

L7-балансировщик работает на прикладном слое и понимает HTTP. Он читает запрос: метод, путь /orders/42, заголовки, cookie. Благодаря этому он умеет то, чего L4 не может:

  • роутить по содержимому — запросы на /api/ отправить в один пул, на /images/ в другой;
  • терминировать TLS — расшифровать HTTPS на входе, чтобы бэкенды получали обычный HTTP и не тратили силы на шифрование;
  • балансировать по отдельным запросам, а не по соединениям: в одном keep-alive-соединении может прилететь сто запросов, и L7 раскидает их по разным бэкендам.

За гибкость L7 платит тем, что должен разобрать каждый запрос — это чуть дороже. На практике для веб-сервисов почти всегда берут L7: он умнее и как раз говорит на языке HTTP.

У последнего пункта есть следствие, которое потом долго мучает в проде: до вашего сервиса запросы приходят от балансировщика, а не от клиента. Значит, и адрес отправителя в логах — это адрес балансировщика, один и тот же у всех запросов подряд. Настоящий адрес клиента балансировщик кладёт в заголовок X-Forwarded-For (рядом с уже знакомым X-Forwarded-Proto, который говорит, что снаружи был https). Пока приложение этот заголовок не разбирает, любое ограничение «не больше N запросов с одного адреса» и вся география в логах считаются по балансировщику — то есть не работают вовсе. Доверять заголовку можно только от своего балансировщика: клиент прислать его может и сам, написав там что угодно.

Алгоритмы распределения

Как балансировщик выбирает, на какой экземпляр отправить очередной запрос? Стратегий три, и неверно выбранную видно по симптому. Round-robin («по кругу») раздаёт по очереди: первый запрос на бэкенд №1, второй на №2, третий на №3, потом снова на №1; это честно, пока экземпляры одинаковы по мощности и запросы равны по стоимости. Когда один запрос отвечает мгновенно, а другой висит минуту, round-robin заваливает занятый экземпляр, и у части клиентов растёт задержка при свободных соседях; это лечит least-connections («меньше всего соединений»): запрос уходит туда, где сейчас меньше активных соединений. А когда один клиент должен попадать на один и тот же экземпляр, балансировщик берёт признак запроса, IP клиента или значение из URL, считает от него хеш и отправляет по нему всегда в одно место, по хешу; это нужно для sticky-сессий, о которых ниже, и симптом неверного выбора здесь обратный: экземпляры загружены неровно, потому что крупный клиент прибит к одному.

Есть вариации (взвешенный round-robin, когда более мощным экземплярам дают больше трафика), но идея та же: раскидать поток разумно.

Health-checks: как отсеять мёртвый экземпляр

Балансировщик обязан знать, какие экземпляры живы, иначе он будет упорно слать запросы на упавший и клиенты получат ошибки. Для этого он регулярно делает health-check — проверку здоровья. Обычно это отдельный HTTP-эндпоинт вроде /health, который балансировщик дёргает раз в несколько секунд.

Пока экземпляр отвечает 200 OK — он в ротации, на него идёт трафик. Как только он перестал отвечать или начал возвращать ошибки несколько раз подряд — балансировщик выводит его из ротации и перестаёт слать запросы. Когда экземпляр снова начнёт отвечать, его вернут обратно. Так пул сам себя лечит: упавшая копия просто исключается, а клиент продолжает получать ответы от живых. Это одна из основ надёжности распределённого сервиса.

«Несколько раз подряд» — не фигура речи, а две конкретные настройки, и от них зависит, заметят ли клиенты отказ. Первая — как часто спрашивать: раз в секунду, раз в пять. Вторая — сколько неудачных ответов подряд считать приговором и сколько удачных нужно, чтобы вернуть экземпляр обратно. Перемножьте — получите время, в течение которого трафик продолжает литься в мёртвую копию: проверка раз в 5 секунд и порог в 3 неудачи — это до 15 секунд ошибок у клиентов. Порог в единицу — другая крайность: экземпляр начнёт выпадать из ротации от одной случайно потерянной проверки. Отсюда и подбирают: спрашивать почаще (это дёшево), а порог держать небольшим, но не единичным.

Работает и второй способ, одновременно с первым: не спрашивать отдельно, а смотреть на живой трафик. Экземпляр начал отдавать ошибки или обрывать соединения на настоящих запросах — балансировщик исключает его сам, не дожидаясь очередной проверки. Так быстрее: авария видна с первого же сломанного ответа, а не через три пропущенных проверки.

И отдельная деталь про возвращение. Только что поднявшийся экземпляр уже отвечает 200, но работать в полную силу ещё не готов: кэши пустые, пул соединений не прогрет, у JVM горячий код ещё не скомпилирован. Дадите ему сразу полную долю трафика — он захлебнётся и снова вывалится из ротации, а через минуту вернётся, и так по кругу. Чтобы этого не было, в балансировщиках есть медленный старт: вернувшемуся экземпляру первые секунды дают трафика поменьше и наращивают долю постепенно.

Sticky-сессии и почему лучше stateless

Иногда возникает соблазн привязать клиента к одному экземпляру: чтобы все его запросы шли на один и тот же бэкенд. Это называется sticky session (липкая сессия). Зачем? Если сервис хранит состояние пользователя у себя в памяти — например, содержимое корзины или данные залогиненной сессии — то этот клиент обязан возвращаться туда, где эти данные лежат. Иначе на другом экземпляре его «не узнают».

Проблема в том, что sticky-сессии ломают всю красоту балансировки. Экземпляр с прилипшими к нему клиентами упал — и все их сессии пропали вместе с памятью. Нагрузка распределяется неравномерно. Плавно выкатить обновление тоже труднее.

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

Где это применяется

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

Во-первых, сервис должен быть stateless. Почему — сказано выше, в разделе про sticky-сессии. Состояние — в базу или в общий кэш.

Во-вторых, важен graceful shutdown — аккуратная остановка. Когда экземпляр выключается (при обновлении или масштабировании вниз), он должен сначала сняться из ротации балансировщика, дать здоровью показать «я больше не готов», дождаться завершения текущих запросов и только потом остановиться. Иначе клиенты, чьи запросы летели на него в момент выключения, получат обрывы соединения. Обычно это связка health-эндпоинта, keep-alive-соединений и правильной обработки сигнала завершения.

В-третьих, полезно знать, где балансировщик живёт в вашей инфраструктуре. В Kubernetes роль L4-балансировки внутри кластера играет Service, а на входе снаружи, с L7-роутингом по путям и терминированием TLS, — Ingress. В облаке AWS это управляемые балансировщики перед вашими экземплярами — как они вписываются в сеть, разбираем в статье про сеть в AWS.

Где спотыкаются начинающие:

  • Хранят сессию в памяти экземпляра и удивляются, что «пользователя разлогинивает». Это sticky-сессии дали трещину: запрос ушёл на другой экземпляр, где состояния нет. Лечится выносом состояния наружу.
  • Забывают про health-эндпоинт или делают его слишком поверхностным. Если /health всегда отвечает 200, даже когда приложение не может работать, балансировщик будет слать трафик в заведомо сломанный экземпляр. Но и обратная крайность опасна: если в проверку затащить всё подряд, включая общую для всех экземпляров базу, то при её недоступности из ротации разом выпадут все — и вместо частичной деградации получится полный отказ. Разумная середина: проверять то, что сломано именно у этого экземпляра, а на общие зависимости реагировать иначе.
  • Останавливают сервис резко, без graceful shutdown. Экземпляр исчезает раньше, чем балансировщик успел вывести его из ротации, и часть запросов обрывается.
  • Путают L4 и L7 и ждут от L4-балансировщика роутинга по URL. Тот не заглядывает внутрь соединения и про URL ничего не знает — для этого нужен L7.
Дополнительно: при первом чтении можно пропустить

Глубже: CDN: балансировщик на краю светарасширенное

Картинки, стили, скрипты и видео составляют большую часть трафика сайта, а сервису они не нужны: он лишь отдаёт байты. CDN это сеть узлов по всему миру, которая отдаёт такие файлы вместо вас: пользователь из Новосибирска получает файл с узла в Новосибирске, а не с вашего сервера в Москве, и ваш сервер получает один запрос на файл вместо миллиона.

Узел CDN это кэш: первый запрос он переадресует к вам, на источник (origin), сохраняет ответ и дальше отдаёт копию, пока не истечёт срок. Срок задаёте вы тем же Cache-Control из статьи про HTTP, и от него зависит главная проблема CDN: инвалидация. Выкатили новую версию app.js, а узлы по всему миру ещё сутки отдают старую. Просить CDN «забудь этот файл» можно, но это медленно и стоит денег. Поэтому статику именуют хэшем содержимого: app.3f9c2e.js, и новая версия это новый адрес, который никто не кэшировал, а старый может жить вечно с Cache-Control: max-age=31536000, immutable. Кэшировать нельзя только точку входа, index.html, которая на новые имена ссылается: у неё no-cache и проверка ETag.

Что уходит на источник и что нет, решает ключ кэша: обычно адрес и часть заголовков из Vary. Строка запроса с меняющимся параметром (?t=1780617600) делает каждый запрос уникальным и превращает CDN в прокси без кэша. Ответы пользователю с Cookie и Authorization CDN не кэширует по умолчанию, и правильно: иначе один пользователь получит страницу другого. Динамический API за CDN тоже ставят, но ради защиты от перегрузки и TLS на краю, а не ради кэша.

Защита ссылок: закрытые файлы (оплаченное видео, документы) отдают через CDN по подписанным ссылкам с ограниченным сроком, а источник закрывают от прямого доступа, чтобы ссылку на него нельзя было достать из страницы. Как это устроено для объектного хранилища, разбирает статья про эксплуатацию object storage. И последнее: CDN стоит перед балансировщиком, а не вместо него. Запрос, который прошёл кэш насквозь, приходит на ваш вход как обычно, только с адресом узла CDN вместо адреса пользователя, и настоящий адрес нужно брать из X-Forwarded-For, доверяя ему только от известных узлов.

Коротко

  • Балансировщик раскидывает трафик между экземплярами; L4 видит соединения, L7 читает HTTP и умеет роутить по пути и терминировать TLS.
  • Экземпляры равноправны и без состояния: сессия в общем хранилище, health-эндпоинт честный, остановка через снятие из ротации.
  • CDN это кэш на краю: срок задаёт Cache-Control, статику именуют хэшем содержимого и отдают навсегда, точку входа не кэшируют; личные ответы и меняющиеся параметры мимо кэша.
  • Закрытые файлы через CDN по подписанным ссылкам с закрытым источником; адрес пользователя за CDN в X-Forwarded-For от доверенных узлов.
  • Балансировщик сам точка отказа: вход обеспечивают несколькими адресами в DNS, плавающим адресом, парой «активный — резервный» или anycast.
  • У балансировщика свои таймауты: меньше времени работы ручки — клиент получает ошибку шлюза при успешном запросе в журнале; повторы на другом экземпляре безопасны только для идемпотентных операций.
  • Выкат — два шага: сначала пометить себя неготовым и дождаться, пока балансировщик это увидит, потом дожить принятые запросы и закрыть долгие соединения.
  • Балансировка соединений и запросов различается для долгих соединений: новый экземпляр не получит уже подключённых клиентов, а распределять запросы внутри соединения умеет только прокси уровня приложения.

Что почитать дальше