Как я за сутки восстановил свою соцсеть после того, как хостинг обнулил мой сервер

Так выглядит Агора сейчас :) agorasocial.ru, 4 ноября 2026 года

Так выглядит Агора сейчас :) agorasocial.ru, 4 ноября 2026 года

TL;DR. 2 ноября сервер моей соцсети «Агора» на Beget перестал отвечать. 3 ноября хостинг подтвердил: данные VPS обнулены, бинарные бэкапы потеряны. Меня спасли три вещи: зашифрованные офсайт-бэкапы базы, которые я настроил за день до аварии; escrow-ключ для личных сообщений, заложенный в схему ещё раньше; и скрипты переезда, которые я успел написать в репозитории. Агора открылась на новом сервере примерно через 3 часа после того, как Beget подтвердил потерю. Цена: примерно сутки данных и несколько граблей, о которых расскажу ниже. Главная моя ошибка: мастер-ключ шифрования лежал только в окружении серверного процесса и в бэкап не входил.

Коротко для тех, кто не читал прошлые статьи

Я Арсений, мне 15 лет, единственный разработчик «Агоры», российской соцсети для учеников, учителей и студентов. В первой статье я рассказывал, как написал её с нуля, во второй разбирал комментарии и то, что изменилось за три месяца. Сейчас пользователей около 200, и всё так же без единого рубля на рекламу.

Стек, чтобы было понятно, что именно падало:

  • React + TypeScript + Vite, статика сайта лежит на обычном хостинге (jino);

  • self-hosted Supabase (Postgres 17) в Docker, рядом Bun-сервисы под PM2 и Caddy в Docker;

  • личные сообщения с E2E-шифрованием;

  • Android-приложение на Expo / React Native;

  • медиа в Яндекс Object Storage.

Весь бэкенд жил на одном VPS у Beget. Спойлер: это была главная архитектурная ошибка, и в выводах я к ней вернусь.

Архитектура до и после аварии

Архитектура до и после аварии

Хронология

Как я за сутки восстановил свою соцсеть после того, как хостинг обнулил мой сервер - 3
  • 01.11: днём я настроил офсайт-бэкапы базы: pg_dump → GPG-шифрование → Яндекс S3, недельные и месячные копии. Последняя копия сделана 01.11 в 03:00 UTC.

  • 02.11: сервер перестал отвечать: SSH сбрасывает соединение, API молчит. Статика сайта на другом хостинге жива. Позже выяснилось, что это кибератака на Beget (новость на Хабре).

  • 03.11, около 15:10 UTC: Beget подтвердил, что данные VPS, S3 и K8s обнулены, бинарные бэкапы VPS потеряны, остались только файловые. Совет один: сменить все ключи.

  • 03.11, около 18:00 UTC: Агора снова открыта на новом сервере.

В копии, с которой я поднимался: 203 пользователя, 1030 постов и около 1800 личных сообщений. Всё, что случилось после 03:00 UTC 01.11, потеряно, то есть примерно сутки.

Бэкап базы: настроил за день до аварии (повезло)

Я давно знал, что копия «на том же сервере» на самом деле не копия. Скрипт после ночного дампа шифрует его GPG (на сервере лежит только открытый ключ, закрытый у меня) и кладёт в Яндекс S3: воскресные копии храню 4 недели, копии первого числа 6 месяцев.

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

А вот настройки сервера в бэкап не входили. Ни .env, ни конфиги Caddy, ни compose-файлы. Это оказалось самым болезненным, об этом дальше.

Мастер-ключ потерян, личные сообщения нечитаемы

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

В итоге база есть, а все приватные ключи превратились в набор нечитаемых байтов. Вся старая переписка нечитаема. Это моя ошибка.

Выручило то, что в схему заранее был заложен escrow: к каждому зашифрованному сообщению прилагается копия, которую открывает ключ оператора. Этот ключ хранился у меня локально, отдельно от сервера. Это был «план Б», в который я, честно говоря, не очень верил.

Как я за сутки восстановил свою соцсеть после того, как хостинг обнулил мой сервер - 4

Я написал скрипт, который работает целиком на моём компьютере (ни тексты сообщений, ни escrow-ключ никуда не уходят):

  1. читает выгрузку шифротекстов и состав диалогов из копии базы;

  2. генерирует новую пару ключей каждому из 203 пользователей, приватную часть оборачивает новым мастер-ключом;

  3. расшифровывает каждое сообщение через escrow;

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

  5. каждое новое сообщение проверяет обратной расшифровкой;

  6. пишет SQL одной транзакцией, который я применяю на сервере до запуска сервиса ключей с новым мастер-ключом.

Спойлер: Упрощённая логика скрипта (без ключей)

for (const msg of data.messages) {
  const text = await decryptEscrow(msg.escrow_content);   // ключ оператора, только в памяти
  const members = membersOf[msg.conversation_id];

  if (members.length === 2) {
    content = await encryptForPair(text, sender, peer);    // на пару ключей
    assert(await decryptAsPeer(content) === text);        // проверка с обеих сторон
    assert(await decryptAsSender(content) === text);
  } else if (members.length === 1) {
    content = await encryptForOne(text, members[0]);      // на ключ единственного участника
    assert(await decryptAsOwner(content) === text);
  } else continue;                                        // диалог без участников

  sql.push(updateMessage(msg.id, content));
}

Сначала я полностью прогнал скрипт на копии базы: ошибок не было. Только после этого пошёл боевой прогон.

Результат: новые ключи получили 160 пользователей, восстановлено 1218 сообщений, ещё 44 лежали в пустых диалогах (из них не осталось ни одного участника), ошибок 0. Клиенты сами замечают новый публичный ключ в профиле и забирают новый приватный из сервиса ключей.

Раз я писал про E2E в прошлых статьях, скажу про escrow отдельно. Escrow это компромисс, и делать вид, что его нет, я не буду. Он ослабляет идею «сервер ничего не может прочитать», зато именно он вернул людям переписку. Если у вас так не устроено, храните мастер-ключ хотя бы в нескольких местах офлайн.

Новый сервер: переехал по скриптам из репозитория

Я арендовал VPS в Санкт-Петербурге: 2 vCPU, 4 ГБ, 50 ГБ. План переезда и скрипты я написал ещё 02.11, пока ждал ответа от Beget, поэтому разворачивание шло по списку:

Шаг

Что делает

bootstrap

система, SSH только по ключам, firewall, Docker, Bun, PM2

setup-supabase

ключи, .env, запуск Supabase

restore-db

заливка дампа

make-env

.env всех сервисов

ecosystem (PM2)

запуск сервисов

Caddyfile

HTTPS и домены

Все ключи Supabase (JWT-секрет, anon- и service-ключи, пароль базы) пришлось сделать новыми: старые значения лежали на уничтоженном сервере. Из-за этого появилась первая проблема.

Старые сборки приложения получали 401, починил в Caddy

Публичный anon-ключ был вшит в уже вышедшие сборки Android-приложения и в другие мои проекты. JWT-секрет утерян, ключ теперь новый, и старые клиенты получали 401. За первые три часа набралось 106 неудачных входов по OTP. Обновление по воздуху (OTA) до людей со старыми сборками не доходило.

Решение: Caddy подменяет старый ключ на новый в заголовках apikey и Authorization и в query-параметре (так подключаются websocket-ы Supabase Realtime):

@old_apikey header apikey {$OLD_ANON_KEY}
request_header @old_apikey apikey {$ANON_KEY}

@old_bearer header Authorization "Bearer {$OLD_ANON_KEY}"
request_header @old_bearer Authorization "Bearer {$ANON_KEY}"

@old_ws query apikey={$OLD_ANON_KEY}
uri @old_ws query apikey {$ANON_KEY}

Значения подставляются из окружения Caddy. Это безопасно, потому что anon-ключ публичный и даёт права только гостя. Заодно заработали сайты и приложения, которые я не успевал пересобрать.

Веб-пуши умерли, Android-пуши отправил вручную

Новые VAPID-ключи значит старые подписки мертвы, браузеры переподписываются при следующем входе. Пуши в Android я разослал прямо с сервера по токенам устройств из офсайт-копии (Expo-токены от наших ключей не зависят).

Экран «технические работы» не показывался (завис ≠ упал)

Флаг тех. работ лежит отдельно, как status.json на статик-хостинге, и задумано это было ровно на такой случай. Но экран не показывался. Причина в том, что сервер не упал, а завис. Запрос в базу без таймаута держал загрузку минутами, а флаг проверялся вместе с этим запросом.

Исправил так: AbortSignal на 8 секунд на запрос в базу, а если во флаге enabled: true, экран показывается сразу, не дожидаясь базы. Вывод: таймауты нужны везде, особенно в проверках «всё ли в порядке».

Мониторинг дважды промолчал

У меня была внешняя проверка доступности в GitHub Actions раз в час. Во время аварии она дважды не прислала алерт:

  • code=$(curl ...) под bash -e падал на таймауте (код 28), и шаг умирал вместо отправки уведомления. Лечится || true;

  • четыре проверки с повторами не влезали в timeout-minutes.

Исправленный кусок:

check() {
  for i in 1 2; do
    # || true: при таймауте curl выходит с ошибкой, и без этого
    # bash -e ронял весь шаг вместо уведомления
    code=$(curl -s -m 10 --connect-timeout 8 -o /dev/null -w '%{http_code}' "$2" || true)
    [ "$code" -ge 200 ] && [ "$code" -lt 500 ] && return
    sleep 10
  done
  echo "• $1 (код $code)" >> problems.txt
}

Ещё заметил, что GitHub-cron срабатывал заметно реже, чем раз в час. Как единственная защита этого мало, но как внешняя проверка лучше, чем ничего.

Github Actions

Github Actions
Cтраница статуса. История начинается с чистого листа, потому что сервер новый

Cтраница статуса. История начинается с чистого листа, потому что сервер новый

Деплой не работал, потому что раннер стоял на упавшем сервере

Деплой шёл через self-hosted раннер GitHub Actions, который стоял на самом сервере. Сервер лёг, и деплоить стало нечем. Я добавил в deploy.yml входной параметр runner: по умолчанию self-hosted, но в аварию можно запустить на ubuntu-latest. Заливка с GitHub-раннеров по FTP ловила таймауты хостинга, поэтому для неё я заменил FTP-экшен на lftp mirror (заливка занимает около 15 минут).

Звонки: TURN-сервер тоже был на Beget

Звонки 1-на-1 (WebRTC) требуют TURN-сервера для пользователей за NAT. Мой TURN тоже стоял на Beget и тоже был уничтожен. Какое-то время при нажатии на звонок показывалось сообщение «временно не работают» (флаг лежит в базе, чтобы клиенты не пытались соединяться зря).

Потом я поднял coturn на основном сервере. Раз он теперь делит машину с Supabase, ограничений пришлось добавить побольше:

  • слушает только публичный IP;

  • квоты на сессии и ограничение скорости;

  • запрещён relay на приватные и служебные диапазоны (защита от SSRF через TURN и от доступа к docker-сетям Supabase);

  • без TCP-relay и без CLI;

  • ограниченный диапазон relay-портов.

Креды выдаются по схеме TURN REST: временное имя <время жизни>:<id пользователя> и пароль как HMAC от него. Эндпоинт сначала проверяет токен пользователя. Проверка через релей между разными сетями: около 115 пакетов в секунду в каждую сторону.

Ещё одна ловушка: TTL кредов я поднял до 8 часов, иначе звонок через релей обрывается, когда протухают креды.

Интерфейс веб-звонка (компьютер)

Интерфейс веб-звонка (компьютер)

Telegram-алерты с нового сервера: IPv6 не доступен, HTTP/2 виснет

С нового сервера Telegram по IPv6 недоступен, а POST по HTTP/2 просто висел. Помогло curl -4 --http1.1. Сами алерты это скрипт, который каждые 5 минут проверяет диск, память, PM2, контейнеры, API и свежесть бэкапа и пишет сообщение только при смене состояния.

Мелочи, которые вылезли позже

  • Ссылки на старый CDN-домен в базе (в постах, комментариях, группах) переписал на новый, остатков не осталось.

  • Модель NSFW-проверки лежала только на старом сервере. После переезда проверка загрузок молча не работала: ни ошибки, ни предупреждения. Скачал модель заново, и сервис написал «модель загружена». Тихие отказы самые неприятные.

  • Платёжный магазин в ЮKassa оказался другим, чем в моих старых записях: на «старых» данных приходил 401. Разобрался, и платежи заработали.

Выводы

  1. Ключи шифрования и .env надо класть в зашифрованный офсайт-бэкап, а не держать только в окружении процесса. Теперь отдельный скрипт собирает все .env, конфиги Caddy и compose-файлы в GPG-архив и отправляет в облако.

  2. Бэкап, который не проверяли восстановлением, не бэкап. Мне повезло, что я успел проверить хотя бы базу. Но проверял только её, а про мастер-ключ так и не спросил себя «а что, если его нет?».

  3. Скрипты переезда надо хранить в репозитории. Не «я помню, как это делается», а bootstrap.sh и README с порядком шагов и списком подводных камней. Например, подсеть Docker для Supabase пришлось создавать вручную с конкретным диапазоном: триггеры в базе шлют вебхуки на фиксированный адрес шлюза.

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

  5. Таймауты везде: запросы к базе, curl, джобы Actions. Зависший сервер хуже упавшего.

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

  7. У E2E должен быть «план Б» (escrow или несколько офлайн-копий мастер-ключа), иначе одна потерянная переменная окружения стоит всей переписки.

Цифры на сегодня

  • ~3 часа от подтверждения потери данных до открытой Агоры;

  • ~сутки данных потеряно (копия от 01.11, 03:00 UTC);

  • 203 пользователя, 1030 постов в копии;

  • 1218 личных сообщений восстановлено, 0 ошибок;

  • 106 неудачных OTP-входов со старых сборок за первые три часа.

Что дальше

Осталось много уборки: часть настроек (например, конфиг coturn) всё ещё не входит в автоматический бэкап, и при следующем переезде мне придётся воссоздавать его по заметкам. TLS для TURN не настроен, клиенты откатываются на обычный порт. И я хочу, наконец, вынести мониторинг на отдельный сервер, чтобы он не падал вместе с остальным.

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

Как и в прошлый раз, пишу, чтобы зафиксировать опыт. Если у вас есть вопросы по конкретным решениям или идеи, как сделать лучше, пишите в комментариях, буду рад разобрать.

Ссылка на проект, социальную сеть для учеников, учителей и студентов: agorasocial.ru.

Автор: ars_stu

Источник

Оставить комментарий