Компьютерные сети UDP и QUIC: когда без TCP лучше и что изменил QUIC
0%

UDP и QUIC: когда без TCP лучше и что изменил QUIC

UDP и QUIC: когда без TCP лучше и что изменил QUIC

В предыдущей статье TCP выглядел готовым ответом: надёжность, порядок, управление потоком и перегрузкой приложены к сокету бесплатно. Проблема в том, что «бесплатно» здесь значит «без выбора». Вы не скажете TCP: «этот кадр видеозвонка устарел, не ретранслируй его». Не скажете: «отдай что пришло, дырку переживу». Не сохраните соединение при переходе с Wi-Fi на LTE. И не обновите TCP: он живёт в ядрах миллиардов устройств и в памяти миллионов middlebox’ов, уверенных, что знают, как выглядит правильный сегмент.

Статья — про две реакции на эти ограничения. Старая: не пользоваться транспортом вообще. UDP (RFC 768, 1980, три страницы) — это IP плюс два номера порта и контрольная сумма. Новая: построить свой транспорт поверх UDP в user space. Так появился QUIC (RFC 9000, 2021) — не «TCP на UDP», а TCP + TLS + часть HTTP/2, переосмысленные и слитые в один протокол, который выкатывается релизом браузера, а не сменой поколения роутеров.

Аналогия на всю статью: TCP — заказное письмо с уведомлением, вы платите за трек-номер, повторную доставку и строгую очерёдность. UDP — открытка, брошенная в ящик: дёшево, быстро, без обещаний. QUIC — курьерская служба, которую вы наняли и настроили сами: возит открытки, но по вашим правилам нумерации, срокам и шифрованию, и правила вы меняете, когда хотите.


Часть I. UDP: транспорт, который почти ничего не делает

Восемь байт и полторы гарантии

Весь UDP — четыре 16-битных поля. Что он добавляет к IP:

Что даёт Как Цена
Демультиплексирование номера портов отправителя и получателя 4 байта
Обнаружение порчи (слабое) 16-битная сумма по данным и псевдозаголовку IP 2 байта; в IPv4 отключается значением 0
Границы сообщений одна датаграмма = один recvfrom() размер ограничен путём

Всё. Ни номеров последовательности, ни подтверждений, ни таймеров, ни состояния соединения в ядре, ни рукопожатия, ни управления потоком и перегрузкой. UDP-сокет — именованная точка входа в IP: «доставь как получится, получателю сообщи, кому отдавать».

Три следствия, которые надо принять сразу. Датаграмма атомарна: приходит целиком или не приходит; фрагментированный на IP-уровне пакет собирает ядро, и потеря одного фрагмента убивает всю датаграмму. Порядок не гарантирован: при ECMP-балансировке по разным путям перестановки происходят регулярно, а не теоретически. Дубликаты возможны — их создаёт не UDP, а сеть: петля маршрутизации, дублирование на туннеле, ретрансмиссия на канальном уровне.

Про контрольную сумму: в IPv4 она необязательна (0 = «не считал»), в IPv6 обязательна (RFC 8200), потому что в IPv6-заголовке нет собственной суммы; исключение — туннели (RFC 6935). Даже включённая сумма ловит не всё: 16 бит — одна пропущенная ошибка на 65 тысяч испорченных пакетов. Поэтому серьёзные протоколы поверх UDP считают собственную криптографическую целостность (QUIC, WireGuard, DTLS).

Демультиплексирование и connect() на UDP-сокете

TCP-соединение определяется 4-кортежем. UDP-сокет по умолчанию определяется 2-кортежем (локальный адрес, локальный порт) и принимает датаграммы от кого угодно — отсюда обязанность приложения смотреть на адрес отправителя, который возвращает recvfrom(), и не доверять ему.

Но есть важнейший приём — connect() на UDP-сокете. Он не отправляет ни одного пакета, а лишь фиксирует в ядре удалённый 2-кортеж:

$ ss -u -a -n
State   Recv-Q  Send-Q   Local Address:Port    Peer Address:Port
UNCONN  0       0              0.0.0.0:68            0.0.0.0:*    # DHCP-клиент, слушает всех
UNCONN  0       0           127.0.0.53:53            0.0.0.0:*    # systemd-resolved
ESTAB   0       0         192.168.1.10:51423        1.1.1.1:53    # «подключённый» сокет резолвера
  • Сокет принимает датаграммы только от этого пира — ядро фильтрует за вас.
  • Можно send() вместо sendto(): маршрут ищется один раз и кэшируется, на высоких пакетных скоростях экономия заметна.
  • Начинают доставляться ICMP-ошибки. Это главное: без connect() ядру некому отдать ICMP «port unreachable», и ошибка молча теряется.

Что происходит, когда никто не слушает

$ sudo tcpdump -n -i lo -c 2 'udp port 9999 or icmp'
14:22:07.551204 IP 127.0.0.1.42311 > 127.0.0.1.9999: UDP, length 12
14:22:07.551239 IP 127.0.0.1 > 127.0.0.1: ICMP 127.0.0.1 udp port 9999 unreachable, length 48

ICMP type 3 code 3. Приложение увидит это как ECONNREFUSED, но только на connected-сокете и только на следующей операции:

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("127.0.0.1", 9999))   # ни одного байта в сети не появилось
s.send(b"hello")                  # успешно: датаграмма ушла
s.settimeout(1.0)
try:
    s.recv(1024)                  # здесь прилетит ICMP-ошибка от предыдущего send
except ConnectionRefusedError:
    print("порт закрыт")

Асинхронность ошибок — фундаментальное свойство UDP: об ошибке предыдущей отправки вы узнаёте во время следующей операции, а часто не узнаёте вовсе, потому что ICMP массово фильтруется на фаерволах (см. NAT, фаерволы и VPN). Никогда не стройте логику только на ICMP — нужен собственный таймаут.

Размер датаграммы: 65507, 1472, 1232 и 512

Теоретический максимум полезной нагрузки в IPv4 — 65535 − 20 (IP) − 8 (UDP) = 65507 байт. Практический куда меньше:

Числа, которые стоит помнить: 1472 — максимум UDP-данных в IPv4 при MTU 1500; 1452 — то же для IPv6; 1200 — размер, который QUIC требует поддерживать на любом пути; 1232 — рекомендованный максимум ответа DNS по UDP (1280 − 40 − 8), закреплённый DNS Flag Day 2020; 512 — исторический предел ответа DNS без EDNS0 (RFC 1035).

Правило простое: фрагментация IP — не оптимизация, а поломка. Датаграмма 4 КБ идёт тремя фрагментами, и при потере пакета 1% вероятность потери датаграммы почти 3%. Проверить MTU пути:

$ ping -M do -s 1472 -c 1 1.1.1.1     # -M do = запретить фрагментацию
1480 bytes from 1.1.1.1: icmp_seq=1 ttl=57 time=8.34 ms
$ ping -M do -s 1473 -c 1 1.1.1.1
ping: local error: message too long, mtu=1500

Через туннель MTU меньше: типично 1420 для WireGuard, 1450 для VXLAN. Если приложение шлёт по 1472 байта, а трафик заворачивается в туннель — вы платите фрагментацией за каждый пакет.

Что вы обязаны написать сами

Выбирая UDP, вы не «избавляетесь от накладных расходов» — вы берёте на себя обязательства, которые TCP выполнял за вас:

Всё в этой карте TCP реализует давно, тщательно и на миллиардах устройств. Ваша реализация будет проще и почти наверняка хуже — особенно управление перегрузкой, которое легко написать «работает у меня» и очень трудно написать «не убивает сеть под нагрузкой». Отсюда правило:

Если вам нужна надёжная доставка поверх UDP — вам нужен не UDP, а QUIC. Библиотеки написаны, RFC отлажены.

UDP оправдан ровно тогда, когда какая-то из гарантий TCP вам вредна, а не просто не нужна.

Когда UDP действительно лучше

  • DNS. Пакет туда, пакет обратно; рукопожатие TCP удвоило бы задержку каждого резолва. Отсюда предел 512 байт и переход на TCP при усечении ответа (флаг TC).
  • Игры реального времени. Позиция игрока, отправленная 200 мс назад, бесполезна: пришла новая. Ретрансмиссия старого состояния только съест канал. Типичный подход — слать полное состояние 20–60 раз в секунду и не переспрашивать вовсе.
  • Голос и видео (RTP/RTCP, RFC 3550). Потерянный аудиокадр маскируется интерполяцией за микросекунды; ожидание ретрансмиссии в 100 мс слышно как заикание. Это база WebRTC — см. реальное время.
  • Метрики и логи (statsd, syslog по UDP). Потеря части сэмплов приемлема, блокировка приложения на медленном сборщике — нет: UDP никогда не заблокирует отправителя.
  • Туннели и оверлеи: WireGuard, VXLAN, GENEVE, GTP-U, IPsec/NAT-T. Здесь UDP проходит через NAT и не создаёт «TCP поверх TCP» — патологию, при которой два механизма ретрансмиссии борются друг с другом и латентность взрывается.
  • Синхронизация времени (NTP, PTP): важна не надёжность, а минимальная и симметричная задержка; очередь ретрансмиссий испортила бы измерение.

UDP в проде: где именно теряются пакеты

Неочевидный факт: на нормальном сервере большая часть потерь UDP происходит не в сети, а в буфере приёма вашего же сокета. Приложение не успевает читать, буфер переполняется, ядро молча выбрасывает датаграммы. Ни ошибки в логах, ни ICMP — просто пропавшие данные.

$ nstat -az | grep -Ei 'udp(indatagrams|noports|inerrors|rcvbuferrors)'
UdpInDatagrams        482913377    0.0
UdpNoPorts            1204         0.0     # прилетело на порт, который никто не слушает
UdpInErrors           93172        0.0
UdpRcvbufErrors       93172        0.0     # <-- переполнение буфера сокета: не успеваем читать

# Кто именно переполняется: -m показывает память сокета, d = дропы этого сокета
$ ss -u -a -n -m -p
State  Recv-Q  Send-Q  Local Address:Port  Peer Address:Port  Process
UNCONN 212992  0             0.0.0.0:8125       0.0.0.0:*      users:(("statsd",pid=2117,fd=12))
     skmem:(r212992,rb212992,t0,tb212992,f0,w0,o0,bl0,d93172)

Recv-Q равен rb (лимит буфера) — сокет забит под завязку; d93172 — сколько датаграмм уже выброшено. Лечение по убыванию эффективности:

  1. Читать быстрее: отдельный поток, который только принимает и складывает в очередь; обработка — дальше по конвейеру.
  2. recvmmsg()/sendmmsg() — пачками, один системный вызов на десятки датаграмм. На 100k+ пакетов/с разница кратная.
  3. SO_REUSEPORT — N процессов на одном порту, ядро раскладывает по хэшу 4-кортежа.
  4. Увеличить буфер — последнее средство, оно лишь сдвигает момент переполнения:
sudo sysctl -w net.core.rmem_max=8388608     # потолок для SO_RCVBUF
sudo sysctl -w net.core.rmem_default=1048576 # дефолт для сокетов, которые ничего не просили
sudo sysctl -w net.core.netdev_max_backlog=5000  # очередь между NIC и стеком, сюда упираются при burst

Ещё два места потерь и их счётчики: очередь сетевой карты (ip -s link show eth0, ethtool -S eth0 | grep -i drop) и conntrack, если на пути NAT или фаервол (nf_conntrack: table full, dropping packet в dmesg).

Код: «надёжный слой», который придётся писать всегда

def request(sock, addr, payload, attempts=3, timeout=0.3):
    """Запрос-ответ поверх UDP: свой идентификатор, свой таймаут, свой ретрай."""
    req_id = int(time.time() * 1000) & 0xFFFFFFFF
    packet = struct.pack("!I", req_id) + payload
    for attempt in range(attempts):
        sock.sendto(packet, addr)
        wait = timeout * (2 ** attempt)          # 0.3, 0.6, 1.2 с
        deadline = time.monotonic() + wait
        sock.settimeout(wait)
        while time.monotonic() < deadline:
            try:
                data, src = sock.recvfrom(65535)  # буфер меньше датаграммы = молча отрезанный хвост
            except socket.timeout:
                break
            if src != addr:                       # чужой отправитель — игнорируем
                continue
            if struct.unpack("!I", data[:4])[0] != req_id:  # ответ на ПРОШЛУЮ попытку
                continue                                    # ...ради этого и нужен req_id
            return data[4:]
    raise TimeoutError("нет ответа после %d попыток" % attempts)

Две строчки здесь встречаются в багрепортах чаще всего: проверка отправителя и проверка идентификатора запроса. Без второй запоздавший ответ на первую попытку будет принят за ответ на вторую — тихая рассинхронизация, которую невозможно воспроизвести. Сервер обязан быть идемпотентным: дубликаты неизбежны (см. семантику доставки).

Тёмная сторона: усиление и отражение

UDP не имеет рукопожатия, а значит, не проверяет адрес отправителя. Подделав исходный IP, атакующий заставляет сервер отвечать жертве. Если ответ больше запроса — получается усиление:

Протокол Усиление Механизм
memcached (UDP, 11211) до 50 000× stats items возвращает мегабайты
NTP monlist ~550× список последних 600 клиентов
DNS ANY / DNSSEC 30–60× большой ответ на 60-байтный запрос
CLDAP, SSDP 30–50× ответ на короткий запрос

Так были получены рекордные DDoS: 1.35 Тбит/с на GitHub в 2018 году — memcached-усиление. Выводы для любого UDP-сервиса: не отвечайте больше, чем получили, пока адрес не подтверждён (ровно это QUIC зафиксировал как правило трёх крат); ограничивайте скорость ответов по адресу источника (в DNS это RRL); не выставляйте UDP-сервисы наружу без нужды; включайте BCP38 (RFC 2827) на границах — не выпускать пакеты с чужим исходным адресом. Подробнее — в треке по безопасности.

