Redis переключился на пустую реплику и обнулил все сессии
В четверг вечером, без деплоев и без предупреждений от хостинга, у продакшена разом разлогинились все пользователи — тысячи одновременно. Никто не менял код, никто не трогал балансировщик. Причина нашлась в Redis: Sentinel честно выполнил автоматический failover и произвёл в мастера реплику, которая на тот момент хранила ноль ключей. Это разбор того инцидента — что видели в логах, какие версии отбросили и что в итоге поменяли в конфигурации, чтобы такое не повторилось.
Содержание
- Что случилось: массовый разлогин без единого деплоя
- Что показывали логи и метрики
- Гипотеза первая: OOM и вытеснение ключей по maxmemory
- Гипотеза вторая: сетевой сбой и сплит-брейн
- Реальная причина: реплику подключили заново и не проверили полную синхронизацию
- Что изменили: мониторинг синхронизации и защита от преждевременного failover
Что случилось: массовый разлогин без единого деплоя
Сессии в приложении хранились в Redis — стандартная связка express-session + connect-redis на Node.js-бэкенде, крутившемся на паре серверов за балансировщиком. Redis был не одиночный: мастер плюс одна асинхронная реплика, между ними три инстанса Sentinel для автоматического failover. Схема на бумаге выглядела разумно — единая точка отказа устранена, при падении мастера Sentinel сам переключит роли.
В 21:40 по логам началась лавина 401 Unauthorized и принудительных редиректов на страницу входа. Support-канал в Telegram наполнился однотипными сообщениями: «выкинуло из аккаунта», «просит войти заново», «корзина пустая». За четыре минуты счётчик активных сессий в приложении упал с обычных для вечера значений почти до нуля. Никакой деградации по CPU, диску или сети на веб-серверах не было — они продолжали отвечать быстро, просто каждый запрос с существующей cookie сессии получал в ответ «сессия не найдена».
Первая мысль дежурного инженера — кто-то откатил деплой сессионного мидлвара или поменял секрет для подписи cookie. Проверили: деплоев за последние 6 часов не было, SESSION_SECRET в переменных окружения не менялся. Cookie в браузере у затронутых пользователей были на месте, с корректной подписью — то есть проблема была не в подписи cookie, а в том, что по её sid в Redis ничего не находилось. Похожий, но более узкий сценарий — когда вылетает не вся аудитория разом, а только часть пользователей — разобран в статье про сессии, которые слетают у части пользователей; там причина обычно в неравномерном роутинге между инстансами, а не в самом хранилище.
Что показывали логи и метрики
Первым делом подняли redis-cli -h <master> INFO replication и увидели неожиданное: адрес, который приложение считало мастером Redis, за последние 20 минут сменился. В логах Sentinel нашлась цепочка событий:
+sdown master mymaster 10.20.1.11 6379
+odown master mymaster 10.20.1.11 6379 #quorum 2/2
+new-epoch 4
+try-failover master mymaster 10.20.1.11 6379
+vote-for-leader <sentinel-id> 4
+selected-slave slave 10.20.1.12:6379 10.20.1.12 6379 @ mymaster 10.20.1.11 6379
+failover-state-select-slave master mymaster 10.20.1.11 6379
+failover-state-send-slaveof-noone slave 10.20.1.12:6379
+failover-state-wait-promotion slave 10.20.1.12:6379
+promoted-slave slave 10.20.1.12:6379
+failover-state-reconf-slaves master mymaster 10.20.1.11 6379
+failover-end master mymaster 10.20.1.11 6379
+switch-master mymaster 10.20.1.11 6379 10.20.1.12 6379
То есть Sentinel честно посчитал старый мастер недоступным (sdown, затем кворумный odown), объявил failover и продвинул реплику 10.20.1.12 в новые мастера. Формально всё по протоколу. Проблема вскрылась при следующей же команде:
redis-cli -h 10.20.1.12 DBSIZE
(integer) 0
Новый мастер был абсолютно пуст. Ноль ключей означал ноль сессий — все пользователи одновременно перестали существовать для приложения с точки зрения авторизации. При этом старый мастер 10.20.1.11, когда до него получилось достучаться руками, оказался живым и с полным набором ключей — просто на пару минут перестал отвечать на пинги Sentinel.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотеза первая: OOM и вытеснение ключей по maxmemory
Первая версия — вытеснение по политике maxmemory-policy. В конфиге стояло allkeys-lru с лимитом 2 ГБ, и логичным подозрением было, что Redis начал агрессивно вытеснять ключи сессий под давлением памяти, а метрика evicted_keys подскочила.
Проверили INFO memory и INFO stats на обоих узлах — used_memory держался около 40% от лимита, evicted_keys за сутки был нулевым что на старом, что на новом узле. Ни OOM killer в dmesg, ни сообщений MISCONF о запрете записи из-за неудачного BGSAVE в логах Redis не нашлось. Версию с вытеснением по памяти отбросили — сам процесс Redis данные не терял, они просто физически не успели попасть на второй узел.
Гипотеза вторая: сетевой сбой и сплит-брейн
Вторая версия — кратковременный сетевой затык между дата-центром и площадкой, где стоял Sentinel-кворум, из-за чего возник классический сплит-брейн: старый мастер продолжал принимать запись, а Sentinel параллельно назначил нового мастера, и часть трафика с приложения ушла на «неправильный» узел.
Отчасти это было близко к истине — блип действительно был, у провайдера в мониторинге сети нашёлся провал packet loss на пару минут в сторону узла 10.20.1.11. Но это объясняло только сам триггер failover, а не то, почему новый мастер оказался пустым. Сплит-брейн в чистом виде означал бы, что на старом мастере какое-то время продолжали писаться сессии, которые потом надо было бы догнать репликацией — а тут реплика не отставала на несколько минут, она не имела данных вообще, с самого начала.
Реальная причина: реплику подключили заново и не проверили полную синхронизацию
Разгадка нашлась в истории изменений инфраструктуры за два дня до инцидента. Старую реплику 10.20.1.12 пересоздавали — под ней кончилось место на диске, узел пересобрали с нуля и заново подключили командой REPLICAOF 10.20.1.11 6379. Дальше должна была пройти полная синхронизация: мастер делает BGSAVE, передаёт RDB-снимок реплике, та загружает его в память, и только после этого начинается потоковая репликация новых команд.
Проблема была в межсетевом экране между узлами: правило для передачи RDB-снимка (это отдельное TCP-соединение поверх обычного порта репликации, но с более строгим таймаутом repl-timeout) регулярно обрывалось при передаче большого дампа. В логах мастера это выглядело безобидно:
* Starting BGSAVE for SYNC with target: disk
* Synchronization with replica 10.20.1.12:6379 succeeded
* Replica 10.20.1.12:6379 asks for synchronization
* Full resync requested by replica 10.20.1.12:6379
* Starting BGSAVE for SYNC with target: disk
Обратите внимание на повтор — реплика раз за разом запрашивала полный ресинк заново, потому что предыдущая попытка обрывалась по таймауту где-то на середине передачи, а repl-backlog на мастере не хранил достаточно истории, чтобы продолжить с частичного ресинка. Никто не смотрел на это в моменте: узел числился в INFO replication как slave0, отвечал на PING, TCP-порт был открыт — с точки зрения Sentinel это выглядело как обычная, просто временно нагруженная реплика. Единственный признак беды — поле master_sync_in_progress:1, которое оставалось равным единице почти постоянно, вместо того чтобы кратко мелькнуть и уйти в ноль. Этот показатель никто не мониторил.
Когда старый мастер на пару минут перестал отвечать на пинги Sentinel из-за сетевого блипа, кворум сентинелов честно выбрал единственную доступную реплику и продвинул её — ту самую, что уже двое суток крутилась в цикле неудачных ресинков и физически не содержала ни одного ключа сессий. Формально Sentinel не нарушил протокол: он выбирает реплику по приоритету и по тому, отвечает ли она на команды, а не по факту завершённости первичной синхронизации. Именно этот зазор между «реплика жива» и «реплика реально содержит данные» и стал причиной инцидента. Механику самого процесса репликации и то, почему реплика вообще может отставать или зависать в ресинке, подробно разбирали отдельно в статье о том, как работает репликация и отставание реплики — она пригодится, чтобы понимать, что именно проверять в INFO replication до и после инцидента.
Что изменили: мониторинг синхронизации и защита от преждевременного failover
После разбора внесли несколько изменений, и все они были нацелены на один и тот же зазор — между «узел отвечает» и «узел готов быть мастером».
Во-первых, добавили отдельный алерт на master_sync_in_progress и на master_link_status по каждой реплике — если синхронизация не завершается за разумное время (у нас это несколько минут при размере датасета в единицы гигабайт, но у вас порог будет зависеть от объёма данных и канала), инженер получает уведомление раньше, чем реплика попадёт в failover-кворум.
Во-вторых, включили min-replicas-to-write на мастере, чтобы он переставал принимать запись, если у него не осталось ни одной реально синхронизированной реплики — это не спасает от failover на пустой узел напрямую, но заставляет проблему проявиться громко и сразу, а не тихо ждать своего часа:
min-replicas-to-write 1
min-replicas-max-lag 10
В-третьих, поменяли процедуру ввода новой реплики в строй: теперь после REPLICAOF инженер вручную проверяет master_repl_offset на мастере и slave_repl_offset на реплике — они должны сойтись и дальше расти синхронно — и только после этого добавляет узел в конфиг Sentinel через sentinel monitor. До этого момента новый узел стоит вне кворума, чтобы его нельзя было продвинуть в мастера случайно.
В-четвёртых, разобрались с firewall — оказалось, правило для порта репликации ограничивало таймаут неудачно короткой величиной по сравнению с repl-timeout в конфиге Redis (по умолчанию 60 секунд); подняли лимит на firewall и заодно увеличили repl-backlog-size, чтобы при кратковременных обрывах реплика могла договориться о частичном ресинке вместо полного дампа заново.
В-пятых — и это отдельный урок — пересмотрели, что вообще должно жить только в Redis. Сессии критичны для входа пользователей, но не критичны как данные бизнеса: договорились, что при полном обнулении Redis приложение должно вежливо отправлять на повторный логин, а не падать с ошибками 500, и добавили это поведение явным try/catch вокруг сессионного мидлвара. Отдельно завели вопрос о вынесении «долгоживущих» частей состояния (например, содержимого корзины) в базу данных с TTL, а не только в кеш — Redis остаётся быстрым слоем, но не единственным источником истины для того, что не должно теряться от одного неудачного failover.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Разве Sentinel не должен проверять, что реплика реально синхронизирована, прежде чем её повышать?
Sentinel проверяет доступность узла и его роль в топологии, но не глубину или полноту синхронизации данных на момент голосования — он ориентируется на то, отвечает ли узел на команды и на текущий replica-priority. Ответственность за то, чтобы в кворум попадали только полностью синхронизированные реплики, лежит на процессе эксплуатации: мониторинге master_link_status, min-replicas-to-write и осознанном порядке ввода новых узлов.
Можно ли было настроить replica-priority 0 для новой реплики, пока она догоняется?
Да, это одна из самых простых защит: пока узел не готов, выставляете ему replica-priority 0 в конфиге — с таким приоритетом Sentinel никогда не выберет его как кандидата в мастера. После подтверждения, что синхронизация завершена и офсеты сошлись, приоритет возвращают к обычному значению. Мы добавили этот шаг в чек-лист по вводу реплик отдельным пунктом.
Почему решили не переходить сразу на Redis Cluster вместо связки мастер-реплика с Sentinel?
Кластерный режим решает другую задачу — горизонтальное шардирование данных, а не саму проблему неполной синхронизации при подключении узла; риск «продвинули недосинхронизированный узел» актуален и для кластера. Плюс переход на кластер — это отдельная миграция с изменением на стороне клиента (кластерные клиенты работают иначе с ключами и MULTI/EXEC), которую не стоит делать вдогонку после инцидента без отдельного плана и тестового прогона; если такой переход всё же в планах, стоит заранее посмотреть настройку Redis-кластера, там отдельно разобраны нюансы шардирования и отказоустойчивости.
А если данные в Redis теряются просто при обычном перезапуске, без всякого failover — это тот же класс проблем?
Не совсем — это чаще вопрос настроек персистентности (save, appendonly) и того, дожидается ли процесс завершения BGSAVE перед остановкой. Такой сценарий с потерей данных именно при перезапуске разобран отдельно в статье о том, почему Redis теряет данные после перезапуска; в нашем инциденте персистентность на мастере была настроена корректно, проблема была именно в незавершённой синхронизации реплики.
Как быстро обнаружить, что реплика ещё не досинхронизирована, если у меня их несколько?
Смотрите на master_repl_offset мастера и сравнивайте с slave_repl_offset каждой реплики через INFO replication — если разрыв не сокращается со временем, а master_sync_in_progress подолгу держится на единице, узел не готов. Это стоит завернуть в отдельную проверку в системе мониторинга, а не полагаться на то, что «реплика в списке — значит, всё хорошо».
Что делать, если сессии в Redis уже потерялись и пользователей массово разлогинило?
Кроме честного повторного логина сделать в моменте почти ничего нельзя — сессионные данные без резервной копии восстановить неоткуда. Именно поэтому разумно закладывать в архитектуру graceful-деградацию: код должен явно обрабатывать ситуацию «Redis ответил, но нужного ключа нет» как штатный кейс, а не как исключение, приводящее к ошибке 500.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →