HAProxy L4 vs L7 и HTTP/2: как масштабировать обработку между серверами

L4 против L7, разбор мифа «1 TCP = 1 backend» для HTTP/2, конфиги HAProxy правильно/неправильно/неоптимально и таймауты с retry под SLA

В прошлой статье серии мы разложили бюджет 300 мс на составляющие, посчитали по закону Литтла, что под нагрузкой 2000–5000 rps и проверкой 100–200 мс в системе одновременно живёт около 1000 запросов, и выбрали HTTP/2 как транспорт между клиентом и обработчиком: одно соединение, множество мультиплексированных streams, TLS-handshake амортизируется.

Но выбор транспорта на уровне протокола — только половина задачи. У нас не один обработчик, а пул серверов, и между клиентом и пулом стоит балансировщик. Здесь начинается вопрос, который на первый взгляд кажется технической деталью, а на деле определяет, будет ли пул из нескольких инстансов вообще работать как пул: как HAProxy распределяет 1000 одновременных HTTP/2-запросов одного клиентского соединения между несколькими бэкендами — и распределяет ли вообще.

Рабочий стенд. Всё, о чём идёт речь в серии, собрано в запускаемый пример performance/highload-lowlatency: HAProxy L7 + пул Go/Java-бэкендов + клиент-нагрузчик, docker compose up. В этой статье — фрагменты его реальных конфигов haproxy.cfg (L7) и haproxy-l4.cfg (L4, для сравнения).

HAProxy как развилка: один и тот же входящий поток запросов расходится по-разному в зависимости от режима L4 или L7

В статье

L4 против L7

HAProxy умеет работать на двух принципиально разных уровнях, и это не просто настройка, а разный контракт с трафиком. В mode tcp (L4) балансировщик видит только байты TCP-потока — он не разбирает HTTP, не знает, что такое запрос, заголовок или HTTP/2-stream. В mode http (L7) HAProxy терминирует протокол прикладного уровня, понимает границы запросов, заголовки, пути — и может принимать решения на основе содержимого каждого отдельного запроса, а не только факта существования TCP-соединения.

Из этого различия следует всё остальное. L4 — минимальный overhead: HAProxy почти не трогает данные, только перекладывает байты между сокетами. Это позволяет, например, оставить TLS нетронутым до самого приложения — mTLS «доезжает» от клиента до сервиса без промежуточной точки терминации, что важно в моделях с жёсткими требованиями к end-to-end шифрованию. Но у этой простоты цена: HAProxy в L4-режиме не может делать path- или header-based роутинг, не видит HTTP-статусов для health-check уровня приложения, не умеет retry на уровне запроса (только на уровне соединения) и не даёт HTTP-observability — в логах будут TCP-сессии, а не запросы с методами, путями и статусами.

L7, наоборот, открывает весь набор возможностей, ради которых обычно и ставят балансировщик перед пулом сервисов: маршрутизация по path/header, терминация TLS с последующим переиспользованием соединений к бэкендам, retries и redispatch на уровне отдельного запроса, rate limiting, circuit breaking по observe layer7, полноценные HTTP-логи и метрики. Плата — дополнительный CPU на разбор протокола и терминацию TLS, и тот факт, что HAProxy становится полноценным участником HTTP-диалога, а не прозрачной трубой.

Критерий L4 (mode tcp) L7 (mode http)
Что видит HAProxy байты TCP-потока HTTP-запросы, заголовки, streams
TLS может остаться сквозным (mTLS до приложения) обычно терминируется на HAProxy
Path/header routing нет да
Retry/redispatch только на уровне соединения на уровне отдельного запроса
HTTP-observability нет (только TCP-сессии) да (метод, путь, статус, тайминги)
Overhead минимальный выше (разбор протокола, TLS)
Балансировка HTTP/2-streams одного соединения нет — соединение целиком на одном backend да — каждый stream независимо

Для нашего сквозного кейса — HTTP/2 (или gRPC поверх HTTP/2), ~1000 in-flight запросов, SLA < 300 мс — вывод практический: если нет жёсткого требования mTLS-до-приложения, которое обязывает оставить TLS сквозным, L7 почти всегда правильный выбор. Причина не в отвлечённой любви к фичам, а в том, что разбирается в следующем разделе: в L4-режиме мультиплексирование HTTP/2 просто не работает так, как от него ожидают.

Миф «1 TCP = 1 backend»

