
Для пользователя может показаться, что переход с HTTP/2 на HTTP/3 — это просто замена TCP на UDP в конфиге. Но для серверного ПО с многопроцессной архитектурой этот шаг превращается в настоящую «головную боль». Классическая схема с accept(), на которой годами строилась работа с TCP‑соединениями, в мире QUIC попросту не существует. Пакеты летят в UDP‑порт, и ядро ОС больше не знает, какому именно рабочему процессу их отдать.
В оригинальном nginx это привело к тому, что поддержка HTTP/3 уже долгое время остается «экспериментальной» и ограниченной: она страдает от проблем с обрывами сессий и деградации сервиса при обновлении конфигурации. Для многих это стало стоп‑фактором для внедрения протокола в реальный продакшен.
В этой статье мы расскажем, как в Angie 1.11 нам удалось устранить эти фундаментальные недостатки. Мы не просто добавили поддержку протокола, а пересмотрели механику взаимодействия с ядром. Путь от простых хешей до создания полноценного аналога accept() для QUIC с помощью BPF‑программ позволил нам заявить: реализация HTTP/3 в Angie закончена, лишена «детских болезней» nginx и полностью готова к эксплуатации в высоконагруженных средах.
Добро пожаловать под капот современного транспорта данных.
Далее передаю слово Владимиру — одному из разработчиков модуля HTTP/3 в nginx, который является автором нового механизма и поделится всеми подробностями.

Владимир Хомутов
С 2012 года разработчик nginx, а с 2022 разработчик Angie.
Почему HTTP/3 совсем не то же самое что и HTTP/{1,2}
Дело в том, что HTTP/3 работает поверх протокола QUIC, который базируется на UDP, в отличие от предыдущих версий HTTP, работавших поверх TCP. В этой статье мы не будем рассматривать, чем HTTP/3 отличается с точки зрения семантики, — там как раз никаких революций. Это по‑прежнему URL‑запросы с различными методами, аргументами и заголовками, а также ответы с традиционными кодами. Даже если их представление изменилось (теперь оно бинарное, а не текстовое), суть осталась прежней. А вот что действительно поменялось — так это транспорт и то, как прикладной уровень взаимодействует с транспортным протоколом.
Итак, UDP. Это значит, что мы имеем дело с пакетами, которые могут потеряться или прийти не в том порядке, в котором их отправляли. Также отсутствует контроль скорости передачи. За всеми этими (и многими другими!) вещами в предыдущих версиях следил TCP‑стек, как правило, реализованный в ядре ОС. Теперь за это отвечает протокол QUIC. Он занимается нумерацией пакетов, следит за порядком получения, контролирует скорость передачи данных и размер пакетов. Кроме того, он обеспечив��ет целостность передаваемых данных за счёт интегрированной криптографии. И работает всё это на сегодняшний день не в ядре, а в пользовательском пространстве (user space). Вполне возможно, что когда‑нибудь QUIC появится в ядре (такие проекты уже есть) и мы вернёмся к старой парадигме, но пока что Angie самостоятельно реализует весь стек QUIC и HTTP/3.
Таким образом, внедрение QUIC — это включение в вашу программу большого объёма сетевого кода, тесно интегрированного с TLS. Последнее налагает дополнительные требования к используемой SSL‑библиотеке. Уровень поддержки необходимых примитивов в библиотеках сильно разнится, что иногда может приводить даже к несовместимости на сетевом уровне.
Помимо сложностей, QUIC привносит и новые возможности. Например, он позволяет поддерживать соединения при смене IP‑адреса клиента или сервера (миграция), обеспечивает бо́льшую приватность, быстрое восстановление сессий (0-RTT) и по‑настоящему независимые потоки данных в рамках одного соединения. Это выгодно отличает его от HTTP/2, где потеря одного пакета в потоке тормозила все остальные, так как они зависели от единого TCP‑соединения (проблема Head‑of‑Line Blocking).
Как устроен TCP-сервер
Из‑за особенностей архитектуры (использование нескольких процессов для работы на многоядерных системах) возникают сложности при внедрении протокола QUIC. Чтобы их осознать, сначала нужно понять, как устроена работа с клиентами на примере TCP‑сервера. Возьмём простую конфигурацию:
worker_processes 2; events { } http { server { listen 127.0.0.1:8080; location / { return 200 "Hello, world\n"; } } }
Сначала запускается мастер‑процесс, который читает конфигурацию и создаёт listen‑сокет. Затем он через fork() порождает два рабочих процесса (по одному на ядро). Рабочие процессы ожидают новых соединений в бесконечном цикле, выполняя системный вызов accept(). Когда подключается клиент, одному из процессов возвращается новый клиентский сокет, который тот использует для общения. Ядро ОС отвечает за то, чтобы данные от клиента попадали в правильный рабочий процесс через этот конкретный сокет.
Чтобы продвинуться дальше, важно рассмотреть ещё две процедуры, существенно влияющие на работу: загрузку новой конфигурации и обновление бинарного файла без прерывания обслуживания (graceful reload и graceful upgrade). Если вам нужно изменить настройки Angie, вы, конечно, не хотите останавливать сервер и разрывать существующие соединения. То же самое касается и обновления версии самого сервера.
Как связано обновление конфигурации и TCP‑сервер? В Angie для обновления настроек пользователь изменяет конфигурационный файл и просит систему применить изменения. В этот момент мастер‑процесс считывает новую конфигурацию и запускает новые рабочие процессы, которые начинают её использовать. Старые процессы продолжают обслуживать сущ��ствующие соединения, но новые запросы они больше не принимают, так как закрывают listen‑сокеты. В моменте может существовать несколько наборов рабочих процессов, каждый из которых работает со своей версией конфигурации. Новые соединения будут обрабатываться только актуальными рабочими процессами.
Например, вот какую картину можно наблюдать при обновлении конфигурации:
$ ps aux|grep angie root 26092 angie: master process v1.11.0 #1 [./sbin/angie] nobody 26093 angie: worker process #1 nobody 26094 angie: worker process #1 nobody 26095 angie: worker process #1 nobody 26096 angie: worker process #1 # kill -HUP `cat logs/angie.pid` $ ps aux|grep angie root 26092 angie: master process v1.11.0 #2 [./sbin/angie] nobody 26094 angie: worker process is shutting down #1 nobody 27084 angie: worker process #2 nobody 27085 angie: worker process #2 nobody 27086 angie: worker process #2 nobody 27087 angie: worker process #2
Мы видим мастер‑процесс, запущенный от суперпользователя, и четыре рабочих процесса, работающих без привилегий. Изначальные процессы работали с конфигурацией № 1, а новые — с конфигурацией № 2. Один старый процесс всё ещё активен — в нём остались незавершённые пользовательские соединения.
Процесс обновления бинарного файла концептуально похож, но в этом случае запускается новый мастер‑процесс (из нового исполняемого файла), который порождает свой набор рабочих процессов. При этом старый и новый «мастера» работают параллельно (каждый со своим набором рабочих процессов). Существующие соединения продолжают обслуживаться старыми процессами до их завершения, а новые могут попадать в любой из экземпляров системы (как в старые, так и в новые рабочие процессы).
А вот так таблица процессов может выглядеть в динамике:
# ps aux|grep angie root 101664 angie: master process v1.11.0 #1 [./sbin/angie] nobody 101665 angie: worker process #1 nobody 101666 angie: worker process #1 nobody 101667 angie: worker process is shutting down #1 nobody 101668 angie: worker process #1 root 101676 angie: master process v1.11.0 #2 [./sbin/angie] nobody 101753 angie: worker process #2 nobody 101754 angie: worker process #2 nobody 101755 angie: worker process #2 nobody 101756 angie: worker process #2
Здесь два мастер‑процесса, каждый со своим набором рабочих процессов, причём второй работает уже со вторым поколением конфигурации.
Вы всегда можете узнать поколение конфигурации в Angie через API.
В любом из этих сценариев выполняется правило: существующие соединения продолжают обслуживаться в исходном рабочем процессе, а новые — могут принимаються новыми процессами. Всё это возможно благодаря тому, что установлением соединений занимается ядро, а новые процессы умеют «забирать» их оттуда. Существующие соединения работают через уже созданные сокеты, которые принадлежат конкретному процессу, поэтому никакой путаницы возникнуть не может.
Как работает UDP/QUIC-сервер
Что же меняется при переходе на QUIC? Теперь за приём входящих соединений отвечает не ядро, а непосредственно процесс Angie. Вместо готового установленного соединения, полученного через вызов accept(), мы просто получаем UDP‑пакеты (из слушающего сокета). Их содержимое должно быть корректно обработано и отнесено либо к существующим соединениям, либо к новым, либо к «мусору».
Если раньше существовал атомарный способ принять клиентское подключение (вызов accept(): ядро само проводит TCP‑хендшейк и обращается к приложению только при успехе), то теперь мы сталкиваемся с проблемой. Чтобы установить соединение, нужно обменяться серией пакетов с клиентом внутри одного и того же рабочего процесса. Однако возможна ситуация, когда ответный пакет будет получен другим рабочим процессом, так как он слушает тот же самый сокет. В результате в базовом сценарии QUIC мог бы работать только в конфигурациях с единственным рабочим процессом (да и то с оговорками), что, конечно, неприемлемо для высокопроизводительных систем.
Привязка клиентов к процессам через reuseport
Итак, у нас есть несколько процессов, которые слушают один и тот же порт и читают оттуда UDP‑пакеты. При этом вычитанный пакет может быть предназначен другому процессу. Даже если мы поймём, что этот пакет «не наш», мы всё равно не знаем, куда его передать. Эту проблему необходимо решить.
Для решения мы можем воспользоваться сокетной опцией reuseport (которая включает SO_REUSEPORT или SO_REUSEPORT_LB). Хотя её название и история могут ввести в заблуждение, на современных системах она позволяет нескольким процессам совместно использовать пару «адрес:порт». При этом каждый процесс должен иметь собственный сокет. То есть вместо схемы «1 сокет на N процессов» мы переходим к схеме «N сокетов на N процессов».
Ядро само занимается распределением входящих пакетов по сокетам: он�� хеширует данные пакета (включая IP‑адрес и порт клиента) и по результату выбирает сокет из reuseport‑группы. Благодаря этому неизменный клиент всегда попадает на один и тот же сокет и, следовательно, в нужный рабочий процесс.
Использование опции reuseport в директиве listen:
worker_processes 2; events { } http { ssl_certficate ... ssl_certificate_key ... server { listen 127.0.0.1:8080 quic reuseport; location / { return 200 "Hello, world\n"; } } }
Такое решение хоть и работает, но не является идеальным:
Неравномерность: распределение клиентов в адресном пространстве IP может быть неслучайным, из‑за чего нагрузка распределится между рабочими процессами не поровну.
Смена адреса: IP‑адрес клиента может измениться либо из‑за механизмов миграции QUIC, либо из‑за особенностей работы NAT.
Процесс обновления: сложные сценарии с обновлением конфигурации или бинарного файла снова приводят к исходной проблеме — разделению сокета между несколькими наборами процессов. Это происходит потому, что после
fork()мастер‑процесса сокеты наследуются и делятся между разными поколениями рабочих процессов.
Привязка клиентов к процессам через BPF
Для решения этих проблем был представлен BPF‑модуль. Это специфичная для Linux технология, которая позволяет приложению вмешиваться в процесс выбора ядром сокета для входящего пакета. Данный ��ункционал расширяет возможности reuseport: вместо простого распределения пакетов по хешу он позволяет загрузить в ядро собственный алгоритм выбора сокета. Ниже представлена схема, иллюстрирующая, как это устроено.

Как работает такой алгоритм? В первой версии (ещё в nginx) для простоты было решено привязывать клиентские QUIC‑соединения к номеру рабочего процесса. Это реализовывалось так: каждый QUIC‑пакет содержит Destination Connection ID (DCID) — идентификатор назначения, который может меняться в процессе жизни соединения. Мы использовали это свойство, чтобы закодировать идентификатор сокета (полученный через SO_COOKIE) прямо в DCID.
BPF‑модуль создавал в ядре таблицу, в которой было указано соответствие между сокетами и их идентификаторами. Размер таблицы был фиксированным и определялся количеством процессов в исходной конфигурации (с небольшим запасом). Программа анализировала QUIC‑пакет, извлекала оттуда DCID и на его основе определяла, в какой сокет направить пакет. Новые пакеты (без DCID) могли направляться в любой сокет.
Подключение BPF происходит добавлением директивы quic_bpf on в конфигурации:
worker_processes 2; events { } quic_bpf on; http { ssl_certficate ... ssl_certificate_key ... server { listen 127.0.0.1:8080 quic reuseport; location / { return 200 "Hello, world\n"; } } }
По сути, это аналог механизма sticky cookie в балансировщиках нагрузки. Это решало проблему миграции клиентов, но всё равно плохо работало в сложных сценариях. К тому же такая схема раскрывала внешнему миру внутренние детали реализации рабочих процессов. Она также выходила из строя при запуске новых мастер‑процессов во время обновления бинарного файла. А при обновлении конфигурации новые пакеты всё равно могли попадать к старым рабочим процессам.
Чтобы минимизировать негативные последствия, было сделано так, чтобы старые рабочие процессы не отвечали на запросы новых соединений или отвечали пакетом retry. Это основывалось на предположении, что клиент сделает несколько попыток, и со временем (когда у него сменится порт или старые процессы завершатся) он попадёт на новый рабочий процесс, где сможет успешно установить соединение. Конечно, это решение не было идеальным и приводило к временной деградации сервиса после перезагрузки конфигурации.
Использование клиентских сокетов
В какой‑то момент стало понятно, что у каждого клиента должен быть свой сокет — по аналогии с TCP. Фактически нам потребовался аналог accept() для QUIC. Данный подход и был реализован при помощи BPF в последних версиях Angie. Это позволило решить проблемы, возникавшие при обновлении конфигурации и исполняемого файла. Ниже приведена схема нового подхода — как видите, она существенно сложнее предыдущей.

Теперь BPF‑модуль осведомлён о количестве запущенных экземпляров Angie и числе рабочих процессов в каждом из них. Каждый экземпляр поддерживает таблицу со списком принятых соединений (соответствие немодифицированного DCID конкретному клиентскому сокету). Кроме того, в ядре ведётся таблица со слушающими сокетами для каждого экземпляра.
Каждый пакет, направленный на порт, который Angie слушает с опцией quic, сначала попадает в BPF‑программу. Она запускает процесс выбора сокета (и, следовательно, рабочего процесса) для обработки, последовательно проверяя условия:
Наличие Session ID: если в пакете есть известный идентификатор сессии — выбирается сокет уже существующего соединения.
Новое соединение: если сессия не найдена — пакет считается запросом на новое соединение.
Выбор экземпляра: для новых соединений случайным образом выбирается экземпляр Angie, который будет их обслуживать (если запущено более одного мастера).
Выбор сокета: далее по хешу клиента выбирается слушающий сокет конкретного экземпляра.
Теперь, когда рабочий процесс получает запрос на создание нового соединения через слушающий сокет, он создаёт новый клиентский сокет и через BPF добавляет в таблицу запись с соответствующим DCID. Это гарантирует, что все последующие пакеты будут доставлены именно в этот сокет.
При завершении работы экземпляра рабочий процесс закрывает слушающий сокет, удаляет записи о нём из BPF‑таблиц и перестаёт принимать новые запросы, в то время как старые соединения продолжают стабильно работать. При обновлении конфигурации или запуске нового мастера и старые, и новые процессы корректно обновляют записи в таблицах ядра, что позволяет BPF‑модулю безошибочно маршрутизировать трафик в любой ситуации.
Изменение конфигурации BPF-модуля
Важно отметить: включая BPF‑модуль в конфигурации, вы не просто меняете внутренние настройки Angie, но и модифицируете глобальные объекты в ядре (привязанные к reuseport‑группе сокетов). Единожды включив BPF, его нельзя выключить без полного перезапуска процессов. Даже если вы уберёте его из новой конфигурации, программа, загруженная предыдущей версией, всё равно останется в ядре.
Размер таблицы активных соединений ограничен и рассчитывается по формуле:N = worker_connections × MAX_SERVER_IDS, где:
worker_connections— значение соответствующей директивы в конфигурации, при которой были порождены BPF‑таблицы;MAX_SERVER_IDS— ��аксимальное количество QUIC Server ID на одно соединение (сейчас это предустановленное значение, равное 8).
Здесь требуется пояснение: идентификатор соединения (Connection ID) в протоколе QUIC может меняться многократно в течение сессии, чтобы затруднить отслеживание самого факта наличия соединения (для повышения приватности). Поэтому в любой момент времени может существовать более одного ID, относящегося к конкретному соединению. А MAX_SERVER_IDS как раз задаёт лимит на количество таких одновременно действующих идентификаторов.
Заключение
Подводя итог, можно сказать, что переход на HTTP/3 — это не просто смена версии протокола, а фундаментальное изменение парадигмы передачи данных в вебе. Основная сложность заключается не в семантике HTTP, которая осталась прежней, а в необходимости адаптации серверного ПО к принципиально иной модели соединений, основанной на QUIC и UDP.
Вот основные архитектурные отличия при использовании протоколов HTTP/1, 2 и HTTP/3 в Angie на текущий момент:
HTTP/1.1 | HTTP/2 | HTTP/3 | |
Представление | Текст | Бинарное | |
Транспорт | TCP | QUIC поверх UDP | |
Безопасность | TLS поверх TCP | QUIC (TLS интегрирован в протокол) | |
Сетевой стек (транспортный уровень) | Реализация в ядре | Реализация в Angie | |
Потоки внутри соединения | Нет | Есть, но могут блокировать друг друга (HoL) | Потоки независимы |
Шифрование | Опционально | Опционально* | Неотъемлемая часть протокола |
Выбор процесса для соединения | Выбирает ядро (как результат системного вызова | Выбирает BPF‑модуль при получении UDP пакета на основании данных о соединениях от Angie | |
Выбор протокола клиентом | Предопределенный порт 80, ALPN список для TLS на порту 443 | Заголовок Alt‑Svc в ответе, список протоколов в DNS записи. | |
Совместимость | Все поддерживаемые ОС | BPF‑модуль доступен только для Linux, для других ОС поддержка лимитирована (один рабочий процесс, изменение конфигурации может прерывать существующие соединения) | |
В отличие от TCP, где ядро операционной системы берёт на себя всю сложность управления соединениями и их балансировки между процессами, в мире QUIC эта ответственность ложится на само приложение. Как мы увидели на примере Angie, это порождает ряд нетривиальных задач: от первичной балансировки пакетов до поддержки сложных сценариев вроде миграции соединений и бесшовного обновления конфигурации или бинарного файла.
Эволюция решений в Angie — от примитивного подхода с SO_REUSEPORT, унаследованного из nginx, до сложной системы с индивидуальными клиентскими сокетами и множеством таблиц в BPF — наглядно демонстрирует, как новые стандарты интегрируются в проверенные временем многопроцессные архитектуры. Ключевым достижением стало создание аналога accept() для QUIC с помощью eBPF, что позволило вернуть систему к привычной и надёжной модели обработки соединений. Несмотря на возросшую сложность и зависимость от специфических возможностей Linux, этот подход открывает путь к стабильной и высокопроизводительной работе HTTP/3 в высоконагруженных средах.