HTTP/3
C Web Framework поддерживает HTTP/3 (RFC 9114) — третью версию протокола HTTP поверх QUIC (RFC 9000). QUIC работает по UDP и устраняет проблемы HOL-blocking, медленного старта TCP и задержек установки соединения: рукопожатие объединяет транспорт и TLS 1.3, а каждый запрос идёт независимым потоком.
Кратко
HTTP/3 включается по запросу: требует флага сборки -DINCLUDE_HTTP3=yes и секции http3 в конфигурации сервера. После включения он автоматически анонсируется клиентам через заголовок Alt-Svc поверх HTTP/1.1 и HTTP/2.
Требования
| Компонент | Требование |
|---|---|
| OpenSSL | 3.5.0 или выше — для QUIC TLS API (SSL_set_quic_tls_cbs и др.) |
| Сеть | Доступный UDP-порт (по умолчанию совпадает с TCP-портом сервера) |
| TLS | Секция tls обязательна — QUIC не имеет режима без шифрования (нет аналога h2c) |
Фреймворк использует из libssl только QUIC TLS API. Весь транспортный стек QUIC (фреймы, восстановление потерь, контроль перегрузки, CID-демультиплексор) написан вручную и не полагается на встроенный QUIC OpenSSL.
Сборка
HTTP/3 по умолчанию выключен. Включите его флагом CMake:
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DINCLUDE_POSTGRESQL=yes \
-DINCLUDE_MYSQL=yes \
-DINCLUDE_REDIS=yes \
-DINCLUDE_SQLITE=yes \
-DINCLUDE_HTTP3=yes # ← HTTP/3 / QUIC
cmake --build . -j$(nproc)Флаг -DINCLUDE_HTTP3=yes проверяет, что OpenSSL ≥ 3.5 и что собранный libssl реально экспортирует QUIC TLS API (некоторые дистрибутивы собраны с no-quic при том же номере версии). Остальная часть фреймворка остаётся совместимой с OpenSSL 1.1.1+, поэтому HTTP/3 — единственный компонент с повышенным требованием.
Сборка без флага
Если включить http3 в config.json, но собрать сервер без -DINCLUDE_HTTP3=yes, сервер сообщит об ошибке конфигурации и не запустится.
Конфигурация
HTTP/3 настраивается секцией http3 внутри конкретного сервера. Тот же vhost продолжает раздавать HTTP/1.1 и HTTP/2 по TCP — UDP-порт обслуживает только h3.
{
"servers": {
"s1": {
"domains": ["example.com"],
"ip": "0.0.0.0",
"port": 443,
"tls": {
"fullchain": "/etc/ssl/certs/fullchain.pem",
"private": "/etc/ssl/private/privkey.pem",
"ciphers": "TLS_AES_256_GCM_SHA384 TLS_CHACHA20_POLY1305_SHA256"
},
"http": {
"routes": { "/": { "GET": { "file": "...", "function": "index" } } }
},
"http3": {
"enabled": true,
"port": 443,
"alt_svc": true,
"alt_svc_max_age": 86400
}
}
}
}Параметры
| Параметр | Тип | По умолчанию | Описание |
|---|---|---|---|
enabled | bool | false | Включает HTTP/3 для сервера. Обязательно для работы h3 |
port | число | TCP-порт сервера | UDP-порт для QUIC (1–65535). По умолчанию совпадает с port vhost — это то, что Alt-Svc анонсирует и что клиенты пробуют первым |
alt_svc | bool | true | Анонсировать HTTP/3 в заголовке Alt-Svc поверх ответов HTTP/1.1 и HTTP/2 |
alt_svc_max_age | число | 86400 | Срок кеширования Alt-Svc клиентом (сек) |
Почему без host в Alt-Svc
Значение Alt-Svc строится в формате h3=":port"; ma=max_age — без имени хоста. Согласно RFC 7838 пустой host означает «тот же самый», что корректно для каждого vhost на этом слушателе и не привязывает клиентов к имени, которого может не быть в сертификате.
При http3.enabled: true без секции tls — это ошибка конфигурации («http3 requires a tls section»); QUIC по стандарту всегда использует TLS 1.3.
Сосуществование с HTTP/1.1 и HTTP/2
Один vhost одновременно обслуживает три версии протокола:
TCP :443 → ALPN согласовывает h2 или http/1.1
UDP :443 → только h3- Согласование ALPN разделено: QUIC-соединение предлагает только
h3, TCP —h2иhttp/1.1. Смешивание запрещено. - Выбор vhost внутри соединения — по SNI, идентично TCP+TLS.
- Клиент узнаёт о доступности h3 из заголовка
Alt-Svc, который сервер добавляет в ответы по HTTP/1.1 и HTTP/2. Браузер не пробует UDP «наугад» — он узнаёт о h3 именно отсюда (или из DNS HTTPS-записи).
После получения Alt-Svc: h3=":443"; ma=86400 браузер в фоне пробует HTTP/3 и при успехе переключается. Если UDP заблокирован, клиент незаметно остаётся на TCP — серверной логики отката не требуется.
Возможности
QUIC-транспорт
- Соединение объединяет транспортное рукопожатие и TLS 1.3 — один RTT на установку
- Контроль перегрузки NewReno, CUBIC или BBR с pacing — выбирается ключом
http3_cc - Миграция соединений и валидация пути; выдача и отзыв идентификаторов соединения (CID); приём обновления ключей по ходу соединения (key update, RFC 9001 §6); stateless reset
- Соединение следует за своими датаграммами: после миграции его обслуживание переносится на тот воркер, которому ядро их теперь отдаёт
- Проверка адреса клиента: Retry-токен (политика
auto/always/never) иNEW_TOKENдля повторных соединений; при исчерпании лимита соединений клиент получаетCONNECTION_REFUSED, а не тишину - Защита от усиления трафика (anti-amplification 3×)
- IPv4 и IPv6: семейство задаёт
ipвиртуального хоста; сайт на обеих семьях — две записи вservers, различающиеся только адресом - QUIC v2 (RFC 9369) и совместимое согласование версий (RFC 9368) — по ключу
http3_version_2, по умолчанию выключено
HTTP/3
- Полный набор фреймов: DATA, HEADERS, SETTINGS, GOAWAY и др.
- Управляющий поток и QPACK-потоки кодера/декодера
- Тела запросов (DATA, с выгрузкой во временный файл для больших объёмов)
- Трейлеры и 103 Early Hints — те же API (
add_trailer,add_early_hint/send_early_hints), что и в HTTP/2 - 100 Continue — промежуточный ответ
- Параллельные запросы в одном соединении; предел задаётся
http3_max_streams_bidi(по умолчанию 100)
Приоритеты (RFC 9218)
HTTP/2 объявил свою схему приоритетов устаревшей и не предложил замены; RFC 9218 — эта замена, и в HTTP/3 она реализована. Клиент сообщает, насколько ответ срочен, а сервер отправляет в этом порядке.
Два носителя с одинаковым значением — словарь RFC 8941 из двух членов:
GET /app.css HTTP/3
priority: u=0, i| Член | Значения | По умолчанию | Смысл |
|---|---|---|---|
u | 0–7 | 3 | Срочность. 0 — самый срочный, 7 — наименее |
i | булев флаг | отсутствует | Инкрементальность: ответ полезен по частям, поэтому его можно чередовать с соседями |
То же значение приезжает кадром PRIORITY_UPDATE по управляющему потоку — до того, как поток запроса появился, или спустя долгое время после. Кадр переопределяет заголовок, но только в тех членах, которые несёт: PRIORITY_UPDATE с одним u=5 не сбрасывает i, заданный заголовком запроса.
Что сервер с этим делает:
- Разная срочность — более срочный ответ уезжает первым. Файл в 4 КБ, запрошенный за передачей в 64 МБ, приходит через 0,1 мс с
priority: u=0вместо 85 мс без сигнала. - Одна срочность, не инкрементальные — ответы дочитываются по одному. То, что они блокируют, всё равно не начнётся, пока они не кончились, так что делить между ними соединение некому во благо.
- Одна срочность, инкрементальные — делят соединение, забирая бюджет записи ходами целиком, а не дробя его на крохи.
Настраивать здесь нечего, а соединение без сигналов приоритета идёт прежним путём и по прежней цене. Доходят ли сигналы до планировщика, видно в /metrics → http3.priority_applied: «клиент присылает приоритеты» и «сервер по ним действует» — разные утверждения, и этот счётчик их разделяет.
Терпимость к неверным значениям
Значение, которое является корректным словарём, но несёт незнакомый член, член неверного типа или срочность вне 0–7, игнорируется, как требует RFC 9218 §4.1: косметическая ошибка пира не должна стоить страницы. Ошибка — только значение, которое вообще не словарь (ключ без значения после =, лишняя запятая), и только на пути PRIORITY_UPDATE, где RFC предписывает H3_FRAME_ERROR.
Заголовок priority в своих ответах сервер не отправляет — RFC 9218 §5 разрешает ему переопределять собственную срочность, но потребителя у этого нет.
QPACK
QPACK реализован полностью: динамические таблицы обеих сторон, оба потока инструкций, blocked streams с подтверждениями и отменой.
Настройка
Как и для HTTP/2, тонкие параметры — переменные окружения из секции main.env. Каждый ключ проверяется по типу и диапазону: значение не того типа или вне диапазона — ошибка конфигурации, сервер откажется стартовать (а reload с таким значением будет отвергнут целиком, не оставляя смеси старых и новых параметров).
Транспорт и конечная точка
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_max_connections | 65536 | Глобальный лимит QUIC-соединений в процессе (64–4000000; 0 недопустим). При переполнении новый клиент получает CONNECTION_REFUSED |
http3_buffer_memory_limit | 25 % RAM | Процессный бюджет динамических буферов QUIC (приём, отправка, CRYPTO), в байтах. 0 — отключить лимит |
http3_rx_batch | 32 | Размер пакета recvmmsg (1–256) |
http3_so_rcvbuf | 0 (ядро) | SO_RCVBUF для UDP-сокета |
http3_so_sndbuf | 0 | SO_SNDBUF для UDP-сокета |
http3_handshake_rate | 500 | Лимит новых рукопожатий в секунду на процесс. 0 — отключить |
http3_handshake_burst | 1000 | Пик бакета рукопожатий |
http3_stateless_reset_rate | 100 | Скорость бакета stateless reset. 0 — отключить |
http3_stateless_reset_burst | 200 | Пик бакета stateless reset |
http3_version_negotiation_rate | 100 | Скорость ответов Version Negotiation. 0 — отключить |
http3_version_negotiation_burst | 200 | Пик бакета VN |
http3_max_connections — сколько QUIC-соединений процесс держит одновременно, суммарно по всем воркерам. Ограничение про память, а не про дескрипторы: каждое соединение несёт криптосостояние, таблицы восстановления потерь и буферы. Превышение не проходит молча — новый клиент получает CONNECTION_CLOSE с кодом CONNECTION_REFUSED и узнаёт об отказе сразу, а не по собственному таймауту. Текущее число, пик и лимит видны в /metrics → quic.connections; если current в рабочей нагрузке упирается в лимит — поднимайте его, проверив, что памяти хватает.
http3_buffer_memory_limit — процессный бюджет (в байтах) на всё, что растёт с нагрузкой, а не с числом соединений: сегменты приёма датаграмм, очереди отправки, CRYPTO-буферы рукопожатия, буферы потоков и память QPACK-сессий. Исчерпание не роняет сервер: рост нового буфера просто отклоняется, соединения продолжают работать с тем, что уже имеют, а счётчик quic.memory.refused в /metrics показывает, сколько раз отказ случился. Умолчание — четверть физической памяти, вычисляется при старте; 0 отключает бюджет целиком (сервер пишет об этом в журнал). Растущий refused при честной нагрузке — сигнал, что памяти под буферы не хватает.
http3_rx_batch — сколько датаграмм один вызов recvmmsg забирает из сокета за раз. Больше — меньше системных вызовов на пакет при высокой нагрузке; меньше — короче цикл при тихом трафике и меньше памяти под массивы батча. Умолчание подобрано под типичный сервер; менять стоит по профилю, а не наугад.
http3_so_rcvbuf / http3_so_sndbuf — размеры буферов приёма и отправки UDP-сокета (SO_RCVBUF / SO_SNDBUF), в байтах. 0 — оставить выбор ядру. Приём стоит поднимать, когда датаграммы теряются в пиках: ядро не успевает забирать их из очереди, и на стороне сервера это выглядит как необъяснимые потери на ровном месте. Ядро ограничивает максимум значениями net.core.rmem_max / net.core.wmem_max — запросить больше можно, установится меньше.
http3_handshake_rate / http3_handshake_burst — бакет на новые рукопожатия в секунду на процесс. Рукопожатие — самая дорогая часть соединения: выведение ключей и серверный флайт на каждый Initial, а Initial-пакет подделывается одним sendto. Бакет пополняется со скоростью rate и вмещает burst, поэтому мгновенный пик легитимных подключений не режется, а устойчивый флуд упирается в rate. При исчерпании Initial молча отбрасывается — честный клиент переотправит его сам, а счётчик handshake_rate_limited в /metrics покажет срабатывание.
http3_stateless_reset_rate / http3_stateless_reset_burst — stateless reset — это ответ на пакет, адресованный идентификатору соединения, которого больше нет: сервер перезапустили, соединение истекло по таймауту. Клиенту ответ нужен, чтобы не ждать таймаут зря, но каждый ответ стоит выведения ключа, поэтому распыление случайных CID не должно покупать у нас вычисления — бакет это останавливает.
http3_version_negotiation_rate / http3_version_negotiation_burst — Version Negotiation отвечает клиенту, предложившему незнакомую версию QUIC. Ответ не стоит отправляющему ничего, поэтому ограничен отдельно: это самый чистый кандидат на усиление трафика с постороннего адреса.
Версии протокола: QUIC v2
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_version_2 | false | Обслуживать QUIC v2 (RFC 9369) и переводить на неё соединения по RFC 9368 |
QUIC v2 — это не новый протокол, а v1 с четырьмя переставленными константами: другая соль вывода Initial-ключей, префикс quicv2 у меток защиты пакетов, другой ключ целостности Retry и повёрнутые коды типов длинного заголовка. Ничего больше не меняется. Смысл этой перестановки — не скорость и не новые возможности, а борьба с окостенением сети: промежуточное оборудование, научившееся разбирать «QUIC» по константам v1, на v2 спотыкается и тем самым обнаруживает себя, пока это ещё можно исправить.
{ "main": { "env": { "http3_version_2": true } } }Выключенный ключ не «почти не влияет», а не влияет вовсе. Сервер не называет v2 в пакете Version Negotiation, не принимает Initial этой версии (отвечает Version Negotiation) и не отправляет транспортный параметр version_information — то есть остаётся ровно тем сервером RFC 9000, каким был.
Включённый ключ даёт две вещи:
- клиент, который сразу говорит на v2, обслуживается на v2;
- клиент, который начал на v1 и перечислил v2 в своём
version_information, переводится на v2 первым же ответным флайтом — это совместимое согласование версий (RFC 9368 §2.3), и оно не стоит ни одного лишнего round trip.
Первый пакет новой версии — это объявление
Клиент узнаёт о переходе по полю Version в длинном заголовке, и по стандарту вправе прочитать в этом пакете только его, отбросив всё остальное содержимое. Поэтому сервер ничего важного в него не кладёт: ServerHello уезжает следующим пакетом. Реализации, складывающие рукопожатие в пакет-объявление, получают рукопожатие, которое зависает до таймаута простоя при полностью корректном поведении обеих сторон.
Наблюдать за тем, пользуется ли кто-нибудь v2, следует по /metrics (см. «Диагностика»): включённая опция и работающая опция — разные вещи, потому что промежуточное оборудование, отбрасывающее незнакомые версии, делает её бездействующей молча.
Проверка адреса (Retry)
Retry решает проблему, которой нет в TCP: QUIC-рукопожатие начинает клиент, а его адрес в Internet подделывается одним sendto. До подтверждения адреса сервер обязан считать отправителя ненадёжным — отвечать в пределах anti-amplification-лимита и не тратить на рукопожатие больше минимума. Retry — это дополнительный round trip, на котором сервер бросает клиенту вызов: датаграмма с поддельного адреса вызов не получит и на него не ответит, а честный клиент отвечает и доказывает владение адресом.
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_retry | auto | Политика Retry: auto — включать при превышении http3_retry_threshold, always — всегда, never — никогда |
http3_retry_threshold | 1000 | Порог полуоткрытых рукопожатий, при котором auto начинает отвечать Retry (0–4000000) |
http3_new_token | true | Выдавать NEW_TOKEN после рукопожатия: следующее соединение клиента докажет адрес без Retry |
http3_token_lifetime_sec | 86400 | Срок жизни NEW_TOKEN-токенов, сек (Retry-токен живёт фиксированные 10 с) |
http3_retry — политика этого подтверждения. auto (умолчание) включает Retry только при признаках атаки, always — для каждого нового клиента (максимальная защита ценой round trip для всех новых подключений), never — никогда (закрытая сеть или стенд).
http3_retry_threshold — порог для auto: число рукопожатий, начатых, но не завершённых (в /metrics — quic.handshakes.inflight). Честный клиент завершает рукопожатие за десятки миллисекунд и уходит из этого счётчика; застревают в нём полуоткрытые рукопожатия с адресов, которые ответов не получают, — то есть флуд с поддельных адресов. Отсюда правило: тысяча установленных соединений — нормальная нагрузка, тысяча полуоткрытых — атака. 0 превращает auto в always.
http3_new_token — выдавать ли после завершённого рукопожатия токен NEW_TOKEN. Клиент сохраняет его и предъявляет при следующем соединении: адрес уже доказан, Retry не нужен, и постоянные клиенты не платят за проверку адреса ни одного round trip'а. Ключ токенов генерируется заново при каждом старте процесса, поэтому рестарт сервера (или балансировщик без общего ключа) обесценивает ранее выданные токены — для клиента это не ошибка, он просто один раз пройдёт через Retry.
http3_token_lifetime_sec — срок жизни NEW_TOKEN. RFC 9000 §8.1.3 требует ограничивать его, чтобы украденный токен нельзя было переигрывать вечно; умолчание — сутки. Retry-токен живёт фиксированные 10 секунд и этим ключом не управляется: ему нужно пережить ровно один round trip.
Своей даты или чужого адреса Retry-токен ответит INVALID_TOKEN вслух (RFC 9000 §8.1.3), чтобы клиент, зациклившийся на токене, который он не может исправить, не застрял навсегда. Пара счётчиков retry_sent/token_valid в секции quic маршрута /metrics — ответ на вопрос «работает ли Retry»: каждый выданный Retry должен возвращаться валидным токеном, и устойчивый разрыв означает, что клиенты не доезжают через лишний round trip.
Потоки и контроль потока
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_idle_timeout_sec | 30 | Таймаут простоя соединения, сек (1–3600) |
http3_keepalive_sec | 0 | Интервал keep-alive PING, сек (0–3600). 0 — не держать молчащие соединения |
http3_max_udp_payload_size | 1350 | Максимальный размер датаграммы, который сервер отправляет и анонсирует (1200–1350) |
http3_initial_max_data | 1048576 | Начальное окно приёма на соединение (1 МиБ) |
http3_initial_max_stream_data | 262144 | Начальное окно приёма на поток (256 КиБ) |
http3_max_streams_bidi | 100 | Сколько запросных потоков клиент может открыть одновременно (1–65536) |
http3_max_streams_uni | 8 | Лимит однонаправленных потоков (3–65536; минимум 3 — сам протокол: управляющий и два QPACK) |
http3_recv_window_max | 16777216 | Потолок авто-тюна приёмного окна соединения, как http2_recv_window_max в HTTP/2 |
http3_active_cid_limit | 4 | Сколько идентификаторов соединения хранится для пира — запас на миграцию (2–8) |
http3_ack_delay_ms | 25 | Максимальная задержка ACK, которую сервер анонсирует пиру (0–16383, RFC 9000 §18.2) |
Контроль потока в QUIC устроен как в HTTP/2: получатель анонсирует окно и выделяет новое только по мере вычитывания данных. Эти параметры — размер того, что сервер обещает держать, то есть прямая связь с памятью под соединение.
http3_idle_timeout_sec — соединение закрывается, если пакетов от клиента не было столько секунд. Эффективное значение — минимум из нашего и анонсированного клиентом: побеждает меньший. Больший таймаут даёт мобильным клиентам вернуться из сна тем же соединением, без нового рукопожатия; меньший быстрее освобождает память мёртвых соединений. Активные соединения не рвутся: протокол сам держит их живыми, а таймаут считается от последнего пакета.
http3_keepalive_sec — интервал, с которым сервер сам напоминает о себе PING-фреймом, чтобы молчащее соединение не закрылось (RFC 9000 §10.1.2). Ноль — не напоминать вовсе, и это умолчание.
Ключ нужен именно потому, что http3_idle_timeout_sec половину задачи не решает: эффективный таймаут — меньший из двух объявленных, а браузеры объявляют около 30 секунд, так что поднять его только на сервере невозможно. Без keep-alive пауза в полминуты стоит соединения, и следующий переход по сайту платит новым рукопожатием; с keep-alive соединение переживает паузы, пока браузер отвечает.
Плата за включение — соединение живёт вместе со своей памятью, пока отвечает клиент, поэтому умолчание выключено: сколько соединений держать, решает не протокол, а нагрузка. Пинговать ушедшего клиента сервер не может: активностью считается только приём, поэтому соединение с исчезнувшим пиром всё равно закроется по таймауту простоя, сколько бы PING-ов ни ушло. Фактическое значение зажимается половиной договорённого таймаута простоя (и минимум одна секунда) — PING должен успеть не только уйти, но и получить подтверждение. Сколько их отправлено, видно в /metrics как quic.keepalive_sent — отдельно от quic.pto_probes_sent, потому что на проводе это один и тот же фрейм, а означают они противоположное: keep-alive говорит, что ничего не происходило, проба — что путь перестал отвечать.
http3_max_udp_payload_size — максимальный размер датаграммы, который сервер обещает принимать: значение анонсируется клиенту в transport parameters. Исходящие датаграммы оно не ограничивает — их размер подбирает DPLPMTUD, от 1350 байт вверх до потолка пути (1472 для IPv4, 1452 для IPv6), но не выше того, что симметрично обещал клиент. Нижняя граница 1200 — минимум, который RFC 9000 гарантирует пройти через любой путь; потолок 1350 — размер буфера, в который сервер собирает пакеты, и обещать больше значило бы обещать то, чего в коде нет.
http3_initial_max_data — начальное окно приёма на уровне соединения: сколько байт клиент может отправить суммарно по всем потокам, пока сервер не выделит дополнительное окно (MAX_DATA). Это ограничение памяти: ровно столько неприкаянных данных может одновременно висеть в буферах приёма.
http3_initial_max_stream_data — то же на уровне одного потока. Это главный тормоз одиночной большой загрузки: с окном 256 КиБ клиент с гигабайтным телом останавливается и ждёт MAX_STREAM_DATA каждые 256 КиБ. Дальше вступает авто-тюн — окно растёт, когда сервер вычитывает быстрее, чем идёт round trip.
http3_max_streams_bidi — сколько запросных потоков клиент может открыть одновременно; аналог SETTINGS_MAX_CONCURRENT_STREAMS в HTTP/2. Каждый открытый поток — состояние и память, поэтому их число ограничено. Клиент, открывший поток сверх лимита, ведёт себя некорректно, и соединение закрывается STREAM_LIMIT_ERROR — как требует RFC. Умолчание 100 — то, на что рассчитаны браузеры.
http3_max_streams_uni — лимит однонаправленных потоков клиента. Сам протокол требует минимум три: управляющий поток HTTP/3 и два потока QPACK (кодер и декодер) — отсюда нижняя граница 3. Сегодняшним клиентам больше не нужно.
http3_recv_window_max — потолок, до которого авто-тюнер выращивает приёмное окно соединения. Правило то же, что в TCP: окно должно вмещать произведение пропускной способности на RTT, иначе скорость упрётся в окно, а не в канал. Для одиночных больших загрузок на широких каналах с большой задержкой потолок поднимают. Равенство с http3_initial_max_data (меньше нельзя по валидации) фиксирует окно без роста — как http2_recv_window_max в HTTP/2.
http3_active_cid_limit — сколько идентификаторов соединения сервер держит наготове для клиента. CID — это будущее имя соединения на новом пути: когда клиент меняет сеть (Wi-Fi → LTE, NAT-rebind), он продолжает тем же соединением, адресуясь запасным CID, и смена адреса не рвёт соединение. Минимум 2 требует RFC; больше — больше запаса на миграции, цена — память нескольких слотов.
Настройки у миграции есть и на стороне воркеров, но настраивать там нечего — это стоит знать при чтении /metrics. Ядро раздаёт датаграммы воркерам по хешу адресной четвёрки, а QUIC-соединение переживает смену адреса, поэтому после миграции пакеты приходят уже не тому воркеру, который соединение завёл. Сервер это замечает и переносит соединение к нему; в /metrics → quic.routing видно local, foreign и rehomed. Норма — rehomed соизмерим с migrations.validated, а foreign мал. Много foreign при нулевом rehomed бывает только во время перезагрузки, когда сокет уже передан новому поколению.
http3_ack_delay_ms — максимальная задержка подтверждений, которую сервер анонсирует клиенту. Вместо ACK на каждый принятый пакет сервер может накопить их и подтвердить одним; анонсированное значение клиент вычитает из оценки RTT, поэтому задержанные ACK не раздувают её. Больше — меньше ACK-трафика при скачивании; 0 — подтверждать каждый пакет немедленно.
Контроль перегрузки
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_initcwnd_packets | 10 | Начальное окно перегрузки в датаграммах (2–64) |
http3_cc | newreno | Алгоритм контроля перегрузки: newreno, cubic или bbr |
http3_pacing | true | Размазывать отправку по времени вместо залпа (обязателен при http3_cc: "bbr") |
http3_amplification_factor | 3 | Во сколько раз сервер может ответить до подтверждения адреса (RFC 9000 §8.1, 1–16). Отход от 3 громко логируется при старте — менять стоит только в тестах |
http3_initcwnd_packets — тот же выбор, что initcwnd у TCP. RFC 9002 §7.2 рекомендует десять датаграмм и ограничивает начальное окно 14 720 байтами, то есть примерно 12 пакетами; на длинном RTT этого мало — файл в 30 КБ едет два round trip'а только на разгоне окна, и при RTT в 130 мс это лишние 130 мс на каждый ассет. Значение, отличное от 10, — осознанное отклонение оператора, и сервер пишет о нём при старте в syslog:
quic: http3_initcwnd_packets is 30, not the 10 RFC 9002 §7.2 recommendsСтроки нет в журнале — значит, значение до сервера не дошло: проверьте, что ключ лежит именно в main.env.
http3_cc выбирается для каждого нового соединения; reload влияет только на новые. Любое значение, кроме newreno, cubic и bbr, считается ошибкой конфигурации.
Все три решают одну задачу — сколько данных держать в пути, чтобы использовать канал полностью и не создать очередь, которую он не переварит. Но исходные данные у них разные, и именно поэтому на разных каналах результат расходится в разы.
NewReno
Алгоритм по умолчанию — тот, что RFC 9002 §7 описывает напрямую. Окно перегрузки живёт по четырём правилам:
- Медленный старт. Каждый подтверждённый байт добавляет байт к окну, то есть окно удваивается за каждый round trip. Продолжается, пока окно меньше порога
ssthresh(изначально он бесконечен — то есть первая потеря и есть конец медленного старта). - Избегание перегрузки. Дальше окно растёт на одну датаграмму за окно подтверждённых данных — линейно, по датаграмме за round trip. Остаток от деления не отбрасывается, а копится: без этого на больших окнах рост встал бы совсем, потому что подтверждение почти всегда меньше окна, делённого на размер датаграммы.
- Потеря. Окно и
ssthreshуменьшаются вдвое, но не ниже двух датаграмм. Одновременно начинается период восстановления: всё, что было отправлено до его начала, относится к той же потере, поэтому пачка потерянных пакетов уменьшает окно один раз, а не по разу на пакет. - Устойчивая перегрузка (§7.6). Если потеряно всё, что отправлено за промежуток длиннее трёх PTO, то это уже не перегрузка, а переставший работать путь: окно падает до минимума, и медленный старт начинается заново. Отдельно от потерь стоит PTO-проба — её разрешено отправлять сверх окна, потому что её задача как раз вытянуть подтверждение, когда окно закрыто.
Слабое место — цена одной потери. На канале в 100 Мбит/с с RTT 100 мс в пути помещается около 900 датаграмм; после единственной потери окно становится 450 и набирается обратно по одной датаграмме за round trip — это порядка 45 секунд на полной скорости. Именно этот провал и закрывают два других алгоритма, каждый по-своему.
CUBIC (RFC 9438)
CUBIC оставляет ту же модель «окно, реагирующее на потерю», но меняет обе константы Reno — и уменьшение, и рост.
- Уменьшение мягче: окно множится на β = 0,7, а не на 0,5. Прежнее значение запоминается как
W_max— точка, в которой путь уже один раз упёрся. - Рост идёт по времени, а не по раундам:
W(t) = C·(t − K)³ + W_max, гдеC = 0,4, аK = ∛(W_max·(1−β)/C)— время, за которое кривая возвращается кW_max. Форма кубической кривой и есть весь смысл: сразу после потери окно растёт быстро, уW_maxвыполаживается (там уже было больно), а если и там ничего не случилось — снова разгоняется, ища новый потолок. Независимость от RTT — второе следствие: соединения с разной задержкой на общем канале получают сопоставимые доли, тогда как у Reno доля обратно пропорциональна RTT. - Fast convergence. Если очередная потеря случилась раньше, чем окно дошло до прежнего
W_max, значит доступная полоса уменьшилась — вероятно, пришёл новый сосед. ТогдаW_maxопускается до 0,85 от текущего окна, чтобы освободить место быстрее, чем это сделала бы одна лишь кривая. - TCP-friendly регион. Параллельно считается окно, которое на этом же месте имел бы Reno (α = 3(1−β)/(1+β) = 9/17 датаграммы за round trip), и берётся большее из двух. Без этого правила на коротких RTT и малых окнах кубическая кривая оказывалась бы медленнее Reno — то есть «улучшенный» алгоритм проигрывал бы на локальных каналах.
Вся арифметика целочисленная, включая кубический корень (двоичный поиск): контроллер вызывается на каждое подтверждение, и плавающая точка в транспортном горячем пути — это цена, которую платят вечно.
BBR (draft-cardwell-iccrg-bbr-congestion-control)
BBR отвечает не на вопрос «сколько держать в полёте», а на вопрос «с какой скоростью отдавать». Окно у него остаётся, но только как страховка от неверной оценки. Модель пути состоит из двух измеряемых величин:
- BtlBw — максимум скорости доставки в скользящем окне из 10 round trip. Скорость считается как доставленные байты, делённые на интервал их доставки, причём оба конца интервала записываются в момент отправки — иначе получится скорость отправителя, а не пути. Образцы, помеченные
app-limited(данные кончились раньше окна), никогда не понижают оценку: они меряют приложение, а не канал. - RTprop — минимум RTT за последние 10 секунд, то есть задержка пути без очереди.
Отдача идёт со скоростью BtlBw × gain через пейсер, а окно держится равным 2 × BDP. Значение gain задаётся фазой, и фазы — это, по сути, весь алгоритм:
| Фаза | Что делает |
|---|---|
| STARTUP | gain ≈ 2,89 (2/ln 2) — скорость удваивается за раунд, как медленный старт. Конвейер считается полным, когда три раунда подряд не поднимают оценку полосы хотя бы на 25 % |
| DRAIN | gain ≈ 0,35 — слить очередь, которую построил STARTUP, примерно за то же время, за какое она была построена |
| PROBE_BW | цикл из восьми раундов: один на 1,25× (не появилось ли ещё полосы), один на 0,75× (вернуть созданную этим очередь), шесть ровно по оценке. Фаза, с которой цикл начинается, выбирается по часам — иначе соединения, стартовавшие вместе, пробовали бы канал строем и мерили бы собственный караван, а не путь |
| PROBE_RTT | не реже раза в 10 с окно опускается до четырёх датаграмм на 200 мс: стоячая очередь скрывает истинную задержку ровно столько, сколько стоит, поэтому измерить её можно только опустошив путь |
Потеря не является сигналом модели, но и не игнорируется: окно уменьшается ровно на потерянные байты, и один раунд действует packet conservation — в полёт возвращается только то, что из него вышло. Так путь, который действительно теряет трафик, не продолжает получать полное окно, пока модель догоняет. Устойчивая перегрузка сбрасывает модель целиком и возвращает в STARTUP, сохраняя единственное — RTprop: задержка распространения это свойство пути, а не эпизода перегрузки, и перемерять её лишним PROBE_RTT незачем.
Сравнение
| NewReno | CUBIC | BBR | |
|---|---|---|---|
| Что решает | сколько байт в полёте | сколько байт в полёте | с какой скоростью отдавать; окно — страховка |
| Сигнал | потеря | потеря | измеренные BtlBw и RTprop |
| Рост без потерь | +1 датаграмма за RTT | кубическая кривая к W_max и выше, независимо от RTT | скорость = BtlBw × gain фазы |
| Реакция на потерю | окно ×0,5 | окно ×0,7 + fast convergence | −потерянные байты и раунд packet conservation |
| Возврат после одиночной потери на большом BDP | десятки секунд | секунды | не реагирует: модель не менялась |
| Потери не от перегрузки (Wi-Fi, LTE) | обваливается | держится заметно лучше | почти не замечает |
| Отношение к очереди | заполняет буфер до потери | заполняет буфер до потери | держит ≈BDP, периодически сливает |
| Соседство в узком канале | самый уступчивый | умеренно напористее Reno | напористее обоих: отдаёт быстрее, чем «разрешает» потеря |
| Пейсинг | желателен | желателен | обязателен — им и задаётся скорость |
| Состояние на соединение | окно, порог, момент восстановления | плюс W_max, K, начало эпохи, минимальный RTT | плюс фильтр полосы, RTprop, фаза, счётчики раундов |
| Периодические просадки | нет | нет | PROBE_RTT: 4 датаграммы на 200 мс раз в 10 с |
| Когда выбирать | канал с потерями только от перегрузки; максимальная уступчивость к соседям | проводные каналы с большим BDP, длинные маршруты | каналы, где потери не означают перегрузку: мобильные сети, Wi-Fi, международные маршруты |
Замер на 64 МБ (loopback с искусственными потерями, медиана трёх прогонов) показывает цену этих различий:
| Потери | NewReno | CUBIC | BBR |
|---|---|---|---|
| 0 % | 484 МБ/с | 467 МБ/с | 484 МБ/с |
| 2 % | 329 МБ/с | 387 МБ/с | 387 МБ/с |
| 10 % | 18 МБ/с | 75 МБ/с | 240 МБ/с |
На чистом канале выбор не имеет значения; на канале с потерями он определяет всё — 10 % потерь превращают NewReno в 18 МБ/с, а BBR остаётся на 240. Обратная сторона ровно та, что в таблице: BBR намеренно отдаёт данные быстрее, чем «разрешает» потеря, и в узком общем канале ведёт себя настойчивее CUBIC. А короткая просадка раз в десять секунд — это PROBE_RTT, часть алгоритма, а не сбой; на графике мониторинга она выглядит как регулярный провал полосы на 200 мс.
bbr требует включённого http3_pacing: сервер управляет отдачей именно через пейсер, и пара "bbr" + "http3_pacing": false отклоняется при загрузке конфигурации.
http3_pacing растягивает отправку вместо того, чтобы отдавать окно сети одним куском. Стартовый флайт уходит целиком (бюджет всплеска не меньше начального окна, поэтому поднятый http3_initcwnd_packets даёт заказанный залп), а дальше сендеру позволено опережать собственное расписание не более чем на миллисекунду текущей скорости — тот же квант, что у планировщика fq в Linux, и не больше окна перегрузки. Темп от этого не меняется: меняется порционность. Раньше бюджет всплеска был навсегда заморожен на начальном окне, и на быстром пути это резало передачу на куски по 3–4 датаграммы — лишние системные вызовы и короткие GSO-серии на ровном месте, без всякой пользы для очередей. Подтверждения и PTO-пробы не задерживаются никогда. Выключать имеет смысл разве что при отладке.
http3_amplification_factor — во сколько раз сервер может ответить до подтверждения адреса. RFC 9000 §8.1 требует максимум 3× от полученного: неограниченный ответ на Initial превратил бы сервер в усилитель DDoS-атаки с постороннего адреса. Больше трёх имеет смысл только на стенде; любое отличие от 3 сервер громко фиксирует в журнале при старте — в проде это значение отличаться не должно.
0-RTT (early data)
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_early_data | false | Принимать 0-RTT: запрос возобновляющегося клиента приезжает на round trip раньше |
Клиент, который уже был здесь и сохранил session ticket, может отправить запрос вместе с самым первым пакетом — не дожидаясь рукопожатия. Это экономит полный round trip: на канале с RTT 130 мс страница начинает грузиться на 130 мс раньше.
Плата за это заложена в самом протоколе: 0-RTT-запрос воспроизводим. Любой, кто скопировал датаграмму, может отправить её снова, и сервер не отличит копию от оригинала — AEAD подтверждает подлинность, но не новизну.
Наш сервер отвечает на это тем, что не выполняет запрос, пока рукопожатие не завершено. Данные принимаются в потоки, но обработчик не запускается: ни записи в базу, ни чтения сессии. Завершить рукопожатие копия не может — у атакующего нет ключей, — поэтому воспроизведённый запрос не делает ничего и соединение умирает по idle timeout. Из этого следуют два практических вывода:
- ограничивать 0-RTT безопасными методами (GET/HEAD) не требуется — POST в early data так же безопасен, как в обычном соединении;
- ответ приходит не раньше конца рукопожатия. Экономится время до сервера, а не обратно.
Включение — решение оператора: по умолчанию выключено.
{ "main": { "env": { "http3_early_data": true } } }Что стоит знать при эксплуатации: тикеты привязаны к транспортным параметрам, с которыми были выданы. Меняете http3_initial_max_data, http3_max_streams_bidi, http3_idle_timeout_sec или другой параметр из таблиц выше — ранее выданные тикеты перестают возобновляться, и клиенты один раз проходят полное рукопожатие. Это требование RFC 9001 §7.4.1, а не сбой.
Проверить, что работает, можно по /metrics, секция quic:
"early_data.offered": 128,
"early_data.accepted": 126,
"early_data.packets": 141,
"early_data.bytes": 13904offered минус accepted — отвергнутые тикеты: либо конфигурация менялась, либо сработала защита от повторов.
Защита от злоупотреблений
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_max_field_section_size | 1048576 | Лимит размера блока заголовков (1 МБ) |
http3_abort_rate | 100 | Бюджет Rapid Reset. 0 — отключить |
http3_abort_burst | 200 | Пик бакета Rapid Reset |
http3_ctrl_rate | 100 | Лимит флуда управляющими фреймами (GOAWAY и т.п.). 0 — отключить |
http3_ctrl_burst | 200 | Пик управляющего бакета |
http3_max_field_section_size — лимит на декодированный блок заголовков запроса. Мягкое превышение — ответ 431 Request Header Fields Too Large, соединение выживает; жёсткий предел (×8 от лимита) закрывает соединение H3_EXCESSIVE_LOAD: блок заголовков в восемь раз больше лимита — это уже не запрос, а атака. Мегабайт по умолчанию с запасом покрывает длинные cookie и JWT; обычным запросам хватает единиц килобайт, и на публичном сервере лимит стоит ужимать.
http3_abort_rate / http3_abort_burst — бюджет Rapid Reset (CVE-2023-44487): клиент открывает поток и сразу отменяет его, вынуждая сервер проделать часть работы над каждым. Отмена запроса до того, как на него ответил сервер, списывается из бакета. Единичные отмены — нормальное поведение (пользователь ушёл со страницы) и бюджет не заметят, а серии отмен закроют соединение H3_EXCESSIVE_LOAD.
http3_ctrl_rate / http3_ctrl_burst — лимит на управляющие фреймы, которые ничего не продвигают: GOAWAY и MAX_PUSH_ID, повторяющие текущее значение, и фреймы неизвестного типа, которые сервер пропускает. В нормальном соединении их единицы, поэтому лимит щедрый относительно нормы и закрывает соединение H3_EXCESSIVE_LOAD только при явном флуде.
PRIORITY_UPDATE под этот лимит не попадает и настройки не имеет. Браузер присылает такой фрейм на каждый запрос (Chrome — два), то есть его частота равна частоте запросов, а не частоте флуда: любой лимит «фреймов в секунду» рано или поздно оказался бы ниже честной нагрузки. Вместо него действует кредит, который сервер начисляет за принятые запросы, — пир, который шлёт приоритеты, не открывая потоков, исчерпает начальный запас и получит H3_EXCESSIVE_LOAD, а обычный клиент до лимита не доходит никогда, потому что число его запросов и так ограничено кредитом потоков QUIC. Исчерпание видно в /metrics как http3.abuse.priority_budget.
Пример:
{
"main": {
"env": {
"http3_max_connections": 50000,
"http3_rx_batch": 64,
"http3_cc": "bbr",
"http3_max_field_section_size": 524288
}
}
}Диагностика: счётчики и qlog
Зашифрованный бинарный протокол поверх теряющего транспорта нельзя отлаживать чтением дампа: без ключей в датаграмме не видно ничего, а в ответе не видно, что происходило по дороге. Поэтому у HTTP/3 два инструмента, и они отвечают на разные вопросы. /metrics говорит, как часто что-то происходит во всём процессе; qlog говорит, в каком порядке это происходило с одним соединением.
| Параметр | По умолчанию | Описание |
|---|---|---|
http3_qlog_dir | "" | Каталог для трасс. Пусто — qlog выключен |
http3_qlog_connections | 10 | Сколько соединений подряд писать (0–100000) |
{ "main": { "env": {
"http3_qlog_dir": "/var/log/cwfr/qlog",
"http3_qlog_connections": 10
} } }Каталог создаётся при загрузке конфигурации; если создать его нельзя, сервер откажется стартовать, а не молча не напишет ничего. На каждое соединение создаётся файл <original destination connection id>.sqlog — по одной записи на событие, формат JSON-SEQ (RFC 7464), который читают готовые визуализаторы (qvis, qvis.quictools.info): диаграммы окна перегрузки, RTT и потоков строятся из него без всякой подготовки.
Лимит http3_qlog_connections — не украшение: файл на каждое соединение под нагрузкой это отказ в обслуживании, устроенный собственной отладкой. Считаются первые N соединений после загрузки конфигурации; reload счётчик взводит заново, поэтому включить qlog на работающем сервере можно перезагрузкой конфигурации — трассы получат следующие N соединений. Строки пишутся сразу, без буфера: журнал, теряющий хвост, бесполезен ровно в том случае, ради которого его чаще всего включают — соединение зависло, и ответ в последних событиях перед тишиной.
События: connectivity:connection_started / connection_state_updated / connection_closed, transport:packet_sent / packet_received / packet_dropped (с причиной) / parameters_set / ecn_state_updated, recovery:metrics_updated (окно, байты в полёте, RTT), packet_lost, congestion_state_updated, loss_timer_expired, mtu_probe_sent / mtu_probe_lost / mtu_updated.
В /metrics секция quic отвечает на вопросы о механизмах, которые работают молча и молча же отказывают:
"recv.gro_messages": 4, "recv.gro_segments": 8,
"ecn.tx_marked": 16, "ecn.rx.ce": 0,
"ecn.validated": 0, "ecn.validation_failed": 4,
"pmtu.probes_sent": 1, "pmtu.probes_succeeded": 1,
"pmtu.probes_lost": 0, "pmtu.blackholes": 0,
"pmtu_bytes": { "samples": 1, "avg": 1472, "hist": { ... } }recv.gro_* — приёмная разгрузка (UDP_GRO): ядро отдаёт несколько датаграмм одним буфером, сервер разбирает его обратно. Отношение segments к messages и есть коэффициент склейки; ноль под нагрузкой означает, что ядро или контейнер UDP_GRO не дали — узнать об этом иначе нельзя, отказ опции намеренно не фатален.
ecn.* — ECN отказывает тише всего: сервер метит пакеты, middlebox по дороге стирает или подделывает коды, валидация RFC 9000 §13.4.2 выключает ECN для соединения, и снаружи это выглядит как путь, на котором никогда не бывает перегрузки. Пара validated / validation_failed разделяет «путь не умеет ECN» и «путь его портит». ecn.ce_congestion — реакция на перегрузку без единой потери, то есть ровно то, ради чего ECN и нужен: в packets_lost этого не видно.
pmtu.* — поиск размера пакета (RFC 8899). probes_sent против probes_succeeded говорит, доходит ли поиск до конца, blackholes — что поднятый размер пришлось вернуть после серии PTO, а гистограмма pmtu_bytes показывает, где стоит масса: все соединения на базовых 1350 байтах и все на 1472 — это два очень разных сервера.
При включённом http3_version_2 появляются ещё два счётчика:
"version2.connections": 12, "version2.negotiated": 9version2.connections — сколько соединений обслужено по v2, version2.negotiated — сколько из них попало туда совместимым согласованием, а не потому, что клиент сразу попросил v2. Первый отделяет «опция включена» от «опцией пользуются»: если он стоит на нуле при заметной нагрузке, значит v2 до сервера не доходит — скорее всего её отбрасывает промежуточное оборудование, и никакой другой признак об этом не сообщит. Второй отделяет сервер, который объявляет v2 и никого не переводит, от сервера, чьи клиенты просто предпочитают v1.
Ограничения
| Возможность | Статус | Комментарий |
|---|---|---|
| 0-RTT / early data | Да, по запросу | Ключ http3_early_data, по умолчанию выключен; запрос не выполняется до конца рукопожатия |
| Приоритеты (RFC 9218) | Да | Заголовок priority и PRIORITY_UPDATE, планирование по срочности. Свой priority в ответах сервер не отправляет |
| Server Push | Нет | Та же мотивация, что и в HTTP/2 |
| WebSocket-over-h3 | Нет | Extended CONNECT (RFC 9220) не планируется: его не поддерживает ни один браузер. WebSocket работает поверх HTTP/1.1 и HTTP/2 — клиент открывает его по TCP |
| HTTP/3-клиент | Нет | Только серверная роль |
| CUBIC / BBR | Да | Оба выбираются через http3_cc; BBR требует http3_pacing |
| UDP GSO | Да | Пакетная отправка через UDP_SEGMENT |
| GRO / ECN / DPLPMTUD | Да | GRO receive, валидируемый ECN и path-MTU probing с откатом |
| IPv6-эндпоинт | Да | "ip": "::1" или "ip": "[::1]" — и TCP, и UDP слушают этот адрес. Сокет создаётся v6-only, поэтому обе семьи — это две записи в servers с одним и тем же портом |
| QUIC v2 (RFC 9369) | Да, по запросу | Ключ http3_version_2, по умолчанию выключен. Вместе с ним — совместимое согласование версий (RFC 9368): соединение, начатое на v1, переводится на v2 без лишнего round trip. Выключенный ключ оставляет прежнее поведение: незнакомая версия получает Version Negotiation и клиент возвращается на v1 |
| qlog | Да, по запросу | Событийный журнал QUIC в формате JSON-SEQ (.sqlog), открывается в qvis. Включается ключом http3_qlog_dir, по умолчанию выключен |
Проверка
curl
# Требуется curl, собранный с поддержкой HTTP/3
curl -v --http3 https://example.com/
# Проверка заголовка Alt-Svc по TCP
curl -sI https://example.com/ | grep -i alt-svc
# → alt-svc: h3=":443"; ma=86400Браузер
Chrome автоматически переключится на HTTP/3 после получения заголовка Alt-Svc. На вкладке «Сеть» (Network) в DevTools колонка Protocol покажет h3 для запросов по QUIC.
openssl
# Проверка, что собранный libssl экспортирует QUIC TLS API
openssl version
# → OpenSSL 3.5.x (или выше)