Прокси и балансировка: L4 vs L7, nginx, health checks, sticky sessions
У вас один DNS-адрес и двадцать серверов. Между ними стоит коробка, которая решает, кому достанется запрос. Эта коробка — самое влиятельное устройство в вашей инфраструктуре: она определяет, что видит клиент во время деплоя, как выглядит отказ одного узла, чей IP запишется в логи и почему по вторникам в 11:03 у вас всплеск 502-х.
Аналогия, которая держится всю статью: ресепшн в бизнес-центре. Охранник на входе может работать двумя способами. Первый: посмотреть на пропуск, сказать «вам на седьмой этаж» и пропустить человека дальше — сам разговор с сотрудником произойдёт наверху, охранник его не услышит. Второй: принять посетителя, выслушать, зачем он пришёл, самому подняться, всё уладить и вынести ответ. Первый — это L4. Второй — L7. Первый обрабатывает тысячи людей в час и ничего не знает о делах компании; второй знает всё, но упирается в собственную пропускную способность и становится частью зоны доверия.
Дальше по тексту предполагается, что вы уже прошли TCP (соединения, keepalive, RST), HTTP (заголовки, коды, версии) и TLS (SNI, терминация). Карта уровней — в обзоре трека.
Три разных зверя, которые все называют «прокси»
Слово «прокси» используют для трёх устройств с противоположной ролью. Путаница здесь стоит дорого: конфиги и модели угроз у них разные.
| Forward proxy | Reverse proxy | Балансировщик | |
|---|---|---|---|
| Кто его настроил | клиент (или его сеть) | владелец сервиса | владелец сервиса |
| Кто про него знает | клиент | никто снаружи | никто снаружи |
| Что скрывает | клиента от сервера | сервер от клиента | количество серверов |
| Типовая задача | корпоративный выход в интернет, фильтрация, кэш | TLS, кэш, WAF, роутинг по путям | распределение нагрузки, отказоустойчивость |
| Примеры | Squid, corporate MITM, Tor | nginx, Envoy, Cloudflare | IPVS, HAProxy, NLB/ALB |
Reverse proxy и балансировщик на практике — один и тот же процесс: nginx одновременно терминирует TLS, режет по путям и раскидывает по upstream. Разделение полезно концептуально: reverse proxy решает «что делать с запросом», балансировщик — «кому его отдать».
настроен на прокси] --> P1[Squid] P1 --> S1[Любой сайт] P1 --> S2[Любой сайт] end subgraph REV["Reverse proxy: сервер знает о прокси"] C2[Браузер
ничего не знает] --> P2[nginx на 443] P2 --> B1[app-1] P2 --> B2[app-2] P2 --> B3[app-3] end
Отдельно стоит прозрачный (transparent) прокси: трафик заворачивается на него маршрутизацией или iptables без ведома клиента. Именно он даёт классический симптом «у меня работает, а у клиента нет» — подробнее в статье про NAT, фаерволы и VPN.
L4 против L7: где именно проходит разрез
Это главный выбор в теме. Он определяется одним вопросом: насколько глубоко в пакет мы залезаем, прежде чем принять решение.
L4-балансировщик работает с 4-кортежем (src IP, src port, dst IP, dst port). Он принимает решение один раз, на первом SYN, запоминает его в таблице соединений и дальше просто пересылает пакеты. Он не разбирает TCP-поток, не знает про HTTP-запросы и не может «переиграть» решение: если бэкенд начал отвечать 500-ми, L4 этого не заметит.
L7-прокси принимает соединение на себя: завершает рукопожатие TCP, терминирует TLS, читает запрос целиком (или заголовки), выбирает бэкенд и открывает к нему отдельное соединение — как правило, из заранее прогретого keepalive-пула. Одно клиентское соединение может за свою жизнь съездить к пяти разным бэкендам, потому что решение принимается на каждый запрос.
| Критерий | L4 | L7 |
|---|---|---|
| Единица решения | соединение | запрос |
| Пропускная способность на узел | десятки—сотни Гбит/с (XDP, DPDK) | единицы Гбит/с, упор в CPU на TLS и парсинг |
| Задержка, добавляемая прокси | десятки микросекунд | сотни микросекунд — единицы миллисекунд |
| Видит содержимое | нет (кроме SNI в открытом ClientHello) | да, полностью |
| Ретрай при ошибке бэкенда | невозможен | штатная функция |
| Роутинг по URL/заголовку/cookie | нет | да |
| Сквозной TLS до бэкенда | да, естественно | требует re-encrypt |
| Что видит бэкенд как src IP | клиента (DSR/DR) или LB (NAT) | всегда прокси |
| Протоколы | любые TCP/UDP, включая QUIC и Postgres | тот, который прокси умеет парсить |
Практический вывод: на границе почти всегда стоит L4, за ним — L7. L4 дёшево размазывает терабиты и переживает SYN-флуд, L7 делает умные вещи для тех, кто уже прошёл. Ставить L7 сразу на край — значит платить CPU за каждый мусорный пакет.
Три способа реализовать L4
- DNAT (LVS-NAT, кубовский kube-proxy): LB переписывает адрес назначения, ответ обязан пройти обратно через LB, чтобы адрес переписали назад. Просто и работает где угодно; но LB пропускает через себя весь трафик, а исходящий обычно в 10–20 раз больше входящего. Плюс типичная ловушка асимметричной маршрутизации: если бэкенд имеет собственный шлюз в интернет, ответ уйдёт мимо LB, и клиент получит пакет с чужим src IP, который тут же отбросит.
- DSR / Direct Routing (LVS-DR): LB меняет только MAC-адрес назначения, IP-заголовок не трогает. Бэкенд держит VIP на loopback (
ip addr add 198.51.100.10/32 dev lo) и обязан не отвечать на ARP для этого адреса (arp_ignore=1,arp_announce=2), иначе адрес начнут анонсировать десять машин сразу. Ответ уходит клиенту напрямую: LB видит только входящий трафик, и его пропускная способность перестаёт быть узким местом. Цена — бэкенды должны быть в одном L2-сегменте, а health check становится единственным способом узнать, жив ли бэкенд. - IPIP/GRE-туннель: тот же DSR, но через маршрутизируемую сеть; платим 20 байтами заголовка и уменьшением эффективного MTU (см. канальный уровень).
Современные софтовые L4 — Google Maglev, Facebook katran на XDP, Cloudflare Unimog — устроены как ECMP от маршрутизаторов на десятки одинаковых узлов LB, где каждый узел независимо вычисляет бэкенд консистентным хешем. Ключевая проблема такой схемы: при добавлении узла LB маршрутизатор перекладывает часть потоков на новый узел, у которого нет записи о существующих соединениях. Именно поэтому хеш обязан быть консистентным — новый узел должен для того же 4-кортежа выбрать тот же бэкенд, что и старый. Об этом статья Maglev, NSDI 2016 — она читается как учебник по инженерным компромиссам.
Как отличить L4 от L7 за одну минуту
Снимите дамп на бэкенде и посмотрите на источник соединений.
# на бэкенде: кто к нам приходит
sudo tcpdump -i any -nn 'tcp port 8080 and tcp[tcpflags] & tcp-syn != 0' -c 5
11:02:41.118273 IP 10.0.0.9.51234 > 10.0.1.21.8080: Flags [S], seq 2947183, win 64240, options [mss 1460,sackOK,TS val 913 ecr 0,nop,wscale 7], length 0
11:02:41.118912 IP 10.0.0.9.51236 > 10.0.1.21.8080: Flags [S], seq 883412, win 64240, ...
11:02:41.119540 IP 10.0.0.9.51238 > 10.0.1.21.8080: Flags [S], seq 1102934, win 64240, ...
Все SYN приходят с одного адреса 10.0.0.9 и растущих портов — это L7: соединения открывает сам прокси. Если бы перед вами стоял L4 в режиме DSR или tunnel, вы бы видели адреса реальных клиентов из интернета, а wscale/mss менялись бы от клиента к клиенту.
Второй признак — количество соединений относительно RPS:
ss -tn state established '( dport = :8080 )' | wc -l # на L7-прокси
У L7 с keepalive-пулом это число почти постоянно (десятки) при любом RPS. У L4 оно равно числу клиентских соединений и скачет вместе с нагрузкой.
В Wireshark разница выглядит так. Откройте дамп, снятый одновременно на клиенте и на бэкенде, и сравните tcp.seq первого SYN. У L4 (DSR/tunnel) начальный номер последовательности совпадает — это буквально то же самое соединение, и Follow TCP Stream покажет один непрерывный поток от клиента до сервера. У L7 номера разные, у клиента и у бэкенда два независимых рукопожатия, и TLS-рукопожатий тоже два (или одно, если до бэкенда идёт открытый HTTP внутри доверенной сети). Фильтр tcp.flags.syn == 1 && tcp.flags.ack == 0 плюс колонка tcp.stream — самый быстрый способ увидеть, сколько соединений реально живёт.
Сколько прокси между клиентом и вашим кодом
В типичном проде их четыре-пять, и каждый что-то делает с адресом клиента.
TLS 1, кэш, WAF] EDGE -->|промах кэша| L4[L4 LB
ECMP + Maglev] L4 --> NGX[L7 nginx
роутинг, ретраи] NGX --> SC[Sidecar Envoy
mTLS, circuit breaker] SC --> APP[Приложение] APP --> DB[(БД)] EDGE -->|попадание| U
Каждая стрелка — потенциальный таймаут, каждый узел — потенциальный источник 5xx, который придётся отличать от чужого. Правило выживания: у каждого хопа должен быть свой идентификатор запроса в заголовке и в логе. Обычно это X-Request-Id, генерируемый на самом первом прокси и прокидываемый дальше, либо трассировка через traceparent (W3C Trace Context) — тогда путь запроса собирается автоматически. Без этого разбор инцидента превращается в сопоставление таймстампов из пяти логов.
Судьба адреса клиента:
- L4 в режиме DSR/tunnel сохраняет адрес; в режиме DNAT — подменяет.
- L7 всегда подменяет: соединение к бэкенду открывает он. Настоящий адрес живёт в
X-Forwarded-For(де-факто) илиForwarded(RFC 7239, де-юре). - Для не-HTTP протоколов существует PROXY protocol (спека HAProxy): прокси дописывает перед первым байтом соединения одну строку с исходными адресами. v1 текстовая и читается глазом в tcpdump:
PROXY TCP4 203.0.113.7 198.51.100.10 56324 443\r\n
Важнейший нюанс безопасности: X-Forwarded-For — это данные от клиента, пока вы не докажете обратное. Любой может послать X-Forwarded-For: 127.0.0.1 и обойти ваш IP-allowlist. Доверять можно только тем адресам слева, которые добавил хоп, находящийся под вашим контролем. В nginx это выражается явно:
set_real_ip_from 10.0.0.0/8; # адреса моих собственных прокси
set_real_ip_from 172.16.0.0/12; # и подсеть edge-узлов
real_ip_header X-Forwarded-For;
real_ip_recursive on; # идти справа налево, пока адреса «свои»
real_ip_recursive on заставляет nginx откусывать доверенные адреса с конца списка и взять первый недоверенный — это и есть настоящий клиент. С off он возьмёт последний адрес в цепочке, что при двух прокси даст адрес соседнего прокси. Ошибка стоит того, что rate limit по IP начинает банить собственную инфраструктуру.
Алгоритмы: кому отдать запрос
Round-robin — по кругу. Тривиален, O(1), не требует состояния о бэкендах. Работает отлично, когда запросы однородны, а серверы одинаковы. Ломается, когда один запрос стоит 2 мс, а другой 2 с: очередь «дорогих» запросов может собраться на одном узле. Веса (weight=3) чинят только разницу в железе, а не в стоимости запросов.
Least connections — тому, у кого сейчас меньше активных соединений. Это дешёвый прокси-показатель загруженности, и на разнородных запросах он заметно лучше RR. Наивная реализация — O(N) на каждый запрос, с кучей — O(log N).
Но у «отдать самому свободному» есть неприятное свойство, известное как herd behaviour: если решение принимают несколько независимых прокси, все они одновременно увидят «самый свободный» узел и завалят его. Классическое лекарство — power of two choices: выбрать двух случайных кандидатов и отдать запрос тому, у кого меньше нагрузка. Результат из теории (Mitzenmacher, The Power of Two Choices in Randomized Load Balancing): максимальная загрузка падает с Θ(log N / log log N) при чисто случайном выборе до Θ(log log N) — экспоненциальное улучшение за одно дополнительное сравнение. Это дефолт в Envoy (LEAST_REQUEST с choice_count: 2) и доступно в nginx как random two least_conn.
import random
from collections import Counter
def pick_random(backends, inflight):
"""Чисто случайный выбор. O(1) времени, O(1) памяти."""
return random.choice(backends)
def pick_p2c(backends, inflight):
"""Power of two choices. O(1) времени, состояние — только счётчик inflight."""
a, b = random.sample(backends, 2)
return a if inflight[a] <= inflight[b] else b
def simulate(pick, n_backends=50, n_requests=50_000):
backends = [f"b{i}" for i in range(n_backends)]
inflight = Counter({b: 0 for b in backends})
peak = 0
for i in range(n_requests):
b = pick(backends, inflight)
inflight[b] += 1
peak = max(peak, inflight[b])
# каждый запрос живёт ~n_backends шагов: имитируем завершение
if i >= n_backends:
inflight[backends[i % n_backends]] = max(0, inflight[backends[i % n_backends]] - 1)
return peak
# Порядок величин на практике: random даёт пик в разы выше, чем p2c
print("random:", simulate(pick_random)) # например, 34
print("p2c: ", simulate(pick_p2c)) # например, 9
Least time / EWMA — учитывает не только число соединений, но и скользящее среднее времени ответа. Лучше всех ловит «медленный, но живой» бэкенд (деградировавший диск, GC-пауза, сосед по гипервизору). Цена — нужна честная метрика латентности и защита от переобучения на выбросах. Доступен в NGINX Plus (least_time), в Envoy через LEAST_REQUEST с весами, в Finagle/Linkerd — как основной режим.
Консистентное хеширование: когда бэкенд должен быть одним и тем же
Нужно, когда у бэкенда есть локальное состояние: кэш, соединение с шардом, буфер сессии. Обычный hash(key) % N при изменении N перемешивает все ключи — при выпадении одного узла из десяти кэш-попадания падают почти до нуля, и на бэкенды прилетает лавина (см. CDN и edge про cache stampede).
Кольцо (ketama) решает это так: и узлы, и ключи хешируются в одно круговое пространство, ключ достаётся первому узлу по часовой стрелке. Удаление узла перекладывает только его дугу — примерно 1/N ключей.
import bisect
import hashlib
class HashRing:
"""Консистентное хеширование кольцом с виртуальными узлами."""
def __init__(self, nodes=(), vnodes: int = 160):
self._vnodes = vnodes
self._ring: list[int] = [] # отсортированные позиции на кольце
self._owner: dict[int, str] = {} # позиция -> имя узла
for n in nodes:
self.add(n)
@staticmethod
def _hash(key: str) -> int:
# 64 бит от sha1 достаточно: криптостойкость тут не нужна, нужна равномерность
return int.from_bytes(hashlib.sha1(key.encode()).digest()[:8], "big")
def add(self, node: str) -> None:
"""O(V log(NV)) на вставку с учётом сдвига списка."""
for i in range(self._vnodes):
pos = self._hash(f"{node}#{i}")
bisect.insort(self._ring, pos)
self._owner[pos] = node
def remove(self, node: str) -> None:
for i in range(self._vnodes):
pos = self._hash(f"{node}#{i}")
idx = bisect.bisect_left(self._ring, pos)
if idx < len(self._ring) and self._ring[idx] == pos:
self._ring.pop(idx)
self._owner.pop(pos, None)
def get(self, key: str) -> str:
"""O(log(NV)) на выбор узла — бинарный поиск по кольцу."""
if not self._ring:
raise LookupError("кольцо пустое: нет живых бэкендов")
idx = bisect.bisect(self._ring, self._hash(key)) % len(self._ring)
return self._owner[self._ring[idx]]
ring = HashRing(["cache-1", "cache-2", "cache-3"])
before = {k: ring.get(k) for k in (f"user:{i}" for i in range(10_000))}
ring.remove("cache-2")
after = {k: ring.get(k) for k in before}
moved = sum(1 for k in before if before[k] != after[k])
print(f"переехало ключей: {moved / len(before):.1%}") # порядка 33% — это доля выбывшего узла
Сложность: get — O(log(N·V)) по времени, память O(N·V). Виртуальные узлы (V ≈ 100–200) нужны, чтобы дуги были примерно равны: с V=1 разброс нагрузки между узлами достигает разов.
Альтернативы, которые стоит знать:
- Rendezvous (HRW) hashing: для ключа считаем
hash(key, node)по всем узлам и берём максимум. O(N) на выбор, зато не нужны виртуальные узлы и переезжает ровно доля выбывшего узла. Хорош при малом N. - Maglev hashing: строится таблица фиксированного размера M (простое число, обычно 65537), заполняемая так, что распределение почти идеально равномерное, а при изменении состава переезжает минимум записей. Выбор — O(1) по индексу
hash(key) % M. Именно это стоит в Envoy (MAGLEV) и в аппаратных L4 — там O(log N) на пакет уже дорого. - Bounded-load consistent hashing (статья Google, 2016): кольцо плюс потолок нагрузки на узел; при переполнении ключ уходит к следующему. Спасает от «горячего ключа», который в чистом кольце убивает один узел.
Health checks: как не убить кластер проверками
Балансировщик обязан знать, кто жив. Способов два, и нужны оба.
Пассивные (passive / outlier detection): смотрим на реальный трафик. Соединение отвергнуто, таймаут, три 5xx подряд — узел временно выводится из ротации. Ничего не стоит, реагирует мгновенно, но требует трафика: на низком RPS узел может «болеть» минутами незамеченным. Это единственный режим в бесплатном nginx (max_fails / fail_timeout).
Активные (active): LB сам ходит на /healthz каждые N секунд. Обнаруживает проблему до того, как её увидит пользователь, и позволяет вернуть узел в строй автоматически. Цена — трафик проверок (при 50 LB и интервале 1 с бэкенд получает 50 rps чистых проверок) и риск, что проверка проверяет не то.
Три параметра, которые нужно понимать буквально:
fall(unhealthy threshold) — сколько неудач подряд до вывода. Меньше 2 нельзя: одна потеря пакета выкинет здоровый узел.rise(healthy threshold) — сколько успехов до возврата. Асимметрия намеренная: выводить надо быстро, возвращать осторожно, иначе получите flapping — узел мигает в ротации, каждый раз забирая порцию запросов и роняя их.interval+timeout— таймаут проверки обязан быть меньше интервала, иначе проверки накладываются друг на друга. Время обнаружения отказа ≈fall × interval, и именно это число надо сравнивать с вашим SLO.
Что класть в /healthz, а что нет
Самая дорогая ошибка в этой теме выглядит так: разработчик делает честный /healthz, который проверяет доступность базы. База на секунду тормозит — все бэкенды одновременно отвечают 503, LB выводит из ротации весь пул, и сайт падает целиком, хотя 90% запросов вообще не ходили в базу. Проверка превратила деградацию в отказ.
если здоровых меньше 50%,
считать здоровыми всех] PANIC --> HALF[Часть запросов проходит]
Правила, выстраданные индустрией:
- Liveness ≠ readiness. Liveness отвечает «процесс не завис, перезапуск поможет». Readiness — «я готов принимать трафик прямо сейчас». Балансировщик смотрит на readiness, супервизор (Kubernetes, systemd) — на liveness. Смешивать их — значит получить бесконечный цикл перезапусков при недоступности зависимости.
- Проверка не должна ходить в общие зависимости, если только от них не зависит буквально каждый запрос. Общая зависимость превращает частный отказ в тотальный.
- Fail open на уровне пула. Envoy умеет
panic threshold: если здоровых меньше порога (по умолчанию 50%), он игнорирует health checks и шлёт трафик всем. Логика простая: если «здоровых нет», скорее всего сломаны проверки, а не весь мир. - Проверка должна идти тем же путём, что и трафик. Классика: LB проверяет порт 8080 напрямую, а трафик идёт через nginx на 443 внутри пода. nginx умер — проверки зелёные, пользователи получают connection refused.
- Slow start после возврата. Только что поднятый узел имеет холодный кэш, пустой пул соединений к БД, неразогретый JIT. Влить в него 1/N трафика мгновенно — значит получить всплеск задержек и, возможно, повторный вывод из ротации. HAProxy:
slowstart 30s, Envoy:slow_start_config, NGINX Plus:slow_start=30s. - Дренаж (connection draining) при выводе. Перед остановкой узел должен перестать получать новые запросы, но доработать текущие. Порядок: снять из ротации → подождать (
interval × fall+ запас) → закрыть keepalive-соединения черезConnection: close→ SIGTERM. Если пропустить ожидание, часть клиентов получит RST в середине запроса, аpreStop: sleep 5в Kubernetes существует ровно для этого.
Health checks в реальных командах
# что именно проверяет LB — повторите его запрос руками, с тем же Host
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
-H 'Host: api.example.com' http://10.0.1.21:8080/healthz
200 0.004s
# видно ли проверки на бэкенде и с какой периодичностью
sudo tcpdump -i any -nn -A 'tcp port 8080 and tcp[((tcp[12:1] & 0xf0) >> 2):4] = 0x47455420' -c 3
11:04:01.001 IP 10.0.0.9.40122 > 10.0.1.21.8080: Flags [P.], length 88
GET /healthz HTTP/1.1
Host: api.example.com
User-Agent: nginx-health-check
11:04:02.001 IP 10.0.0.9.40124 > 10.0.1.21.8080: ...
Фильтр выше ловит пакеты, начинающиеся с GET — удобный способ увидеть, кто и как часто ходит на приложение. Если проверок не видно вообще, а LB считает узел живым, значит он проверяет что-то другое (TCP-connect вместо HTTP — частый дефолт).
Разница между «порт закрыт» и «пакеты дропаются» видна сразу:
tcpdump -nn 'host 10.0.1.21 and tcp port 8080'
# порт закрыт: мгновенный отказ, health check упадёт за миллисекунды
11:05:10.100 IP 10.0.0.9.41002 > 10.0.1.21.8080: Flags [S], seq 12345
11:05:10.100 IP 10.0.1.21.8080 > 10.0.0.9.41002: Flags [R.], seq 0, ack 12346
# фаервол DROP: SYN уходит в пустоту, отказ обнаружится только по таймауту
11:05:20.200 IP 10.0.0.9.41004 > 10.0.1.21.8080: Flags [S], seq 55555
11:05:21.220 IP 10.0.0.9.41004 > 10.0.1.21.8080: Flags [S], seq 55555 # ретрансмиссия
11:05:23.260 IP 10.0.0.9.41004 > 10.0.1.21.8080: Flags [S], seq 55555
В Wireshark это два узнаваемых узора. [RST, ACK] в ответ на SYN — красная строка, отказ за один RTT. Ретрансмиссии SYN — Wireshark помечает их как [TCP Retransmission] с экспоненциально растущими интервалами 1 с, 2 с, 4 с; отказ обнаружится за секунды, и всё это время health check держит соединение. Отсюда практическое правило: в интранете предпочитайте REJECT вместо DROP — быстрый отказ гораздо дружелюбнее к балансировщикам, чем «тишина».
Sticky sessions: зачем и чем платим
Прилипание нужно, когда бэкенд хранит что-то, чего нет у соседей: сессию в памяти, открытый WebSocket (реальное время), локальный кэш пользователя, многошаговую загрузку файла.
Последние три шага — вся суть проблемы. Sticky session превращает отказ одного узла в потерю данных для его пользователей. Три способа реализации и их цена:
| Способ | Как работает | Плюсы | Минусы |
|---|---|---|---|
ip_hash |
хеш от адреса клиента | не требует cookie, работает для не-HTTP | CGNAT и корпоративный NAT сажают тысячи людей на один бэкенд; мобильный клиент теряет прилипание при смене сети |
| cookie от прокси | прокси ставит свою cookie с идентификатором узла | точное прилипание, переживает смену IP | только HTTP, cookie надо помечать Secure/HttpOnly, идентификатор узла утекает наружу |
hash $key consistent |
консистентное хеширование по произвольному ключу (сессия, user id) | переживает изменение состава пула, минимум переездов | нужен стабильный ключ в запросе |
Общая цена, которую платят всегда:
- Неравномерность. Прилипание по определению мешает балансировке. Один «тяжёлый» клиент (или один офис за NAT) может нагрузить узел в разы сильнее соседей.
- Невозможность честного дренажа. Вывод узла = потеря состояния его пользователей, если только состояние не реплицируется.
- Автомасштабирование почти не работает. Новые узлы получают только новые сессии; разгрузить существующие нельзя.
- Отладка усложняется. «Баг воспроизводится у одного из пяти пользователей» — классический симптом одного сломанного узла плюс sticky.
Правильный ответ в большинстве случаев: вынести состояние наружу — сессия в Redis, подписанный токен у клиента, файлы в объектном хранилище. Тогда любой запрос можно отдать любому узлу, и вся глава становится ненужной. Sticky остаётся законным для случаев, где состояние физически неотделимо (WebSocket-соединение, SSE-поток, локальный кэш ради cache locality) — и там лучше использовать консистентное хеширование, а не ip_hash.
nginx на практике
Разберём боевой конфиг построчно. Это не «hello world», а то, что реально стоит перед приложением.
upstream app {
# least_conn лучше round-robin при разнородных запросах;
# random two least_conn (1.15.1+) добавляет защиту от herd behaviour
least_conn;
# max_fails/fail_timeout — единственный health check в бесплатном nginx (пассивный):
# 3 неудачи за 10 с -> узел выведен на 10 с
server 10.0.1.21:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.22:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.23:8080 max_fails=3 fail_timeout=10s weight=2; # железо мощнее
server 10.0.1.29:8080 backup; # включится, когда лягут все
# пул постоянных соединений к бэкендам: НЕ «максимум соединений»,
# а сколько ИДЛОВЫХ держать в кеше на каждый worker-процесс
keepalive 64;
keepalive_timeout 60s; # должен быть МЕНЬШЕ, чем idle timeout приложения
keepalive_requests 1000; # переоткрывать соединение, чтобы не копить состояние
}
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/ssl/api.crt;
ssl_certificate_key /etc/ssl/api.key;
# --- заголовки к бэкенду ---
location / {
proxy_pass http://app;
# HTTP/1.1 + пустой Connection обязательны, иначе keepalive выше не работает:
# по умолчанию nginx ходит на бэкенд по HTTP/1.0 с Connection: close
proxy_http_version 1.1;
proxy_set_header Connection "";
# Host: без этого бэкенд получит "app" — имя upstream, а не домен
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id; # для сквозной трассировки
# --- таймауты: каждый должен быть осознанным числом ---
proxy_connect_timeout 2s; # установка TCP: в своей сети это единицы мс
proxy_send_timeout 30s; # пауза между записями в бэкенд
proxy_read_timeout 30s; # пауза между чтениями ОТ бэкенда (не общее время!)
# --- ретраи ---
# error — соединение отвергнуто/оборвано
# timeout — не дождались
# http_502/503/504 — бэкенд явно сказал, что ему плохо
# non_idempotent НЕ включаем: иначе POST может выполниться дважды
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2; # всего 2 попытки, не «обойти весь пул»
proxy_next_upstream_timeout 5s; # суммарный бюджет на ретраи
# --- буферизация ---
proxy_buffering on; # ответ копится в nginx: бэкенд освобождается раньше
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
}
# стриминг и SSE: буферизацию обязательно выключить, иначе события копятся в прокси
location /events {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h; # поток живёт долго, «тишина» — норма
}
# WebSocket: апгрейд надо прокинуть явно
location /ws {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # см. map ниже
proxy_read_timeout 300s;
}
# sticky по сессии через консистентное хеширование (бесплатный nginx)
location /legacy {
proxy_pass http://legacy_pool;
}
}
# без этого map WebSocket сломается на обычных запросах
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream legacy_pool {
hash $cookie_JSESSIONID consistent; # нет cookie -> распределение по кругу
server 10.0.2.11:8080;
server 10.0.2.12:8080;
}
Пять мест, где чаще всего ошибаются:
proxy_pass http://app;без слеша противhttp://app/. Со слешем nginx отрезает префикс изlocation.location /api/ { proxy_pass http://app/; }превратит/api/usersв/users. Без слеша путь уйдёт как есть. Половина «404 только через прокси» — отсюда.- Забытый
proxy_http_version 1.1. Директиваkeepaliveв upstream молча не работает: nginx открывает новое соединение на каждый запрос, бэкенд копит TIME_WAIT, латентность растёт на RTT + рукопожатие. proxy_read_timeoutсчитают общим временем запроса. Это таймаут бездействия между чтениями. Ответ, который отдаётся по чуть-чуть, может идти часами.proxy_next_upstreamсnon_idempotent. Включает ретрай POST — то есть повтор списания денег. Ретраить неидемпотентные запросы можно только с ключом идемпотентности на стороне приложения.$proxy_add_x_forwarded_forбезset_real_ip_from. Дописывает адрес к тому, что прислал клиент, — то есть аккуратно сохраняет подделку.
L4 на nginx: модуль stream
stream {
upstream pg {
least_conn;
server 10.0.3.11:5432 max_fails=2 fail_timeout=5s;
server 10.0.3.12:5432 max_fails=2 fail_timeout=5s;
}
server {
listen 5432;
proxy_pass pg;
proxy_connect_timeout 1s;
proxy_timeout 1h; # долгие соединения к БД — норма
proxy_protocol on; # передать реальный IP приложению
}
# SNI-роутинг: L4 с одним заглядыванием в открытый ClientHello
map $ssl_preread_server_name $tls_backend {
api.example.com api_pool;
grpc.example.com grpc_pool;
default default_pool;
}
server {
listen 443;
ssl_preread on; # прочитать SNI, НЕ терминируя TLS
proxy_pass $tls_backend;
}
}
ssl_preread — важный промежуточный режим: прокси заглядывает в открытый ClientHello, читает SNI и маршрутизирует, но TLS не терминирует. Сквозное шифрование сохраняется, а роутинг по доменам появляется. С распространением Encrypted Client Hello (ECH) этот приём перестанет работать — SNI станет зашифрованным.
Гонка keepalive и случайные 502
Самый частый «мистический» баг на L7. Прокси держит идловое соединение к бэкенду; бэкенд по своему таймауту решает его закрыть и шлёт FIN. Ровно в этот момент прокси отправляет в это соединение запрос.
Симптом: единичные 502 с частотой, пропорциональной времени простоя, и запись в error.log:
2026/07/16 11:07:12 [error] 1123#0: *84512 upstream prematurely closed connection
while reading response header from upstream, client: 203.0.113.7,
server: api.example.com, request: "GET /api/users HTTP/1.1", upstream: "http://10.0.1.22:8080/api/users"
Лечение — keepalive-таймаут прокси должен быть строго меньше таймаута бэкенда, с запасом на RTT. Если у Go-сервера IdleTimeout: 75 * time.Second, ставьте в nginx keepalive_timeout 60s. Это правило универсально: у внешнего звена таймаут всегда короче, чем у внутреннего. Ту же логику применяют ко всей цепочке таймаутов — клиент ждёт дольше, чем edge, edge дольше, чем LB, LB дольше, чем приложение. Иначе получаете «оборванный» запрос, который на самом деле выполнился.
Отладка: команды, которые реально нужны
# 1. Обойти DNS и постучаться в конкретный бэкенд с правильным Host и SNI
curl -v --resolve api.example.com:443:10.0.1.21 https://api.example.com/health
* Added api.example.com:443:10.0.1.21 to DNS cache
* Trying 10.0.1.21:443...
* Connected to api.example.com (10.0.1.21) port 443
* ALPN: server accepted h2
* Server certificate: CN=*.example.com
> GET /health HTTP/2
> Host: api.example.com
< HTTP/2 200
< server: nginx
< x-served-by: app-1 <- сам добавьте этот заголовок, он экономит часы
Заголовок вида X-Served-By (или X-Backend, Server-Timing) — самая дешёвая инвестиция в отладку балансировки. В nginx: add_header X-Served-By $upstream_addr always;.
# 2. Разложить время запроса по фазам: где именно теряются секунды
curl -sS -o /dev/null -w '@-' https://api.example.com/slow <<'FMT'
dns: %{time_namelookup}s
connect: %{time_connect}s
tls: %{time_appconnect}s
ttfb: %{time_starttransfer}s
total: %{time_total}s
FMT
dns: 0.021s
connect: 0.043s
tls: 0.098s
ttfb: 2.310s <- ждём бэкенд, сеть ни при чём
total: 2.318s
Разрыв между tls и ttfb — время работы приложения плюс очередь на прокси. Если он большой, а connect маленький, сеть невиновна и надо идти в диагностику со стороны сервера.
# 3. Состояние соединений на самом балансировщике
ss -tn state established '( sport = :443 )' | wc -l # клиентских соединений
ss -tn state established '( dport = :8080 )' | wc -l # соединений к бэкендам
ss -tn -o state time-wait | wc -l # TIME_WAIT: копятся без keepalive
18432 # клиентов много
64 # к бэкендам — ровно keepalive-пул, всё правильно
312 # TIME_WAIT в норме
Если второе число сопоставимо с первым — keepalive к бэкендам не работает (см. пункт 2 про proxy_http_version). Если TIME_WAIT измеряется десятками тысяч, вы близки к исчерпанию исходящих портов: на одну пару (dst IP, dst port) доступно около 28 тысяч портов из net.ipv4.ip_local_port_range, и после этого connect() начнёт возвращать EADDRNOTAVAIL.
sysctl net.ipv4.ip_local_port_range # net.ipv4.ip_local_port_range = 32768 60999
sysctl net.ipv4.tcp_tw_reuse # 2 — переиспользование TIME_WAIT для исходящих
nstat -az | grep -E 'TcpExtListenOverflows|TcpExtListenDrops|TcpExtTCPBacklogDrop'
ListenOverflows больше нуля — accept-очередь переполнена, а значит прокси не успевает принимать соединения: смотрите worker_connections, somaxconn и backlog в listen.
# 4. Проверить сам путь до VIP: anycast или обычная маршрутизация
traceroute -T -p 443 api.example.com
1 10.0.0.1 0.412 ms
2 172.16.4.1 1.204 ms
3 198.51.100.1 8.331 ms
4 198.51.100.10 8.402 ms <- VIP: дальше traceroute не идёт, L4 не декрементит TTL как хост
Обратите внимание: L4-балансировщик обычно не появляется в traceroute отдельной строкой — он пересылает пакеты, не терминируя соединение. Если вы видите разные пути при повторных запусках, вы попали в ECMP: пакеты одного потока идут одинаково (хеш по 4-кортежу), но разные потоки — по разным линкам.
# 5. Убедиться, что TLS терминирует именно тот, кто должен
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
subject=CN = *.example.com
issuer=C = US, O = Let's Encrypt, CN = R11
notBefore=Jun 12 09:11:04 2026 GMT
notAfter=Sep 10 09:11:03 2026 GMT
Если сертификат выписан на имя edge-провайдера, а не на ваше, — TLS терминируется у CDN, и весь трафик в открытом виде проходит через него. Это не всегда плохо, но это должно быть осознанным решением, а не сюрпризом.
Что ломается в проде
502 против 504 против 499. 502 Bad Gateway — прокси получил от бэкенда мусор, RST или преждевременный FIN. 504 Gateway Timeout — бэкенд молчал дольше proxy_read_timeout. 499 — нестандартный код nginx: клиент закрыл соединение сам, не дождавшись ответа. Всплеск 499 почти всегда означает, что таймаут на стороне клиента (мобильное приложение, другой сервис) короче, чем реальное время ответа. Лечится не на прокси, а либо ускорением бэкенда, либо согласованием таймаутов.
Ретрай-шторм. Бэкенд деградировал, прокси начинает ретраить, нагрузка растёт, деградация усиливается. Ретраи обязаны быть ограничены не только числом попыток, но и бюджетом: «не более 10% дополнительных запросов от общего объёма» (retry_budget в Envoy, эта же идея в Finagle и в SRE Book, глава Handling Overload). Плюс экспоненциальная задержка с джиттером. Простое proxy_next_upstream_tries 3 без бюджета в момент отказа умножает нагрузку на три.
Thundering herd после рестарта прокси. Перезапустили nginx — все keepalive-соединения умерли, тысячи клиентов одновременно переустанавливают TLS. Пик CPU на несколько секунд, часть запросов таймаутится. Лечение: nginx -s reload вместо рестарта (старые воркеры доживают текущие соединения), раскатка по одному узлу и worker_shutdown_timeout.
Балансировка на уровне DNS обманывает. Несколько A-записей раздаются по кругу, но клиенты кэшируют результат, а корпоративные резолверы кэшируют его надолго и на всех сотрудников сразу. DNS годится для грубого раскидывания по регионам, а не для отказоустойчивости: TTL 60 с не означает, что через 60 с все переключатся. Детали кэширования — в статье про DNS.
gRPC на L4 не балансируется. gRPC держит одно долгоживущее HTTP/2-соединение и мультиплексирует в него все вызовы. L4 распределяет соединения, а не запросы — значит, клиент навсегда прилипнет к одному бэкенду, и добавление узлов не поможет. Нужен либо L7-прокси с поддержкой HTTP/2 (Envoy, nginx с grpc_pass), либо клиентская балансировка с резолвером. Подробнее — в статье про RPC и gRPC.
Kubernetes: externalTrafficPolicy. Значение Cluster (дефолт) даёт ровную балансировку, но подменяет адрес источника через SNAT — приложение видит IP узла. Значение Local сохраняет адрес клиента, но раздаёт трафик только подам на том же узле: если на одном узле три пода, а на другом один, распределение будет 1:3. Выбирать приходится осознанно.
Асимметрия деплоя. Во время rolling update половина пула — новые узлы с холодным кэшем. Least connections пошлёт им больше трафика, потому что у них меньше соединений, — ровно наоборот тому, что нужно. Отсюда важность slow start.
Эволюция: как индустрия сюда пришла
Последний пункт важен для будущего: QUIC ломает базовое допущение L4-балансировки. Соединение идентифицируется не 4-кортежем, а Connection ID, и клиент может сменить IP (переход Wi-Fi → LTE), не разрывая соединение. Балансировщик обязан уметь извлекать Connection ID и маршрутизировать по нему — иначе после смены сети пакеты уедут на другой бэкенд. Схему решения описывает QUIC-LB: сервер кодирует идентификатор бэкенда прямо внутрь Connection ID. Подробнее про сам протокол — в UDP и QUIC.
Чеклист перед продом
- Понятно, где L4, а где L7, и почему именно так.
- Бэкенд видит настоящий IP клиента: XFF с
set_real_ip_fromили PROXY protocol. -
X-Forwarded-Forот внешнего клиента перезаписывается, а не дописывается. - Health check не ходит в общую БД и не превращает деградацию в отказ.
-
fall≥ 2,rise>fall,timeout<interval; время обнаружения посчитано. - Проверка идёт тем же путём и портом, что и трафик.
- Есть slow start для возвращающихся узлов и дренаж для выводимых.
- Keepalive-таймаут прокси меньше idle-таймаута бэкенда (иначе случайные 502).
- Таймауты монотонно убывают от клиента к приложению.
- Ретраи ограничены бюджетом, POST не ретраится без ключа идемпотентности.
- В ответе есть
X-Request-Idи признак бэкенда. - Sticky sessions используются только там, где состояние нельзя вынести.
- Мониторится:
upstream_response_timeпо бэкендам отдельно, число 499/502/504, длина accept-очереди, исходящие порты.
Мини-итог
Балансировщик — это место, где сходятся все компромиссы. L4 быстр и слеп: он решает один раз за соединение, зато переваривает терабиты и не лезет в содержимое. L7 умён и дорог: он видит запрос, умеет ретраить и роутить, но становится частью зоны доверия и упирается в CPU. Алгоритм выбора бэкенда почти всегда должен быть least_conn или P2C, а консистентное хеширование — только там, где у бэкенда есть локальное состояние. Health checks обязаны быть асимметричными (быстро выводить, медленно возвращать) и не проверять общие зависимости, иначе они сами станут причиной отказа. Sticky sessions — не функция, а компенсация архитектурной ошибки; хорошая новость в том, что эта ошибка обычно исправима.
И главное практическое: почти каждый «сетевой» инцидент на этом уровне разбирается тремя командами — curl -w с разбивкой по фазам, ss -tn на прокси и tcpdump на бэкенде. Если после них картина не сложилась, значит проблема не в балансировке.
Источники
- nginx: ngx_http_upstream_module и ngx_stream_proxy_module — официальная документация, читается как справочник.
- HAProxy Configuration Manual — эталонное описание health checks,
slowstart,cookieи режимов балансировки. - Envoy: Load Balancing — лучшее из доступного описание outlier detection, panic threshold и P2C.
- Maglev: A Fast and Reliable Software Network Load Balancer, NSDI 2016.
- Consistent Hashing with Bounded Loads, Google, 2016.
- The Power of Two Choices in Randomized Load Balancing, Mitzenmacher.
- RFC 7239 — Forwarded HTTP Extension и PROXY protocol.
- Google SRE Book: Load Balancing in the Datacenter и Handling Overload.
- katran — исходники продакшн-L4 на XDP.
Что дальше
CDN и edge: как работает раздача, инвалидация, географическая маршрутизация — про то, что стоит слева от вашего балансировщика: как сотни PoP решают, кому отдать запрос, почему инвалидация кэша сложнее, чем кажется, и как anycast и GeoDNS выбирают ближайшую точку.