Эвакуация за один день: как я перенёс два приложения и детектив-ARG из облака под ударами на собственный VPS


Привет, Хабр. Это продолжение истории студии из одного человека. В прошлый раз я рассказывал, как собрал детектив-ARG на чистой статике, а бэкенд моих двух приложений жил на serverless в Яндекс Облаке: Cloud Functions, YDB, Object Storage. Инфраструктура стоила ноль рублей в месяц, и я этим гордился.
В ночь на 8 октября беспилотники атаковали дата-центр Яндекса в Сасово. 9 октября - дата-центр в Калуге. В ночь на 11 октября - третий, во Владимире, после чего Yandex Cloud перешёл в аварийный режим: две зоны доступности недоступны, «оставшуюся конфигурацию ресурсов считаем неустойчивой». Пострадавших среди людей нет, и это главное. А у меня на этом облаке жили бэкенды двух опубликованных приложений и сайт с игрой.
12 октября я провёл собственную эвакуацию: за один день перенёс 40 роутов и 39 таблиц на один VPS, а утром 13-го у меня уже были алерты в телеграме, своя метрика посещаемости без сторонних скриптов и античит, которого не было никогда. Приложения в поле не заметили ничего.
Под катом - хронология, почему serverless эвакуировать труднее всех, и пять уроков, каждый из которых стоил мне пары часов.
<cut>
Неделя, когда горели дата-центры

Короткая хроника по открытым источникам:
Ночь на 8.10 - ЦОД в Сасово (Рязанская область): попадание, пожар, работа площадки полностью остановлена, зона ru-central1-b недоступна. Сбои у десятков компаний: Циан, девелоперы, автобусные билеты.
9.10 - ЦОД в Калужской области: несколько модулей полностью выведены из строя.
Ночь на 11.10 - ЦОД во Владимире: работа остановлена, Yandex Cloud признан работающим в аварийном режиме, недоступны зоны ru-central1-a и ru-central1-b, более 80 сервисов компании деградировали.
Яндекс добавил атаки БПЛА в список форс-мажоров, договорился с Selectel, VK Cloud и К2Cloud об ускоренном размещении клиентов и рекомендовал задействовать планы восстановления на внешних площадках. Восстановление повреждённых мощностей, по оценкам экспертов, займёт от недель до месяцев.
Почему serverless эвакуировать труднее всех
Партнёрская программа эвакуации - отличная вещь, если у тебя виртуальные машины и кластеры Kubernetes: их есть куда переносить и на чём поднять. Но моя инфраструктура была serverless: функции, документная БД YDB с AWS-совместимым API, бакеты. Это проприетарные API конкретного облака. «Ускоренное размещение у партнёров» для функции не работает - её нельзя поднять на чужой площадке, её можно только переписать.
Мои активы на тот момент: бэкенд Копилки Отказов (25 роутов, 24 таблицы), бэкенд Судоку Арены (15 роутов, 15 таблиц), статика сайта с тремя сезонами ARG, телеграм-бот поддержки. Нагрузка маленькая, данные почти пустые - это и было преимуществом: тащить почти нечего, переносить надо код.
Спасло то, что код изначально был на обычном стеке: Node 22 + TypeScript + Hono.js. Никакой облачной экзотики внутри логики - только адаптеры к YDB по краям. Значит, переносим, а не переписываем. Цель: один VPS, docker compose, фиксированный чек в месяц.
Урок нулевой: проверяй виртуализацию до оплаты
Эвакуация началась с ложного старта. Первый VPS, который я поднял утром 12 октября, выглядел рабочим: система ставится, docker ставится, деплой проходит. Но:
$ swapon /swapfile
swapon: /swapfile: swapon failed: Operation not permittedSwap невозможен. Дальше: ufw роняет сеть контейнера (в ядре нет модуля conntrack), fail2ban не банит (нет iptables-модулей), dockerd стартует только с iptables: false и host-сетью. Поддержка честно ответила: модули включать не будем. Диагноз - OpenVZ, контейнерная виртуализация с общим ядром хоста. Прод с биллингом на площадке без swap и файрвола я не держу.
Проверка занимает секунду:
$ systemd-detect-virt
openvz # ← стоп, уходим
kvm # ← работаемТеперь это шаг ноль любого захода на новый сервер. Второй провайдер прошёл проверку (KVM), и дальше всё заняло один день.
День эвакуации

temp-infra/
├── Caddyfile # три домена, статика сайта + прокси на api
├── docker-compose.yml # caddy / api / games / cron
├── services/
│ ├── api/ # Копилка: 25 роутов + 2 фоновые задачи, 24 таблицы
│ ├── games/ # Судоку: 15 роутов + 2 крона, 15 таблиц
│ ├── cron/ # push-tick поминутно, кампании почасово, лига вс 23:59
│ └── bot/ # телеграм-боты (живут на втором VPS, об этом ниже)
└── site/ # статика сайта и ARGПорядок: инвентарь всех роутов функций (таблица «роут - что делает - какие таблицы»), затем порт один-в-один - те же пути, те же форматы ответов, та же JWT-логика. YDB заменена на better-sqlite3 с теми же схемами. Данные не эвакуировали: их было почти ноль, честнее начать с чистой БД - подписки и лига наполнятся новыми событиями.
Ключевое решение: домены не менялись. A-записи api.tempapp.ru и api-games.tempapp.ru переключились на новый IP, TLS перевыпустился через Let’s Encrypt, и приложения в поле продолжили ходить по тем же адресам на новый бэкенд. Никакого «обновите приложение из стора».
Деплой - 20 строк bash: rsync репозитория, docker compose up -d --build, обратная связь ~50 секунд. Вечером того же дня на боевых доменах был зелёный смоук: авторизация с ротацией токенов, идемпотентность покупок, лимиты, рефералка end-to-end.
Параллельно с переездом на сайт выкатились второй и третий сезоны детектива - сборка уехала тем же rsync’ом, что и весь сайт. Эвакуация инфраструктуры и релиз контента случились в одни сутки, и это лучший знак: процесс не останавливался ни на час.
Урок первый: оффсайт-копия - это не опция
Сасово доказало то, о чём все читают в учебниках: пожар в дата-центре - не метафора из презентации про DR, а твой прод. Поэтому схема бэкапов строилась сразу под «сгорел ЦОД»:
дамп SQLite каждый час, десять последних локально;
раз в сутки дамп уезжает rsync’ом на второй VPS - тот самый, где уже живут мои тг-боты;
логика простая: локальные дампы рядом с базой пожару не переживают.
Восстановление «с нуля»: купить VPS - ssh-copy-id - ./deploy.sh - поднять SQLite из последнего дампа. Цель - полчаса от «всё сгорело» до «работает». На этой неделе это перестало быть паранойей.
Урок второй: docker игнорирует ufw
Публикуешь порт в compose:
ports:
- "3001:3001"- и docker вставляет правило в цепочку DOCKER-USER до правил ufw. ufw status честно показывает, что 3001 закрыт, а он доступен снаружи. Мой телеграм-релей должен принимать запросы только с основного сервера, и «закрыто в ufw» было иллюзией.
Лечение - правило именно в DOCKER-USER:
iptables -I DOCKER-USER -p tcp --dport 3001 ! -s <IP_ОСНОВНОГО_СЕРВЕРА> -j DROPплюс systemd-юнит, который поднимает его после перезагрузки (docker сбрасывает цепочку при рестарте). Проверка: с чужого адреса - таймаут, со своего - 200.
Урок третий: fetch не считает 403 ошибкой
Баг, который жил сутки и нашёлся случайно. Алерты устроены так: кроны шлют POST /alert на телеграм-релей с заголовком x-internal-secret. В коде было:
await fetch(process.env.TG_RELAY, { method: 'POST', body: JSON.stringify({ text }) });
// ну отправили и отправилиfetch резолвится при любом HTTP-статусе. 403 - не исключение. А заголовок в одном из вызовов забыли проставить. Итог: сутки алерты молча умирали на релее - система уведомлений была слепа к собственной поломке.
Теперь канон:
const res = await fetch(url, opts);
if (!res.ok) throw new Error(`alert relay: HTTP ${res.status}`);Мета-вывод: у системы уведомлений должен быть хотя бы один путь, не зависящий от неё самой. У меня это утренний дайджест: если ночного дампа нет - дайджест скажет «ДАМПА НЕТ», а идёт он по уже проверенному маршруту.
Урок четвёртый: compose restart не перечитывает .env
Мелочь, стабильно кусающая всех: docker compose restart перезапускает контейнер со старым окружением. Отредактировал .env, сделал restart - переменная в контейнере старая. Правильно:
docker compose up -d # перечитывает env_file и пересоздаёт контейнер при измененияхБонус раз: метрика без Метрики
Яндекс.Метрика на сайте перестала работать, чинить сторонний скрипт в эти дни было странным приоритетом, а вопрос «заходит ли вообще кто-нибудь» остался. Сайт - статика за Caddy, значит всё уже пишется в access-лог. Включил JSON-логи и написал скрипт в cron-контейнер: каждое утро в 09:00 он парсит вчерашние сутки и шлёт мне в телеграм дайджест - уникальные пары IP+User-Agent (погрешность на NAT принята осознанно), топ-10 страниц, топ-5 источников, сколько отсеяно ботов. Плюс строки из SQLite: регистрации за сутки, активные подписки, состояние дампа и диска.
Ноль скриптов на клиенте, ноль персональных данных, блокировщикам нечего блокировать. Минус: событийной воронки внутри страниц нет - верну, когда будет что мерить.
Бонус два: античит для вечнозелёной игры
ARG на статике имеет конфузную особенность: все ответы лежат в JS - открой консоль, и вот они. Пока игроков было мало, это была почти фича («копающий свой» - часть жанра). На фоне переезда прибрался и тут.
Серверный античит убил бы главное свойство игры - вечнозелёность: сезон обязан проходиться офлайн, без сервера, через пять лет. Поэтому вместо открытых слов в коде теперь sha256-хэши:
const digest = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(`temp:${season}:${word.toLowerCase().trim()}`));Соль не даёт сравнить хэш с радугой, нормализация (trim, lower, ё→е) не наказывает за опечатки. Перебор словаря по-прежнему возможен - и это нормально: барьер поднят с «открой консоль» до «напиши скрипт», а кто написал - тот свой. Платный четвёртый сезон будет проверяться сервером (там деньги, требования другие), архив из трёх сезонов останется офлайн навсегда.
Что в итоге
Один день - и инфраструктура перестала быть «облаком, где-то там». Теперь это конкретная машина с известным чеком: дампы каждый час, ночная копия на втором сервере, утром дайджест, при первой оплате - мгновенный алерт. Два телеграм-бота (поддержка и админский) живут на том самом втором VPS - Telegram API из российских сетей недоступен, - и он же принимает ночные бэкапы.
Что бы я сделал иначе: начал бы с systemd-detect-virt - сэкономил бы вечер. И имел бы план эвакуации до того, как он понадобился: весь вторник я импровизировал, и только мелкость моей инфраструктуры сделала импровизацию успешной.
Ссылки: сайт с детективом - https://tempapp.ru (второй и третий сезоны выкатились прямо на фоне переезда, «Инвентаризация» - уже на новом сервере. Копилка Отказов и Судоку Арена - в RuStore. Телеграм-канал студии: https://t.me/tempapp_news
Спрашивайте в комментариях - отвечу на всё техническое. На сюжетное: без спойлеров, дело открыто. Командам Яндекса и Selectel/VK Cloud/К2Cloud, которые эвакуируют клиентов в эти дни, - уважение: неделя у них тяжелее моей.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.