Отдельно про темп. Внесём потери и посмотрим, что будет:

$ sudo tc qdisc add dev eth0 root netem loss 3% delay 80ms 20ms
$ iperf3 -c 10.0.0.5 -u -b 50M -t 10
[ ID] Interval        Transfer     Bitrate         Jitter    Lost/Total Datagrams
[  5] 0.00-10.00 sec  59.6 MBytes  50.0 Mbits/sec  0.412 ms  1301/43142 (3%)
$ sudo tc qdisc del dev eth0 root

Битрейт остался ровно 50 Мбит/с: UDP не замедляется от потерь. Это и свобода, и опасность — приложение с фиксированной скоростью при перегрузке продолжает давить, вытесняя честный TCP-трафик. Отсюда прямое требование RFC 8085: любое приложение на UDP, посылающее больше пары пакетов, обязано реализовать управление перегрузкой.


Часть II. QUIC: транспорт, который можно обновлять

Почему TCP нельзя было починить

Проблема не в идеях TCP, а в том, что он окостенел (ossification). Два независимых механизма. Первый: TCP живёт в ядре, новая опция требует апдейта ОС на обоих концах, средний срок проникновения фичи — 10–15 лет (TCP Fast Open опубликован в 2014-м и до сих пор часто выключен). Второй: middlebox’ы читают и правят заголовки — NAT, фаерволы, «оптимизаторы», DPI в сотовых сетях. Они вырезают неизвестные опции, переписывают номера последовательности, рвут соединения с непонятными флагами; Multipath TCP не проходил примерно через треть путей в интернете.

Урок SCTP и DCCP решающий: новый протокол поверх IP развернуть невозможно, потому что для NAT и фаерволов существуют только TCP, UDP и ICMP. Поэтому QUIC спрятался внутрь UDP. А чтобы middlebox’ы не начали «оптимизировать» и его, QUIC шифрует почти весь свой заголовок — это не только приватность, но и защита от окостенения на будущее.

Что такое QUIC в одном абзаце

QUIC — надёжный, мультиплексированный, всегда зашифрованный транспорт поверх UDP в user space. Он забирает себе: установку соединения (как TCP), шифрование и аутентификацию (TLS 1.3 — встроенный, а не «поверх»), надёжную доставку и управление перегрузкой (как TCP, но точнее), мультиплексирование независимых потоков (как HTTP/2, но без head-of-line blocking) и идентификатор соединения, не привязанный к IP-адресу (чего нет нигде). Опорные документы: RFC 8999 — инварианты, единственное, что обещано всем версиям навсегда; RFC 9000 — транспорт; RFC 9001 — встроенный TLS 1.3; RFC 9002 — потери и перегрузка.

Анатомия пакета: что видно в проводе

Раскладка UDP-датаграммы и QUIC-пакетов: что открыто, что зашифровано

Иерархия, которую часто путают: датаграмма UDP может содержать несколько QUIC-пакетов (coalescing — так Initial и Handshake едут вместе); QUIC-пакет имеет номер и содержит фреймы; фрейм — единица смысла: STREAM, ACK, CRYPTO (данные TLS), MAX_DATA, PING, CONNECTION_CLOSE, NEW_CONNECTION_ID, PATH_CHALLENGE и ещё около двадцати типов.

Главное архитектурное различие с TCP: номер пакета и позиция данных в потоке — разные вещи. В TCP номер последовательности означает одновременно «какой пакет» и «какое место в потоке», отсюда неоднозначность ретрансмиссии: получив ACK, вы не знаете, на оригинал он или на копию (отсюда алгоритм Карна и загрубление оценки RTT). В QUIC номер пакета строго растёт и никогда не переиспользуется: потерянные данные едут заново в новом пакете с новым номером. Каждое подтверждение однозначно, каждый замер RTT точен.

Рукопожатие: сколько стоит первый байт

Экономия в один RTT кажется мелочью, пока не посчитать: при RTT 150 мс (мобильная сеть, межконтинентальный путь) это 150 мс на каждом новом соединении, то есть половина воспринимаемой задержки загрузки страницы с ресурсами на нескольких доменах.

Про 0-RTT важен риск, а не только выгода. Данные шифруются ключом из билета прошлой сессии и отправляются до того, как сервер подтвердил свою свежесть — значит, атакующий может записать пакет и воспроизвести позже, сервер не отличит. Отсюда правило: в 0-RTT только идемпотентные запросы. HTTP/3 даже завёл код 425 Too Early, которым сервер просит повторить запрос уже в защищённом режиме (RFC 9001, раздел 9.2).

Потоки: устранение head-of-line blocking

