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.