MAATRIX / Блог / После деплоя восемь тысяч клиентов переподключились разом

После деплоя восемь тысяч клиентов переподключились разом

MAATRIX

В восемь вечера в пятницу инженер выкатил рутинный деплой — правка конфигурации логирования, ничего рискованного на первый взгляд. Через минуту после раскатки CPU на всех новых подах ушёл в потолок, база ответила «too many clients already», а дежурному прилетело сразу шесть алертов. Восемь тысяч клиентов, которые держали постоянное WebSocket-соединение с сервисом, переподключились почти в одну и ту же секунду — и положили инфраструктуру, рассчитанную на ровный трафик, а не на всплеск. Разбираем инцидент по шагам: что видели в метриках, какие версии отбросили и что в итоге поменяли в проде.

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

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

Деплой был обычным rolling update: оркестратор поочерёдно останавливал старые поды и поднимал новые, чтобы не было простоя. Формально всё прошло штатно — все поды перезапустились, readiness-пробы зеленые, ошибок в CI/CD не было. Проблема проявилась не в момент деплоя, а через 30–60 секунд после него:

  • CPU на новых подах прыгнул почти до максимума и держался там несколько минут;
  • время ответа выросло в разы, часть запросов стала падать по таймауту;
  • балансировщик начал отдавать клиентам 502 и 504;
  • база данных начала отклонять новые подключения с ошибкой о превышении лимита соединений;
  • служба авторизации, через которую проходит каждое новое WS-подключение, легла под нагрузкой и потянула за собой остальное.

Внешне это выглядело как классическая перегрузка «из ниоткуда»: деплой формально завершился успешно, а через полминуты сервис начал разваливаться сам по себе.

Что показывали логи и метрики

Первым делом подняли графики за последний час. Картина была характерной и в итоге стала главной подсказкой.

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

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

В логах сервиса авторизации — резкий рост p99 задержки и цепочка таймаутов к базе. В pg_stat_activity на PostgreSQL в этот момент было видно, что число активных соединений упёрлось в max_connections, а большая часть сессий висела в состоянии idle in transaction или ожидала свободного слота в пуле. Это совпадало по времени с всплеском новых WS-подключений: на каждое новое соединение приложение открывало отдельный запрос к базе для проверки токена и подгрузки профиля пользователя.

В логах nginx — резкий рост upstream timed out и connect() failed (111: Connection refused) в те же секунды: новые поды были физически живы, но не успевали принимать входящие соединения быстрее, чем накапливалась очередь.

Пока графики не объясняли причину — только то, что проблема совпала с деплоем и выглядела как одновременный обрыв и одновременное возвращение большого числа клиентов.

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

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

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

Гипотеза первая: DDoS-атака — отбросили за десять минут

Первая мысль дежурного, которая напрашивалась при виде резкого всплеска трафика: атака. Проверили быстро:

  • IP-адреса новых подключений — те же диапазоны, что и обычно, без концентрации на нескольких адресах или подсетях;
  • user-agent и паттерн запросов — стандартный клиент приложения, никаких признаков скрипта или бота;
  • число уникальных подключившихся клиентов — примерно совпадало с числом активных пользователей до деплоя, не превышало его;
  • всплеск начался не в случайный момент, а ровно синхронно с завершением деплоя на каждом поде по очереди.

Совпадение по времени с собственным деплоем, а не с внешним событием, и нормальный профиль трафика сняли эту гипотезу за первые десять минут разбора. Если бы это была атака, распределение IP и user-agent выглядело бы иначе, а всплеск не был бы жёстко привязан к моменту рестарта конкретных подов.

Гипотеза вторая: баг в новой сборке — тоже мимо

Вторая версия: в задеплоенном коммите что-то сломалось — например, лишний тяжёлый запрос при подключении клиента или утечка памяти, из-за которой процесс деградировал под нагрузкой.

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

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

Реальная причина: деплой без graceful-завершения и реконнект без джиттера

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

Первое. Приложение не обрабатывало сигнал SIGTERM при остановке пода. Оркестратор посылал сигнал, ждал terminationGracePeriodSeconds (по умолчанию 30 секунд) и затем убивал процесс. Но само приложение за эти секунды не переставало принимать новые соединения и не закрывало старые сокеты аккуратно — оно просто продолжало работать до SIGKILL, после которого все открытые TCP-соединения обрывались резко, без close-фрейма и без предупреждения клиенту. Для клиентской библиотеки это выглядело как обрыв сети, а не как штатное закрытие.

Второе. Клиентская логика переподключения была написана с фиксированной задержкой — «разрыв соединения → подождать одну секунду → переподключиться», без случайного разброса (джиттера). Пока разрывы были единичными (обычный пользователь потерял wifi), это не создавало проблем. Но когда за одну секунду обрывались сразу тысячи сокетов одного пода, все клиенты этого пода переподключались тоже практически одновременно, с разницей в миллисекунды.

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

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

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

Правки шли на трёх уровнях — сервер, клиент и процесс деплоя.

На стороне сервера добавили обработку SIGTERM: при получении сигнала приложение перестаёт принимать новые WS-подключения, рассылает существующим клиентам close-фрейм с кодом, говорящим «уходим на обслуживание, переподключайтесь через X секунд», и только после этого либо после истечения грейс-периода закрывает сокеты. terminationGracePeriodSeconds увеличили, чтобы хватало времени на аккуратное закрытие большого числа соединений, а не только на «попытаться».

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

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

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

Добавили специфический алерт на «синхронный обрыв и всплеск WS-подключений» — раньше такой паттерн не отслеживали отдельно от общих алертов на CPU и 5xx, хотя именно он был первым сигналом задолго до того, как база начала отклонять соединения. И отдельно завели rate limiting на эндпоинт хендшейка авторизации — не чтобы блокировать легитимных клиентов, а чтобы при повторении подобного сценария сервис деградировал предсказуемо (часть клиентов подождёт лишнюю секунду), а не падал каскадом.

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

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

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

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

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

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

Разве readiness-проба не должна была поймать проблему до того, как под начал принимать трафик?

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

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

Увеличение лимита отодвигает порог, но не устраняет причину — синхронный залп подключений. При достаточно большом числе клиентов (в этом случае — восемь тысяч, а рост числа пользователей продолжается) любой фиксированный лимит рано или поздно будет пройден тем же паттерном. Джиттер на клиенте и graceful-завершение на сервере убирают саму синхронность, а не просто повышают потолок, в который она упирается.

Можно ли было поймать это на стейджинге до прода?

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

Это специфично для WebSocket или касается и обычных HTTP-сервисов?

Механизм универсален для любых постоянных соединений и для клиентов с автоматическим ретраем — то же самое может произойти с долгоживущими gRPC-стримами, SSE или даже с обычными HTTP-клиентами, у которых ретраи без джиттера синхронизируются после общего сбоя (например, после недоступности сервиса на минуту). WebSocket просто делает эффект нагляднее, потому что соединения массово держатся открытыми, а не устанавливаются заново на каждый запрос.

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

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

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