Сравнение блокировки головы очереди в TCP и в QUIC

HTTP/2 научился мультиплексировать запросы в одно TCP-соединение — и уткнулся в то, что TCP про эти потоки ничего не знает. Одна потеря останавливает выдачу всем потокам, потому что ядро обязано соблюсти порядок байтов. На хорошей сети выигрыш от мультиплексирования перевешивает; на сети с 2% потерь HTTP/2 умеет проигрывать шести параллельным соединениям HTTP/1.1.

QUIC переносит понятие потока в транспорт. Идентификатор потока — переменной длины, младшие два бита кодируют вид:

Биты Тип потока Инициатор Пример
0x00 двунаправленный клиент запрос-ответ HTTP/3
0x01 двунаправленный сервер зарезервировано, используется редко
0x02 однонаправленный клиент управляющий поток, QPACK-энкодер
0x03 однонаправленный сервер server push, QPACK-декодер

Потоков могут быть тысячи, создание потока ничего не стоит (нет рукопожатия — просто первый STREAM-фрейм с новым ID), а порядок гарантируется внутри потока, а не между потоками. Честная оговорка: HOL-блокировка не исчезает полностью — QPACK (RFC 9204) со сжатием заголовков через динамическую таблицу вносит зависимость между потоками, и в нём есть режимы, позволяющие торговать степенью сжатия на независимость. Детали — в статье про HTTP.

Жизненный цикл потока

Возможность отменить один поток недооценена. Пользователь ушёл со страницы, а картинка на 2 МБ ещё качается: HTTP/3 шлёт RESET_STREAM, сервер прекращает передачу, канал освобождается для нужного.

Надёжность: чем QUIC точнее TCP

RFC 9002 описывает механизмы, которые в TCP появлялись двадцать лет отдельными заплатками, — здесь они встроены с первого дня:

Механизм В TCP В QUIC
Селективное подтверждение опция SACK, договаривается в SYN, до 3 блоков ACK-фрейм всегда несёт диапазоны, их до 2^62
Неоднозначность ретрансмиссии есть; лечится алгоритмом Карна ценой точности нет: номера пакетов не переиспользуются
Задержка ACK неявная, приёмник её не сообщает поле ACK Delay в микросекундах — RTT считается точно
Обнаружение потери 3 дубликата ACK + RACK-TLP (RFC 8985) порог по номеру пакета (3) и по времени (9/8 RTT) сразу
Таймаут RTO, минимум обычно 200 мс PTO с зондами, без резкого обнуления окна
ECN опционально, часто ломается на пути счётчики ECT0/ECT1/CE прямо в ACK-фрейме
Пространства номеров одно три: Initial, Handshake, Application

Управление перегрузкой при этом то же самое: RFC 9002 описывает NewReno как эталон, реальные реализации берут CUBIC или BBR — те же алгоритмы, что в TCP, и канал QUIC делит с ним честно. Принципиальная разница в том, что алгоритм теперь в вашем процессе: смена CUBIC на BBR — флаг в конфиге библиотеки, а не sysctl и тем более не апдейт ядра у клиента.

Connection ID и миграция соединения

TCP-соединение — это 4-кортеж. Сменился IP клиента (Wi-Fi → LTE, смена NAT-порта, засыпание телефона) — соединение мертво: рукопожатие, TLS, slow start заново. QUIC идентифицирует соединение Connection ID, к адресу отношения не имеющим: пакет с знакомым CID с нового адреса — то же соединение.

Две детали, которые легко упустить. Зачем менять CID при миграции: оставь клиент старый, наблюдатель на пути связал бы «домашний» и «мобильный» адреса одного пользователя — поэтому сервер заранее раздаёт пул CID, а клиент берёт свежий; это встроенная защита от корреляции. Окно перегрузки сбрасывается: новый путь — новые характеристики, переносить cwnd было бы опасно; миграция экономит рукопожатие и TLS, но не разгон.

Есть ещё stateless reset: потеряв состояние (перезапуск, попадание на другой инстанс), сервер не может послать CONNECTION_CLOSE — у него нет ключей. Вместо этого он шлёт пакет, оканчивающийся заранее выданным токеном сброса; клиент узнаёт токен и честно закрывает соединение вместо ожидания таймаута. Аналог RST, но не подделываемый.

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

QUIC-сервер отвечает на первый же пакет сертификатом — готовый усилитель, если не поставить ограничитель. RFC 9000, раздел 8 вводит два правила. Правило трёх крат: сервер не отправляет более трёх объёмов полученного, пока адрес не подтверждён; поэтому датаграмма с Initial обязана быть дополнена паддингом до 1200 байт, иначе сертификат не уложится в лимит и рукопожатие затянется на лишние круги. Retry: при подозрении на атаку сервер отвечает пакетом Retry с криптографическим токеном, не создавая состояния; клиент повторяет Initial с токеном — адрес подтверждён. Прямой аналог SYN cookies, но встроенный в протокол, а не пришитый сбоку.

Управление потоком в QUIC двухуровневое, и это необходимо: без лимита на поток один жадный поток съел бы весь буфер соединения и заблокировал остальные (ровно та проблема, которую HTTP/2 решал своим flow control поверх TCP-окна, получив два независимых механизма, мешающих друг другу). MAX_STREAM_DATA — сколько байт можно отправить в конкретный поток, MAX_DATA — сколько суммарно по соединению, MAX_STREAMS — сколько потоков разрешено открыть. Симметрично есть DATA_BLOCKED и STREAM_DATA_BLOCKED: отправитель явно сообщает «я упёрся в ваш лимит». Диагностически это золото — в TCP аналогичная ситуация (zero window) видна только по дампу.

Чем платим за QUIC

  • Процессор. Каждый пакет расшифровывается отдельно, сборка потоков идёт в user space, системных вызовов больше. Первые продакшн-замеры Google давали примерно двукратный расход CPU на байт против TLS/TCP; сегодня разрыв меньше благодаря UDP GSO/GRO и sendmmsg, но он есть (SIGCOMM 2017).
  • Нет разгрузки в железе. У TCP есть TSO/LRO/checksum offload, а часто и kTLS; для QUIC аппаратная поддержка только появляется.
  • UDP блокируют и тротлят. В части корпоративных и мобильных сетей UDP/443 закрыт или ограничен по скорости, поэтому любой QUIC-клиент обязан падать обратно на TCP — браузеры гоняют попытки параллельно.
  • Отладка сложнее: tcpdump показывает только «UDP, length 1252»; нужны ключи сессии или qlog.
  • Балансировка сложнее: L4-балансировщик по 4-кортежу ломает миграцию.
  • Меньше видимости для инфраструктуры: IDS и корпоративные прокси не видят внутрь. Для кого-то фича, для кого-то блокирующее требование комплаенса.

Практика: включить, проверить, отладить

Нужен curl, собранный с ngtcp2/quiche (curl --version должен показывать HTTP3 в Features). Живой запрос и что в нём видно:

$ curl -sv --http3-only -o /dev/null https://cloudflare-quic.com/ 2>&1 | grep -E '^[*<>]' | head -14
*   Trying 104.18.6.161:443...
* QUIC cipher selection: TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
* Connected to cloudflare-quic.com (104.18.6.161) port 443
* using HTTP/3
* [HTTP/3] [0] OPENED stream for https://cloudflare-quic.com/
> GET / HTTP/3
< HTTP/3 200
< alt-svc: h3=":443"; ma=86400

Сравнение версий одной командой — самый убедительный аргумент в спорах:

$ for v in --http1.1 --http2 --http3-only; do
    printf "%-14s " "$v"
    curl -s -o /dev/null $v -w 'connect=%{time_connect} appconnect=%{time_appconnect} ttfb=%{time_starttransfer}\n' \
         https://cloudflare-quic.com/
  done
--http1.1      connect=0.031 appconnect=0.074 ttfb=0.108
--http2        connect=0.031 appconnect=0.073 ttfb=0.106
--http3-only   connect=0.000 appconnect=0.038 ttfb=0.071
#              ^^^^^^^^^^^^^ у QUIC нет отдельной фазы TCP-соединения:
#              транспорт и криптография — одно рукопожатие

Как клиент узнаёт, что сервер умеет HTTP/3? Заголовком Alt-Svc: h3=":443"; ma=86400 в ответе по HTTP/1.1 или HTTP/2 (тогда следующее соединение пойдёт по QUIC) либо заранее — из DNS-записи HTTPS (RFC 9460), что убирает лишний круг:

$ dig +short HTTPS cloudflare.com
1 . alpn="h3,h2" ipv4hint=104.16.132.229,104.16.133.229
#     ^^^^^^^^^^ сервер заявляет h3 ещё до первого соединения

Что видно в tcpdump — и почему этого мало:

$ sudo tcpdump -n -i any -c 6 'udp port 443'
15:41:02.118374 IP 192.168.1.10.49221 > 104.18.6.161.443: UDP, length 1252
15:41:02.118512 IP 192.168.1.10.49221 > 104.18.6.161.443: UDP, length 1252
15:41:02.147201 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 1252
15:41:02.147203 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 1252
15:41:02.147204 IP 104.18.6.161.443 > 192.168.1.10.49221: UDP, length 706

Первые две датаграммы ровно по 1252 байта — это Initial с паддингом; три ответных подряд — сертификат, упирающийся в лимит трёхкратного усиления. Больше tcpdump не скажет ничего: ни номеров пакетов, ни ACK, ни потерь. ss тоже бесполезен — покажет один UNCONN-сокет без состояния, потому что состояния соединения в ядре просто нет, оно в памяти процесса.

Что видно в Wireshark

Без ключей фильтр quic показывает версию, Connection ID, тип пакета — и, что удивляет новичков, полностью разобранный пакет Initial, включая ClientHello с SNI и ALPN. Это не дыра: ключи Initial выводятся из DCID по опубликованной в RFC 9001, раздел 5.2 соли, чтобы промежуточные узлы могли отвечать корректными сообщениями об ошибке. Всё после Initial — шифротекст. С ключами — классический приём:

export SSLKEYLOGFILE=/tmp/quic-keys.log     # curl, Chrome и Firefox пишут секреты сессии сюда
curl --http3-only https://cloudflare-quic.com/ -o /dev/null
# Wireshark: Preferences -> Protocols -> TLS -> (Pre)-Master-Secret log filename

После этого видны фреймы, и работают полезные фильтры:

quic.long.packet_type == 0        # только Initial
quic.frame_type == 0x02           # ACK-фреймы
quic.stream.stream_id == 0        # первый запрос HTTP/3
quic.connection.id                # проследить миграцию: тот же CID, другой IP
http3                             # разобранный HTTP/3 поверх расшифрованного QUIC

Потеря пакета в расшифрованном дампе выглядит как пропуск номера пакета, за которым следует ACK-фрейм с диапазоном без этой дырки; данные приезжают заново в пакете с новым номером. Сравните с TCP, где ретрансмиссия несёт тот же seq и Wireshark рисует [TCP Retransmission].

Есть и то, чего у TCP не было никогда, — qlog: реализации пишут структурированный лог событий транспорта (отправленные пакеты, признанные потерянными, эволюция cwnd и RTT) в JSON, а qvis рисует по нему интерактивные графики. Это отладка изнутри стека, а не догадки по дампу: QLOGDIR=/tmp/qlog ./client https://example.com/ умеют quic-go, quiche и ngtcp2.

Прод: что настроить на сервере

server {
    listen 443 ssl;                 # HTTP/1.1 и HTTP/2 по TCP
    listen 443 quic reuseport;      # HTTP/3 по UDP; reuseport — ровно на одном server-блоке
    http2 on;
    http3 on;
    ssl_protocols TLSv1.3;          # QUIC требует именно TLS 1.3

    add_header Alt-Svc 'h3=":443"; ma=86400' always;  # иначе браузер не узнает про h3
    ssl_early_data off;             # 0-RTT включают осознанно: см. риск повтора
}

Обязательный системный тюнинг — про буферы UDP, потому что стек QUIC читает сокет из user space и штрафуется за каждую задержку планировщика:

# Рекомендация авторов quic-go и большинства реализаций: не меньше 7.5 МБ
sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000

# Если UdpRcvbufErrors растёт — ваш QUIC-сервер «видит» потери, которых в сети не было,
# и душит окно перегрузки. Медленно, без единой ошибки в логах.
watch -n1 "nstat -az | grep -E 'UdpRcvbufErrors|UdpInErrors'"

sudo ethtool -K eth0 rx-gro-list on   # UDP GRO: ядро отдаёт пачку датаграмм одним recvmsg

Со стороны приложения тот же смысл имеет UDP_SEGMENT (GSO): один sendmsg на 64 КБ, ядро режет на MTU; quic-go, quiche и msquic используют это автоматически, если ядро умеет.

Балансировка. Классический L4-балансировщик хэширует 4-кортеж — и ломает миграцию: после смены адреса пакеты уедут на бэкенд, который про соединение ничего не знает. Правильный способ — кодировать идентификатор сервера внутрь Connection ID (подход QUIC-LB), тогда маршрутизация идёт по CID и миграция работает; то же требуется для anycast. Пока балансировщик не QUIC-aware, миграция у вас на бумаге, и обнаружится это только на мобильных клиентах. Тема продолжается в прокси и балансировке и CDN и edge.

Когда QUIC выигрывает, а когда нет

Условия Кто быстрее Почему
Мобильная сеть, RTT 100–300 мс, потери 1–5% QUIC заметно нет HOL-блокировки, экономия RTT, лучшее восстановление
Много мелких объектов в одном соединении QUIC потоки независимы, отмена дешёвая
Переключение Wi-Fi ↔ LTE, роуминг QUIC миграция соединения без пересоздания
Короткие API-запросы к новому хосту QUIC 1-RTT вместо 2, 0-RTT при повторе
Датацентр, RTT меньше 1 мс, потерь нет TCP выигрыш от RTT нулевой, offload и kTLS решают
Крупные файлы на CPU-bound сервере TCP sendfile и TSO против расшифровки каждого пакета
Сеть, где UDP тротлится TCP QUIC упрётся в policer

Вывод: QUIC — протокол для интернета с плохими и меняющимися путями, а не универсальный ускоритель. Внутри датацентра gRPC поверх HTTP/2 и TCP остаётся разумным выбором (см. RPC и gRPC). Решает измерение, а не вера; методика — в треке про производительность.

Из QUIC уже выросло больше, чем HTTP/3: DATAGRAM-фреймы (RFC 9221) дают UDP-семантику внутри защищённого соединения — с шифрованием, управлением перегрузкой и обходом NAT «в комплекте»; на них построен WebTransport (см. реальное время); MASQUE проксирует UDP и IP внутри QUIC, и на этом стоят механизмы приватного релея, где прокси не знает содержимого, а сервер не знает адреса клиента.

Типичные ошибки

  • «UDP быстрее TCP». UDP быстрее ровно до момента, когда вам понадобится надёжность. Наивные ретрансмиссии поверх UDP обычно медленнее и злее к сети, чем TCP, который полировали сорок лет.
  • Отправлять датаграммы больше MTU пути. Фрагментация умножает вероятность потери, а не-первые фрагменты часто отбрасываются фаерволами. Держитесь ниже 1400 байт, на туннелях — ниже 1300.
  • Считать sendto() без ошибки успехом. Ошибка предыдущей отправки прилетит на следующей операции, а без connect() не прилетит вообще.
  • Не проверять адрес отправителя и идентификатор запроса. Первое — открытая дверь для подмены, второе — источник невоспроизводимых рассинхронизаций.
  • Отвечать больше, чем получили, непроверенному адресу. Так строятся усилители DDoS.
  • Диагностировать «сеть теряет пакеты», не посмотрев UdpRcvbufErrors. В большинстве случаев теряет ваш собственный сокет.
  • Слать с фиксированной скоростью, игнорируя потери. RFC 8085 требует управления перегрузкой не из вежливости: без него вы вытесняете чужой трафик.
  • Включить HTTP/3, не проверив, что балансировщик понимает Connection ID. Миграция сломается на мобильных клиентах, а в датацентре вы этого не увидите никогда.
  • Отправлять неидемпотентные запросы в 0-RTT. Повтор записанного пакета выполнит операцию дважды.
  • Ждать от tcpdump и ss картины QUIC-соединения. Состояния в ядре нет; нужны SSLKEYLOGFILE или qlog.

Мини-итог

  • UDP — это IP плюс порты, контрольная сумма и границы сообщений. Всё остальное вы пишете сами, и это обязательство, а не свобода.
  • UDP оправдан, когда гарантии TCP вредны: свежесть важнее полноты (медиа, игры), один запрос-ответ (DNS, NTP), broadcast и multicast, туннели, метрики.
  • Главный источник потерь UDP на сервере — переполнение буфера приёма сокета. Смотрите UdpRcvbufErrors и ss -m до того, как обвинять сеть.
  • Отсутствие рукопожатия делает UDP усилителем DDoS. Правило «не отвечай больше, чем получил, непроверенному адресу» универсально.
  • QUIC появился не потому, что TCP плох, а потому, что TCP нельзя обновить: он в ядрах и в middlebox’ах. Спрятавшись в UDP и зашифровав заголовок, QUIC вернул транспорту способность эволюционировать.
  • QUIC даёт: 1-RTT (и 0-RTT) вместо 2-RTT, потоки без head-of-line blocking, точный RTT без неоднозначности ретрансмиссии, миграцию соединения по Connection ID, обязательное шифрование.
  • Платим процессором, отсутствием аппаратной разгрузки, сложностью отладки и балансировки, необходимостью держать fallback на TCP.
  • Выигрыш QUIC максимален на длинных путях с потерями и на мобильных клиентах, близок к нулю внутри датацентра.

Источники

Что дальше

Мы разобрали два транспорта без гарантий: голый UDP, где всё на вас, и QUIC, где поверх UDP построен полноценный шифрованный транспорт с потоками и миграцией. Оба начинаются с шага, который мы всё это время принимали как данность: где-то надо взять IP-адрес по имени. И именно этот шаг — самый частый источник загадочных «у нас всё работает, а у клиента нет».

DNS: иерархия, типы записей, резолвинг, кэширование, DoH/DoT — про распределённую базу, которая держится на UDP и кэшах, про то, что на самом деле означает TTL, и про то, как записи HTTPS из этой статьи позволяют браузеру узнать про HTTP/3 ещё до первого пакета.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов