Перейти к содержимому
Проект бесплатный — поддержать донатом или купить рекламу
>AISTUDY_
AUTHORСвежий выпуск №017 → Резервная копия продакшена
Авторская колонка · резервная копия продакшенаСЕРИЯ 017
Авторская колонка · выпуск №017

Резервная копия
продакшена

Три стадии DR-резерва и день, когда основной сервер лёг — что сработало, что нет, техблок для внедрения и два промпта.

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

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

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

Схема 1Четыре стадии резервного сервера
Стадия 1

SSH-доступ к резервной копии

Задача: если основной сервер умрёт, не потерять код и данные.

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

Как сделано: второй, недорогой сервер у другого провайдера. Доступ к нему — через SSH-туннели: один прокидывал SOCKS-прокси, второй — порт PostgreSQL напрямую на локальную машину. С этого сервера можно было забрать дамп базы и код бэкенда.

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

Стадия 2

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

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

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

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

Схема 2Почему сторож не решает сам
Почему именно такДать сторожу право самому переключать боевой трафик — значит получить новый класс аварий. Один ложный health-check (сетевой сбой, плановые работы) — и сторож решает, что основной сервер мёртв, включает резерв, пока основной на самом деле жив и продолжает принимать запросы. Получаются два сервера, оба уверены, что они боевые, оба пишут в свои базы — split-brain, который потом придётся вручную сращивать. Временный простой из-за того, что резерв не включился сам, — это плохо, но не критично. Два источника правды, одновременно пишущие в две разные базы, — это авария, которую нельзя откатить одной командой.

Итог: на бумаге всё выглядело готовым — есть репликация, мониторинг, скрипт, предохранитель от случайного срабатывания. Но у этой стадии был изъян, который вскрылся только в реальной аварии: репликация базы гонялась постоянно и потому была обкатана на практике каждый день. А путь «поднять весь стек с холодного старта и убедиться, что он жив» ни разу не проверялся в реальных условиях — только на уже тёплом сервисе в разработке. Именно эту вторую проверку и не успели довести до конца после предыдущего падения — 30 июня это стало очевидно.

День аварии

30 июня 2026

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

Автоматика второй стадии не сработала. Скрипт активации ждал ответа от бэкенда 5 секунд и, не дождавшись, считал активацию проваленной. Холодный старт бэкенда занимал около 12 секунд — почти в 2.5 раза дольше заложенного таймаута. Число «5 секунд» было подобрано когда-то на уже прогретом сервисе в разработке и с тех пор ни разу не пересчитывалось для холодного старта — единственного случая, который имеет значение в реальной аварии. Исправление: заменил жёсткий таймаут на цикл повторных попыток длиной 40 секунд.

Схема 3Таймаут против реального времени старта

Дальше — список конкретных проблем, каждая со своей причиной.

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

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

Чат-модуль вообще не переключился на резерв автоматически — его пришлось поднимать с нуля из архивной резервной копии, и здесь всплыла цена решения держать этот модуль отдельным форком открытого проекта. Пароль от бэкапа не был под рукой — подбирали. Сборка падала: более новая версия одной из библиотек убрала нужную функцию интерфейса — пришлось откатить эту библиотеку на версию ниже. Один из внутренних пакетов не был перечислен в списке зависимостей — чистая сборка из git падала независимо от аварии. Библиотека обработки изображений была собрана под macOS, а резервный сервер — Linux, рантайм падал сразу при старте — подложили линуксовый бинарник вручную. Ни одна из этих проблем не связана с самой аварией напрямую — они не были обнаружены раньше, потому что этот модуль никогда не пересобирался «с нуля» на резервной площадке заранее.

Аварийный баннер «работаем на резерве» показывал ошибку авторизации залогиненным пользователям. Причина: баннер проверял авторизацию через ручку API, которая принимает только один тип токена (Bearer), а у залогиненных пользователей токен лежал в куки другого типа. Исправление: переключил баннер на другую ручку, которая читает нужный тип куки.

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

Пересборка фронтенда на резервном сервере оказалась невозможной технически. У резервного сервера 3.8 ГБ оперативной памяти и 2 ядра. Сборка современного фронтенда на таком объёме памяти привела бы к остановке процесса системой (OOM-killer) — и остановила бы не тестовую сборку, а единственный работающий на тот момент экземпляр продукта. Собрал фронтенд на другом, более мощном компьютере и перенёс готовые артефакты сборки на резервный сервер вручную, поправив абсолютные пути, зашитые в файлы сборки под другую машину.

Диск резервного сервера был заполнен на 93%, свободных 2.2 ГБ — и об этом никто не знал заранее. Порога-алерта на заполнение диска не было настроено. Ещё одна сборка добила бы диск до отказа посреди восстановления. Почистил разросшиеся системные логи и кэши, освободил место до 80%.

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

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

Разбор

Что на самом деле не было готово

Из разбора аварии выделяются конкретные причины, а не общее «нужно было готовиться лучше».

4.1 Резерв реплицировал только базу данных, а не код

Это означает, что «у нас есть DR» было верно наполовину: под капотом работала только реплика данных, а фронтенд, бэкенд и чат-модуль обновлялись вручную и как получится. Любое обновление основного сервера с этого момента начинало расходиться с резервом незаметно.

4.2 Единый источник настроек на деле не был единственным

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

4.3 Секреты хранились только на диске основного сервера

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

4.4 Резервная площадка недоразмерена для обслуживания

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

4.5 Внешние связи жёстко привязаны к хосту

Доменные имена для приёма сообщений от мессенджеров, локальные адреса вместо явного конфига между сервисами. Смена площадки автоматически ломала всё это, потому что оно не проектировалось как «может переехать».

4.6 Возврат на основной сервер не проработан

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

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

Решение

Почему новая постоянная площадка, а не апгрейд резерва

Сравнение здесь — не «маленький резерв против новой ноды», а «старый боевой сервер против новой ноды». Старый сервер, который лёг 30 июня, держал 12 ядер, 44 ГБ памяти и 240 ГБ диска — это была полноценная боевая машина. Новая постоянная площадка, на которую сейчас переезжает продукт, — 16 ядер, 62 ГБ памяти, 2 ТБ диска: по каждой характеристике сильнее прежнего боевого сервера (ядер больше на 33%, памяти — на 41%, диска — более чем в 8.5 раза), а не только сильнее маленького резерва.

Схема 4Старый боевой сервер против новой ноды

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

Маленький резервный сервер в схеме остаётся — как холодная копия для доступа к бэкапам, а не как боевая нода. Вместе с переездом на новую площадку: единый источник настроек подключения к базе без хардкода в обход него, план переключения без простоя по DNS (точка входа в интернет не меняется — переезжает только то, что стоит за ней), и явное решение по каждому внешнему сервису вместо предположения «само разберётся». Прежний резервный сервер после стабилизации будет выведен из активной схемы.

Техника

Технический блок: что внедрять разработчику, DevOps и безопасности

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

Схема 5Один источник конфигурации против шести копий

Разработчику: единый источник конфигурации подключений

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

# антипаттерн - так было в шести файлах
DATABASE_URL = "postgresql://user:pass@oldhost:5432/db"

# правильно - один источник, всё остальное читает из него
from app.settings import settings
db_url = settings.DATABASE_URL

Одного правила «не делай так» недостаточно — оно перестаёт соблюдаться при первом же дедлайне. Нужна проверка, которая ломает сборку:

# .ci/check-no-hardcoded-dsn.sh - запускать в CI на каждый PR
if grep -rE "postgresql://[^$]" --include="*.py" app/ | grep -v "app/settings.py"; then
  echo "Найден захардкоженный DSN вне settings.py"
  exit 1
fi

Такая проверка стоит недорого один раз и снимает именно тот класс ошибок, который в аварии чинили трижды за одну сессию.

DevOps: health-check с учётом холодного старта

Антипаттерн — то, что было в скрипте активации: короткий фиксированный таймаут, подобранный на глаз по уже прогретому сервису.

# антипаттерн
curl -sf --max-time 5 http://localhost:8000/health || exit 1

# правильно - таймаут с запасом под измеренный холодный старт + повторные попытки,
# а не единственная проверка
COLD_START_MEASURED=12   # секунд; измерено вручную: systemctl stop, затем start, засечь время до 200
TIMEOUT=$((COLD_START_MEASURED * 2))
DEADLINE=$((SECONDS + TIMEOUT))
until curl -sf --max-time 2 http://localhost:8000/health || [ $SECONDS -ge $DEADLINE ]; do
  sleep 1
done

Число COLD_START_MEASURED нельзя подбирать на глаз — его нужно один раз реально замерить (остановить сервис, запустить, засечь секундомером время до первого успешного ответа), и пересчитывать при каждом заметном изменении кода запуска.

DevOps: пороги на резервной площадке, а не только на боевой

Резерв обычно никто не мониторит, пока он не активен — порог диска в 93% на резерве не был виден именно поэтому.

# cron на резервном сервере, раз в 15 минут
*/15 * * * * disk=$(df --output=pcent / | tail -1 | tr -dc '0-9'); \
  [ "$disk" -ge 85 ] && curl -s -X POST "$ALERT_WEBHOOK" -d "disk at ${disk}% on standby node"

То же самое — для памяти и для расхождения версий кода между боевым сервером и резервом (простейший вариант — сравнение хэша последнего коммита на обеих площадках по расписанию).

Безопасность: секреты и защита от дублей при failback

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

Идемпотентность как защита от дублейОтдельный риск безопасности — не проникновение, а дубли при возврате основного сервера в строй: если оживший сервер продублирует уже обработанное событие (платёж, вебхук), это не взлом, но это инцидент. Защита не специфична для DR — это обычная идемпотентность на приёме внешних событий, и её стоит сделать независимо от аварий.
# идемпотентность на приёме платёжного вебхука
def handle_payment_webhook(event):
    key = event["id"]              # уникальный ID события от платёжного шлюза
    if already_processed(key):     # проверка в базе/кэше перед обработкой
        return                     # тот же ивент второй раз - молча игнорируем
    process_payment(event)
    mark_processed(key)

И отдельно, текстом в runbook, а не в памяти одного человека: при оживлении старого сервера первым действием — systemctl disable --now для его бэкенда и ботов, до того как они успеют что-либо отправить наружу.

Промпт 1

Аудит существующего DR

Если резервная схема уже есть — эти проверки покажут, где она на самом деле недоделана. Отдать своему агенту как есть.

Проаудируй нашу схему резервного переключения (DR/failover) по следующим пунктам,
для каждого не просто скажи да/нет, а покажи файл/конфиг, на основании которого
сделан вывод:

1. Cold-start таймауты. Найди health-check в скрипте активации резерва.
   Замерь реальное время холодного старта каждого сервиса (полная остановка,
   затем запуск с секундомером), не время ответа уже прогретого сервиса.
   Таймаут в health-check должен быть минимум в 2-2.5 раза больше замеренного
   холодного старта, иначе автоматика будет считать живой сервис мёртвым
   именно тогда, когда он нужнее всего.

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

3. Что реально синхронизируется на резерв, а что нет. Составь список: база
   данных - синхронизируется как (потоковая репликация/бэкап/вообще никак).
   Код бэкенда - как. Код фронтенда - как. Любые кастомные форки сторонних
   продуктов - как. Если ответ на любой из пунктов "никак" или "вручную,
   когда вспомнили" - резерв будет отставать от продакшена, и это выяснится
   только в момент аварии.

4. Ресурсы резервной площадки на обслуживание, а не только на рантайм.
   Хватит ли диска и памяти не просто чтобы сервис работал, а чтобы его
   можно было пересобрать/обновить/развернуть заново под давлением: сборка
   фронтенда, распаковка обновлений, дампы базы. Сравни с тем, что реально
   требуется для сборки, а не с тем, что нужно для холостого простоя.

5. Мониторинг базовых порогов на резервной площадке. Диск, память -
   есть ли алерт хотя бы на 85-90% заполнения. Резерв обычно никто не смотрит,
   пока он не активен - именно поэтому там незаметно накапливаются проблемы.

6. Внешние связи без localhost-допущений. Найди все места, где один сервис
   стучится к другому по localhost/127.0.0.1 вместо явного адреса из конфига.
   Каждое такое место - это связь, которая порвётся молча, если сервисы
   когда-нибудь окажутся на разных машинах.

7. Геозависимые и хост-зависимые внешние интеграции. Вебхуки мессенджеров,
   привязка к конкретному IP/домену у платёжных систем, гео-ограничения
   внешних API. Для каждой - что произойдёт при смене площадки, и является
   ли переключение её адреса ручной операцией, которую легко забыть.

8. Failback, а не только failover. Что случится, если старый (упавший)
   сервер оживёт сам по себе после ремонта. Его сервисы автостартуют?
   Могут ли они продублировать платежи, перехватить вебхук мессенджера
   или иначе задеть внешние системы параллельно с уже работающим резервом?
   Если да - нужна явная процедура "первым делом при оживлении - заглушить",
   зафиксированная текстом, а не в памяти одного человека.

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

По каждому пункту - конкретный ответ с доказательством (путь к файлу, вывод
команды), не общее "вроде настроено".
Промпт 2

Построить DR с нуля

Если резервной схемы ещё нет — вот порядок стадий, а не сразу полная автоматика.

Спроектируй и опиши план резервного переключения (DR/failover) для нашего продукта
с нуля, поэтапно - не сразу полная автоматика. Иди в следующем порядке:

1. Стадия доступа. Второй сервер у другого провайдера (не у того же, что основной -
   иначе авария одного провайдера утащит оба). SSH-туннели или VPN для доступа:
   проброс порта базы данных, доступ к последнему дампу и копии кода. Цель этой
   стадии - не терять данные, если основной сервер умрёт. Трафик она не принимает,
   и это нормально для первой стадии.

2. Единый источник конфигурации с первого дня. Ни один адрес базы/хоста/порта
   не пишется буквально в коде - только через общий конфиг/settings, читающий
   переменные окружения. Добавь CI-проверку (grep по коду на предмет "живых"
   строк подключения вне файла настроек), которая ломает сборку при находке -
   это дешевле сделать сейчас, чем распутывать после того как хардкод
   размножится по нескольким файлам.

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

4. Мониторинг с правом алертить, без права переключать самому. Сервис-сторож,
   который проверяет здоровье основного сервера и в случае проблемы уведомляет
   человека, но включает резерв только по ручному флагу/команде. Обоснование:
   ложное срабатывание при автоматическом переключении создаёт сценарий
   с двумя мастерами (split-brain) - оба сервера принимают запросы и пишут
   в свои базы, и это тяжелее откатить, чем временный простой. Автоматически
   включать резервный трафик - решение для более зрелой стадии, только после
   того как налажен мониторинг конфликтов записи и есть основания полностью
   доверять health-check (см. пункт 6 про учения).

5. Ресурсы резервного сервера - под обслуживание, не только под простой.
   Он должен уметь пересобрать фронтенд/бэкенд под давлением (память, диск,
   CPU для сборки), а не только держать процесс запущенным. Заложи это
   в выбор тарифа сразу, не только исходя из того что нужно "просто чтобы
   отвечало".

6. Учения с реальным холодным стартом. Не только проверка, что репликация
   работает (она и так работает каждый день) - раз в квартал полностью
   остановить прод, поднять весь стек с нуля на резервном сервере (не только
   базу - фронтенд, бэкенд, боты, внешние интеграции), замерить время
   и зафиксировать что пошло не так. Без этого шага "у нас есть DR"
   не проверено, а предполагается.

7. Явная failback-процедура текстом, до того как она понадобится. Что делать,
   если основной сервер оживёт сам после ремонта: первым делом - остановить
   его сервисы (боты, бэкенд), чтобы не было дублей платежей/перехвата вебхуков,
   и только потом разбираться, возвращать ли его в строй. Идемпотентность
   на приёме платёжных вебхуков (уникальный ключ события, проверка "уже
   обработано") отдельно защищает от дублей независимо от DR - имеет смысл
   сделать в любом случае.

Для каждого пункта - конкретная реализация под наш стек, а не общие слова.

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

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

Серия 017 · 2026-07-01 · резервное переключение для продакшена — три стадии, день аварии, техблок и два промпта
Авторская колонка · выпуск №017 · «Резервная копия продакшена»

// Обсуждение

Можно писать анонимно. Укажите email, чтобы получать уведомления об ответах.