MAATRIX / Блог / Обрывы каждые десять минут: keepalive балансировщика был короче клиентского

Обрывы каждые десять минут: keepalive балансировщика был короче клиентского

MAATRIX

Клиент жалуется: длинные запросы, стриминговая выдача и вебсокет-соединения обрываются посреди работы — не всегда, но с пугающей регулярностью. Первая мысль обычно про «нестабильную сеть» или баг в приложении, но как только кто-то замеряет секундомером интервал между обрывами и видит одну и ту же цифру у разных клиентов, версия меняется: это не случайность, а чей-то таймаут. Разбираем, как найти этот таймаут в цепочке из клиента, балансировщика и бэкенда, и почему сам факт «ровно одинаковый интервал» сразу сужает список подозреваемых до одного.

Симптом: обрыв строго по расписанию

Жалобы выглядели однотипно: соединение живёт какое-то время, а потом обрывается без внятной ошибки на стороне клиента — просто "connection reset" или "socket hang up". Для обычных коротких HTTP-запросов это осталось бы незамеченным (запрос просто повторился бы), но здесь речь шла про три типа нагрузки, для которых обрыв заметен сразу:

  • долгие запросы к API с тяжёлыми вычислениями на бэкенде (отчёты, экспорт данных);
  • потоковая передача ответа (стриминг файлов, SSE — Server-Sent Events);
  • вебсокет-соединения для realtime-обновлений в интерфейсе.

Первое, что стоило сделать — не гадать про "плохую сеть", а собрать конкретные тайминги обрывов с обеих сторон: со стороны клиентского приложения (или логов nginx access log с $request_time) и со стороны бэкенда. Оказалось, что интервал между "соединение установлено" и "соединение оборвано" у разных пользователей, в разное время суток, при разной нагрузке — почти идентичный. Плюс-минус доли секунды, но кратно повторяющийся.

Это ключевой диагностический момент: случайная сетевая проблема (потеря пакетов, нестабильный Wi-Fi, перегруженный аплинк) даёт разброс — обрывы происходят в разное время после установки соединения, зависят от нагрузки, не повторяются с точностью. Иногда за похожей картиной "необъяснимых обрывов" стоит вообще другой механизм — например, конфликт MTU и фрагментация пакетов, но там обрывы привязаны к размеру передаваемых данных, а не к времени. В нашем случае обрыв происходил ровно через одинаковый интервал независимо от того, что происходило в соединении — это почти всегда таймер. Кто-то в цепочке считает секунды бездействия (idle time) и рвёт соединение по достижении лимита. Осталось понять, кто именно.

Как устроен keepalive и idle timeout — и почему у каждого звена свой

Здесь стоит проговорить механизм подробно, потому что путаница в терминах — частый источник неправильных выводов.

HTTP-соединение с keep-alive (или переиспользуемое соединение в HTTP/2, или долгоживущее TCP-соединение вебсокета) физически остаётся открытым между отдельными запросами или в промежутках без активности. Это сделано специально — устанавливать новое TCP- (и тем более TLS-) соединение на каждый запрос дорого по времени и ресурсам, поэтому клиент и сервер по умолчанию стараются держать канал открытым и переиспользовать его.

Но "держать открытым" не означает "вечно". У каждого участника цепочки — а между браузером/приложением-клиентом и бэкендом почти всегда есть один или несколько промежуточных узлов (балансировщик нагрузки, reverse-proxy, API-gateway, CDN) — есть свой независимый таймер бездействия (idle timeout). Логика таймера простая: если по соединению N секунд не было трафика, оно считается "мёртвым" или бесполезным и закрывается, чтобы не занимать ресурсы (файловые дескрипторы, память под буферы, слоты в пуле соединений).

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

  1. Клиент — таймаут ожидания ответа/бездействия в HTTP-клиенте или библиотеке (браузер, curl, HTTP-клиент в приложении).
  2. Промежуточный узел — балансировщик нагрузки, nginx/HAProxy/Traefik как reverse-proxy, облачный Load Balancer (ALB, GCP HTTP(S) LB и подобные).
  3. Бэкенд — сам сервер приложения (веб-сервер или прикладной фреймворк: Gunicorn, uWSGI, Node.js HTTP-сервер, встроенный сервер в фреймворке).

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

Важный нюанс: обрыв происходит именно "по бездействию", то есть таймер обычно считает время без трафика в обе стороны, а не общее время жизни соединения. Поэтому для активных коротких запросов проблема незаметна — трафик идёт постоянно, таймер бездействия не успевает сработать. Она проявляется именно там, где между пакетами возникают паузы: ожидание ответа от тяжёлого запроса, пауза в стриминге, "тихие" периоды в вебсокете, когда обновлений нет.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Проверка бэкенда: сервер соединение не рвал

Прежде чем разбирать конфигурацию промежуточных узлов, стоило исключить самую очевидную версию — что закрывает соединение сам бэкенд. Проверка делается по логам приложения: ищем, есть ли в момент обрыва запись о том, что сервер сам закрыл сокет (например, из-за собственного read_timeout/request_timeout, ошибки обработчика, паники воркера).

В нашем случае в логах бэкенда в момент каждого обрыва не было вообще ничего — ни ошибки, ни закрытия соединения, ни рестарта воркера. Приложение продолжало штатно работать над запросом (или ждало следующего сообщения в вебсокете) и не подозревало, что клиент уже "отвалился". Это важный диагностический сигнал: если бэкенд молчит, а клиент видит обрыв — значит, соединение закрыл кто-то между ними, а не конечные точки. Это тот же принцип, что и в разборе периодических 502 от nginx: прежде чем чинить бэкенд, стоит убедиться, что проблема вообще на его стороне.

Полезная практика для таких случаев — логировать на бэкенде RST/FIN на уровне ОС (tcpdump на интерфейсе бэкенда в момент воспроизведения проблемы) и сверять с логами промежуточного узла:

sudo tcpdump -i any -nn 'tcp[tcpflags] & (tcp-rst|tcp-fin) != 0 and host <ip_бэкенда>' -w /tmp/obryv.pcap

Если пакет RST/FIN приходит на бэкенд от адреса балансировщика — значит, инициатор закрытия именно он, а не клиент и не сама сеть где-то дальше. Это сразу переносит фокус расследования с приложения на инфраструктурный слой.

Находим виновника: таймаут балансировщика короче ожидаемого

Дальше — прямая проверка конфигурации всех промежуточных звеньев на предмет настроенного idle timeout. Название параметра зависит от того, что стоит в роли балансировщика:

КомпонентПараметрЧто означает
nginx (как reverse-proxy)keepalive_timeout, proxy_read_timeoutсколько держать соединение с клиентом / сколько ждать данных от апстрима
HAProxytimeout http-keep-alive, timeout client, timeout tunnelтаймаут keep-alive, общий клиентский таймаут, таймаут для long-lived соединений (вебсокеты)
TraefikidleTimeout (в transport/entrypoint)таймаут бездействия соединения
Облачный Load Balancer (ALB и аналоги)Idle timeout (обычно настраивается в консоли/API)таймаут бездействия на самом балансировщике

В нашем случае интервал обрывов совпал именно с значением, выставленным на балансировщике — оно оказалось заметно короче, чем таймаут и на клиенте, и на бэкенде. Это и есть корневая причина: явное числовое несоответствие между настройками idle timeout на разных звеньях цепи. Балансировщик по умолчанию (или по унаследованной от старого шаблона конфигурации) был настроен на более короткий интервал, чем ожидали те, кто настраивал клиент и бэкенд — они ориентировались на "разумные" значения (например, несколько минут), не зная, что где-то посередине стоит более жёсткий лимит.

Важно: конкретное число тут не так важно, как сам принцип рассинхрона — у разных технологий и версий разные значения по умолчанию, они меняются от релиза к релизу и от провайдера к провайдеру. Не стоит опираться на "стандартные" цифры из чужого проекта — нужно проверить актуальное значение в документации конкретной версии ПО или конкретного облачного балансировщика и сверить его с остальными звеньями руками.

Правило синхронизации таймаутов по всей цепочке

Диагноз понятен — решение прямое: явно выставить idle timeout на всех участниках цепочки, а не полагаться на значения по умолчанию каждого компонента по отдельности. При этом важен не только сам факт синхронизации, но и направление неравенства.

Общий принцип, не зависящий от конкретного используемого ПО: таймаут промежуточных звеньев должен быть равен или немного больше, чем таймаут на бэкенде — а не короче. Логика простая: пусть бэкенд первым решает, когда закрывать неактивное соединение (он лучше знает про свою бизнес-логику — сколько может длиться операция), а промежуточные узлы просто не должны обрывать соединение раньше него. Если сделать наоборот — короткий таймаут на прокси при длинном на бэкенде — именно прокси первым и будет рвать живые соединения, что мы и наблюдали в инциденте.

Практически это выглядит как цепочка неравенств:

timeout(клиент) >= timeout(промежуточные прокси/балансировщики) >= timeout(бэкенд)

Причём знак между промежуточными звеньями и бэкендом должен быть "больше или равно", с небольшим запасом (обычно на несколько секунд больше), а не "меньше". Пример настройки для связки nginx как reverse-proxy перед бэкендом на 300 секунд бездействия:

# на бэкенде (например, в конфиге приложения) — 300s
# на nginx — берём с запасом, а не меньше
http {
    keepalive_timeout   310s;   # клиентская сторона nginx
    proxy_read_timeout   310s;  # сторона nginx -> бэкенд
    proxy_send_timeout   310s;
}

Для HAProxy аналогичный принцип:

defaults
    timeout client          310s
    timeout server           310s
    timeout http-keep-alive 310s

