Привет, Хабр. Это продолжение истории студии из одного человека. В прошлый раз я рассказывал, как собрал детектив-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 permitted

Swap невозможен. Дальше: 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, которые эвакуируют клиентов в эти дни, - уважение: неделя у них тяжелее моей.

Комментарии (0)