Главная/Storage/Урок

7.6. Backup и restore

Цели

После этого материала вы сможете:

  • сделать резервную копию volume вспомогательным container'ом и восстановить её;
  • объяснить, почему копирование файлов живой базы даёт непригодную копию;
  • сделать консистентную копию PostgreSQL двумя способами и выбрать между ними;
  • проверить копию восстановлением, а не наличием файла;
  • находить неиспользуемые volumes и удалять их безопасно;
  • узнать, сколько места занимает каждый volume.

Предварительные знания

Ключевые термины

ТерминОбъяснение
вспомогательный containerОдноразовый container, монтирующий volume для работы с данными
консистентностьСвойство копии соответствовать одному моменту времени
torn pageСтраница, записанная наполовину на момент копирования
WALЖурнал упреждающей записи PostgreSQL
danglingVolume, не используемый ни одним container'ом
логическая копияДамп в виде команд SQL
физическая копияКопия файлов данных

Теория

Почему volume нельзя просто скопировать с host

Технически можно: данные лежат в /var/lib/docker/volumes/<имя>/_data. Практически это плохая идея:

ПроблемаСледствие
Требуется root на hostСкрипт резервного копирования получает лишние права
Путь зависит от драйвераДля NFS-volume каталога может не быть вовсе
Docker не гарантирует стабильность структурыВнутреннее устройство может измениться
Не работает для удалённого демонаDOCKER_HOST указывает на другую машину

Штатный способ — вспомогательный container, монтирующий volume и каталог назначения:

bash
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 и его свойства

bash
docker compose exec -T db pg_dump -U postgres -Fc mydb > backup.dump
СвойствоЗначение
СогласованностьСнимок одной транзакции
Блокирует записьНет
Формат -FcСжатый, восстанавливается выборочно
Формат обычного текстаЧитаемый SQL, восстанавливается через psql
Переносимость между версиямиДа, в сторону старшей
РазмерМеньше файлов данных
Время восстановленияБольше, чем у физической копии

Ключ -T у docker compose exec обязателен: без него выделяется псевдотерминал, и двоичный поток портится переводом строк.

Проверка копии

Резервная копия, которую не восстанавливали, — это не резервная копия, а файл.

Минимальная проверка:

  1. Восстановить в новый volume или базу.
  2. Сравнить содержимое с оригиналом: число записей, контрольные суммы.
  3. Удалить проверочный объект.

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

Неиспользуемые volumes

bash
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:

bash
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

bash
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/^/  /'

Ожидаемый вывод:

text
═══ исходное состояние ═══
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 .. Проверим, что было бы с шаблоном:

bash
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

Ожидаемый вывод:

text
═══ архив по шаблону /data/* ═══
  data/main.txt
  data/nested/
  data/nested/deep.txt

.hidden пропал, и пути стали относиться к data/ вместо корня — при восстановлении файлы легли бы в /data/data/. Обе ошибки тихие.

Восстановление в новый volume:

bash
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'

Ожидаемый вывод:

text
═══ восстановлено ═══
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.

Проверка копии сравнением

bash
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

Ожидаемый вывод:

text
═══ сравнение оригинала и восстановленного ═══
  ✓ volumes идентичны: содержимое, права, владельцы

Проверка обнаруживает и порчу данных: убедимся, что она вообще способна что-то заметить.

bash
docker run --rm -v restored-vol:/data alpine:3.21 sh -c 'echo испорчено > /data/main.txt'
./verify.sh source-vol restored-vol | head -4

Ожидаемый вывод:

text
  ✗ РАСХОЖДЕНИЕ:
    2c2
    < 8f3a2b1c...  ./main.txt
    ---
    > 1d4e7f9a...  ./main.txt

Проверка, которая никогда не падает, ничего не проверяет. Убедиться в её работоспособности — обязательный шаг.

bash
docker volume rm source-vol restored-vol > /dev/null

Почему копия живой базы непригодна

bash
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}'

Ожидаемый вывод:

text
═══ записей в базе ═══
  50000

═══ копируем файлы данных на ходу (так делать нельзя) ═══
  архив создан без ошибок: 12.4M

Архив создан, ошибок нет. Теперь попробуем его использовать:

bash
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

Ожидаемый вывод:

text
═══ запускаем 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

bash
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 "  ✗ данные различаются"

Ожидаемый вывод:

text
═══ записей до копирования: 150000 ═══
═══ pg_dump на работающей базе ═══
  дамп: 3.8M
═══ восстановление в чистую базу ═══
  записей после восстановления: 150000
  ✓ совпадает
═══ контрольная сумма данных ═══
  оригинал:     4b1f8e2c9d3a7f605e8b1c2d3a4f5e6b
  восстановлено: 4b1f8e2c9d3a7f605e8b1c2d3a4f5e6b
  ✓ данные идентичны

База при этом ни на секунду не останавливалась, запись не блокировалась, а копия точно соответствует одному моменту времени.

Сравнение по количеству записей недостаточно — оно не поймает искажение содержимого. Контрольная сумма по всем строкам ловит и это.

Вариант с остановкой

bash
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

Ожидаемый вывод:

text
═══ корректная физическая копия: с остановкой ═══
  архив: 12.6M
  логи запуска:
    LOG:  database system is ready to accept connections
  записей: 150000

Строки not properly shut down нет — база была остановлена штатно, файлы согласованы. Все записи на месте.

Цена — простой на время архивации. Для небольших баз это приемлемо, для больших выбирают pg_dump или pg_basebackup.

bash
cd /tmp/db-backup && docker compose down -v > /dev/null 2>&1
cd /tmp && rm -rf /tmp/db-backup /tmp/vol-backup

Размеры volumes и поиск неиспользуемых

bash
echo "═══ сколько занимают volumes ═══"
docker system df -v 2>/dev/null | sed -n '/VOLUME NAME/,/^$/p' | head -12

Ожидаемый вывод:

text
═══ сколько занимают 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'а.

Безопасная процедура уборки:

bash
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

Ожидаемый вывод:

text
  3f8a91c2e7d45b6a09fe1c3d8b27a4e5f60c9d1e2a3b4c5d6e7f8091a2b3c4d5  (2.1G)
      base
      pg_wal
      postgresql.conf

  prod-cache  (340M)
      index.db
      thumbnails

  Ничего не удалено. Для удаления выбранного:
    docker volume rm <имя>

Заглянув внутрь, видно главное: первый volume содержит каталог данных PostgreSQL. Слепой docker volume prune -a уничтожил бы его без вопросов.

Именно поэтому шаг «посмотреть содержимое» стоит трёх секунд, а его пропуск иногда стоит базы.

bash
rm -f /tmp/safe-prune.sh

Практическое упражнение

Задание. Напишите скрипт резервного копирования PostgreSQL в Compose, удовлетворяющий шести требованиям.

  1. Копия делается на работающей базе, без простоя.
  2. Копия автоматически проверяется восстановлением в отдельную базу.
  3. Проверка сравнивает не только число записей, но и содержимое.
  4. При расхождении скрипт возвращает ненулевой код и не удаляет предыдущую копию.
  5. Хранится последние N копий, старые удаляются.
  6. Отдельная команда восстанавливает выбранную копию в рабочую базу с подтверждением.

Дополнительно: докажите, что проверка способна обнаружить испорченную копию.

Подсказки

Подсказка 1

docker compose exec -T обязателен для двоичных потоков: без -T выделяется терминал и дамп портится.

Подсказка 2

Для требования 3 подойдёт агрегатная контрольная сумма по всем таблицам с детерминированным порядком.

Подсказка 3

Для проверки требования «обнаруживает порчу» испортите копию — например, обнулите байты в середине файла.

Решение

Показать решение
bash
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

Ожидаемый вывод:

text
═══ Требования 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. Это подтверждает целостность дампа, но не проверяет главного сценария катастрофы — отказа самого сервера или его диска. Полноценная процедура восстанавливает копию на другую машину и хранит архивы вне этого узла. Скрипт также не шифрует копии, хотя дамп содержит все данные приложения в открытом виде.

Проверка результата

bash
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
Копии хранятся на том же дискеТак удобнееОтказ диска уничтожит и данные, и копии
Ротация до создания новой копииКажется логичнымПри неудаче не останется ни одной копии

Контрольные вопросы

На понимание:

  1. Почему volume копируют вспомогательным container'ом, а не с host?
  2. Почему tar czf b.tar.gz /data/* теряет часть файлов?
  3. Почему копия файлов живой базы может не восстановиться?
  4. Как pg_dump добивается согласованности без остановки базы?
  5. Что означает LINKS 0 в docker system df -v?

На применение:

  1. Как сохранить владельцев файлов в архиве и при восстановлении?
  2. Как проверить, что резервная копия пригодна?
  3. Как безопасно найти и удалить неиспользуемые volumes?

На диагностику:

  1. После восстановления приложение не может писать в volume. Причина?
  2. Диск заполнен, все container'ы удалены. Порядок действий?

Краткое резюме

  1. Volume копируют вспомогательным container'ом: не нужен root на host, работает с любым драйвером.
  2. Форма tar ... -C /data . включает скрытые файлы; шаблон /data/* их теряет.
  3. --numeric-owner и запуск от root сохраняют владельцев при архивации и восстановлении.
  4. Копия файлов работающей базы соответствует прерванной базе, а не согласованному состоянию.
  5. Такой архив создаётся без ошибок — проблема обнаруживается только при восстановлении.
  6. pg_dump использует транзакционный снимок: копия согласована, запись не блокируется.
  7. Физическая копия с остановкой базы корректна и проще, но требует простоя.
  8. Копию проверяют восстановлением в отдельную базу и сравнением содержимого.
  9. Сравнение по числу записей не ловит искажение данных — нужна контрольная сумма.
  10. Ротацию выполняют после успешной проверки, иначе можно остаться без копий.
  11. docker system df -v показывает размер volumes и число ссылок на них.
  12. Перед удалением неиспользуемого volume в него заглядывают: prune -a вслепую уничтожает данные.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: back up and restore volumeshttps://docs.docker.com/engine/storage/volumes/#back-up-restore-or-migrate-data-volumesВспомогательный container, tar
Docker: docker cphttps://docs.docker.com/reference/cli/docker/container/cp/Копирование между host и container
Docker: docker system dfhttps://docs.docker.com/reference/cli/docker/system/df/Размеры и ссылки на volumes
Docker: docker volume lshttps://docs.docker.com/reference/cli/docker/volume/ls/Фильтр dangling
PostgreSQL: pg_dumphttps://www.postgresql.org/docs/current/app-pgdump.htmlСогласованность, форматы, ключ -Fc
PostgreSQL: backup and restorehttps://www.postgresql.org/docs/current/backup.htmlЛогические и физические копии
PostgreSQL: continuous archivinghttps://www.postgresql.org/docs/current/continuous-archiving.htmlpg_basebackup, WAL, точка восстановления
GNU tarhttps://www.gnu.org/software/tar/manual/tar.html-C, --numeric-owner

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