Skip to content

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.

Требования

КомпонентТребование
OpenSSL3.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:

bash
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.

json
{
    "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
            }
        }
    }
}

Параметры

ПараметрТипПо умолчаниюОписание
enabledboolfalseВключает HTTP/3 для сервера. Обязательно для работы h3
portчислоTCP-порт сервераUDP-порт для QUIC (1–65535). По умолчанию совпадает с port vhost — это то, что Alt-Svc анонсирует и что клиенты пробуют первым
alt_svcbooltrueАнонсировать 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 из двух членов:

http
GET /app.css HTTP/3
priority: u=0, i
ЧленЗначенияПо умолчаниюСмысл
u073Срочность. 0 — самый срочный, 7 — наименее
iбулев флаготсутствуетИнкрементальность: ответ полезен по частям, поэтому его можно чередовать с соседями

То же значение приезжает кадром PRIORITY_UPDATE по управляющему потоку — до того, как поток запроса появился, или спустя долгое время после. Кадр переопределяет заголовок, но только в тех членах, которые несёт: PRIORITY_UPDATE с одним u=5 не сбрасывает i, заданный заголовком запроса.

Что сервер с этим делает:

  • Разная срочность — более срочный ответ уезжает первым. Файл в 4 КБ, запрошенный за передачей в 64 МБ, приходит через 0,1 мс с priority: u=0 вместо 85 мс без сигнала.
  • Одна срочность, не инкрементальные — ответы дочитываются по одному. То, что они блокируют, всё равно не начнётся, пока они не кончились, так что делить между ними соединение некому во благо.
  • Одна срочность, инкрементальные — делят соединение, забирая бюджет записи ходами целиком, а не дробя его на крохи.

Настраивать здесь нечего, а соединение без сигналов приоритета идёт прежним путём и по прежней цене. Доходят ли сигналы до планировщика, видно в /metricshttp3.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_connections65536Глобальный лимит QUIC-соединений в процессе (64–4000000; 0 недопустим). При переполнении новый клиент получает CONNECTION_REFUSED
http3_buffer_memory_limit25 % RAMПроцессный бюджет динамических буферов QUIC (приём, отправка, CRYPTO), в байтах. 0 — отключить лимит
http3_rx_batch32Размер пакета recvmmsg (1–256)
http3_so_rcvbuf0 (ядро)SO_RCVBUF для UDP-сокета
http3_so_sndbuf0SO_SNDBUF для UDP-сокета
http3_handshake_rate500Лимит новых рукопожатий в секунду на процесс. 0 — отключить
http3_handshake_burst1000Пик бакета рукопожатий
http3_stateless_reset_rate100Скорость бакета stateless reset. 0 — отключить
http3_stateless_reset_burst200Пик бакета stateless reset
http3_version_negotiation_rate100Скорость ответов Version Negotiation. 0 — отключить
http3_version_negotiation_burst200Пик бакета VN

http3_max_connections — сколько QUIC-соединений процесс держит одновременно, суммарно по всем воркерам. Ограничение про память, а не про дескрипторы: каждое соединение несёт криптосостояние, таблицы восстановления потерь и буферы. Превышение не проходит молча — новый клиент получает CONNECTION_CLOSE с кодом CONNECTION_REFUSED и узнаёт об отказе сразу, а не по собственному таймауту. Текущее число, пик и лимит видны в /metricsquic.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_2falseОбслуживать QUIC v2 (RFC 9369) и переводить на неё соединения по RFC 9368

QUIC v2 — это не новый протокол, а v1 с четырьмя переставленными константами: другая соль вывода Initial-ключей, префикс quicv2 у меток защиты пакетов, другой ключ целостности Retry и повёрнутые коды типов длинного заголовка. Ничего больше не меняется. Смысл этой перестановки — не скорость и не новые возможности, а борьба с окостенением сети: промежуточное оборудование, научившееся разбирать «QUIC» по константам v1, на v2 спотыкается и тем самым обнаруживает себя, пока это ещё можно исправить.

json
{ "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_retryautoПолитика Retry: auto — включать при превышении http3_retry_threshold, always — всегда, never — никогда
http3_retry_threshold1000Порог полуоткрытых рукопожатий, при котором auto начинает отвечать Retry (0–4000000)
http3_new_tokentrueВыдавать NEW_TOKEN после рукопожатия: следующее соединение клиента докажет адрес без Retry
http3_token_lifetime_sec86400Срок жизни NEW_TOKEN-токенов, сек (Retry-токен живёт фиксированные 10 с)

http3_retry — политика этого подтверждения. auto (умолчание) включает Retry только при признаках атаки, always — для каждого нового клиента (максимальная защита ценой round trip для всех новых подключений), never — никогда (закрытая сеть или стенд).

http3_retry_threshold — порог для auto: число рукопожатий, начатых, но не завершённых (в /metricsquic.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_sec30Таймаут простоя соединения, сек (1–3600)
http3_keepalive_sec0Интервал keep-alive PING, сек (0–3600). 0 — не держать молчащие соединения
http3_max_udp_payload_size1350Максимальный размер датаграммы, который сервер отправляет и анонсирует (1200–1350)
http3_initial_max_data1048576Начальное окно приёма на соединение (1 МиБ)
http3_initial_max_stream_data262144Начальное окно приёма на поток (256 КиБ)
http3_max_streams_bidi100Сколько запросных потоков клиент может открыть одновременно (1–65536)
http3_max_streams_uni8Лимит однонаправленных потоков (3–65536; минимум 3 — сам протокол: управляющий и два QPACK)
http3_recv_window_max16777216Потолок авто-тюна приёмного окна соединения, как http2_recv_window_max в HTTP/2
http3_active_cid_limit4Сколько идентификаторов соединения хранится для пира — запас на миграцию (2–8)
http3_ack_delay_ms25Максимальная задержка 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-соединение переживает смену адреса, поэтому после миграции пакеты приходят уже не тому воркеру, который соединение завёл. Сервер это замечает и переносит соединение к нему; в /metricsquic.routing видно local, foreign и rehomed. Норма — rehomed соизмерим с migrations.validated, а foreign мал. Много foreign при нулевом rehomed бывает только во время перезагрузки, когда сокет уже передан новому поколению.

http3_ack_delay_ms — максимальная задержка подтверждений, которую сервер анонсирует клиенту. Вместо ACK на каждый принятый пакет сервер может накопить их и подтвердить одним; анонсированное значение клиент вычитает из оценки RTT, поэтому задержанные ACK не раздувают её. Больше — меньше ACK-трафика при скачивании; 0 — подтверждать каждый пакет немедленно.

Контроль перегрузки

ПараметрПо умолчаниюОписание
http3_initcwnd_packets10Начальное окно перегрузки в датаграммах (2–64)
http3_ccnewrenoАлгоритм контроля перегрузки: newreno, cubic или bbr
http3_pacingtrueРазмазывать отправку по времени вместо залпа (обязателен при http3_cc: "bbr")
http3_amplification_factor3Во сколько раз сервер может ответить до подтверждения адреса (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 задаётся фазой, и фазы — это, по сути, весь алгоритм:

ФазаЧто делает
STARTUPgain ≈ 2,89 (2/ln 2) — скорость удваивается за раунд, как медленный старт. Конвейер считается полным, когда три раунда подряд не поднимают оценку полосы хотя бы на 25 %
DRAINgain ≈ 0,35 — слить очередь, которую построил STARTUP, примерно за то же время, за какое она была построена
PROBE_BWцикл из восьми раундов: один на 1,25× (не появилось ли ещё полосы), один на 0,75× (вернуть созданную этим очередь), шесть ровно по оценке. Фаза, с которой цикл начинается, выбирается по часам — иначе соединения, стартовавшие вместе, пробовали бы канал строем и мерили бы собственный караван, а не путь
PROBE_RTTне реже раза в 10 с окно опускается до четырёх датаграмм на 200 мс: стоячая очередь скрывает истинную задержку ровно столько, сколько стоит, поэтому измерить её можно только опустошив путь

Потеря не является сигналом модели, но и не игнорируется: окно уменьшается ровно на потерянные байты, и один раунд действует packet conservation — в полёт возвращается только то, что из него вышло. Так путь, который действительно теряет трафик, не продолжает получать полное окно, пока модель догоняет. Устойчивая перегрузка сбрасывает модель целиком и возвращает в STARTUP, сохраняя единственное — RTprop: задержка распространения это свойство пути, а не эпизода перегрузки, и перемерять её лишним PROBE_RTT незачем.

Сравнение

NewRenoCUBICBBR
Что решаетсколько байт в полётесколько байт в полётес какой скоростью отдавать; окно — страховка
Сигналпотеряпотеряизмеренные 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 с искусственными потерями, медиана трёх прогонов) показывает цену этих различий:

ПотериNewRenoCUBICBBR
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_datafalseПринимать 0-RTT: запрос возобновляющегося клиента приезжает на round trip раньше

Клиент, который уже был здесь и сохранил session ticket, может отправить запрос вместе с самым первым пакетом — не дожидаясь рукопожатия. Это экономит полный round trip: на канале с RTT 130 мс страница начинает грузиться на 130 мс раньше.

Плата за это заложена в самом протоколе: 0-RTT-запрос воспроизводим. Любой, кто скопировал датаграмму, может отправить её снова, и сервер не отличит копию от оригинала — AEAD подтверждает подлинность, но не новизну.

Наш сервер отвечает на это тем, что не выполняет запрос, пока рукопожатие не завершено. Данные принимаются в потоки, но обработчик не запускается: ни записи в базу, ни чтения сессии. Завершить рукопожатие копия не может — у атакующего нет ключей, — поэтому воспроизведённый запрос не делает ничего и соединение умирает по idle timeout. Из этого следуют два практических вывода:

  • ограничивать 0-RTT безопасными методами (GET/HEAD) не требуется — POST в early data так же безопасен, как в обычном соединении;
  • ответ приходит не раньше конца рукопожатия. Экономится время до сервера, а не обратно.

Включение — решение оператора: по умолчанию выключено.

json
{ "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": 13904

offered минус accepted — отвергнутые тикеты: либо конфигурация менялась, либо сработала защита от повторов.

Защита от злоупотреблений

ПараметрПо умолчаниюОписание
http3_max_field_section_size1048576Лимит размера блока заголовков (1 МБ)
http3_abort_rate100Бюджет Rapid Reset. 0 — отключить
http3_abort_burst200Пик бакета Rapid Reset
http3_ctrl_rate100Лимит флуда управляющими фреймами (GOAWAY и т.п.). 0 — отключить
http3_ctrl_burst200Пик управляющего бакета

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.

Пример:

json
{
    "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_connections10Сколько соединений подряд писать (0–100000)
json
{ "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": 9

version2.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

bash
# Требуется 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

bash
# Проверка, что собранный libssl экспортирует QUIC TLS API
openssl version
# → OpenSSL 3.5.x (или выше)

Выпущено под лицензией MIT.