Одностороннее сжатие заголовков в nginx: когда динамическая таблица работает только у клиента
Проверка началась с простого стенда: один nginx, одно соединение и четыре полностью одинаковых запроса подряд. В каждом запросе - крупный `authorization` размером около 244 байт и неизменный `x-request-id`. Дальше остаётся только измерить, во что превращается блок заголовков после сжатия в обе стороны - от клиента к серверу и обратно.
Картина получилась неожиданно асимметричной. В ответах nginx верхняя строка измерений оставалась константой: сколько запросов ни повторяй, размер сжатого блока заголовков ответа не меняется и держится на уровне ~131 байта. А вот при передаче тех же заголовков в обратном направлении (клиент → сервер) после первого запроса включается "настоящая магия": со второго запроса набор схлопывается примерно в тридцать раз. То есть клиент динамической таблицей пользуется, а сервер - нет.
Причём дело не в кривой сборке или случайной ошибке конфигурации. На HTTP/3 это воспроизводится в дефолтном варианте: модуль, конечно, нужно собрать явно (`--with-http_v3_module`, потому что в `auto/options` оба HTTP/2 и HTTP/3 по умолчанию выключены), но в конфиге достаточно `listen 8443 quic`, без дополнительных директив. В HTTP/2 к тому же серверу выходит такая же константа: 129, 129, 129... - снова без заметной динамики. Если вам интересна практическая сторона и контекст эксперимента, он хорошо разворачивается в материале про оптимизацию http2 nginx и сжатие заголовков.
Почему это важно: половина смысла HPACK/QPACK просто не используется сервером
И HPACK (HTTP/2), и QPACK (HTTP/3) задуманы не только как "бинаризация", а прежде всего как экономия на повторяющихся заголовках через динамическую таблицу. Идея простая: повторяющееся поле один раз попадает в таблицу, а затем передаются только ссылки на индекс - именно это и даёт эффект "заголовки сжимаются в разы". В наблюдаемом стенде клиент этим механизмом пользуется (поэтому размеры падают со второго запроса), а nginx - нет ни в HTTP/2, ни в HTTP/3.
Снаружи этот нюанс почти незаметен: ни access‑лог, ни error‑лог на штатных уровнях не подсказывают, что серверная сторона принципиально не использует динамическую таблицу. В документации nginx об этом тоже нет прямого и понятного объяснения, хотя в RFC такой режим допускается - просто это не тот пункт, который легко найти, если заранее не знать, что искать.
Любопытная деталь из экосистемы: из пяти реализаций QPACK, которые сравнивались в разборе, две всё-таки записывают данные в таблицу кодировщика. При этом именно "приёмная половина" (декодирование того, что присылает клиент) недавно становилась источником проблем: этой весной в nginx прилетал use‑after‑free с оценкой 9.2, связанный как раз с обработкой QPACK. Получается парадокс: динамику сервер сам не использует, но риски и сложность декодирования всё равно остаются.
Версии, на которых разбиралось поведение
Для точности была зафиксирована матрица компонентов: nginx `release-1.31.3` (и `release-1.31.2` для истории фикса), а также несколько реализаций QPACK (включая `quic-go/qpack`, `cloudflare/quiche`, `litespeedtech/ls-qpack` и `google/quiche`, где важно помнить, что это непрерывное зеркало без релизных тегов - снимок ветки `main` на конкретную дату).
Что показывает код: dynamic везде "0", insert_count тоже "0" - и encoder‑поток не открывается
В HTTP/3 кодировщик заголовков ответа живёт в `ngx_http_v3_filter_module.c`. Каждое поле там упаковывается через одну из трёх схем: ссылка на таблицу, литерал с именем из таблицы, полный литерал. У первых двух есть параметр `dynamic`, который определяет - обращаться к динамической таблице или к статической. Дальше начинается самое показательное: если просмотреть вызовы функций кодирования в актуальном релизе, они все идут со вторым аргументом, равным нулю. То есть динамическая таблица при формировании ответных заголовков не задействуется вообще.
Следом идёт префикс секции полей (перед списком заголовков), где передаются `insert_count` и `delta_base`. Это как раз декларация "сколько записей из динамической таблицы нужно декодеру, чтобы разобрать секцию". И здесь снова одни нули - и для обычных заголовков, и для early hints, и для трейлеров. Фактически сервер каждым ответом "сообщает": для декодирования моей секции динамическая таблица не нужна.
Есть и третий, самый прямой маркер. Чтобы пополнять динамическую таблицу у собеседника в HTTP/3, сервер должен открыть encoder‑поток (однонаправленный QUIC‑поток типа 0x02) и отправлять по нему инструкции вставки. Если посмотреть, какие однонаправленные потоки nginx реально поднимает сам, там обнаруживаются CONTROL и DECODER, но не SERVER ENCODER - ветка кода под него существует, однако на практике не активируется. То есть даже "канала" для заполнения таблицы нет - сжатие в сторону клиента остаётся статическим.
---
Чем это оборачивается на практике (дополнение)
Первое следствие - переплата трафика на каждом ответе, особенно на "болтливых" API, где набор ответных заголовков стабилен: куки, политики безопасности, вариации кеширования, диагностика. Когда динамика отключена, повторяющиеся поля каждый раз идут почти "с нуля", и экономия ограничивается статической таблицей и общими приёмами кодирования. Это и есть "цена неиспользуемой половины" HPACK/QPACK: протокол умеет лучше, но серверная сторона сознательно не добирает эффект.
Второе - влияние на задержки в условиях ограниченного канала. Да, заголовки обычно меньше тела ответа, но на мобильных сетях, в QUIC‑соединениях с потерями и на сервисах с большим количеством мелких ответов (например, много запросов к JSON‑эндпоинтам) именно "шапка" начинает заметно участвовать в общей стоимости транзакции.
Третье - путаница вокруг админских ожиданий. Люди включают HTTP/2 или HTTP/3, рассчитывая на "бесплатное" ускорение, и закономерно приходят к вопросам вроде "почему `nginx http2 header compression` не даёт ожидаемой динамики на ответах?". Здесь важно понимать: это не классическая проблема в стиле "забыли включить директиву", а выбранная модель поведения. Поэтому и запросы вида "настройка сжатия заголовков nginx" или даже экзотическое "nginx hpck настройка" часто упираются не в параметры, а в архитектурное решение реализации.
Четвёртое - `nginx http2 authorization header` как частный случай. Большой `Authorization` чаще встречается в запросах (и там клиентская динамика как раз помогает), но иногда токены или подписи оказываются и в ответных заголовках (например, в сценариях проксирования, специфичных шлюзах или при выдаче временных ключей). В таких схемах отсутствие динамического сжатия на ответах может быть особенно заметным.
Наконец, что можно сделать "здесь и сейчас", если ваша цель - именно оптимизация http2 nginx на уровне заголовков, а не теоретическая чистота протокола. Практичные меры сводятся к снижению объёма и повторяемости самих заголовков: убирать лишние диагностические поля в продакшене, не дублировать метаданные между уровнями прокси, аккуратно обращаться с `Set-Cookie`, не раздувать `Vary`, а для идентификаторов вроде `x-request-id` оценивать, действительно ли он должен уходить во все ответы. Отдельно стоит проверить, не добавляют ли апстримы (приложение, сервис‑меш, WAF) "тяжёлые" заголовки автоматически - на суммарном размере это сказывается сильнее, чем кажется.
Если же хочется углубиться именно в механику и в то, как это проявляется на реальных замерах и в коде, полезно держать под рукой разбор про nginx http2 header compression и одностороннее сжатие: он хорошо показывает, как "константа в ответах" сочетается с "резким схлопыванием" в запросах - и почему это не выглядит как случайность.
