Компьютерные сети Прокси и балансировка: L4 vs L7, nginx, health checks, sticky sessions
0%

Прокси и балансировка: L4 vs L7, nginx, health checks, sticky sessions

Прокси и балансировка: 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 решает «что делать с запросом», балансировщик — «кому его отдать».

Отдельно стоит прозрачный (transparent) прокси: трафик заворачивается на него маршрутизацией или iptables без ведома клиента. Именно он даёт классический симптом «у меня работает, а у клиента нет» — подробнее в статье про NAT, фаерволы и VPN.

L4 против L7: где именно проходит разрез

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

Что видит балансировщик на 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 — самый быстрый способ увидеть, сколько соединений реально живёт.

Сколько прокси между клиентом и вашим кодом

В типичном проде их четыре-пять, и каждый что-то делает с адресом клиента.

Путь запроса через слои прокси и судьба адреса клиента

Каждая стрелка — потенциальный таймаут, каждый узел — потенциальный источник 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% запросов вообще не ходили в базу. Проверка превратила деградацию в отказ.

Правила, выстраданные индустрией:

  1. Liveness ≠ readiness. Liveness отвечает «процесс не завис, перезапуск поможет». Readiness — «я готов принимать трафик прямо сейчас». Балансировщик смотрит на readiness, супервизор (Kubernetes, systemd) — на liveness. Смешивать их — значит получить бесконечный цикл перезапусков при недоступности зависимости.
  2. Проверка не должна ходить в общие зависимости, если только от них не зависит буквально каждый запрос. Общая зависимость превращает частный отказ в тотальный.
  3. Fail open на уровне пула. Envoy умеет panic threshold: если здоровых меньше порога (по умолчанию 50%), он игнорирует health checks и шлёт трафик всем. Логика простая: если «здоровых нет», скорее всего сломаны проверки, а не весь мир.
  4. Проверка должна идти тем же путём, что и трафик. Классика: LB проверяет порт 8080 напрямую, а трафик идёт через nginx на 443 внутри пода. nginx умер — проверки зелёные, пользователи получают connection refused.
  5. Slow start после возврата. Только что поднятый узел имеет холодный кэш, пустой пул соединений к БД, неразогретый JIT. Влить в него 1/N трафика мгновенно — значит получить всплеск задержек и, возможно, повторный вывод из ротации. HAProxy: slowstart 30s, Envoy: slow_start_config, NGINX Plus: slow_start=30s.
  6. Дренаж (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;
}

Пять мест, где чаще всего ошибаются:

  1. proxy_pass http://app; без слеша против http://app/. Со слешем nginx отрезает префикс из location. location /api/ { proxy_pass http://app/; } превратит /api/users в /users. Без слеша путь уйдёт как есть. Половина «404 только через прокси» — отсюда.
  2. Забытый proxy_http_version 1.1. Директива keepalive в upstream молча не работает: nginx открывает новое соединение на каждый запрос, бэкенд копит TIME_WAIT, латентность растёт на RTT + рукопожатие.
  3. proxy_read_timeout считают общим временем запроса. Это таймаут бездействия между чтениями. Ответ, который отдаётся по чуть-чуть, может идти часами.
  4. proxy_next_upstream с non_idempotent. Включает ретрай POST — то есть повтор списания денег. Ретраить неидемпотентные запросы можно только с ключом идемпотентности на стороне приложения.
  5. $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 на бэкенде. Если после них картина не сложилась, значит проблема не в балансировке.

Источники

Что дальше

CDN и edge: как работает раздача, инвалидация, географическая маршрутизация — про то, что стоит слева от вашего балансировщика: как сотни PoP решают, кому отдать запрос, почему инвалидация кэша сложнее, чем кажется, и как anycast и GeoDNS выбирают ближайшую точку.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов