В продакшене нет ничего страшнее бэкапа, который «кажется рабочим». Пока вы не делали полноценный restore и не проверили результат, риск потерять данные и время остаётся высоким. В этой статье соберём воспроизводимый процесс backup restore в CI для MySQL и PostgreSQL: развернём изолированный sandbox на Docker, настроим восстановление как из логических дампов, так и с поддержкой PITR, и добавим автоматическую верификацию данных. Всё — без публикации портов наружу и с учётом безопасности.
Цели и подход
Нам нужны не просто файлы бэкапов, а уверенность, что:
- восстановление запускается автоматически в CI по расписанию или по событию (появление нового бэкапа);
- оно проходит в «песочнице» без доступа в интернет и внешних сервисов;
- результат проверяется набором быстрых тестов: целостность схемы, выборочные агрегаты, версии миграций;
- поддерживается сценарий точечного восстановления по времени (PITR), чтобы точно знать, что сможем вернуться на нужный момент;
- отчёт о результатах доступен из логов CI, а артефакты (логи и контрольные отчёты) сохраняются для аудита.
Ключевая идея: в CI мы поднимаем ephemeral‑среду, восстанавливаем базу из актуальных бэкапов, проводим verification и уничтожаем окружение. Повторяемость важнее скорости, но скорость важна, чтобы тесты запускались регулярно.
Безопасность: минимальные привилегии и изоляция
Работа с реальными дампами подразумевает чувствительные данные. Минимальные правила для CI:
- не публикуем порты баз наружу; используем внутреннюю сеть Docker и сервисы без host‑порта;
- обнуляем egress (исключаем доступ в интернет) для контейнеров, где это возможно; если нужно — разрешаем доступ только к внутреннему реестру артефактов;
- не используем продакшен‑пароли; у контейнеров свой логин/пароль, действительный только в CI;
- скрываем секреты в переменных CI; включаем маскирование в логах;
- очищаем тома после прогона, отключаем персистентность вне job;
- для MySQL/PostgreSQL включаем ограничение памяти и CPU в контейнерах, чтобы не «долбануть» по агенту CI;
- по возможности анонимизируем чувствительные колонки на этапе создания бэкапа или сразу после restore отдельным скриптом в sandbox.
Если в пайплайне требуется жёстче ограничить сетевой трафик контейнеров, пригодится детальный разбор iptables/nft для Docker — см. статью «Фаерволл для Docker и правила безопасности» по ссылке Docker firewall и безопасность.
Где брать бэкапы для CI
Потоки зависят от вашей стратегии:
- логические дампы:
mysqldumpилиmydumperдля MySQL,pg_dumpдля PostgreSQL; - физические бэкапы: Percona XtraBackup для MySQL/MariaDB,
pg_basebackupили специализированные инструменты для PostgreSQL; - PITR: binlog/GTID для MySQL, WAL для PostgreSQL.
В CI обычно стартуют с логических дампов — проще, быстрее поднять. Для валидации PITR лучше дополнить сценарием с binlog/WAL, чтобы проверить критический путь аварийного восстановления «до секунды». Для хранения бэкапов в объектном хранилище можно использовать проверенные инструменты — см. практику из материала «S3‑бэкапы: Restic/Borg» по ссылке S3‑backups Restic/Borg.