Здесь скрывается ловушка, в которую легко попасть, если переносить интуицию «одно соединение — один backend» без поправки на режим балансировщика. Ключевое разграничение — не «HTTP/1.1 против HTTP/2», а L4 против L7. В mode tcp (L4) балансировка происходит один раз — при установлении TCP-соединения, и дальше весь трафик этого соединения идёт на выбранный backend, каким бы протоколом внутри он ни был. В mode http (L7) картина другая: HAProxy разбирает каждый запрос отдельно, поэтому даже последовательные HTTP/1.1-запросы в рамках одного keep-alive-соединения балансируются поштучно — с http-reuse соседние запросы одного клиента вполне могут уйти на разные бэкенды. Иначе говоря, «1 соединение = 1 backend» — свойство L4, а не HTTP/1.1; в L7 его нет уже для HTTP/1.1. HTTP/2 просто доводит проблему L4 до предела, и вот почему.

HTTP/2 меняет саму единицу мультиплексирования. Одно TCP-соединение клиента содержит множество независимых streams — по сути, параллельных логических запросов-ответов, которые едут по одному TCP-каналу перемежаясь на уровне фреймов. Вопрос в том, что происходит с этими streams на балансировщике — и ответ прямо зависит от режима из предыдущего раздела.

В mode tcp (L4) HAProxy применяет алгоритм балансировки один раз — в момент установления TCP-соединения — и весь трафик этого соединения, сколько бы HTTP/2-streams внутри него ни было, уходит на один и тот же выбранный backend на всё время жизни соединения. balance roundrobin в этом режиме балансирует соединения, а не запросы. Если клиент держит одно долгоживущее HTTP/2-соединение (а именно так и работает большинство H2-клиентов — открыть один канал и мультиплексировать в нём всё), то весь его трафик, включая тысячу параллельных in-flight запросов, физически может прийти на один-единственный инстанс из пула. Остальные бэкенды в это время простаивают. Это и есть миф «1 TCP = 1 backend»: он не выдумка, а реальное поведение L4-балансировки — просто ошибочно ожидать от него другого.

В mode http (L7) HAProxy терминирует HTTP/2-соединение клиента и видит отдельные streams как отдельные запросы. К бэкендам HAProxy держит собственный пул соединений (в том числе тоже по HTTP/2, если так настроено), и каждый stream — то есть каждый отдельный запрос клиента — маршрутизируется независимо, согласно алгоритму балансировки, а не привязывается намертво к тому бэкенду, который получил первый stream этого клиентского соединения. Именно поэтому один клиент с одним H2-соединением может увидеть свои запросы равномерно размазанными по всем инстансам пула.

flowchart TD C["Клиент
1 H2-соединение"] --> HP["HAProxy L7"] HP -->|"stream 1"| A["Srv A"] HP -->|"stream 3"| B["Srv B"] HP -->|"stream 5"| D["Srv C"] HP -->|"stream 7"| A

flowchart TD
  C["Клиент
1 H2-соединение"] --> HP["HAProxy L7"] HP -->|"stream 1"| A["Srv A"] HP -->|"stream 3"| B["Srv B"] HP -->|"stream 5"| D["Srv C"] HP -->|"stream 7"| A
HTTP/2 streams одного клиента расходятся по бэкендам (L7)

Это не теория — именно так ведёт себя рабочий стенд серии. Клиент-нагрузчик ходит на HAProxy через :8080 (h2c prior-knowledge, тот самый L7-режим), и запросы одного клиента равномерно распределяются по всем 4 бэкендам пула (go-backend-1/2/3, java-backend). В одном локальном прогоне стенда при проверке 100–200 мс латентность держалась порядка p50 ≈ 160 мс, p95 ≈ 230 мс, p99 ≈ 270 мс (цифры иллюстративны — конкретные значения зависят от машины и нагрузки). Если бы фронтенд работал в L4-режиме (как в haproxy-l4.cfg), одно долгоживущее H2-соединение клиента осело бы на одном бэкенде, а остальные три простаивали бы — и хвост латентности выглядел бы совсем иначе, потому что вся тысяча in-flight запросов давила бы на мощность одного инстанса, а не четырёх.

Конфиги HAProxy

Дальше — конкретные фрагменты, привязанные к бюджету SLA < 300 мс и проверке 100–200 мс из сквозного кейса серии. Все три варианта опираются на реальные конфиги стенда: ✅ и ⚠️ — на haproxy.cfg, ❌ — на haproxy-l4.cfg как иллюстрацию мифа из предыдущего раздела.

defaults
    mode tcp
    # option httplog недоступен в mode tcp — HAProxy не видит HTTP,
    # только TCP-сессии. Нет таймаутов на уровне запроса, нет retry
    # на уровне запроса — только на уровне соединения.
    timeout client 2s
    timeout connect 100ms
    timeout server 500ms   # это простой TCP-потока, НЕ время ответа

frontend fe_tcp
    bind :8080
    default_backend be_check_pool_tcp

backend be_check_pool_tcp
    balance roundrobin      # балансирует СОЕДИНЕНИЯ, не запросы
    # health-check — просто TCP-connect, готовность приложения не проверяется
    server go-backend-1 go-backend-1:9000 check
    server go-backend-2 go-backend-2:9000 check
    server go-backend-3 go-backend-3:9000 check
    server java-backend  java-backend:9000  check

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

defaults
    mode http
    option httplog
    timeout client        2s
    timeout connect       100ms
    timeout server         500ms   # запас над проверкой 100-200мс, но это
                                     # защита от зависшего бэкенда, не расчёт SLA
    timeout http-keep-alive 1s
    timeout http-request    5s

    option redispatch        # при неудаче — на ДРУГОЙ backend, не тот же самый
    retries 1                # безопасно только для идемпотентных запросов

frontend fe_http2
    bind :8080 proto h2      # h2c prior-knowledge (без TLS, внутренняя сеть)
    default_backend be_check_pool

backend be_check_pool
    balance roundrobin
    option httpchk GET /healthz   # готовность приложения, не просто открытый порт
    http-reuse safe                # переиспользовать keep-alive к бэкенду
    server go-backend-1 go-backend-1:9000 proto h2 check
    server go-backend-2 go-backend-2:9000 proto h2 check
    server go-backend-3 go-backend-3:9000 proto h2 check
    server java-backend  java-backend:9000  proto h2 check

Ключевая деталь — bind :8080 proto h2. Без TLS ALPN недоступен, поэтому протокол на listener фиксируется явно: proto h2 означает приём именно h2c prior-knowledge (клиент сразу шлёт HTTP/2-preface, без предварительного HTTP/1.1-запроса с Upgrade: h2c). Это важно держать в голове, потому что не все клиенты умеют говорить prior-knowledge — к этому нюансу вернёмся в конце статьи. http-reuse safe здесь выписан явно, хотя это и дефолт HAProxy: директива документирует намерение переиспользовать соединения к бэкенду (новое HTTP/2-соединение на каждый запрос не открывается, пока обмен завершается чисто) и страхует от случайного never в унаследованном конфиге.

defaults
    mode http
    option httplog
    timeout client  2s
    timeout connect 100ms
    timeout server  2s      # !! 2с при проверке 100-200мс и SLA < 300мс —
                             # таймаут не защищает бюджет, а маскирует деградацию
    # option redispatch не указан — при неудаче retry идёт на ТОТ ЖЕ backend
    retries 1

frontend fe_http2
    bind :8080 proto h2
    default_backend be_check_pool

backend be_check_pool
    # balance не указан — используется дефолт (round robin), но это
    # осознанный выбор или случайность? Под неравномерную длительность
    # проверки (Go/Java-инстансы) стоит сравнить с leastconn.
    option httpchk GET /healthz
    # http-reuse не указан — но дефолт HAProxy и так `safe`, соединения к
    # бэкендам переиспользуются. Это НЕ дефект: чтобы выжать больше, ставят
    # осознанно `always`/`aggressive` (с их рисками), а «дефект» тут — `never`
    server go-backend-1 go-backend-1:9000 proto h2 check
    server go-backend-2 go-backend-2:9000 proto h2 check
    server go-backend-3 go-backend-3:9000 proto h2 check
    server java-backend  java-backend:9000  proto h2 check

Этот вариант нигде не сломан явно — он пройдёт smoke-тест и переживёт демо. Но timeout server 2s при проверке 100–200 мс и SLA < 300 мс — это таймаут, который сработает уже после того, как клиент по своему SLA получил ошибку по собственному дедлайну: HAProxy будет ещё почти две секунды ждать ответа, который клиенту уже не нужен, удерживая ресурсы бэкенда и слота в пуле. Отсутствие option redispatch означает, что единственный retry уйдёт туда же, где уже что-то пошло не так. А вот отсутствие явного http-reuse дефектом не является: дефолт HAProxy — safe, соединения к бэкендам и без директивы переиспользуются; лишние соединения плодит только явный http-reuse never, тогда как always/aggressive — это уже осознанная оптимизация со своими рисками (переиспользование соединения до того, как бэкенд подтвердил безопасность этого для не-идемпотентных запросов).

Таймауты, retry, circuit breaking

Таймауты HAProxy — это не абстрактная защита «на всякий случай», а конкретные числа, которые нужно вывести из бюджета 300 мс, а не подобрать интуитивно. timeout server из конфига стенда — 500 мс при проверке 100–200 мс: это не расчётная величина SLA (клиентский дедлайн в 300 мс сработает раньше, если что-то пошло не так), а защита от зависшего бэкенда — HAProxy не должен держать слот в пуле бесконечно, ожидая ответа, который, скорее всего, уже не придёт. Держать timeout server близко к клиентскому SLA (как в разделе ⚠️ выше, где он вообще больше SLA) — типичная ошибка: таймаут балансировщика должен быть инструментом быстрого высвобождения ресурсов, а не второй линией того же самого дедлайна.

Retry — тонкий инструмент, который помогает только при одном условии: запрос идемпотентен. POST /check в стенде — детерминированная имитация проверки без побочных эффектов, поэтому retries 1 в паре с option redispatch (повтор на другом бэкенде, а не том же самом) безопасен и даже полезен — он сглаживает единичные сетевые сбои или подвисший инстанс без участия клиента. В реальном проде включать retry для POST-запросов можно только тогда, когда идемпотентность гарантирована архитектурно — например, через request_id и дедупликацию на стороне обработчика, а не на честном слове. Retry неидемпотентных запросов — верный способ превратить сетевой глитч в задвоенную операцию.

Circuit breaking на уровне HAProxy опирается на активные health-check (option httpchk GET /healthz в конфиге стенда) и пассивное наблюдение за поведением бэкенда в реальном трафике — директивы вида observe layer7 вместе с on-error/on-marked-down позволяют выводить упавший или деградировавший инстанс из ротации быстрее, чем это сделает только активный health-check с его интервалом опроса. Смысл тот же, что и в паттерне circuit breaker на уровне приложения: не продолжать слать трафик туда, где он с высокой вероятностью не будет обработан вовремя, а быстро переключиться на здоровые инстансы, сохраняя бюджет 300 мс для большинства запросов. Более широкий разбор этих паттернов — retry с backoff, circuit breaker, bulkhead, graceful degradation — в статье Resilience-паттерны: circuit breaker, retry, timeout, bulkheadготовится, с 14 сентября; здесь важно, что HAProxy — не замена этим паттернам, а первая линия, которая должна быть настроена в согласии с ними, а не вопреки.

Короткий checklist

  1. Осознанно выбран L4 или L7 — и понятна причина (mTLS до приложения — единственный весомый довод в пользу L4 для нашего профиля нагрузки)?
  2. Frontend принимает HTTP/2 (proto h2 для h2c prior-knowledge или ALPN h2,http/1.1 при TLS), а не остаётся на HTTP/1.1 по умолчанию?
  3. http-reuse осознанно не сбит в never (дефолт safe уже переиспользует соединения к бэкендам); если нужен более агрессивный режим — always/aggressive выбран с пониманием его рисков?
  4. Таймауты (timeout connect/server/client) согласованы с бюджетом SLA < 300 мс, а не подобраны на глаз или скопированы с чужого проекта?
  5. retries/option redispatch включены только там, где запрос идемпотентен, и повтор уходит на другой backend?
  6. Настроен быстрый вывод упавшего бэкенда из ротации (health-check + observe layer7/on-error), а не только TCP-connect как признак готовности?

Что дальше

Мы разобрались, как HAProxy распределяет HTTP/2-streams между бэкендами на уровне L7, и настроили балансировщик под бюджет SLA. Но балансировка — только один конец провода: на стороне клиента тоже нужен пул соединений, который умеет использовать эту мультиплексируемость, не открывая сотни лишних сокетов и не упираясь в HoL blocking там, где HTTP/2 должен был его убрать. Отдельный нюанс, который стоит держать в уме, переходя к следующей статье: не любой HTTP-клиент умеет h2c prior-knowledge — например, JDK java.net.http.HttpClient отправляет HTTP/1.1-запрос с Upgrade: h2c вместо прямого HTTP/2-preface, поэтому фронтенд с proto h2 его отвергает (в стенде для таких клиентов есть отдельный HTTP/1.1-фронтенд :8081). Следующая статья серии разбирает клиентскую сторону на Go: пул соединений и highload-клиент.

См. также

  • Бюджет задержки и выбор транспорта
  • Балансировка нагрузки и горизонтальное масштабированиеСкоро
  • Тихий 502: HTTP/2 coalescing, SNI и HAProxyСкоро
  • Resilience-паттерны: circuit breaker, retry, timeout, bulkheadготовится, с 14 сентября

Обсуждение в Telegram

Присоединиться →

Комментарии