7.6. Backup и restore
Цели
После этого материала вы сможете:
- сделать резервную копию volume вспомогательным container'ом и восстановить её;
- объяснить, почему копирование файлов живой базы даёт непригодную копию;
- сделать консистентную копию PostgreSQL двумя способами и выбрать между ними;
- проверить копию восстановлением, а не наличием файла;
- находить неиспользуемые volumes и удалять их безопасно;
- узнать, сколько места занимает каждый volume.
Предварительные знания
- 7.2. Volumes;
- 7.5. Права доступа, UID и GID — владельцы в архиве.
Ключевые термины
| Термин | Объяснение |
|---|---|
вспомогательный container | Одноразовый container, монтирующий volume для работы с данными |
консистентность | Свойство копии соответствовать одному моменту времени |
torn page | Страница, записанная наполовину на момент копирования |
WAL | Журнал упреждающей записи PostgreSQL |
dangling | Volume, не используемый ни одним container'ом |
логическая копия | Дамп в виде команд SQL |
физическая копия | Копия файлов данных |
Теория
Почему volume нельзя просто скопировать с host
Технически можно: данные лежат в /var/lib/docker/volumes/<имя>/_data. Практически это плохая идея:
| Проблема | Следствие |
|---|---|
Требуется root на host | Скрипт резервного копирования получает лишние права |
| Путь зависит от драйвера | Для NFS-volume каталога может не быть вовсе |
| Docker не гарантирует стабильность структуры | Внутреннее устройство может измениться |
| Не работает для удалённого демона | DOCKER_HOST указывает на другую машину |
Штатный способ — вспомогательный container, монтирующий volume и каталог назначения:
docker run --rm \
-v myvol:/data:ro \
-v "$PWD:/backup" \
alpine:3.21 tar czf /backup/myvol.tar.gz -C /data .
Он работает независимо от драйвера, не требует root на host и одинаково ведёт себя с удалённым демоном.
Флаг :ro на исходном volume — не формальность: он исключает случайную порчу данных ошибкой в команде.
Что должно попасть в архив
| Что | Как обеспечить |
|---|---|
| Владельцы и группы (числовые) | tar от root сохраняет их автоматически |
| Права доступа | Сохраняются tar |
| Символические ссылки | Сохраняются как ссылки |
| Скрытые файлы | -C /data . включает их; /data/* — нет |
| Пустые каталоги | Сохраняются |
| Время модификации | Сохраняется |
Строка про скрытые файлы — источник тихих потерь. Шаблон tar czf b.tar.gz /data/* раскрывается shell'ом и пропускает всё, начинающееся с точки. Правильная форма — -C /data ..
Консистентность: главная тема урока
Копирование файлов работающей базы данных даёт архив, который скорее всего не восстановится.
Причины:
| Причина | Что происходит |
|---|---|
| Данные в буферах СУБД | Часть изменений ещё не на диске |
| Копирование занимает время | Начало и конец архива относятся к разным моментам |
| Torn pages | Страница скопирована наполовину записанной |
| Журнал и данные рассинхронизированы | WAL ушёл вперёд относительно файлов |
Коварство в том, что архив создаётся без ошибок и выглядит нормально. Проблема обнаруживается при восстановлении — то есть в момент, когда копия действительно нужна.
Три корректных подхода:
| Подход | Как | Простой | Когда |
|---|---|---|---|
| Остановить и скопировать | docker compose stop db, затем tar | Есть | Небольшие базы, допустимо окно |
| Логическая копия | pg_dump на работающей базе | Нет | Универсально, переносимо между версиями |
| Физическая копия онлайн | pg_basebackup | Нет | Большие базы, нужна точка восстановления |
Для курса основной — pg_dump: он работает на живой базе, использует транзакционный снимок и даёт согласованный результат.
pg_dump и его свойства
docker compose exec -T db pg_dump -U postgres -Fc mydb > backup.dump
| Свойство | Значение |
|---|---|
| Согласованность | Снимок одной транзакции |
| Блокирует запись | Нет |
Формат -Fc | Сжатый, восстанавливается выборочно |
| Формат обычного текста | Читаемый SQL, восстанавливается через psql |
| Переносимость между версиями | Да, в сторону старшей |
| Размер | Меньше файлов данных |
| Время восстановления | Больше, чем у физической копии |
Ключ -T у docker compose exec обязателен: без него выделяется псевдотерминал, и двоичный поток портится переводом строк.
Проверка копии
Резервная копия, которую не восстанавливали, — это не резервная копия, а файл.
Минимальная проверка:
- Восстановить в новый volume или базу.
- Сравнить содержимое с оригиналом: число записей, контрольные суммы.
- Удалить проверочный объект.
Это выполняется автоматически и должно входить в процедуру резервного копирования, а не быть отдельным ритуалом «раз в квартал».
Неиспользуемые volumes
docker volume ls -f dangling=true
Фильтр dangling=true показывает volumes, не подключённые ни к одному container'у — включая остановленные. Volume остановленного стека Compose в список не попадёт, и это правильно.
Безопасный порядок удаления:
| Шаг | Зачем |
|---|---|
| 1. Получить список | docker volume ls -qf dangling=true |
| 2. Узнать размер | docker system df -v |
| 3. Заглянуть внутрь | Вспомогательный container с ls |
| 4. Сделать копию сомнительных | Дешевле, чем восстанавливать данные |
| 5. Удалить поимённо | Не prune вслепую |
Шаг 3 занимает секунды и не раз спасал: anonymous volume со случайным именем вполне может содержать единственную копию данных.
Напомним про поведение prune (урок 7.2): без --all он удаляет только anonymous volumes.
docker cp
Копирование между container'ом и host:
docker cp mycontainer:/app/report.csv ./report.csv
docker cp ./config.ini mycontainer:/app/config.ini
| Свойство | Значение |
|---|---|
| Работает с остановленным container'ом | Да |
| Копирует из volume | Да, если volume смонтирован |
| Сохраняет владельца | Числовой UID сохраняется |
| Подходит для регулярных копий | Нет |
Последняя строка важна: docker cp — инструмент для разовых операций и отладки. Для регулярного резервного копирования используют вспомогательный container с tar: он работает с volume напрямую, не требует существования container'а и даёт один сжатый файл.
Внутренний механизм
Почему tar в container'е сохраняет владельцев
Процесс tar в вспомогательном container'е работает от root и имеет CAP_CHOWN. При архивации он записывает числовые UID и GID; при распаковке — восстанавливает их.
Именно поэтому восстановление тоже выполняют от root: непривилегированный процесс не сможет назначить чужого владельца, и права после восстановления окажутся неверными.
Опция --numeric-owner фиксирует поведение явно: имена из /etc/passwd вспомогательного образа игнорируются, используются только числа (урок 7.5).
Как pg_dump добивается согласованности
pg_dump открывает транзакцию с уровнем изоляции REPEATABLE READ и работает внутри неё. Все запросы видят состояние базы на момент начала транзакции, независимо от параллельных изменений.
Отсюда свойства: дамп согласован, запись не блокируется, а длительный дамп удерживает старые версии строк и мешает очистке (VACUUM) — на нагруженной базе это заметно.
Команды и примеры
Копия и восстановление volume
mkdir -p /tmp/vol-backup && cd /tmp/vol-backup
docker volume create source-vol > /dev/null
docker run --rm -v source-vol:/data alpine:3.21 sh -c '
mkdir -p /data/nested
echo "важные данные" > /data/main.txt
echo "скрытый файл" > /data/.hidden
echo "вложенный" > /data/nested/deep.txt
chown 10001:10001 /data/main.txt
chmod 600 /data/main.txt
'
echo "═══ исходное состояние ═══"
docker run --rm -v source-vol:/data alpine:3.21 sh -c 'ls -lan /data | tail -n +2'
echo
echo "═══ резервная копия ═══"
docker run --rm -v source-vol:/data:ro -v "$PWD:/backup" \
alpine:3.21 tar czf /backup/source-vol.tar.gz --numeric-owner -C /data .
ls -lh source-vol.tar.gz | awk '{print " архив: " $9 " (" $5 ")"}'
echo
echo "═══ содержимое архива ═══"
tar tzf source-vol.tar.gz | sort | sed 's/^/ /'
Ожидаемый вывод:
═══ исходное состояние ═══
drwxr-xr-x 3 0 0 4096 Jul 30 13:02 .
drwxr-xr-x 1 0 0 4096 Jul 30 13:02 ..
-rw-r--r-- 1 0 0 25 Jul 30 13:02 .hidden
-rw------- 1 10001 10001 28 Jul 30 13:02 main.txt
drwxr-xr-x 2 0 0 4096 Jul 30 13:02 nested
═══ резервная копия ═══
архив: source-vol.tar.gz (283)
═══ содержимое архива ═══
./
./.hidden
./main.txt
./nested/
./nested/deep.txt
Скрытый файл .hidden в архиве — благодаря форме -C /data .. Проверим, что было бы с шаблоном:
docker run --rm -v source-vol:/data:ro -v "$PWD:/backup" \
alpine:3.21 sh -c 'tar czf /backup/wrong.tar.gz /data/* 2>/dev/null'
echo "═══ архив по шаблону /data/* ═══"
tar tzf wrong.tar.gz | sort | sed 's/^/ /'
rm -f wrong.tar.gz
Ожидаемый вывод:
═══ архив по шаблону /data/* ═══
data/main.txt
data/nested/
data/nested/deep.txt
.hidden пропал, и пути стали относиться к data/ вместо корня — при восстановлении файлы легли бы в /data/data/. Обе ошибки тихие.
Восстановление в новый volume:
docker volume create restored-vol > /dev/null
docker run --rm -v restored-vol:/data -v "$PWD:/backup" \
alpine:3.21 tar xzf /backup/source-vol.tar.gz --numeric-owner -C /data
echo "═══ восстановлено ═══"
docker run --rm -v restored-vol:/data alpine:3.21 sh -c 'ls -lan /data | tail -n +2'
Ожидаемый вывод:
═══ восстановлено ═══
drwxr-xr-x 3 0 0 4096 Jul 30 13:02 .
drwxr-xr-x 1 0 0 4096 Jul 30 13:05 ..
-rw-r--r-- 1 0 0 25 Jul 30 13:02 .hidden
-rw------- 1 10001 10001 28 Jul 30 13:02 main.txt
drwxr-xr-x 2 0 0 4096 Jul 30 13:02 nested
Владелец 10001:10001 и права 600 сохранены — именно это делает --numeric-owner вместе с работой от root.
Проверка копии сравнением
cd /tmp/vol-backup
cat > verify.sh <<'SH'
#!/usr/bin/env bash
# Сравнивает два volume по содержимому, правам и владельцам.
set -uo pipefail
A="${1:?volume 1}"; B="${2:?volume 2}"
snapshot() {
docker run --rm -v "$1:/v:ro" alpine:3.21 sh -c '
cd /v && find . -type f -exec sha256sum {} \; | sort
cd /v && find . -printf "%p %m %U:%G\n" 2>/dev/null | sort || \
find . -exec stat -c "%n %a %u:%g" {} \; | sort
'
}
if diff <(snapshot "$A") <(snapshot "$B") > /tmp/verify.diff 2>&1; then
echo " ✓ volumes идентичны: содержимое, права, владельцы"
exit 0
fi
echo " ✗ РАСХОЖДЕНИЕ:"
head -10 /tmp/verify.diff | sed 's/^/ /'
exit 1
SH
chmod +x verify.sh
echo "═══ сравнение оригинала и восстановленного ═══"
./verify.sh source-vol restored-vol
Ожидаемый вывод:
═══ сравнение оригинала и восстановленного ═══
✓ volumes идентичны: содержимое, права, владельцы
Проверка обнаруживает и порчу данных: убедимся, что она вообще способна что-то заметить.
docker run --rm -v restored-vol:/data alpine:3.21 sh -c 'echo испорчено > /data/main.txt'
./verify.sh source-vol restored-vol | head -4
Ожидаемый вывод:
✗ РАСХОЖДЕНИЕ:
2c2
< 8f3a2b1c... ./main.txt
---
> 1d4e7f9a... ./main.txt
Проверка, которая никогда не падает, ничего не проверяет. Убедиться в её работоспособности — обязательный шаг.
docker volume rm source-vol restored-vol > /dev/null
Почему копия живой базы непригодна
mkdir -p /tmp/db-backup && cd /tmp/db-backup
cat > compose.yaml <<'EOF'
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 3s
timeout: 3s
retries: 15
volumes:
db-data:
EOF
docker compose up -d > /dev/null 2>&1
until [ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ]; do sleep 2; done
# Наполняем и создаём постоянную нагрузку на запись
docker compose exec -T db psql -U postgres -d appdb -q <<'SQL'
CREATE TABLE records (id serial PRIMARY KEY, payload text, created timestamptz DEFAULT now());
INSERT INTO records (payload) SELECT md5(g::text) FROM generate_series(1, 50000) g;
SQL
echo "═══ записей в базе ═══"
docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM records' | sed 's/^/ /'
# Нагрузка на запись во время копирования
docker compose exec -d db sh -c '
for i in $(seq 1 200); do
psql -U postgres -d appdb -q -c "INSERT INTO records (payload) SELECT md5(random()::text) FROM generate_series(1,500)"
done
' 2>/dev/null
echo
echo "═══ копируем файлы данных на ходу (так делать нельзя) ═══"
docker run --rm -v db-backup_db-data:/data:ro -v "$PWD:/backup" \
alpine:3.21 tar czf /backup/hot-copy.tar.gz --numeric-owner -C /data . 2>/dev/null
ls -lh hot-copy.tar.gz | awk '{print " архив создан без ошибок: " $5}'
Ожидаемый вывод:
═══ записей в базе ═══
50000
═══ копируем файлы данных на ходу (так делать нельзя) ═══
архив создан без ошибок: 12.4M
Архив создан, ошибок нет. Теперь попробуем его использовать:
cd /tmp/db-backup
docker volume create hot-restore > /dev/null
docker run --rm -v hot-restore:/data -v "$PWD:/backup" \
alpine:3.21 tar xzf /backup/hot-copy.tar.gz --numeric-owner -C /data
echo "═══ запускаем PostgreSQL на восстановленных файлах ═══"
docker run -d --name hot-test -e POSTGRES_PASSWORD=secret \
-v hot-restore:/var/lib/postgresql/data postgres:17-alpine > /dev/null
sleep 15
printf ' статус: %s\n' "$(docker inspect hot-test --format '{{.State.Status}}')"
echo " логи:"
docker logs hot-test 2>&1 | grep -iE 'recover|redo|invalid|corrupt|LOG:|FATAL' | head -6 | sed 's/^/ /'
docker rm -f hot-test > /dev/null
docker volume rm hot-restore > /dev/null
Ожидаемый вывод:
═══ запускаем PostgreSQL на восстановленных файлах ═══
статус: running
логи:
LOG: database system was interrupted; last known up at 2026-07-30 13:10:22 UTC
LOG: database system was not properly shut down; automatic recovery in progress
LOG: redo starts at 0/1A2B3C4
LOG: invalid record length at 0/1D4E5F6: wanted 24, got 0
LOG: redo done at 0/1D4E5F0
LOG: database system is ready to accept connections
Здесь важно быть точным в выводах.
PostgreSQL запустился — но не потому, что копия была хороша. Он выполнил аварийное восстановление по журналу, как после отключения питания. Строка database system was not properly shut down — прямое свидетельство того, что копия соответствует не корректно остановленной базе, а прерванной.
Сколько записей уцелело, зависит от того, что успело попасть в WAL к моменту копирования, — то есть от случайности. Часть транзакций, подтверждённых клиенту до начала архивации, в копии может отсутствовать.
Такой архив — лотерея: иногда восстанавливается полностью, иногда с потерями, иногда база не поднимается вовсе. Проверить заранее нельзя, потому что результат зависит от момента копирования.
Как правильно: pg_dump
cd /tmp/db-backup
sleep 5 # даём нагрузке завершиться
before="$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM records' | tr -d ' \r')"
echo "═══ записей до копирования: $before ═══"
echo "═══ pg_dump на работающей базе ═══"
docker compose exec -T db pg_dump -U postgres -Fc appdb > appdb.dump
ls -lh appdb.dump | awk '{print " дамп: " $5}'
echo
echo "═══ восстановление в чистую базу ═══"
docker compose exec -T db psql -U postgres -q -c 'DROP DATABASE IF EXISTS restored;' -c 'CREATE DATABASE restored;'
docker compose exec -T db pg_restore -U postgres -d restored --no-owner < appdb.dump 2>&1 | tail -2
after="$(docker compose exec -T db psql -U postgres -d restored -tAc 'SELECT count(*) FROM records' | tr -d ' \r')"
echo " записей после восстановления: $after"
[ "$before" = "$after" ] && echo " ✓ совпадает" || echo " ✗ расхождение: было $before, стало $after"
echo
echo "═══ контрольная сумма данных ═══"
sum_src="$(docker compose exec -T db psql -U postgres -d appdb -tAc \
'SELECT md5(string_agg(payload, E"\n" ORDER BY id)) FROM records' | tr -d ' \r')"
sum_dst="$(docker compose exec -T db psql -U postgres -d restored -tAc \
'SELECT md5(string_agg(payload, E"\n" ORDER BY id)) FROM records' | tr -d ' \r')"
printf ' оригинал: %s\n' "$sum_src"
printf ' восстановлено: %s\n' "$sum_dst"
[ "$sum_src" = "$sum_dst" ] && echo " ✓ данные идентичны" || echo " ✗ данные различаются"
Ожидаемый вывод:
═══ записей до копирования: 150000 ═══
═══ pg_dump на работающей базе ═══
дамп: 3.8M
═══ восстановление в чистую базу ═══
записей после восстановления: 150000
✓ совпадает
═══ контрольная сумма данных ═══
оригинал: 4b1f8e2c9d3a7f605e8b1c2d3a4f5e6b
восстановлено: 4b1f8e2c9d3a7f605e8b1c2d3a4f5e6b
✓ данные идентичны
База при этом ни на секунду не останавливалась, запись не блокировалась, а копия точно соответствует одному моменту времени.
Сравнение по количеству записей недостаточно — оно не поймает искажение содержимого. Контрольная сумма по всем строкам ловит и это.
Вариант с остановкой
cd /tmp/db-backup
echo "═══ корректная физическая копия: с остановкой ═══"
docker compose stop db > /dev/null
docker run --rm -v db-backup_db-data:/data:ro -v "$PWD:/backup" \
alpine:3.21 tar czf /backup/cold-copy.tar.gz --numeric-owner -C /data .
docker compose start db > /dev/null
until [ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ]; do sleep 2; done
ls -lh cold-copy.tar.gz | awk '{print " архив: " $5}'
docker volume create cold-restore > /dev/null
docker run --rm -v cold-restore:/data -v "$PWD:/backup" \
alpine:3.21 tar xzf /backup/cold-copy.tar.gz --numeric-owner -C /data
docker run -d --name cold-test -e POSTGRES_PASSWORD=secret \
-v cold-restore:/var/lib/postgresql/data postgres:17-alpine > /dev/null
sleep 12
echo " логи запуска:"
docker logs cold-test 2>&1 | grep -iE 'not properly shut down|ready to accept' | head -2 | sed 's/^/ /'
printf ' записей: %s\n' \
"$(docker exec cold-test psql -U postgres -d appdb -tAc 'SELECT count(*) FROM records' 2>/dev/null | tr -d ' \r')"
docker rm -f cold-test > /dev/null
docker volume rm cold-restore > /dev/null
Ожидаемый вывод:
═══ корректная физическая копия: с остановкой ═══
архив: 12.6M
логи запуска:
LOG: database system is ready to accept connections
записей: 150000
Строки not properly shut down нет — база была остановлена штатно, файлы согласованы. Все записи на месте.
Цена — простой на время архивации. Для небольших баз это приемлемо, для больших выбирают pg_dump или pg_basebackup.
cd /tmp/db-backup && docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/db-backup /tmp/vol-backup
Размеры volumes и поиск неиспользуемых
echo "═══ сколько занимают volumes ═══"
docker system df -v 2>/dev/null | sed -n '/VOLUME NAME/,/^$/p' | head -12
Ожидаемый вывод:
═══ сколько занимают volumes ═══
VOLUME NAME LINKS SIZE
app-data 1 124MB
3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5 0 2.1GB
prod-cache 0 340MB
Столбец LINKS — число container'ов, использующих volume. Ноль означает, что volume не подключён ни к одному, включая остановленные.
Строка со случайным именем и объёмом 2.1 GB — типичная находка: anonymous volume от давно удалённого container'а.
Безопасная процедура уборки:
cat > /tmp/safe-prune.sh <<'SH'
#!/usr/bin/env bash
# Показывает неиспользуемые volumes с содержимым — БЕЗ удаления.
set -uo pipefail
mapfile -t vols < <(docker volume ls -qf dangling=true)
if [ "${#vols[@]}" -eq 0 ]; then
echo " неиспользуемых volumes нет"
exit 0
fi
for v in "${vols[@]}"; do
size="$(docker run --rm -v "$v:/v:ro" alpine:3.21 du -sh /v 2>/dev/null | cut -f1)"
printf '\n %s (%s)\n' "$v" "${size:-?}"
docker run --rm -v "$v:/v:ro" alpine:3.21 \
sh -c 'ls -A /v 2>/dev/null | head -5' | sed 's/^/ /'
done
echo
echo " Ничего не удалено. Для удаления выбранного:"
echo " docker volume rm <имя>"
SH
chmod +x /tmp/safe-prune.sh
/tmp/safe-prune.sh
Ожидаемый вывод:
3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5 (2.1G)
base
pg_wal
postgresql.conf
prod-cache (340M)
index.db
thumbnails
Ничего не удалено. Для удаления выбранного:
docker volume rm <имя>
Заглянув внутрь, видно главное: первый volume содержит каталог данных PostgreSQL. Слепой docker volume prune -a уничтожил бы его без вопросов.
Именно поэтому шаг «посмотреть содержимое» стоит трёх секунд, а его пропуск иногда стоит базы.
rm -f /tmp/safe-prune.sh
Практическое упражнение
Задание. Напишите скрипт резервного копирования PostgreSQL в Compose, удовлетворяющий шести требованиям.
- Копия делается на работающей базе, без простоя.
- Копия автоматически проверяется восстановлением в отдельную базу.
- Проверка сравнивает не только число записей, но и содержимое.
- При расхождении скрипт возвращает ненулевой код и не удаляет предыдущую копию.
- Хранится последние N копий, старые удаляются.
- Отдельная команда восстанавливает выбранную копию в рабочую базу с подтверждением.
Дополнительно: докажите, что проверка способна обнаружить испорченную копию.
Подсказки
Подсказка 1
docker compose exec -T обязателен для двоичных потоков: без -T выделяется терминал и дамп портится.
Подсказка 2
Для требования 3 подойдёт агрегатная контрольная сумма по всем таблицам с детерминированным порядком.
Подсказка 3
Для проверки требования «обнаруживает порчу» испортите копию — например, обнулите байты в середине файла.
Решение
Показать решение
mkdir -p /tmp/pgbackup/backups && cd /tmp/pgbackup
cat > compose.yaml <<'EOF'
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d appdb"]
interval: 3s
timeout: 3s
retries: 20
volumes:
db-data:
EOF
cat > backup.sh <<'SH'
#!/usr/bin/env bash
# Резервное копирование PostgreSQL с обязательной проверкой восстановлением.
set -uo pipefail
DB="${DB:-appdb}"
USER_DB="${USER_DB:-postgres}"
DIR="${DIR:-./backups}"
KEEP="${KEEP:-3}"
SVC="${SVC:-db}"
VERIFY_DB="verify_$$"
log() { printf ' %s\n' "$1"; }
# Контрольная сумма содержимого: не только количество строк (требование 3).
checksum_sql="
SELECT md5(string_agg(t, E'\n' ORDER BY t))
FROM (
SELECT id::text || '|' || payload AS t FROM records
) s;"
count_sql="SELECT count(*) FROM records;"
query() { # query <база> <sql>
docker compose exec -T "$SVC" psql -U "$USER_DB" -d "$1" -tAc "$2" 2>/dev/null | tr -d ' \r'
}
mkdir -p "$DIR"
stamp="$(date +%Y%m%d-%H%M%S)"
target="$DIR/${DB}-${stamp}.dump"
# ── Требование 1: дамп на работающей базе ──
log "снимаю дамп (база продолжает работать)"
src_count="$(query "$DB" "$count_sql")"
src_sum="$(query "$DB" "$checksum_sql")"
if ! docker compose exec -T "$SVC" pg_dump -U "$USER_DB" -Fc "$DB" > "$target"; then
log "✗ pg_dump завершился с ошибкой"
rm -f "$target"
exit 1
fi
log "дамп: $target ($(du -h "$target" | cut -f1))"
# ── Требование 2: проверка восстановлением ──
log "проверяю восстановлением в базу $VERIFY_DB"
docker compose exec -T "$SVC" psql -U "$USER_DB" -q \
-c "DROP DATABASE IF EXISTS $VERIFY_DB;" -c "CREATE DATABASE $VERIFY_DB;" > /dev/null 2>&1
restore_ok=1
if ! docker compose exec -T "$SVC" pg_restore -U "$USER_DB" -d "$VERIFY_DB" --no-owner \
< "$target" > /dev/null 2>&1; then
restore_ok=0
fi
dst_count="$(query "$VERIFY_DB" "$count_sql")"
dst_sum="$(query "$VERIFY_DB" "$checksum_sql")"
docker compose exec -T "$SVC" psql -U "$USER_DB" -q \
-c "DROP DATABASE IF EXISTS $VERIFY_DB;" > /dev/null 2>&1
# ── Требование 4: при расхождении — ненулевой код, копия не публикуется ──
if [ "$restore_ok" -eq 0 ] || [ -z "$dst_count" ] || \
[ "$src_count" != "$dst_count" ] || [ "$src_sum" != "$dst_sum" ]; then
log "✗ ПРОВЕРКА НЕ ПРОЙДЕНА"
log " записей: оригинал=$src_count копия=${dst_count:-нет}"
log " сумма: оригинал=${src_sum:0:16}... копия=${dst_sum:0:16}..."
mv "$target" "$target.FAILED"
log " файл помечен как .FAILED, прежние копии сохранены"
exit 1
fi
log "✓ проверка пройдена: $src_count записей, суммы совпадают"
# ── Требование 5: ротация ──
mapfile -t old < <(ls -1t "$DIR"/${DB}-*.dump 2>/dev/null | tail -n +$((KEEP + 1)))
for f in "${old[@]:-}"; do
[ -n "$f" ] || continue
rm -f "$f"
log "удалена старая копия: $(basename "$f")"
done
log "копий хранится: $(ls -1 "$DIR"/${DB}-*.dump 2>/dev/null | wc -l) (лимит $KEEP)"
SH
chmod +x backup.sh
cat > restore.sh <<'SH'
#!/usr/bin/env bash
# Требование 6: восстановление выбранной копии с подтверждением.
set -uo pipefail
DUMP="${1:?укажите файл копии}"
DB="${DB:-appdb}"
USER_DB="${USER_DB:-postgres}"
SVC="${SVC:-db}"
[ -f "$DUMP" ] || { echo " файл не найден: $DUMP"; exit 1; }
echo " ВНИМАНИЕ: база '$DB' будет перезаписана из $DUMP"
if [ "${FORCE:-0}" != "1" ]; then
printf ' введите имя базы для подтверждения: '
read -r answer
[ "$answer" = "$DB" ] || { echo " отменено"; exit 1; }
fi
docker compose exec -T "$SVC" psql -U "$USER_DB" -q \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB' AND pid<>pg_backend_pid();" \
-c "DROP DATABASE IF EXISTS $DB;" -c "CREATE DATABASE $DB;" > /dev/null 2>&1
if docker compose exec -T "$SVC" pg_restore -U "$USER_DB" -d "$DB" --no-owner < "$DUMP" > /dev/null 2>&1; then
n="$(docker compose exec -T "$SVC" psql -U "$USER_DB" -d "$DB" -tAc 'SELECT count(*) FROM records' | tr -d ' \r')"
echo " ✓ восстановлено, записей: $n"
else
echo " ✗ восстановление не удалось"
exit 1
fi
SH
chmod +x restore.sh
# ── Проверка ──
docker compose up -d > /dev/null 2>&1
until [ "$(docker compose ps --format '{{.Health}}' db)" = "healthy" ]; do sleep 2; done
docker compose exec -T db psql -U postgres -d appdb -q <<'SQL'
CREATE TABLE records (id serial PRIMARY KEY, payload text);
INSERT INTO records (payload) SELECT md5(g::text) FROM generate_series(1, 20000) g;
SQL
echo "═══ Требования 1–3: копия с проверкой ═══"
./backup.sh
echo " код: $?"
echo
echo "═══ Требование 5: ротация (KEEP=2, четыре запуска) ═══"
for i in 2 3 4; do
sleep 1
KEEP=2 ./backup.sh | tail -2
done
ls -1 backups/ | sed 's/^/ /'
echo
echo "═══ Требование 4: проверка обнаруживает порчу ═══"
# Портим копию: обнуляем блок в середине файла
latest="$(ls -1t backups/appdb-*.dump | head -1)"
cp "$latest" backups/broken.dump
size="$(stat -c '%s' backups/broken.dump)"
dd if=/dev/zero of=backups/broken.dump bs=1 seek=$((size / 2)) count=2048 conv=notrunc 2>/dev/null
echo " испорчено 2048 байт в середине копии"
# Восстановим испорченную и посмотрим, что скажет проверка
docker compose exec -T db psql -U postgres -q \
-c 'DROP DATABASE IF EXISTS broken_test;' -c 'CREATE DATABASE broken_test;' > /dev/null 2>&1
if docker compose exec -T db pg_restore -U postgres -d broken_test --no-owner \
< backups/broken.dump > /dev/null 2>&1; then
n="$(docker compose exec -T db psql -U postgres -d broken_test -tAc 'SELECT count(*) FROM records' 2>/dev/null | tr -d ' \r')"
echo " восстановление прошло, записей: ${n:-0} (в оригинале 20000)"
[ "${n:-0}" != "20000" ] && echo " ✓ расхождение обнаружено бы проверкой"
else
echo " ✓ pg_restore отказался восстанавливать испорченную копию"
fi
docker compose exec -T db psql -U postgres -q -c 'DROP DATABASE IF EXISTS broken_test;' > /dev/null 2>&1
rm -f backups/broken.dump
echo
echo "═══ Требование 6: восстановление ═══"
docker compose exec -T db psql -U postgres -d appdb -q -c 'DELETE FROM records WHERE id > 100;'
printf ' записей после потери данных: %s\n' \
"$(docker compose exec -T db psql -U postgres -d appdb -tAc 'SELECT count(*) FROM records' | tr -d ' \r')"
FORCE=1 ./restore.sh "$(ls -1t backups/appdb-*.dump | head -1)"
docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/pgbackup
Ожидаемый вывод:
═══ Требования 1–3: копия с проверкой ═══
снимаю дамп (база продолжает работать)
дамп: ./backups/appdb-20260730-131502.dump (536K)
проверяю восстановлением в базу verify_31427
✓ проверка пройдена: 20000 записей, суммы совпадают
копий хранится: 1 (лимит 3)
код: 0
═══ Требование 5: ротация (KEEP=2, четыре запуска) ═══
✓ проверка пройдена: 20000 записей, суммы совпадают
копий хранится: 2 (лимит 2)
✓ проверка пройдена: 20000 записей, суммы совпадают
удалена старая копия: appdb-20260730-131502.dump
✓ проверка пройдена: 20000 записей, суммы совпадают
удалена старая копия: appdb-20260730-131503.dump
appdb-20260730-131504.dump
appdb-20260730-131505.dump
═══ Требование 4: проверка обнаруживает порчу ═══
испорчено 2048 байт в середине копии
✓ pg_restore отказался восстанавливать испорченную копию
═══ Требование 6: восстановление ═══
записей после потери данных: 100
ВНИМАНИЕ: база 'appdb' будет перезаписана из ./backups/appdb-20260730-131505.dump
✓ восстановлено, записей: 20000
Все шесть требований выполнены.
Три решения, определяющие качество.
Проверка сравнивает контрольную сумму содержимого, а не только количество строк. Число записей совпадёт и при повреждении, затронувшем значения полей, — а такое повреждение как раз наиболее вероятно при частичной порче файла. Сумма по конкатенации id|payload с явным ORDER BY детерминирована и ловит любое изменение данных. Без ORDER BY сумма зависела бы от порядка чтения строк и «расходилась» бы на исправных копиях.
Провалившаяся копия переименовывается в .FAILED, а не удаляется. Удалить её означало бы потерять улику: файл нужен, чтобы понять, что пошло не так. При этом имя перестаёт соответствовать шаблону ротации, поэтому испорченный файл не попадёт в число «последних N» и не вытеснит рабочую копию. Требование 4 выполняется обоими решениями сразу.
Ротация выполняется после успешной проверки, а не до дампа. Порядок здесь — вопрос сохранности: удали скрипт старые копии в начале, и неудачный дамп оставил бы систему вообще без резервных копий. Сейчас худший случай — на одну копию больше лимита, что безобидно.
Чего решение не делает. Проверка восстанавливает копию на тот же сервер PostgreSQL. Это подтверждает целостность дампа, но не проверяет главного сценария катастрофы — отказа самого сервера или его диска. Полноценная процедура восстанавливает копию на другую машину и хранит архивы вне этого узла. Скрипт также не шифрует копии, хотя дамп содержит все данные приложения в открытом виде.
Проверка результата
docker volume create bk-src > /dev/null
docker run --rm -v bk-src:/d alpine:3.21 sh -c 'echo данные > /d/f.txt; echo скрытый > /d/.h'
docker run --rm -v bk-src:/d:ro -v "$PWD:/b" alpine:3.21 tar czf /b/t.tar.gz -C /d .
docker volume create bk-dst > /dev/null
docker run --rm -v bk-dst:/d -v "$PWD:/b" alpine:3.21 tar xzf /b/t.tar.gz -C /d
docker run --rm -v bk-dst:/d alpine:3.21 ls -A /d
docker volume rm bk-src bk-dst > /dev/null; rm -f t.tar.gz
Ожидается, что в восстановленном volume есть оба файла, включая скрытый.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
tar czf b.tar.gz /data/* | Кажется естественным | Пропускает скрытые файлы; -C /data . |
| Копирование файлов работающей базы | Быстро и просто | Копия соответствует прерванной базе |
| Копия считается готовой, если файл создан | Ошибок не было | Проверять восстановлением |
| Проверка только по числу записей | Проще | Не ловит искажение содержимого |
docker compose exec без -T для дампа | Забыли флаг | Терминал портит двоичный поток |
| Восстановление не от root | Запускают с --user | Владельцы из архива не восстановятся |
tar без --numeric-owner | По умолчанию | Имена берутся из /etc/passwd образа |
docker volume prune -a для уборки | Хотели освободить место | Удалит named volumes с данными |
docker cp для регулярных копий | Знакомая команда | Требует container'а; для volume есть tar |
| Копии хранятся на том же диске | Так удобнее | Отказ диска уничтожит и данные, и копии |
| Ротация до создания новой копии | Кажется логичным | При неудаче не останется ни одной копии |
Контрольные вопросы
На понимание:
- Почему volume копируют вспомогательным container'ом, а не с host?
- Почему
tar czf b.tar.gz /data/*теряет часть файлов? - Почему копия файлов живой базы может не восстановиться?
- Как
pg_dumpдобивается согласованности без остановки базы? - Что означает
LINKS 0вdocker system df -v?
На применение:
- Как сохранить владельцев файлов в архиве и при восстановлении?
- Как проверить, что резервная копия пригодна?
- Как безопасно найти и удалить неиспользуемые volumes?
На диагностику:
- После восстановления приложение не может писать в volume. Причина?
- Диск заполнен, все container'ы удалены. Порядок действий?
Краткое резюме
- Volume копируют вспомогательным container'ом: не нужен root на host, работает с любым драйвером.
- Форма
tar ... -C /data .включает скрытые файлы; шаблон/data/*их теряет. --numeric-ownerи запуск от root сохраняют владельцев при архивации и восстановлении.- Копия файлов работающей базы соответствует прерванной базе, а не согласованному состоянию.
- Такой архив создаётся без ошибок — проблема обнаруживается только при восстановлении.
pg_dumpиспользует транзакционный снимок: копия согласована, запись не блокируется.- Физическая копия с остановкой базы корректна и проще, но требует простоя.
- Копию проверяют восстановлением в отдельную базу и сравнением содержимого.
- Сравнение по числу записей не ловит искажение данных — нужна контрольная сумма.
- Ротацию выполняют после успешной проверки, иначе можно остаться без копий.
docker system df -vпоказывает размер volumes и число ссылок на них.- Перед удалением неиспользуемого volume в него заглядывают:
prune -aвслепую уничтожает данные.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: back up and restore volumes | https://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumes | Вспомогательный container, tar |
Docker: docker cp | https://docs.docker.com/reference/cli/docker/container/cp/ | Копирование между host и container |
Docker: docker system df | https://docs.docker.com/reference/cli/docker/system/df/ | Размеры и ссылки на volumes |
Docker: docker volume ls | https://docs.docker.com/reference/cli/docker/volume/ls/ | Фильтр dangling |
PostgreSQL: pg_dump | https://www.postgresql.org/docs/current/app-pgdump.html | Согласованность, форматы, ключ -Fc |
| PostgreSQL: backup and restore | https://www.postgresql.org/docs/current/backup.html | Логические и физические копии |
| PostgreSQL: continuous archiving | https://www.postgresql.org/docs/current/continuous-archiving.html | pg_basebackup, WAL, точка восстановления |
| GNU tar | https://www.gnu.org/software/tar/manual/tar.html | -C, --numeric-owner |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление