Резервное копирование базы данных: как проверить, что копия развернётся

Обсудить
Резервное копирование базы данных: как проверить, что копия развернётся
Реклама. АО «ТаймВэб». erid: 2W5zFJRDRH3

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

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

Потеряны данные примерно 82 пользователей, частично их удалось собрать из внешней системы, куда часть информации уезжала отдельным каналом. Резервная копия существовала, но лежала на том же диске и была того же возраста, что и перезаписанный файл.

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

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

Почему привычные проверки бэкапа ничего не гарантируют

Между «файл создан» и «из файла поднимется база» помещается несколько сценариев, каждый из которых завершается нулевым кодом возврата.

Самый частый связан с конвейером. Строка pg_dump | gzip | aws s3 cp вернёт код последней команды, поэтому падение pg_dump на середине останется незамеченным: загрузка отработает штатно, в хранилище появится обрезанный файл. Включённый pipefail эту слепоту снимает, но в исходном виде конструкция встречается до сих пор.

Проверить, как это выглядит, можно за десять секунд в интерактивном шелле, где pipefail по умолчанию выключен:

( echo "первая часть"; exit 1 ) | gzip > /tmp/broken.gz
echo "exit code = $?"   # 0, потому что gzip отработал штатно
gzip -t /tmp/broken.gz  # архив валиден: обрубок сжат корректно

gzip -t, который часто ставят в мониторинг, отвечает за целостность контейнера и молчит про полноту содержимого. Обрезанный поток, аккуратно упакованный до конца, эту проверку проходит.

Похожим образом ведёт себя pg_restore --list. В зарегистрированной ошибке BUG #16458 битый дамп исправно показывал все таблицы в оглавлении, а восстановление падало на чтении файла, потому что команда читает только заголовок и оглавление в начале архива. Разбор всего архива в никуда даёт больше, поскольку заставляет прочитать и распаковать каждый блок:

pg_restore -f /dev/null db.dump   # ловит обрыв файла, но не отвечает, поднимется ли база

Дальше идут отказы, которые не ловит вообще ничего из перечисленного. Утилита pg_dump старше сервера просто откажется снимать дамп с более новой мажорной версии, о чём документация PostgreSQL предупреждает прямо. Если заполнился диск, ошибка уйдёт в stderr, планировщик отправит её локальной почтой, которой на VPS обычно нет, а ротация через сутки перезапишет последнюю рабочую копию. Врёт и само восстановление: psql без -v ON_ERROR_STOP=1 завершается успехом при ошибках внутри, а pg_restore по умолчанию продолжает работу и печатает счётчик ошибок в конце.

Насколько далеко это заходит, показал GitLab 31 января 2017 года. Из пяти развёрнутых механизмов резервирования не сработал ни один. Дампы падали молча из-за несовпадения версий, письма о падении задачи отбрасывал принимающий почтовый сервер, а спас в итоге случайный снапшот, снятый вручную за шесть часов до аварии. Разбор инцидента компания опубликовала сама.

Тестовое восстановление базы данных на одном сервере

Ответ на исходный вопрос даёт только одна процедура, и в ней четыре шага. Поднять пустой сервер в контейнере, залить туда последнюю копию, задать базе два вопроса, погасить контейнер.

#!/usr/bin/env bash
# -e намеренно не включаем: он не даст прочитать коды упавших команд
set -uo pipefail

PGVER=${PGVER:-17}          # версию берём от прода, не зашиваем в скрипт
BACKUP_DIR=/backup

# выбираем свежую копию без конвейера: ls | head под pipefail ловит SIGPIPE
DUMP=""
for f in "$BACKUP_DIR"/db-*.dump; do
  [ -e "$f" ] || continue
  if [ -z "$DUMP" ] || [ "$f" -nt "$DUMP" ]; then DUMP="$f"; fi
done
[ -n "$DUMP" ] || { echo "копий не найдено"; exit 1; }

CID=$(docker run -d --rm -e POSTGRES_PASSWORD=tmp "postgres:$PGVER")
trap 'docker rm -f "$CID" >/dev/null 2>&1 || true' EXIT   # контейнер умрёт в любом случае

# ждём именно TCP: во время инициализации образа сервер слушает только юникс-сокет
ready=0
for _ in $(seq 1 60); do
  if docker exec "$CID" pg_isready -h 127.0.0.1 -q; then ready=1; break; fi
  sleep 1
done
[ "$ready" -eq 1 ] || { echo "сервер не поднялся за 60 секунд"; exit 1; }

docker exec "$CID" createdb -U postgres verify

# --no-owner и --no-privileges обязательны: ролей из pg_dumpall в чистом контейнере нет
docker exec -i "$CID" pg_restore -U postgres -d verify \
  --no-owner --no-privileges --exit-on-error < "$DUMP" \
  || { echo "восстановление упало на $DUMP"; exit 1; }

# первый вопрос: данные вообще на месте
ROWS=$(docker exec "$CID" psql -U postgres -d verify -tAc "select count(*) from users")
case "$ROWS" in
  ''|*[!0-9]*) echo "не удалось посчитать строки"; exit 1 ;;
esac
[ "$ROWS" -ge 100 ] || { echo "строк всего $ROWS"; exit 1; }

# второй вопрос: данные свежие, копия не снята с отставшего источника
FRESH=$(docker exec "$CID" psql -U postgres -d verify -tAc \
  "select count(*) from messages where created_at > now() - interval '26 hours'")
case "$FRESH" in
  ''|*[!0-9]*) echo "не удалось проверить свежесть"; exit 1 ;;
esac
[ "$FRESH" -gt 0 ] || { echo "свежих записей нет"; exit 1; }

echo "копия $DUMP разворачивается, строк $ROWS"

Каждое решение в этом скрипте закрывает отдельные грабли.

Отключённый set -e выглядит ересью, но с включённым pipefail он убивает скрипт ровно на той строке, коды возврата которой мы собираемся прочитать. Проверки здесь расставлены явно, и это надёжнее автоматического выхода.

Выбор свежего файла сделан циклом, потому что привычное ls -t | head -1 под pipefail однажды выстрелит. Команда head закрывает канал после первой строки, ls получает SIGPIPE, подстановка возвращает ненулевой код. Пока копий мало, вывод помещается в буфер канала и всё работает, а проблема всплывает через полгода накопления файлов.

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

Без --no-owner и --no-privileges восстановление в чистом контейнере просто не пройдёт. Утилита pg_dump не выгружает роли и табличные пространства, их даёт отдельная команда pg_dumpall --globals-only, поэтому с --exit-on-error процесс оборвётся на первой же выдаче прав. По той же причине проект, использующий расширения вроде PostGIS или pgvector, потребует собственного образа, ведь CREATE EXTENSION в стандартном контейнере не отработает.

Версия образа вынесена в переменную намеренно. Прод на 18-й версии и проверка на 17-й дадут ошибку чтения заголовка архива, после чего провалится тест при полностью исправной копии.

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

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

Резервное копирование SQLite без порчи файла

Для бота или небольшого сервиса база часто лежит одним файлом, и его хочется просто скопировать. По документации SQLite такая копия может содержать часть старого и часть нового содержимого и оказаться битой, а отдельным пунктом там названо копирование файла базы без журнала.

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

#!/usr/bin/env bash
set -uo pipefail

DAY=$(date +%F)                       # считаем один раз: на прогоне в полночь дата уедет
SRC=/var/lib/bot/bot.db
DST="/backup/bot-$DAY.db"

sqlite3 "$SRC" "VACUUM INTO '$DST'" || { echo "снять копию не удалось"; exit 1; }

# проверяем именно копию; ждём ровно ok, поэтому сравниваем явно
CHECK=$(sqlite3 "$DST" "PRAGMA integrity_check;")
[ "$CHECK" = "ok" ] || { echo "копия повреждена: $CHECK"; exit 1; }

# integrity_check не смотрит внешние ключи, для них отдельная проверка
FK=$(sqlite3 "$DST" "PRAGMA foreign_key_check;")
[ -z "$FK" ] || { echo "нарушения внешних ключей: $FK"; exit 1; }

echo "копия $DST снята и проверена"

Команда VACUUM INTO доступна с версии 3.27.0 и работает на живой базе, но требует до двойного размера базы свободного места и при обрыве процесса оставляет неполный файл. На сервере с забитым диском это ровно тот сценарий, из-за которого копии молча превращаются в мусор, поэтому копию проверяют сразу после снятия.

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

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

Где хранить резервную копию и сколько это стоит

Копия рядом с базой защищает от одного сценария – от вашей собственной ошибки в данных. Всё остальное она переживает вместе с сервером.

Копия внутри инфраструктуры того же провайдера закрывает больше, но тоже не всё. В описании услуги резервного копирования Timeweb Cloud прямо сказано, что копии размещаются в том же дата-центре, где стоит виртуальная машина. Откатить неудачное обновление такой копией можно, а при потере доступа к аккаунту она пропадает вместе с ним.

На Хабре описан случай, когда сервер удалили автоматически через неделю после просрочки оплаты. Вместе с ним ушли копии в холодном хранилище провайдера, а письма о задолженности лежали в спаме. Из кэшей поисковиков автор вернул около 80% публикаций, база за четыре месяца не вернулась.

Цена внешнего хранения оказывается меньше, чем принято думать при выборе схемы. По прайсу Timeweb Cloud объектное хранилище стоит 99 ₽ за 10 ГБ в месяц и 449 ₽ за 100 ГБ, а исходящий трафик не тарифицируется до 100 ГБ в месяц. У Selectel ледяной класс начинается от 0,89 ₽ за гигабайт в месяц. Yandex Object Storage даёт бесплатный месячный лимит в 1 ГБ хранения и 100 ГБ исходящего трафика, чего для маленькой базы хватает целиком.

Дамп на пару гигабайт с суточной ротацией и дедупликацией редко разрастается выше 10-15 ГБ репозитория, что по тарифу 99 ₽ за 10 ГБ даёт около 100-150 ₽ в месяц.

Сравнение со встроенными автобэкапами выходит не в их пользу. По тому же прайсу они стоят 6 ₽ в месяц за гигабайт диска, поэтому на диске в 30 ГБ выходит 180 ₽ – сопоставимо со стоимостью самой машины начального уровня.

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

Как узнать о тишине, когда бэкап не запустился

Алерт на ошибку выглядит достаточным, пока сервер работает. Если машина легла или планировщик не стартовал, ошибки не будет, и её отсутствие ничем не отличается от успешного прогона.

Поэтому проверку разумнее строить на внешнем наблюдателе, который ждёт сигнала в назначенное время. Здесь важна форма записи в cron, потому что привычная связка через && и || отправит сигнал провала, если сам скрипт отработал, но не прошёл запрос к сервису мониторинга.

17 4 * * * /bin/sh -c '/opt/verify-backup.sh; rc=$?; if [ $rc -eq 0 ]; then curl -fsS -m 10 --retry 5 -o /dev/null https://hc-ping.com/UUID; else curl -fsS -m 10 -o /dev/null https://hc-ping.com/UUID/fail; fi'

Отработавший скрипт шлёт пинг, и сервис молчит. Упавший шлёт сигнал провала. А если сервер не проснулся вовсе, сервис сам заметит, что пинга в назначенное время не было – ровно этот случай и есть та тишина, ради которой всё затевалось. Документация healthchecks.io описывает такую схему как основной сценарий.

Если внешний сервис использовать не хочется, отчасти помогает systemd: директива OnFailure= в секции [Unit] самого сервиса запустит юнит уведомления при ненулевом коде. На лежащем сервере systemd молчит вместе со всем остальным, ровно как и почтовый алерт, поэтому сценарий с полным отказом машины он не закрывает.

Для планировщика пригодится и обёртка chronic из moreutils, которая пропускает вывод только при ненулевом коде, чтобы ящик не забивался сообщениями об успешных прогонах.

Когда всё это избыточно

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

Статистика поводов для восстановления подсказывает то же самое. В опросе Backblaze 62% респондентов назвали причиной обращения к копиям запрос архивного или удалённого файла, дальше идут сбой самого бэкап-софта и отказ диска (вопрос допускал несколько вариантов, поэтому доли не складываются в сотню). Катастрофы в этом списке далеко не на первом месте, и с бытовым запросом справляется обычный суточный дамп с ретенцией. Опрашивали при этом ИТ-руководителей компаний, тогда как у владельца одиночного сервера расклад может отличаться.

Диски отказывают реже, чем принято думать при выборе схемы бэкапа. По годовому отчёту Backblaze за 2025 год годовой процент отказов составил 1,36% против 1,55% годом раньше, причём на VPS диск обычно ещё и в избыточном массиве провайдера.

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

FAQ

Как понять, что копия рабочая, не восстанавливая её целиком?

Полной уверенности без разворота не даёт ни одна проверка. Ближе всего подходят pg_restore -f /dev/null для дампа в custom-формате и PRAGMA integrity_check для SQLite. Обе ловят повреждение файла, но не отвечают на вопрос, поднимется ли база на чистом сервере.

Достаточно ли снапшотов хостера?

Они закрывают откат неудачного обновления и собственную ошибку в конфигурации. За их пределами остаются потеря доступа к аккаунту, блокировка за неоплату, компрометация панели управления и авария в дата-центре, если копия лежит там же.

Почему restic check не гарантирует восстановление?

Без флага чтения данных команда проверяет структурную целостность снапшотов, деревьев и пакетов в репозитории. Содержимое при этом не читается, поэтому обрезанный дамп внутри целого репозитория проверку проходит. Полное чтение включается флагом --read-data, а размазать его по неделе позволяет --read-data-subset=1/7.

Можно ли копировать файл SQLite обычным cp?

На остановленном приложении можно, на работающем нет, потому что рядом живут журнал и файл разделяемой памяти и копия рискует собраться из кусков разных состояний. Безопаснее снимать её через VACUUM INTO или встроенную команду резервного копирования.

Сколько стоит хранить копию у другого провайдера?

Для базы в несколько гигабайт с суточной ротацией репозиторий укладывается в 10-15 ГБ, что по российским тарифам объектного хранилища выходит около 100-150 ₽ в месяц. Бесплатных лимитов отдельных провайдеров хватает, если репозиторий держится в пределах гигабайта.

Как часто проверять восстановление?

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

Итог

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

Минимальный набор для одного сервера собирается за вечер. В нём скрипт с явной проверкой кодов возврата вместо надежды на set -e, ночной разворот копии в контейнер с двумя контрольными запросами, копия за пределами инфраструктуры провайдера и внешний наблюдатель, который заметит тишину.

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

echo -e "Все про серверы, сети, хостинг и еще раз серверы" >/dev/pts/0

Комментарии

С помощью соцсетей
У меня нет аккаунта Зарегистрироваться
С помощью соцсетей
У меня уже есть аккаунт Войти
Инструкции по восстановлению пароля высланы на Ваш адрес электронной почты.
Пожалуйста, укажите email вашего аккаунта
Ваш баланс 10 ТК
1 ТК = 1 ₽
О том, как заработать и потратить Таймкарму, читайте в этой статье
Чтобы потратить Таймкарму, зарегистрируйтесь на нашем сайте