Docker sandbox: compose, сеть и ограничения
Создадим минимальный docker-compose.yml с двумя сервисами: MySQL и PostgreSQL. Никаких портов наружу, только внутренняя сеть. Ограничим ресурсы, отключим привилегии и смонтируем каталог с артефактами бэкапа только в режиме read‑only, а временные директории — как tmpfs при необходимости.
version: "3.9"
services:
mysql:
image: mysql:8.0
environment:
- MYSQL_DATABASE=app
- MYSQL_USER=app
- MYSQL_PASSWORD=app_pass
- MYSQL_ROOT_PASSWORD=root_pass
networks:
- internal
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "app", "-papp_pass"]
interval: 5s
timeout: 3s
retries: 20
deploy:
resources:
limits:
cpus: "2"
memory: 2g
security_opt:
- no-new-privileges:true
postgres:
image: postgres:16
environment:
- POSTGRES_DB=app
- POSTGRES_USER=app
- POSTGRES_PASSWORD=app_pass
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 20
deploy:
resources:
limits:
cpus: "2"
memory: 2g
security_opt:
- no-new-privileges:true
networks:
internal:
internal: true
Дальше в CI job мы загрузим артефакты бэкапа в рабочий каталог, запустим compose, дождёмся healthcheck и выполним восстановление плюс проверочные запросы.
MySQL: восстановление из дампа и проверка
Базовый сценарий для логического дампа:
- Стартуем контейнер и ждём readiness.
- Стримим дамп в
mysqlбез распаковки на диск. - Запускаем smoke‑тесты (SELECT COUNT, проверка схемы, миграций).
# 1) Ожидание готовности
until docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -u app -papp_pass --silent; do sleep 2; done
# 2) Восстановление из gzip-дампа (пример: app.sql.gz)
gzip -cd artifacts/mysql/app.sql.gz | docker compose exec -T mysql mysql -u app -papp_pass app
# 3) Быстрая верификация
queries="
SELECT COUNT(*) AS users FROM users;
SELECT COUNT(*) AS orders FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY;
SHOW TABLES;
"
printf "%s" "$queries" | docker compose exec -T mysql mysql -u app -papp_pass app
Если дамп большой, используйте mydumper/myloader и параллельную загрузку. Для контроля времени закладывайте таймаут на job и отдельный таймаут на restore.
MySQL PITR: применение binlog до нужного времени
PITR‑проверка нужна, чтобы убедиться, что binlog пригоден для восстановления «до секунды». В CI предположим, что у нас есть базовый дамп плюс набор binlog‑файлов за период. Восстановим дамп, затем «прокрутим» binlog до указанной метки времени:
# Восстановление базового дампа
gzip -cd artifacts/mysql/base.sql.gz | docker compose exec -T mysql mysql -u app -papp_pass app
# Применение binlog до времени RESTORE_TO (формат 'YYYY-MM-DD HH:MM:SS')
RESTORE_TO="2025-01-10 10:15:00"
for f in artifacts/mysql/binlog/mysql-bin.*; do
docker compose exec -T mysql sh -lc "mysqlbinlog --stop-datetime='${RESTORE_TO}' /var/lib/mysql/$(basename $f) | mysql -u app -papp_pass app"
done
# Проверка контрольной точки (например, ожидаем, что заказ #123 существует)
printf "%s" "SELECT id,status FROM orders WHERE id=123;" | docker compose exec -T mysql mysql -u app -papp_pass app
Где брать binlog внутри контейнера? В реальной проверке скопируйте binlog‑файлы во временный каталог контейнера перед применением (или смонтируйте read‑only). Если объёмы велики — фильтруйте по дате и используйте --start-datetime и --stop-datetime, чтобы сократить объём. Важно: валидация PITR должна работать без доступа в интернет.
PostgreSQL: восстановление из дампа и проверка
Для pg_dump восстанавливаем custom или directory формат через pg_restore с параллельностью и проверяем результат:
# Ожидание готовности
until docker compose exec -T postgres pg_isready -U app -d app; do sleep 2; done
# Восстановление из custom-дампа
docker compose exec -T postgres sh -lc "pg_restore -U app -d app --clean --if-exists -j 4" < artifacts/postgres/app.dump
# Быстрая верификация
psql_q="
SELECT COUNT(*) AS users FROM public.users;
SELECT COUNT(*) AS orders_last30 FROM public.orders WHERE created_at > now() - interval '30 days';
SELECT extname FROM pg_extension ORDER BY 1;
"
printf "%s" "$psql_q" | docker compose exec -T postgres psql -U app -d app -v ON_ERROR_STOP=1
Для больших дампов задайте -j побольше и убедитесь, что контейнеру хватает CPU/IO. Верификацию делайте короткой (секунды), чтобы запуск был удобен ежедневно.
PostgreSQL PITR: base backup + WAL до цели
Для полноценного PITR в CI удобно иметь локальные артефакты: base backup каталога PGDATA и набор WAL‑сегментов за период. Восстановление делаем в отдельном контейнере или в том же, но с остановкой сервера и подменой PGDATA. Ниже — упрощённый сценарий на один контейнер для демонстрации:
# Остановить Postgres и очистить PGDATA
docker compose exec -T postgres pg_ctl -D "$PGDATA" -m immediate stop || true
docker compose exec -T postgres sh -lc "rm -rf $PGDATA/*"
# Развернуть base backup (например, tar с zstd)
cat artifacts/postgres/basebackup.tar.zst | docker compose exec -T postgres sh -lc "cd $PGDATA && zstd -d | tar -xf -"
# Подготовить recovery: задать restore_command и целевое время
RESTORE_TO="2025-01-10 10:15:00+00"
restore_cmd="cp /wal_archive/%f %p"
docker compose exec -T postgres sh -lc "echo \"restore_command = '$restore_cmd'\" >> $PGDATA/postgresql.conf"
docker compose exec -T postgres sh -lc "echo \"recovery_target_time = '$RESTORE_TO'\" >> $PGDATA/postgresql.conf"
docker compose exec -T postgres sh -lc "touch $PGDATA/recovery.signal"
# Разместить WAL-сегменты в /wal_archive (смонтируйте заранее артефакты)
# Запустить сервер и дождаться завершения recovery
docker compose exec -T postgres pg_ctl -D "$PGDATA" -w start
# Проверка контрольной выборки данных
printf "%s" "SELECT now(), pg_is_in_recovery();" | docker compose exec -T postgres psql -U app -d app -v ON_ERROR_STOP=1
На практике вместо «cp из локальной папки» в restore_command может использоваться ваш инструмент доставки WAL (локальный каталог артефактов CI, а не удалённое хранилище). Важно, чтобы всё работало офлайн, внутри sandbox. После старта сервер автоматически остановит recovery на целевом времени и перейдёт в обычный режим. Проверяйте, что pg_is_in_recovery() вернул false, и выполняйте свои smoke‑запросы.

Верификация: что именно проверять
Помимо «база стартанула», нужно быстро подтвердить консистентность. Минимальный набор:
- контрольные агрегаты: число строк по ключевым таблицам, суммы по инвариантам;
- схема: наличие обязательных таблиц/индексов/расширений, версия миграций;
- бизнес‑индикаторы: несколько точечных кейсов (заказы, балансы, статусы);
- метка времени последней транзакции (сходится ли с ожиданиями относительно PITR).
Примеры запросов:
# MySQL: список таблиц без данных (подозрительно при restore)
printf "%s" "SELECT table_name FROM information_schema.tables WHERE table_schema='app' AND table_rows=0;" | docker compose exec -T mysql mysql -u app -papp_pass app
# PostgreSQL: поиск таблиц без индекса по первичному ключу
printf "%s" "SELECT relname FROM pg_class c JOIN pg_index i ON i.indrelid=c.oid WHERE i.indisprimary IS TRUE;" | docker compose exec -T postgres psql -U app -d app -v ON_ERROR_STOP=1
Всё это должно выполняться быстро (секунды), иначе CI станет узким местом. Для подробной проверки заведите недельные/ночные задания.
Пример пайплайна CI
Ниже пример job в стиле GitHub Actions (аналогично можно сделать в GitLab CI, Jenkins и т. п.). Он скачивает артефакты, поднимает compose, восстанавливает MySQL и PostgreSQL и запускает верификацию. Обратите внимание на таймауты, очистку и маскирование секретов.
name: backup-restore-verification
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
run: |
mkdir -p artifacts/mysql artifacts/postgres
# Здесь разместите загрузку ваших артефактов бэкапов в ./artifacts
- name: Start sandbox
run: |
docker compose up -d
docker compose ps
- name: Wait for DBs
run: |
until docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -u app -papp_pass --silent; do sleep 2; done
until docker compose exec -T postgres pg_isready -U app -d app; do sleep 2; done
- name: MySQL restore
run: |
gzip -cd artifacts/mysql/app.sql.gz | docker compose exec -T mysql mysql -u app -papp_pass app
- name: PostgreSQL restore
run: |
docker compose exec -T postgres sh -lc "pg_restore -U app -d app --clean --if-exists -j 4" < artifacts/postgres/app.dump
- name: Smoke checks
run: |
printf "%s" "SELECT COUNT(*) FROM users;" | docker compose exec -T mysql mysql -u app -papp_pass app
printf "%s" "SELECT COUNT(*) FROM public.users;" | docker compose exec -T postgres psql -U app -d app -v ON_ERROR_STOP=1
- name: Cleanup
if: always()
run: |
docker compose down -v
Для PITR можно сделать отдельную job с параметрами времени восстановления. Храните метку RESTORE_TO в переменных пайплайна или вычисляйте (например, «минус 15 минут от последнего WAL/binlog»), чтобы сценарий оставался детерминированным.
Производительность и стабильность
Несколько практических советов, чтобы CI не «плавал» по времени и не падал от нехватки ресурсов:
- используйте сжатие
zstdи потоковую распаковку — меньше IO на диск; - параллельные restore‑инструменты (
pg_restore -j,myloader) ускоряют в 2–5 раз; - ограничьте размер буферов и памяти контейнеров; ставьте с запасом, но без риска OOM агента;
- для больших стендов выносите CI‑агента на отдельный рабочий узел с быстрым SSD; удобнее всего держать его на VDS с предсказуемыми ресурсами;
- разделяйте быстрые ежедневные проверки и глубокие недельные прогоны с полной валидацией;
- фиксируйте версии образов баз, чтобы исключить дрейф окружения между прогоном и аварией.
Частые ошибки и как их избежать
- «Бэкап есть, а recovery не воспроизводится»: отсутствуют binlog/WAL или метаданные. Проверьте полноту набора: base + журнал + каталог таймлайнов (PostgreSQL).
- «В CI всё ок, в бою — нет»: образы/версии отличаются. Зафиксируйте теги и параметры конфигурации, добавьте проверку совпадений.
- «Песочница утекает наружу»: случайно опубликованные порты, общий network с runner. Проверяйте, что compose‑сеть внутренняя и нет published ports.
- «Долго восстанавливается — не запускаем ежедневно»: разделите пайплайн и тестируйте маленькими «срезами» данных либо используйте инкрементальные бэкапы.
- «Секреты попадают в логи»: используйте переменные‑маски CI, избегайте вывода паролей в командной строке, где это возможно.
Набор минимальной «боевой готовности»
Поставьте себе цель на ближайшую неделю:
- Соберите ежедневный логический дамп MySQL и PostgreSQL с сжатием.
- Настройте CI job с Docker sandbox, restore и двумя‑тремя проверками.
- Сделайте отдельную job для PITR на «минус 15 минут» от отметки бэкапа.
- Сохраняйте логи и итоговый отчёт в артефактах задачи.
- Добавьте алерт, если restore занял больше N минут или проверки упали.
Итоги
Проверка бэкапов в CI — простой способ превратить «надежду» в предсказуемую процедуру. Изолированный sandbox на Docker, короткие smoke‑проверки и периодическая PITR‑валидация дают уверенность, что в критический момент вы нажмёте «по инструкции» и восстановите сервисы за предсказуемое время. Начните с логического backup restore, затем добавьте binlog/WAL и доведите сценарий до полного off‑line восстановления. Регулярность и автоматизация — ваши лучшие друзья в борьбе с сюрпризами.


