Как проверить бэкап: тест восстановления, checksum, integrity_check

Файл бэкапа существует, cron отрабатывает без ошибок — и всё равно в день аварии может оказаться, что дамп обрезан, архив битый, а в базе нет данных за последний месяц. Узнать это можно только проверкой. Ниже — уровни проверки от самой дешёвой до полного тестового восстановления, готовые скрипты для PostgreSQL, MySQL и SQLite и способ автоматизировать всё это раз в неделю.

Что может быть не так с бэкапом

  • Бэкап не пришёл. Cron не сработал после переезда сервера, истёк ключ S3, закончилось место на диске.
  • Файл повреждён при записи, сжатии или передаче.
  • Дамп обрезан. pg_dump упал посередине, а gzip в конвейере честно сжал то, что успел получить, и вернул код 0.
  • Файл цел, но данные не те. Бэкап снимается с реплики, которая отстала; с тестовой базы вместо боевой; таблицу исключили из дампа и забыли.
  • Восстановить нельзя из-за версии клиента, отсутствующих ролей, расширений, кодировки.

Каждый пункт ловится своей проверкой. Ниже они идут от быстрых к полным.

Уровень 1: контрольная сумма и архив

Считайте SHA-256 сразу после создания бэкапа и храните рядом:

sha256sum app-2026-09-29.sql.gz > app-2026-09-29.sql.gz.sha256
# после скачивания из S3 или перед восстановлением
sha256sum -c app-2026-09-29.sql.gz.sha256

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

Целостность gzip-архива проверяется без распаковки на диск:

gzip -t app-2026-09-29.sql.gz && echo "архив цел"

Уровень 2: дамп не обрезан

Главная причина обрезанных дампов — конвейер без pipefail. В bash код возврата конвейера по умолчанию равен коду последней команды, то есть gzip:

#!/usr/bin/env bash
set -euo pipefail   # без pipefail упавший pg_dump не остановит скрипт
pg_dump -d app | gzip > "/backups/app-$(date +%F).sql.gz"

Дополнительно проверьте хвост файла. pg_dump в конце plain-дампа пишет комментарий о завершении, mysqldump — строку Dump completed:

gunzip -c app.sql.gz | tail -n 5 | grep -q 'PostgreSQL database dump complete' \
  || { echo "дамп PostgreSQL обрезан"; exit 1; }

gunzip -c app-mysql.sql.gz | tail -n 3 | grep -q 'Dump completed' \
  || { echo "дамп MySQL обрезан"; exit 1; }

Строка Dump completed есть, если дамп сделан без --skip-comments и --skip-dump-date.

Для custom-формата PostgreSQL (pg_dump -Fc) прочитайте оглавление и весь архив:

pg_restore --list app.dump > /dev/null && echo "оглавление читается"
pg_restore -f /dev/null app.dump && echo "архив читается целиком"

--list читает только оглавление. Второй вызов прогоняет все данные в SQL-скрипт и выбрасывает его — так обнаруживается обрезанный архив.

Уровень 3: SQLite — integrity_check

Для SQLite-файла есть встроенная проверка:

sqlite3 backup.db "PRAGMA integrity_check;"   # полная: страницы, индексы, ограничения
sqlite3 backup.db "PRAGMA quick_check;"       # быстрее, без сверки индексов с таблицами
sqlite3 backup.db "PRAGMA foreign_key_check;" # строки с нарушенными внешними ключами

Хороший результат — одна строка ok (у foreign_key_check — пустой вывод). quick_check подходит для ежедневной проверки больших файлов, integrity_check — для еженедельной. Почему копию живой базы нельзя снимать через cp, разобрано в статье бэкап SQLite без повреждения.

Уровень 4: тестовое восстановление в одноразовом контейнере

Это единственная проверка, которая отвечает на вопрос «смогу ли я восстановиться». Поднимаем пустой PostgreSQL той же мажорной версии, что в продакшне, восстанавливаем дамп и выполняем контрольные запросы. Контейнер без опубликованных портов удаляется после теста.

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

DUMP=${1:?usage: restore-test.sh app.sql.gz}
NAME=pg-restore-test

docker run -d --rm --name "$NAME" -e POSTGRES_PASSWORD=test postgres:16 > /dev/null
trap 'docker stop "$NAME" > /dev/null' EXIT

# ждём основной сервер: во время инициализации он не слушает TCP
until docker exec "$NAME" pg_isready -h 127.0.0.1 -U postgres -q; do sleep 1; done

docker exec "$NAME" psql -U postgres -qc "CREATE ROLE app"
docker exec "$NAME" createdb -U postgres app
gunzip -c "$DUMP" | docker exec -i "$NAME" \
  psql -U postgres -d app -v ON_ERROR_STOP=1 --single-transaction -q > /dev/null

q() { docker exec "$NAME" psql -U postgres -d app -tAc "$1"; }

echo "users:  $(q 'SELECT count(*) FROM users')"
echo "orders: $(q 'SELECT count(*) FROM orders')"

AGE=$(q "SELECT coalesce(extract(epoch FROM now() - max(created_at))::int, -1) FROM orders")
if [ "$AGE" -lt 0 ] || [ "$AGE" -gt 172800 ]; then
  echo "последний заказ старше 2 суток или таблица пуста: $AGE" >&2
  exit 1
fi
echo "restore test: ok"

Подставьте свои ключевые таблицы и порог свежести. CREATE ROLE app нужен, если в дампе есть OWNER TO app; если ролей много, восстановите сначала вывод pg_dumpall --globals-only. Для custom-формата замените восстановление на:

docker exec -i "$NAME" pg_restore -U postgres -d app --no-owner --exit-on-error < app.dump

MySQL проверяется так же. Пароль root в одноразовом контейнере без опубликованных портов можно не задавать:

docker run -d --rm --name my-restore-test \
  -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -e MYSQL_DATABASE=app mysql:8.4
until docker exec my-restore-test mysqladmin ping -h 127.0.0.1 -uroot --silent; do sleep 2; done

gunzip -c app-mysql.sql.gz | docker exec -i my-restore-test \
  mysql -uroot --default-character-set=utf8mb4 app
docker exec my-restore-test mysql -uroot -N -e \
  "SELECT count(*), max(created_at) FROM app.orders"
docker stop my-restore-test

Версию образа берите ту же, что на боевом сервере, — для MariaDB используйте образ mariadb. Все команды восстановления и типичные ошибки собраны в статье как восстановить базу из бэкапа, а про бэкапы баз, которые сами живут в контейнерах, — в статье бэкап БД в Docker и Coolify.

Уровень 5: сравнение с прошлой версией

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

Для SQLite:

for t in $(sqlite3 backup.db "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); do
  echo "$t $(sqlite3 backup.db "SELECT count(*) FROM \"$t\"")"
done > counts-new.txt
diff counts-old.txt counts-new.txt && echo "без изменений"
mv counts-new.txt counts-old.txt

Для PostgreSQL в тестовом контейнере сначала выполните ANALYZE, затем выгрузите оценки по всем таблицам:

ANALYZE;
SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;

n_live_tup — оценка, а не точный count(*), но для поиска резких скачков её достаточно. Рост и небольшие колебания нормальны; насторожить должно падение числа строк или пропавшая таблица.

Автоматизация: еженедельный тест

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

# воскресенье, 05:00: скачать свежий дамп из S3 и восстановить его
0 5 * * 0 /usr/local/bin/restore-test-latest.sh >> /var/log/restore-test.log 2>&1
#!/usr/bin/env bash
# /usr/local/bin/restore-test-latest.sh
set -euo pipefail
EP=https://storage.yandexcloud.net
LATEST=$(aws s3 ls s3://my-backups/pg/ --endpoint-url "$EP" | sort | tail -n 1 | awk '{print $4}')
aws s3 cp "s3://my-backups/pg/$LATEST" /tmp/latest.sql.gz --endpoint-url "$EP"
/usr/local/bin/restore-test.sh /tmp/latest.sql.gz
rm -f /tmp/latest.sql.gz
curl -fsS -m 10 --retry 3 https://hc-ping.com/<uuid-проверки> > /dev/null

Для Selectel эндпоинт — https://s3.ru-1.storage.selcloud.ru. Как дампы попадают в бакет, описано в статье pg_dump по cron в S3.

В CI тест удобно запускать по расписанию на собственном раннере. Пример для GitLab, расписание задаётся в настройках проекта:

restore-test:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  tags: [backup-runner]
  script:
    - ./scripts/restore-test-latest.sh

Облачные раннеры GitHub и GitLab находятся за рубежом. Если в дампе есть персональные данные, скачивание его на такой раннер — передача данных за пределы вашей инфраструктуры; для таких баз используйте свой раннер или сервер.

Мониторинг: «бэкап не пришёл»

Ошибки в логе никто не читает. Надёжнее обратная схема — dead man's switch: скрипт после успешного бэкапа или теста отправляет «пинг» во внешний сервис, а тот поднимает тревогу, если пинга не было дольше ожидаемого интервала. Так ловятся все случаи сразу: скрипт упал, cron не запустился, сервер выключен.

Именно так работает последняя строка curl в скрипте выше. Подойдёт healthchecks.io или его self-hosted версия, Uptime Kuma с push-мониторами или собственная проверка даты последнего объекта в бакете. Пингуйте только после успешного завершения — благодаря set -e до curl скрипт не дойдёт при ошибке.

Как это сделать в dbsend

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

dbsend backup /backups/app.sql -d "$DBSEND_DATABASE_ID" -e postgresql_dump -l nightly
dbsend log
dbsend diff <fromSnapshotId> <toSnapshotId>
dbsend restore <snapshotId> ./restore-test/app.sql --yes

Что делает сервис:

  • для каждой версии хранит SHA-256; dbsend restore сверяет его после скачивания, для SQLite ещё выполняет PRAGMA integrity_check и при ошибке завершается с кодом 6;
  • SQLite-файл при загрузке проверяется PRAGMA quick_check;
  • для SQLite, DuckDB и plain-дампов PostgreSQL и MySQL показывает схему, таблицы и число строк, а dbsend diff сравнивает любые две версии: изменения схемы, таблиц, числа строк и размера — это уровень 5 без самописных скриптов;
  • если бэкап не пришёл по расписанию, присылает алерт в Telegram, на email или в webhook.

Diff и алерты доступны на платных тарифах — смотрите тарифы. Чего dbsend не делает: не подключается к вашему серверу БД и не применяет дамп. Тестовое восстановление PostgreSQL или MySQL в контейнере остаётся за вами: скачайте версию через dbsend restore и запустите скрипт выше. Сжатый архив загружается как generic_file — для него хранятся только размер и контрольная сумма, без разбора таблиц. Подробности — в документации CLI и на странице бэкапов SQLite.

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

Как часто проверять бэкапы?

Контрольную сумму, gzip -t и хвост дампа — при каждом бэкапе, это секунды. Полное тестовое восстановление — хотя бы раз в неделю и обязательно после изменений в процессе бэкапа: смены версии PostgreSQL, нового сервера, новых флагов pg_dump.

Достаточно ли PRAGMA integrity_check для SQLite?

Он подтверждает, что файл структурно цел. Но он не скажет, что в копии свежие данные и что это нужная база. Добавьте проверку числа строк и даты последней записи в ключевых таблицах.

Чем pg_restore --list отличается от тестового восстановления?

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

Можно ли тестировать восстановление на боевом сервере?

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

Как узнать, что бэкап вообще перестал создаваться?

Через внешний контроль: скрипт после успешного бэкапа отправляет пинг, а сервис мониторинга сообщает, если пинга не было дольше интервала. В dbsend на платных тарифах для этого есть алерты о пропущенном бэкапе.