MAATRIX / Блог / Бэкап был, а ключа шифрования не было: три дня без данных

Бэкап был, а ключа шифрования не было: три дня без данных

MAATRIX

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

Что случилось

Интернет-магазин на пять тысяч заказов в месяц работал на одном VPS: PHP-приложение, PostgreSQL, Redis для сессий и очередь на RabbitMQ. Бэкапы базы и файлового хранилища делались через borgbackup раз в сутки, репозиторий лежал на отдельном сервере бэкапов, шифрование — режим repokey-blake2, ключ встроен в сам репозиторий и защищён парольной фразой (passphrase). Passphrase хранилась в файле /etc/backup/borg.env, который читал systemd-таймер перед запуском джобы.

В конце августа во время планового обновления PostgreSQL (переход с 15-й версии на 16-ю через pg_upgrade) что-то пошло не так: pg_upgrade --link отработал с ошибкой на середине, часть файлов данных оказалась в неконсистентном состоянии, кластер не поднимался ни на старой, ни на новой версии. Инженер принял верное по инструкции решение — не чинить кластер руками, а поднять последнюю рабочую копию из бэкапа и накатить недостающие транзакции из WAL. Дальше должно было быть тридцать минут простоя. Стало три дня.

Что видели в логах и метриках

Первым делом проверили, что бэкапы вообще есть:

borg list /mnt/backup-repo

Список архивов — ровно как ожидалось: db-2026-08-01db-2026-08-29, свежий архив за прошлую ночь на месте, размер растёт равномерно, без провалов. Мониторинг бэкапов (отдельный дашборд, который дёргает borg info и шлёт метрику backup_last_success_timestamp в Prometheus) все двадцать девять дней августа рисовал зелёную линию. Джоба cron/systemd ни разу не завершилась с ненулевым кодом — это видно было в journalctl -u borg-backup.timer.

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

borg extract /mnt/backup-repo::db-2026-08-29
Enter passphrase for key /mnt/backup-repo:

Passphrase из /etc/backup/borg.env не подошла. Repository access denied.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые отбросили

Первая версия — опечатка при копировании пароля. Пароль скопировали из файла напрямую через cat и xclip, без ручного ввода, три раза подряд с одинаковым результатом отказа. Отбросили.

Вторая версия — повреждение самого репозитория, из-за которого borg не может даже проверить passphrase, а не то что расшифровать архив. Прогнали:

borg check --verify-data /mnt/backup-repo

Команда потребовала ту же passphrase и на ней же спотыкалась — то есть до проверки целостности дело даже не доходило, вопрос был именно в ключе, а не в данных. Отбросили.

Третья версия — не тот сервер, файл /etc/backup/borg.env подхватывается с другого хоста через NFS или конфиг-менеджмент и на сервере бэкапов фактически лежит другое значение. Проверили дифом md5sum файла на обеих машинах — идентичен. Отбросили.

Четвёртая версия — несовместимость версий borg (клиент новее сервера, разный формат ключа). Проверили borg --version на обеих сторонах — 1.2.x с обеих, разница в патч-версии, на формат ключа не влияет. Отбросили.

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

Настоящая причина

Три месяца назад в компании прошёл внутренний аудит безопасности после ухода одного из бэкенд-разработчиков. По итогам аудита провели ротацию всех секретов, до которых дотянулись: SSH-ключи, API-токены, пароли от админок. В список ротации по шаблонному плейбуку попал и файл /etc/backup/borg.env — просто потому, что имя переменной внутри (BORG_PASSPHRASE) совпало с паттерном *PASS*, по которому Ansible-роль генерировала новые случайные значения и раскладывала их по серверам.

Ansible-роль честно сделала свою работу: сгенерировала новый пароль, записала его в файл, перезапустила таймер бэкапов. С этого дня джоба продолжила исправно отрабатывать — но уже с новым паролем поверх репозитория, ключ которого при создании был зашифрован старым. Borg в режиме repokey хранит сам ключ шифрования данных внутри репозитория, а passphrase нужна только чтобы расшифровать этот внутренний ключ. Смена passphrase без выполнения borg key change-passphrase для существующего репозитория ничего не меняет в реальности хранения — но и не ломает работу бэкапа сразу: репозиторий продолжает принимать новые архивы, потому что доступ к нему проверяется на уровне репозитория, а не на уровне конкретного архива с первого дня.

На деле произошло вот что: старая passphrase к этому моменту нигде не сохранилась. Она никогда не попадала в отдельное хранилище секретов — жила только в том самом файле на сервере, который аудит и переписал. Резервной копии файла, бумажной распечатки экспортированного ключа (borg key export --paper) или записи в командном менеджере паролей никто не делал: считалось, что раз файл лежит на сервере и сервер бэкапится сам, всё под контролем. Круг замкнулся сам на себя — единственная копия ключа могла исчезнуть вместе с сервером, который она же и защищала, и в итоге исчезла проще — её просто затёрли автоматизацией, которая не отличала пароль от бэкапа от пароля к дашборду.

Три дня без данных: как это разворачивалось

День 1. Обнаружили, что текущая passphrase не подходит. Перебрали историю изменений файла через git log в приватном репозитории конфигов (файл borg.env в конфигах не хранился, но роль, которая его генерировала, — да). Нашли коммит с датой ротации трёхмесячной давности и комментарием «security: rotate secrets per audit». Сам сгенерированный пароль в git, разумеется, не попал — Ansible Vault шифрует такие значения, а ключ от vault был у уволившегося разработчика и ещё у одного инженера, который был в отпуске.

День 2. Дозвонились до инженера в отпуске, получили vault-пароль, расшифровали старые переменные роли — но в них хранился шаблон генерации, а не конкретное сгенерированное значение (генератор случайных паролей password_hash с seed от ansible_date_time, то есть невоспроизводимый результат). Параллельно проверили резервные копии самого сервера бэкапов на случай, если где-то на диске остался старый файл borg.env.bak или похожий — find / -name "*borg*env*" -mtime +90 ничего не дал, файлы затирались при каждом деплое роли. Проверили корпоративный менеджер паролей (Bitwarden, командный сейф) — пароля от бэкапов там никогда не было, только личные пароли сотрудников.

День 3. Приняли решение: старые архивы (до даты ротации минус один день) считать невосстановимыми и не тратить на них больше времени. Новые архивы, сделанные после ротации, расшифровались новым паролем без проблем — именно из них подняли базу, потеряв данные примерно за сутки до сбоя (спасли часть через WAL-архив PostgreSQL, который вёлся отдельно и ключа шифрования не требовал вовсе). Магазин заработал вечером третьего дня в состоянии «почти всё на месте», кроме заказов и правок каталога за последние сутки перед сбоем, которые пришлось восстанавливать вручную по логам платёжного шлюза и переписке с покупателями.

Что изменили после разбора

Три решения приняли сразу и без споров.

Во-первых, ключ шифрования вынесли из зоны действия любой автоматической ротации секретов. Сделали borg key export --paper и borg key export --qr-html для текущего репозитория, распечатали и убрали в сейф офиса, плюс отдельно экспортировали ключ в зашифрованный GPG-файл, который лежит в другом облачном хранилище, не связанном с основной инфраструктурой:

borg key export /mnt/backup-repo /root/borg-key-2026-08.txt
gpg --symmetric --cipher-algo AES256 /root/borg-key-2026-08.txt

Во-вторых, описали процедуру borg key change-passphrase как обязательный шаг при любой плановой смене пароля, а не «просто поменять переменную»:

borg key change-passphrase /mnt/backup-repo

Эта команда действительно меняет passphrase у существующего репозитория, в отличие от подмены переменной окружения снаружи. Плейбук ротации секретов теперь явно исключает файлы, помеченные тегом backup-secret, из общего шаблонного правила «переменные с PASS ротируются автоматически» — такие секреты меняются только руками, с обязательным пересохранением экспортированного ключа.

В-третьих, ввели ежеквартальную репетицию восстановления: не «бэкап есть в списке», а полноценный borg extract на тестовый сервер с проверкой, что база поднимается и приложение стартует. Мы уже писали подробно про устройство таких учений — именно регулярный тестовый restore, а не мониторинг «джоба не упала», ловит такие проблемы за недели до реальной аварии, а не в момент, когда терять уже нечего.

Как не наступить на те же грабли

Если у вас шифрованные бэкапы, стоит честно ответить на несколько вопросов, а не только проверить, что джоба зелёная.

  • Ключ (passphrase, keyfile, мастер-ключ KMS) хранится минимум в двух физически разных местах — не «файл на сервере плюс его копия в бэкапе того же сервера».
  • Автоматизация, которая ротирует секреты по маске имени переменной, не трогает секреты для расшифровки бэкапов — они меняются вручную по отдельной процедуре.
  • Есть письменная (не в чьей-то голове) инструкция восстановления, включая то, где физически лежит ключ и кто имеет к нему доступ, если ответственный инженер недоступен.
  • Раз в квартал кто-то реально расшифровывает и поднимает архив на отдельном сервере, а не смотрит на дашборд с надписью «Last backup: success».
  • При смене пароля к репозиторию используется штатная команда инструмента (borg key change-passphrase, restic key add + restic key remove), а не просто замена значения переменной окружения.

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

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему borg не сообщил об ошибке в момент смены пароля?

Потому что смена значения переменной окружения снаружи репозитория никак не связана с внутренним состоянием репозитория. Borg проверяет passphrase только в момент реальной операции с репозиторием (extract, list, check), а не при простом изменении файла на диске. Ошибка проявляется только тогда, когда кто-то пытается воспользоваться репозиторием со старым или чужим паролем.

Разве нельзя было просто перебрать несколько последних значений пароля?

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

Что если бы использовали keyfile вместо repokey?

Отдельный файл-ключ (borg init --encryption=keyfile) не решает проблему сам по себе — он просто переносит тот же риск в другое место: файл-ключ так же нужно бэкапить отдельно от сервера и защищать паролем. Разница в том, что при потере самого сервера с keyfile-репозиторием без резервной копии ключа архив пропадает гарантированно, тогда как repokey хотя бы теоретически восстановим, если найдётся правильная passphrase.

Почему WAL-архив PostgreSQL спас данные, а borg — нет?

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

Как быстро вообще можно поднять с нуля сервер под шифрованные бэкапы с правильной схемой хранения ключа?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →