HTTP/2 не заменяет методы GET и POST, статусы 200 и 404 или привычные заголовки. Протокол меняет способ передачи сообщений. Вместо текстового потока HTTP/1.1 клиент и сервер обмениваются бинарными кадрами, распределяют их по нумерованным потокам и сжимают поля алгоритмом HPACK.
Я разберу один запрос от согласования протокола до последних байтов ответа. Мы вручную прочитаем преамбулу, SETTINGS, HEADERS и DATA, декодируем HPACK и посмотрим, почему HTTP/2 требует два окна управления потоком. Базовую версию протокола в 2026 году описывает RFC 9113, который заменил RFC 7540.
От ALPN до первого кадра
Для HTTPS клиент и сервер выбирают HTTP/2 во время TLS-рукопожатия через ALPN. Идентификатор h2 состоит из байтов 68 32. Проверить согласование можно через OpenSSL.
openssl s_client
-connect example.com:443
-servername example.com
-alpn h2 </dev/null
# Нужная строка
ALPN protocol: h2
curl показывает выбранный протокол, а nghttp выводит отдельные кадры и потоки.
curl -V
curl --http2 -I -v https://example.com/
nghttp -nv https://example.com/
После выбора h2 клиент начинает собственную преамбулу с фиксированной последовательности длиной 24 байта.
50 52 49 20 2a 20 48 54 54 50 2f 32
2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a
В ASCII последовательность выглядит так.
PRI * HTTP/2.0r
r
SMr
r
Строка похожа на необычный запрос HTTP/1.1, но запросом не считается. Она помогает выявить серверы и посредников, которые не понимают HTTP/2. Сразу после 24 байтов клиент обязан отправить SETTINGS.
00 00 00 04 00 00 00 00 00
│ │ │ └────────── stream 0
│ │ └────────────── flags 0
│ └────────────────── type 0x04, SETTINGS
└────────────────────────────── payload length 0
Серверная преамбула устроена иначе. Сервер не отправляет строку PRI * HTTP/2.0, а начинает сразу со своего SETTINGS. Например, сервер может объявить максимум 100 одновременных потоков.
00 00 06 04 00 00 00 00 00
00 03 00 00 00 64
00 03 SETTINGS_MAX_CONCURRENT_STREAMS
00 00 00 64 значение 100
Получатель подтверждает настройки кадром SETTINGS с флагом ACK. Полезная нагрузка при ACK должна быть пустой.
00 00 00 04 01 00 00 00 00
Клиенту не требуется ждать серверный SETTINGS перед отправкой первого запроса. Настройки применяются асинхронно, а их получение подтверждается ACK.
Для просмотра зашифрованных кадров в Wireshark нужен журнал TLS-ключей. Браузер можно запустить с переменной SSLKEYLOGFILE, затем указать полученный файл в настройках TLS и применить фильтр http2.
SSLKEYLOGFILE="$PWD/tls.keys" firefox
Разбирайте только собственный трафик или разрешённый лабораторный стенд. Не применяйте инструменты для слежки, несанкционированного перехвата или нарушения правил сервисов и законов своей страны, включая законодательство России.
Девять байтов кадра и настоящий HPACK
После преамбулы соединение превращается в последовательность кадров. Каждый кадр начинается с заголовка длиной девять байтов.
| Байты | Поле | Смысл |
|---|---|---|
| 0–2 | Length | Размер полезной нагрузки, 24 бита |
| 3 | Type | Тип кадра |
| 4 | Flags | Флаги выбранного типа |
| 5–8 | Stream ID | Зарезервированный бит и 31-битный номер потока |
Длина не включает девятибайтовый заголовок. Поля записаны от старшего байта к младшему. Поток 0 относится ко всему соединению. Клиент открывает потоки с нечётными номерами. Ответ сервера остаётся в том же потоке. Если клиент отправил запрос в потоке 1, HEADERS и DATA ответа тоже получат Stream ID 1.
Минимальный парсер заголовка выглядит так.
def parse_frame_header(data: bytes) -> dict:
if len(data) != 9:
raise ValueError("HTTP/2 frame header must contain 9 bytes")
return {
"length": int.from_bytes(data[0:3], "big"),
"type": data[3],
"flags": data[4],
"stream_id": int.from_bytes(data[5:9], "big") & 0x7fffffff,
}
raw = bytes.fromhex("00 00 10 01 05 00 00 00 01")
print(parse_frame_header(raw))
Результат показывает HEADERS с шестнадцатибайтовой нагрузкой, флагами 0x05 и потоком 1.
{
"length": 16,
"type": 1,
"flags": 5,
"stream_id": 1
}
Флаг 0x04 означает END_HEADERS, то есть конец сжатого блока полей. Флаг 0x01 означает END_STREAM и закрывает направление от клиента к серверу. GET не содержит тела, поэтому клиент завершает свою половину потока одним HEADERS.
Возьмём запрос GET https://example.com/. Логически клиент передаёт четыре псевдозаголовка.
:method = GET
:scheme = https
:path = /
:authority = example.com
В HTTP/2 псевдозаголовки заменяют строку запроса и идут раньше обычных полей. Имена полей передаются в нижнем регистре. Connection, Keep-Alive, Proxy-Connection, Transfer-Encoding и Upgrade запрещены. Поле TE разрешено только со значением trailers.
Полный HEADERS можно записать так.
00 00 10 01 05 00 00 00 01
82 87 84 41 0b 65 78 61 6d 70
6c 65 2e 63 6f 6d
Первые девять байтов описывают кадр.
00 00 10 payload length = 16
01 type = HEADERS
05 END_HEADERS | END_STREAM
00 00 00 01 stream id = 1
Оставшиеся байты кодирует HPACK.
82
87
84
41 0b 65 78 61 6d 70 6c 65 2e 63 6f 6d
HPACK содержит статическую таблицу часто встречающихся полей и динамическую таблицу текущего соединения. Старший бит 1 обозначает индексное представление, а остальные биты содержат индекс.
82 = индекс 2 = :method GET
87 = индекс 7 = :scheme https
84 = индекс 4 = :path /
Байт 41 задаёт литеральное поле с добавлением в динамическую таблицу. Индекс 1 выбирает имя :authority. Байт 0b задаёт длину значения, равную 11 байтам.
41 имя из индекса 1, :authority
0b длина 11
65 78 61 6d 70 6c 65 2e 63 6f 6d
example.com
Учебный пример не использует код Хаффмана, поэтому домен читается как ASCII. В реальном дампе старший бит первого октета длины строки указывает на Хаффман, а оставшиеся семь бит начинают переменное целое с длиной строки.
После вставки :authority = example.com следующая такая же последовательность при сохранённой таблице может сократиться до четырёх байтов.
82 87 84 be
be ссылается на динамическую запись с индексом 62. Такое сокращение не гарантировано. Кодировщик вправе выбрать другое представление, а запись может быть вытеснена. Запросы и ответы также используют раздельные состояния HPACK. Нельзя взять блок из середины соединения и надёжно декодировать его без предыдущего контекста того же направления.
Сервер отвечает заголовками и телом OK.
# HEADERS, stream 1
00 00 10 01 04 00 00 00 01
88
5f 0a 74 65 78 74 2f 70 6c 61 69 6e
5c 01 33
# DATA, stream 1
00 00 03 00 01 00 00 00 01
4f 4b 0a
88 означает :status = 200. Байт 5f выбирает имя content-type, после которого передаётся строка text/plain. Байт 5c выбирает content-length, а значение равно строке 3.
Ответный HEADERS содержит END_HEADERS, но не END_STREAM, поскольку следом идёт тело. DATA передаёт три байта и завершает серверное направление флагом END_STREAM.
Парсер не должен автоматически считать первый байт нагрузки HEADERS началом HPACK. При флаге PADDED сначала передаётся длина дополнения. Устаревший флаг PRIORITY добавляет ещё пять байтов. Если END_HEADERS отсутствует, блок продолжают кадры CONTINUATION того же потока.
Потоки, окна и проблемы, которые видны только в дампе
Кадры нескольких потоков могут чередоваться.
HEADERS stream 1
HEADERS stream 3
DATA stream 1
HEADERS stream 5
DATA stream 3
DATA stream 1
DATA stream 5
Границы кадров не совпадают с границами TCP-сегментов. Один сегмент может содержать несколько кадров, а один крупный кадр может быть разделён между сегментами. Надёжный парсер сначала собирает девятибайтовый заголовок, читает Length и ждёт всю заявленную нагрузку.
HEADERS и следующие за ним CONTINUATION составляют один блок полей. До END_HEADERS между ними нельзя вставлять кадр другого типа или потока. В 2024 году несколько реализаций оказались уязвимы для CONTINUATION Flood. Серверы продолжали принимать бесконечную цепочку CONTINUATION без END_HEADERS и расходовали память или процессорное время. Проблему вызвали ошибки лимитов и обработки незавершённых блоков, а не само правило непрерывности.
HTTP/2 регулирует только DATA. Для каждого направления действуют окно конкретного потока и общее окно соединения. Начальное значение обоих окон равно 65 535 байтам.
stream window 65 535
connection window 65 535
DATA stream 1 16 384
stream window 49 151
connection window 49 151
Отправитель может передать DATA только при наличии места в обоих окнах. Девятибайтовый заголовок кадра окно не расходует, но padding внутри DATA учитывается. Получатель пополняет окна раздельными WINDOW_UPDATE.
# Окно потока 1 увеличивается на 65 535
00 00 04 08 00 00 00 00 01
00 00 ff ff
# Окно соединения увеличивается на 65 535
00 00 04 08 00 00 00 00 00
00 00 ff ff
SETTINGS_INITIAL_WINDOW_SIZE меняет начальные окна потоков, но не окно соединения. Общее окно увеличивается только через WINDOW_UPDATE с Stream ID 0. Ошибка в таком коде часто выглядит как зависшая загрузка. TCP-соединение остаётся открытым, заголовки приходят, но DATA останавливается после исчерпания одного из окон.
Мультиплексирование убирает очередь HTTP/1.1 на прикладном уровне, но HTTP/2 всё ещё работает поверх одного упорядоченного TCP-потока. Потерянный TCP-сегмент задерживает последующие байты всех HTTP/2-потоков. HTTP/3 переносит потоки в QUIC и устраняет такую межпоточную блокировку при потере пакетов, хотя потоки по-прежнему делят общую полосу и управление перегрузкой.
Старая схема приоритетов через дерево зависимостей оказалась сложной и непоследовательно реализованной. RFC 9113 объявил механизм устаревшим, а RFC 9218 ввёл параметры urgency и incremental. Поддержку новой схемы нужно проверять на конкретном клиенте, CDN и сервере.
HTTP/2 Server Push тоже ушёл из практического веба. Chromium отказался от поддержки раньше, Firefox отключил её в версии 132, а крупные браузерные движки больше не дают разработчикам надёжного Push. Для ранней загрузки ресурсов применяют preload и статус 103 Early Hints, но выигрыш требуется подтверждать измерениями.
Бинарный формат не обеспечивает шифрование. RFC допускает HTTP/2 по открытому TCP, когда обе стороны заранее знают протокол. Токен h2c для переключения через Upgrade в RFC 9113 объявлен устаревшим. Крупные браузеры используют HTTP/2 для веб-сайтов поверх TLS.
Мультиплексирование создаёт и новые возможности для атак. Rapid Reset быстро открывает и сбрасывает потоки, пока сервер успевает выделять ресурсы и запускать обработку. Прокси при переводе HTTP/2 в HTTP/1.1 также должен отбрасывать недопустимые псевдозаголовки, управляющие символы и противоречивую длину тела. Иначе разница между двумя парсерами может привести к request smuggling.
Защита требует обновлённого HTTP/2-стека, ограничений на число потоков, размер блока полей, скорость RST_STREAM и длительность незавершённого HEADERS. Одного SETTINGS_MAX_CONCURRENT_STREAMS недостаточно, поскольку часть ресурсов сервер расходует до полного завершения запроса.
Чем кадр HTTP/2 отличается от TCP-пакета?
Кадр относится к HTTP/2, а TCP-сегмент к транспортному уровню. Один сегмент может содержать несколько кадров. Один кадр также может пересекать границы нескольких сегментов.
Чем END_HEADERS отличается от END_STREAM?
END_HEADERS завершает один сжатый блок полей. END_STREAM закрывает одно направление потока. HEADERS может содержать оба флага, только один флаг или ни одного.
Нужен ли Content-Length в HTTP/2?
HTTP/2 определяет размеры DATA без Content-Length, поэтому поле избыточно, но разрешено. Если поле присутствует, значение должно соответствовать фактической длине тела.
Почему Transfer-Encoding chunked запрещён?
HTTP/2 уже делит тело на кадры с явной длиной. Дополнительное chunked-кодирование не нужно и относится к механике HTTP/1.1.
Можно ли декодировать HPACK-блок отдельно от соединения?
Статические индексы декодируются отдельно, но динамические ссылки зависят от предыдущих блоков того же направления. Без состояния декодера результат может оказаться неполным или ошибочным.
HTTP/2 всегда работает через TLS?
Сам стандарт допускает открытый TCP с предварительным знанием протокола. Браузерный веб использует HTTP/2 преимущественно поверх TLS с выбором h2 через ALPN.
Для диагностики я проверяю HTTP/2 в одном порядке. Сначала смотрю ALPN, затем обе преамбулы, SETTINGS и ACK. После этого разделяю кадры по Stream ID, различаю END_HEADERS и END_STREAM, восстанавливаю HPACK отдельно для каждого направления и проверяю оба окна DATA. Такой разбор показывает, где именно возник сбой, в TLS, кадрировании, сжатии заголовков, управлении потоком, прокси или самом приложении.
