MAATRIX / Блог / Соединения рвались ровно через 60 секунд: таймаут прокси против вебсокета

Соединения рвались ровно через 60 секунд: таймаут прокси против вебсокета

MAATRIX

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

Что сломалось

Продукт — чат поддержки с realtime-уведомлениями поверх WebSocket. Клиент открывает соединение через wss://, сервер держит его открытым и пушит события по мере появления: новое сообщение, статус «печатает», обновление тикета. Соединение живёт долго — минуты и часы, а не секунды — это нормальный паттерн для WS, и раньше проблем с длительностью не было.

Симптом появился внезапно, без деплоя с нашей стороны: клиенты начали переподключаться каждую минуту. В браузерной консоли — WebSocket connection closed with code 1006 (аномальное закрытие, без штатного close-фрейма). На бэкенде в логах приложения — ничего: ни исключений, ни признаков, что сервер сам закрыл соединение. Со стороны сервера всё выглядело так, будто клиент просто исчез.

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

Что видели в логах и метриках

Собрали таймлайн по нескольким источникам:

  • Логи приложения (Node.js, ws-библиотека): событие close с кодом 1006 прилетало для каждого соединения через 58-62 секунды после открытия. Разброс небольшой — не round-robin по расписанию, а именно от момента коннекта.
  • Логи nginx (он стоит перед приложением как reverse proxy и терминирует TLS): в access-логе для wss-запросов встречалась строка со статусом 499 — это код, которым nginx помечает случай, когда клиент (в данном случае — сам upstream-канал) закрылся, пока запрос ещё обрабатывался.
  • tcpdump на сервере приложения: видно, что TCP-соединение между nginx и апстримом закрывается пакетом FIN со стороны nginx, а не со стороны клиента и не со стороны Node.js-процесса.

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

Дополнительно подняли error_log nginx на уровень warn и увидели то, что раньше терялось в шуме:

2026/08/26 21:14:03 [warn] 18422#18422: *5031 upstream timed out (110: Connection timed out) while reading response header from upstream

Формулировка «reading response header» сбивала с толку — соединение давно было установлено, никаких новых заголовков никто не ждал. Но именно эта строка появлялась синхронно с каждым обрывом.

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

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

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

Какие гипотезы отбросили

Гипотеза 1: баг в клиентской библиотеке. Отбросили за 10 минут — проблема воспроизводилась в трёх разных клиентах (веб, мобильное приложение, внутренний dashboard на другом стеке), написанных разными командами и без общего кода для работы с сокетами. Общий баг сразу в трёх независимых реализациях маловероятен.

Гипотеза 2: балансировщик перед nginx сбрасывает долгие соединения. Проверили конфиг облачного балансировщика — таймаут там был выставлен на 300 секунд, то есть точно больше минуты. Через тот же tcpdump убедились, что FIN приходит с адреса самого nginx-сервера, а не с адреса балансировщика — значит, до него проблема даже не доходила.

Гипотеза 3: сеть — потеря пакетов или проблема у провайдера. Проверили счётчики retransmit на интерфейсе — в пределах нормы, никакого аномального packet loss. Плюс обрыв происходил слишком регулярно по времени (58-62 секунды) для случайной сетевой деградации — случайные сетевые проблемы не дают такой стабильный интервал.

Гипотеза 4: сама Node.js закрывает неактивные сокеты. Посмотрели дефолтные таймауты HTTP-сервера в приложении — server.timeout и keepAliveTimeout были явно увеличены ещё в прошлом релизе именно из-за прошлых проблем с WS, стояли на нескольких минутах. Да и tcpdump уже показал, что FIN шлёт nginx, а не процесс приложения — эта гипотеза отпала автоматически.

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

В чём была реальная причина

nginx как reverse proxy держит несколько независимых таймаутов на соединение с апстримом, и как минимум два из них напрямую касаются проксирования WebSocket:

  • proxy_read_timeout — сколько nginx ждёт данные от апстрима между двумя последовательными чтениями;
  • proxy_send_timeout — сколько ждёт, что апстрим готов принять данные при записи.

По умолчанию оба параметра равны 60 секундам. Для обычного HTTP-запроса это разумное значение — запрос либо получает ответ за секунды, либо явно завис и его пора рвать. Но у WebSocket-соединения после апгрейда протокола (101 Switching Protocols) нет понятия «следующий ответ»: соединение просто стоит открытым и ждёт, пока появится событие для отправки. Если между двумя фреймами проходит больше 60 секунд тишины — а в чате поддержки статус «печатает» или новое сообщение может не появляться минутами — nginx интерпретирует это как зависший upstream и рвёт TCP-соединение сам, без предупреждения клиенту.

Конфиг location для /ws/ выглядел так — и именно в нём не было явного переопределения таймаутов:

location /ws/ {
    proxy_pass http://backend_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    # proxy_read_timeout и proxy_send_timeout не заданы —
    # используются дефолтные 60s
}

Раньше это не проявлялось, потому что фронтенд сам слал ping-фрейм каждые 25 секунд, и трафик в канале не давал таймауту накопиться. Изменение случилось не в nginx и не в приложении: в последнем релизе фронтенда библиотеку для WS заменили на другую, и в новой версии интервал клиентского heartbeat по умолчанию был больше 60 секунд, а не 25, как раньше — просто «под капотом», без явного упоминания в списке изменений релиза. Формально это не баг клиента — заявленный клиентский keepalive-интервал никто не нарушал, но ровно этого хватило, чтобы в паузах между heartbeat превышался дефолтный proxy_read_timeout на прокси, о существовании которого команда фронтенда просто не знала.

Что изменили после инцидента

Исправление на уровне конфига было простым — явно увеличить таймауты для location с WebSocket:

location /ws/ {
    proxy_pass http://backend_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;

    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
}

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

  1. Развели ответственность за таймауты явно. Правило: proxy_read_timeout на прокси должен быть заметно больше, чем интервал heartbeat клиента (минимум в 2-3 раза) — тогда даже пропуск одного ping-фрейма не обрывает соединение мгновенно.
  2. Добавили server-side ping. Раньше heartbeat был только на клиенте, и сервер полагался на него слепо. Теперь бэкенд сам шлёт ping-фрейм каждые 30 секунд независимо от клиентской реализации — это не даёт каналу простаивать дольше proxy_read_timeout вне зависимости от того, какую библиотеку выберет фронтенд в следующий раз.
  3. Вынесли таймауты WS в отдельный shared-конфиг и подключили через include во все location, где проксируется wss — чтобы при добавлении нового WS-эндпоинта таймауты не приходилось вспоминать заново, а дефолтные 60 секунд не подставлялись молча.

Также завели алерт на код 499 в access-логе nginx с частотой выше базовой — раньше на этот статус вообще не смотрели, он тонул среди обычных 200 и 304. Теперь скачок 499 для WS-location — это первый сигнал именно о проблеме на границе прокси, а не где-то в приложении.

Как проверить у себя, что вы не наступите на то же

Если вы проксируете WebSocket через nginx, Caddy, HAProxy или любой другой reverse proxy — стоит явно проверить пару вещей, не дожидаясь инцидента.

Убедитесь, что таймауты для WS-location переопределены явно, а не остались дефолтными:

nginx -T | grep -A5 "location /ws"

Смоделируйте тишину в канале и посмотрите, сколько живёт соединение без heartbeat — например, откройте WS вручную через websocat и просто не шлите ничего:

websocat wss://example.com/ws/
# ничего не печатаем, засекаем время до разрыва

Если соединение падает примерно через 60 секунд без единого фрейма — у вас тот же дефолт, и стоит явно поднять proxy_read_timeout/proxy_send_timeout (для nginx) или аналогичный параметр в вашем прокси, прежде чем это найдёт кто-то из пользователей раньше вас.

Сравнение таймаутов у разных прокси-серверов для долгих соединений:

ПроксиПараметрДефолт
nginxproxy_read_timeout / proxy_send_timeout60s
HAProxytimeout tunnel (после апгрейда до WS)зависит от timeout client/timeout server, обычно ощутимо меньше требуемого
TraefikrespondingTimeouts.readTimeout0 (без таймаута) по умолчанию для entrypoint, но зависит от версии и конфигурации
Caddyнет отдельного таймаута для апгрейженных соединенийтаймауты на уровне обычного HTTP не применяются после Upgrade

Цифры для HAProxy и Traefik — ориентир по логике поведения, а не гарантия для конкретной версии: перед продакшеном стоит проверить актуальную документацию именно вашей версии прокси, потому что поведение таймаутов после протокольного апгрейда в разных релизах меняли не раз.

Если у вас reverse proxy стоит перед приложением на арендованном VPS, полезно заодно свериться с материалом про работу reverse proxy и путь запроса — там разобрано, через какие этапы проходит запрос, прежде чем понять, где именно застревают долгие соединения. А если обрывы происходят не на 60-й секунде, а на других характерных интервалах — стоит посмотреть на другой похожий случай, когда клиенты отваливались каждые десять минут из-за короткого keepalive — логика поиска причины там очень похожая, просто виноват другой таймаут.

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

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

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

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

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

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

Почему именно 60 секунд, а не другое число?

Это исторический дефолт proxy_read_timeout и proxy_send_timeout в nginx — значение подходит для обычных HTTP-запросов, где ответ либо приходит быстро, либо запрос считается зависшим. Для WebSocket это значение просто не рассчитано на длительное молчание в канале при отсутствии трафика.

Разве nginx не должен понимать, что это WebSocket, и не применять обычные таймауты?

Нет — после 101 Switching Protocols nginx просто проксирует байты в обе стороны на уровне TCP-туннеля, он не разбирает WS-фреймы и не знает, что канал «жив», если в нём нет данных. Таймаут на чтение применяется одинаково что к обычному HTTP-ответу, что к паузе в WS-канале.

Если поставить proxy_read_timeout в 24 часа, это безопасно?

Формально да, но тогда nginx перестаёт быть защитой от реально зависших upstream-процессов — соединение с мёртвым воркером будет висеть сутки вместо того, чтобы закрыться и позволить клиенту переподключиться. Разумнее ставить конечное значение с запасом (сотни секунд) и держать активный heartbeat поверх него, а не полагаться на бесконечный таймаут.

Нужно ли решать проблему на уровне приложения, а не прокси?

И то, и другое — heartbeat в приложении не отменяет необходимость корректно настроить прокси, а настройка прокси не спасает, если приложение не шлёт вообще никаких данных много минут подряд. Это два независимых слоя защиты, и падение одного не должно приводить к обрыву, если работает второй.

Как быстро понять, что проблема именно в прокси, а не в приложении?

Смотрите, кто инициирует закрытие TCP-соединения — через tcpdump или ss на сервере приложения. Если FIN или RST приходит со стороны прокси, а не от самого процесса приложения и не от клиента — это прямое указание на таймаут где-то в цепочке проксирования.

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

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

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