Заметка

HTTP/2 во внутреннем трафике: известный класс граблей

Одно соединение на все запросы, общее окно flow control и пул, который не выбрасывает мёртвое: почему сервисы на HTTP/2 зависают «без ошибок» и что с этим сделали Go и gRPC.

HTTP/2 давно стал дефолтом в браузерном вебе, и оттуда постепенно перебрался во внутренние коммуникации сервисов — чаще всего незаметно, вместе с очередным апгрейдом библиотеки или платформы. Вчера ваш клиент ходил в соседний сервис по HTTP/1.1, сегодня после бампа версии — уже по HTTP/2. Ничего в коде не поменялось, всё работает… до первого простоя, всплеска или потерянного пакета.

Этот пост — про набор граблей, который приезжает вместе с HTTP/2 между сервисами. Набор давно известный: gRPC-сообщество прошло его лет десять назад и обвесилось защитами, Go-сообщество — чуть позже и с громкими issue. Но если ваш стек получил HTTP/2 «бесплатно», этих защит в нём, скорее всего, нет.

Что меняет HTTP/2 — и почему каждая фича стреляет

По сравнению с HTTP/1.1 принципиальных изменений три, и у каждого есть обратная сторона.

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

Мультиплексирование стримов. Запросы едут вперемешку, у каждого свой stream id. Удобно — но состояние сессии (счётчики стримов, лимиты, очереди) становится общим ресурсом, который может протечь или исчерпаться. Ошибка учёта в одном запросе отражается на всех следующих.

Flow control на уровне сессии. Помимо окна на каждый стрим, есть общее окно на всё соединение: получатель подтверждает потреблённые байты (WINDOW_UPDATE), отправитель не имеет права слать сверх окна. Если получатель перестал читать — окно выедается до нуля, и все стримы соединения замирают. Так задумано: RFC честно предупреждает, что неаккуратная работа с flow control ведёт к дедлокам.

Из этих трёх особенностей собирается четыре типовых сценария, которые в проде выглядят одинаково: «сервис перестал отвечать, помог только рестарт».

Грабли 1: полуоткрытое соединение

Между двумя сервисами в проде почти всегда кто-то есть: балансировщик, прокси, conntrack, NAT. У многих из них есть таймауты простоя, и убивают соединение они по-разному — кто-то честными RST в обе стороны, а кто-то просто выкидывает запись из своей таблицы. Плюс банальная потеря FIN-пакета при закрытии. Итог один: клиент держит сокет ESTABLISHED, а на другом конце уже никого нет. Чистый TCP заметит это только при записи, через десятки минут ретрансмитов. С HTTP/1.1 такое соединение убило бы один запрос; с HTTP/2 в мёртвый сокет мультиплексируются все. Один из способов вынести эту прослойку из приложения — sidecarless-меш.

Грабли 2: дедлок flow control

Сервер под нагрузкой (GC-пауза, тяжёлая операция, забитый тредпул) перестаёт вычитывать тела запросов. Общее окно сессии — ноль, все отправители стоят. Если сервер к чтению так и не вернулся — соединение забито навсегда, причём на TCP-уровне оно выглядит здоровым. Диагностический признак на стороне сервера: сокеты в CLOSE_WAIT с непрочитанными байтами в Recv-Q — клиенты отчаялись и ушли, сервер так и не прочитал и не закрыл.

Грабли 3: пул, который не выбрасывает мёртвое

Самые коварные грабли — клиентские. Казалось бы: запросы падают по таймауту, выкинь соединение и открой новое. Но многим реализациям для «выкинуть» нужна ошибка сокета, а её нет — сокет жив (грабли 1) или формально жив (грабли 2). Отдельные запросы падают, сессия остаётся «валидной», пул продолжает её выдавать. Хрестоматийный пример — golang/go#39750: мёртвое HTTP/2-соединение кэшируется в пуле вечно, потому что новые запросы, вставая в него, помечают его «занятым» — и чистильщик idle-соединений его не трогает тоже.

Грабли 4: конкуренция за одну трубу

Даже без сбоев: N воркеров, параллельно пишущих большие тела через одну сессию, делят одно окно flow control и один TCP-буфер. Всплеск нагрузки сам создаёт условия для дедлока из граблей 2 — и сам же в него попадает. Профиль «batch-писатель в десять потоков» на HTTP/2 без тюнинга окон — заявка на инцидент.

Как эти грабли обезвредили взрослые

Все зрелые HTTP/2-экосистемы сошлись на одном и том же наборе защит:

ЗащитаЧто даётКто сделал дефолтом
PING-проверки живостиПолуоткрытое соединение обнаруживается за секунды, а не за полчасаgRPC (keepalive), Go (ReadIdleTimeout)
Лимит возраста соединенияСоединение принудительно ротируется раньше, чем успеет протухнутьgRPC (MAX_CONNECTION_AGE, с джиттером против штормов)
Таймаут на зависший стрим (сервер)Стрим без прогресса сбрасывается и возвращает окно сессииJetty (streamIdleTimeout), gRPC-серверы
Здоровые размеры оконПараллельные большие тела не выедают окно сессииТюнинг под профиль нагрузки, дефолты часто малы

Показательно, почему эти защиты появились: документация gRPC прямо называет причины — прокси, убивающие idle-соединения, и потерянные FIN, после которых TCP молчит до получаса. Ровно грабли 1 и 3 из списка выше, набитые на чужих продах много лет назад.

Наш случай: все грабли в одном инциденте

Свежая иллюстрация из нашей практики. После апгрейда Solr 9 → 10 клиентская библиотека SolrJ перешла на HTTP/2 (cleartext h2c). Сервис-индексатор начал периодически зависать намертво — все запросы к Solr по таймауту, лечится только рестартом. Сосед, пишущий тот же контент тем же клиентом, — без единой ошибки.

Разгадка по пунктам совпала со списком: у соседа запросы идут по одному (одиночный POST не может исчерпать окно сессии), а у индексатора шедулер после каждой паузы сгружает бэклог в десять параллельных воркеров через одну сессию (грабли 4) ровно в момент, когда Solr занят коммитами и перестаёт читать (грабли 2). На сервере — коллекция CLOSE_WAIT с непрочитанными килобайтами, на клиенте нашёлся и полуоткрытый сокет (грабли 1). А клиент SolrJ, молодой и без PING-обвязки, ни разу не выбросил протухшую сессию из пула (грабли 3). Первоначальная версия «это сеть, conntrack убивает idle-соединения» не подтвердилась — сеть оказалась ни при чём, что для этого класса проблем типично: виноватой сначала всегда назначают сеть.

Лечение — тоже из стандартного набора: серверный streamIdleTimeout, окно сессии побольше, серверный idle-таймаут повыше, а стратегически — перевод batch-писателя на HTTP/1.1: для его профиля нагрузки изолированные соединения из пула надёжнее общей трубы.

Чеклист перед внедрением HTTP/2 между сервисами

  • Шлёт ли ваш клиент PING'и по простаивающему соединению? Если нет — как он узнает о полуоткрытом сокете?
  • Умеет ли пул выбрасывать сессию после серии таймаутов, а не только после ошибки сокета?
  • Ограничен ли возраст соединения? Что произойдёт при деплое или скейле сервера — заметят ли клиенты новые эндпоинты?
  • Сбрасывает ли сервер стримы без прогресса, или зависший стрим живёт вечно и держит окно?
  • Сколько параллельных больших тел ваш профиль нагрузки заталкивает в одну сессию? Хватит ли окна?
  • И честный вопрос напоследок: а нужен ли вам здесь HTTP/2 вообще? Для «десятки RPS между двумя подами» пул HTTP/1.1-соединений скучнее, но заметно живучее.

Вывод

HTTP/2 — хороший протокол с честным трейдоффом: экономия соединений в обмен на общую судьбу запросов. Он рассчитан на зрелую обвязку — пинги, ротацию, стрим-таймауты. В браузерном вебе и gRPC она есть из коробки. А вот когда HTTP/2 приезжает во внутренний трафик «бесплатно», обвязку никто не привозит — и вы получаете класс отказов, у которого даже симптом фирменный: «всё зависло, ошибок нет, помог рестарт». Теперь вы знаете, как он выглядит и где смотреть: ss -tan на обеих сторонах и Recv-Q/Send-Q расскажут больше, чем любые логи. А про то, когда такой инцидент можно считать закрытым, — отдельная заметка о severity и definition of recovered.

© 2026 axyi.ru · CC BY 4.0