Чему долгоживущие HTTP-ответы научили нас о посредниках
Основное обещание Runnev — что вы можете держать один HTTP-ответ открытым столько, сколько нужно. На практике это значит понимать, как буферы и таймауты простоя на пути обращаются с потоковым ответом. Это короткий полевой гид — ничего экзотического, просто дружелюбные к стримингу настройки, полезные любому долгоживущему SSE-эндпоинту.
Обещание, которое пришлось заслужить
«Подпишитесь долгоживущим GET» легко сказать и легко показать на тридцать секунд. Трудная часть — это соединение, открытое ещё со вторника. У каждого узла на пути — HTTP-библиотеки клиента, любого прямого прокси, границы CDN, нашего обратного прокси и приложения — есть мнение о том, сколько может занять ответ и сколько его удержать перед передачей дальше. Долгоживущему потоку просто нужно, чтобы каждый из этих узлов был настроен под стриминг — а это стандартно, если знать настройки.
Наши собственные соединения обычно живут часами. Дойти до этого — не борьба с чем-либо, а знание на каждом узле той горстки дружелюбных к стримингу настроек, которые большинство платформ уже применяют или дают включить.
Буферизация: тихая задержка
Первым симптомом был не разрыв, а задержка. Пакеты приходили пачками с интервалом в секунды, а не по мере публикации. Причиной была буферизация ответа: прокси накапливал наш вывод, пока не набирался полный буфер, прежде чем передать хоть что-то. Для обычного ответа это оптимизация пропускной способности. Для потока это баг, потому что весь смысл — передавать каждый пакет в тот миг, как он появился.
Исправление — одна идея, и та же, что нужна любому SSE-эндпоинту: отключить буферизацию ответа на потоковом пути, чтобы каждая запись передавалась в момент появления, а не удерживалась, пока не заполнится буфер. Если вы держите собственный обратный прокси, это правка в одну строку, а большинство управляемых платформ и CDN либо делают это для потоковых ответов, либо дают переключатель.
Мы передаём каждую запись немедленно, а приложение отправляет X-Accel-Buffering: no в
каждом потоковом ответе — широко понятную подсказку, которая просит обратный прокси на пути сбрасывать,
а не буферизовать. Это дешёвая страховка: ничего не стоит, когда его никто не слушает, и помогает,
когда кто-то слушает.
Таймауты простоя, наслоённые
Как только пакеты пошли без задержек, следующим сбоем стали соединения, умирающие почти ровно на 60 секундах, когда поток затихал. Это классический признак таймаута простоя, и ловушка в том, что таймаут никогда не один. Он есть у HTTP-библиотеки клиента, у каждого прокси, у балансировщика нагрузки и у нашего сервера, и соединение умирает по кратчайшему из них.
Помогли две вещи. Во-первых, кадр-комментарий keepalive каждые 15 секунд означает, что с точки зрения любого узла соединение фактически никогда не простаивает; в последних 15 секундах всегда есть трафик, поэтому 60-секундный таймер простоя никогда не срабатывает. Во-вторых, мы подняли таймауты чтения и простоя на контролируемых нами компонентах так, чтобы они с запасом превышали интервал keepalive, чтобы одна задержанная keepalive-передача ничего не роняла.
Интервал keepalive — это настоящий компромисс. Короче — устойчивее к агрессивным посредникам, но больше потраченных байтов на тихих потоках; длиннее — экономнее, но ближе к краю 30- или 60-секундного таймера где-нибудь. Пятнадцать секунд удобно ложатся под 60-секундный таймаут, даже если один кадр задержался, а стоимость в байтах ничтожна — несколько байтов четыре раза в минуту на простаивающее соединение.
У клиентской стороны тоже есть таймауты
Тонкость, стоившая нам дня: у многих HTTP-клиентов есть единый общий таймаут запроса, включающий чтение тела. Для обычного запроса ограничить всё это 30 секундами разумно. Для подписки это гарантирует разрыв на 30 секундах, насколько бы здоровым ни был поток.
Правило, которое мы теперь документируем и реализуем в каждом SDK, — разделить их: держите обычный
таймаут на установление соединения и чтение заголовков ответа, но не ставьте общий дедлайн на чтение
потокового тела. Вместо этого считайте keepalive сигналом живости и переподключайтесь, если никаких
байтов, даже комментария, не пришло в окно, с запасом большее интервала keepalive. В Go это означает
задать ResponseHeaderTimeout, но не Client.Timeout на пути подписки. В
Python — задать таймаут чтения из сокета выше интервала keepalive, а не общий лимит по реальному
времени на всё чтение.
Почему каждый потоковый ответ — no-store
Ещё одно, что стоит сказать прямо: кэширование. Кэш где-либо на пути,
решивший, что поток событий — это кэшируемый GET, удержит ответ, попробует отдать его
следующему подписчику и сломает обоих. Каждый ответ /v1, и особенно ответ подписки, несёт
Cache-Control: no-store. В сочетании с X-Accel-Buffering: no это та пара
заголовков, которая говорит всему пути относиться к ответу как к живой трубе, а не как к объекту для
хранения.
Урок
Долгоживущий HTTP-ответ не экзотичен — так работает SSE, — но он взаимодействует с буферами и таймаутами на пути. Сделать его надёжным — это в основном знание горстки дружелюбных к стримингу настроек выше, тех же, что использует любой SSE-эндпоинт. Если вы держите инфраструктуру перед потоковым сервисом, этот короткий список — почти всё, что вам нужно.