Если в цепочке несколько прокси подряд (например, CDN → облачный Load Balancer → nginx → бэкенд), правило применяется на каждом переходе: каждое следующее по направлению к бэкенду звено не должно иметь таймаут короче предыдущего. Здесь легко ошибиться, потому что каждый слой обычно настраивают разные команды (сетевые инженеры — балансировщик, разработчики — бэкенд), и без явной сверки эти цифры расходятся сами собой — что и произошло в разобранном инциденте.

Практический чек-лист для аудита цепочки:

  • выписать все узлы на пути запроса от клиента до бэкенда (включая CDN, WAF, API-gateway — их часто забывают);
  • для каждого узла найти актуальное значение idle/keep-alive таймаута в конфигурации (не по памяти — смотреть текущий конфиг-файл или консоль провайдера);
  • проверить, что значения не убывают по направлению от клиента к бэкенду;
  • если встроенный дефолт короче остальных — переопределить его явно, а не оставлять "как есть".

Вебсокеты и долгий стриминг: отдельный случай

Для действительно долгоживущих соединений — вебсокетов, SSE, long-polling — тема таймаутов заслуживает отдельного внимания, потому что здесь ставки выше: соединение может быть открыто часами, и любой рассинхрон таймаутов проявится гарантированно, просто вопрос времени.

Ключевая ловушка в том, что многие прокси и балансировщики по умолчанию настроены на профиль обычного короткого HTTP-запроса (секунды, максимум минуты), а не на профиль "открытое соединение на часы". Это не баг конкретного продукта, а следствие того, что подавляющее большинство HTTP-трафика в мире именно такое — короткое. Поэтому дефолтные значения таймаутов почти везде рассчитаны на "обычный" случай, и именно вебсокеты с их долгими паузами без трафика первыми упираются в этот лимит.

Практические моменты, которые стоит проверить отдельно для вебсокетов и стриминга:

  • Апгрейд протокола должен явно поддерживаться прокси — для nginx это заголовки Upgrade/Connection:
location /ws/ {
    proxy_pass http://backend_ws;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;  # отдельный, увеличенный таймаут именно для вебсокет-локации
}
  • Не полагаться на один общий таймаут для всего сервера — для вебсокет-эндпоинтов часто оправдано выделить отдельный location/backend-правило с собственным, увеличенным значением, не трогая общий таймаут для обычных HTTP-запросов (иначе слишком долгий общий таймаут будет держать "зависшие" короткие запросы неоправданно долго).
  • Приложение-уровневый ping/pong — независимо от таймаутов инфраструктуры, полезно, чтобы сама вебсокет-логика на клиенте и сервере слала периодические ping-кадры (например, раз в 20–30 секунд). Это не только держит соединение "живым" с точки зрения idle-таймеров промежуточных узлов (трафик же есть), но и позволяет обеим сторонам быстро обнаружить реально мёртвое соединение, а не ждать TCP-таймаута ОС.
  • Проверка таймаута конкретно на облачных балансировщиках — если в цепочке есть managed Load Balancer, его idle timeout часто настраивается отдельно от остального стека (через консоль/API провайдера), и это самое частое место, где про него забывают при аудите — конфиги nginx/HAProxy проверяют, а настройки облачного балансировщика — нет.

Если приложение использует Server-Sent Events, тот же принцип применим один в один — это тоже долгоживущее HTTP-соединение с редкими паузами, и оно так же уязвимо к короткому таймауту на промежуточном узле, как и вебсокет.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро отличить проблему таймаута от случайной сетевой нестабильности?

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

Обязательно ли ставить одинаковые значения таймаута на всех звеньях?

Нет — правильнее делать таймаут на промежуточных узлах равным или чуть больше, чем на бэкенде, с небольшим запасом в несколько секунд, а не строго идентичным. Без запаса возможна гонка, когда оба узла решают закрыть соединение почти одновременно.

Что делать, если балансировщик управляется другой командой и его конфиг недоступен напрямую?

Уточнить текущее значение idle timeout через администратора этого узла или через консоль/API облачного провайдера (для managed-балансировщиков это обычно видно в настройках). Аудит цепочки таймаутов имеет смысл делать совместно с той командой, которая владеет промежуточным звеном — иначе рассинхрон просто повторится при следующем изменении конфигурации.

Почему обрывы не появлялись сразу после разворачивания балансировщика, а только спустя время?

Часто потому, что нагрузочный профиль меняется — раньше запросы были короче или пауз в трафике почти не было, а с ростом доли тяжёлых операций и вебсокетов паузы бездействия стали чаще упираться в короткий лимит. Рассинхрон был с самого начала, просто раньше не проявлялся на практике.

Нужно ли увеличивать таймаут максимально, "на всякий случай"?

Нет — слишком большой таймаут держит открытыми "мёртвые" соединения дольше нужного, расходуя память и файловые дескрипторы на прокси и бэкенде. Разумный подход — выставить таймаут чуть больше реального максимального времени бездействия, которое допустимо для конкретного типа нагрузки (обычного запроса, стриминга или вебсокета), а не произвольно большое число.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →