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

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

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

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

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

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

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

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

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

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

L4 против L7

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

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

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

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

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

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

Как балансировщик выбирает, на какой экземпляр отправить очередной запрос? Есть несколько простых стратегий:

  • Round-robin («по кругу») — по очереди: первый запрос на бэкенд №1, второй на №2, третий на №3, потом снова на №1. Просто и честно, если все экземпляры одинаковы по мощности и запросы примерно равны по стоимости.
  • Least-connections («меньше всего соединений») — запрос уходит туда, где сейчас меньше всего активных соединений. Хорошо, когда запросы разные: один отвечает мгновенно, другой висит минуту. Round-robin может завалить занятый экземпляр, а least-connections учитывает загрузку.
  • По хешу — балансировщик берёт какой-то признак запроса (например, IP клиента или значение из URL), считает от него хеш и всегда отправляет на один и тот же экземпляр. Так один клиент стабильно попадает на один бэкенд — это нужно для sticky-сессий, о которых ниже.

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

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

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

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

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

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

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

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

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

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

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

Во-вторых, важен 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.

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

Балансировщик стоит на пересечении нескольких тем, которые полезно разложить рядом. Начните с моделей OSI и TCP/IP — тогда деление на L4 и L7 станет очевидным. Дальше — HTTPS и TLS, чтобы понять, что значит «терминировать шифрование на балансировщике». Разберитесь с соединениями и пулами: balancer раскидывает именно соединения и запросы внутри них. И увяжите всё с надёжностью — health-checks, вывод из ротации и graceful shutdown это ровно про то, как сервис переживает отказ отдельных частей.