У резервного копирования есть свойство, которое отличает его от почти всего остального в инфраструктуре. Проверить, работает ли оно, можно ровно одним способом — восстановиться. Всё остальное проверяет что‑то другое.
Ситуация тем неприятнее, что узнаёте вы об этом в единственный день, когда копия понадобилась.
Поэтому в статье рассмотрим пять мест, где схема резервного копирования разваливается.
Ноль в конце формулы
Начну с главного, потому что остальные пять — его частные случаи.
Правило 3-2-1 знают все: три копии, два типа носителя, одна вне площадки. Придумано оно было в те времена, когда под аварией понимали пожар в серверной или смерть диска. Против аварии, у которой есть автор и мотив, оно держится хуже.
Поэтому появился расширенный вариант, 3-2-1-1-0. Добавленная единица — копия неизменяемая или физически отключённая. А ноль — это ноль ошибок при восстановлении, подтверждённых регулярными проверками.
Так вот, ноль в этой формуле стоит последним, а ломается первым. Восстановление никто не проверяет, потому что это долго, требует железа и всегда можно отложить до следующего квартала. В результате система живёт с копиями, о качестве которых не известно ничего.
Проверяется это одним вопросом: когда в последний раз восстанавливались из копии на настоящих данных. Ответ «в прошлом году» или «когда переезжали» означает, что с тех пор поменялись версии, схема и сама процедура, и текущее состояние не проверял никто.
Разумная частота — раз в квартал, с записью того, что восстанавливали, сколько это заняло и что пошло не так. Прогон можно собрать быстро:
#!/usr/bin/env bash
# restore-drill.sh — восстановление свежей копии на отдельный инстанс
set -Eeuo pipefail
DUMP=$(ls -t /backup/pg/*.dump | head -1)
CONT=restore-drill-$(date +%s)
trap 'docker rm -f "$CONT" >/dev/null 2>&1 || true' EXIT
docker run -d --name "$CONT" -e POSTGRES_PASSWORD=drill postgres:16 >/dev/null
until docker exec "$CONT" pg_isready -q; do sleep 1; done
START=$(date +%s)
docker exec -i "$CONT" pg_restore -U postgres -d postgres --no-owner < "$DUMP"
ELAPSED=$(( $(date +%s) - START ))
ROWS=$(docker exec "$CONT" psql -U postgres -tAc
"SELECT count(*) FROM orders WHERE created_at > now() - interval '7 days'")
echo "восстановление: ${ELAPSED}с, заказов за неделю: ${ROWS}"
[ "$ROWS" -gt 0 ] || { echo "данных нет, копия пустая"; exit 1; }
Последние две строки тут важнее всего остального.
Файл вполне может развернуться без единой ошибки и содержать пустые таблицы, так бывает, когда дамп снимали с реплики, которая давно отстала, или с базы, где у пользователя не было прав читать нужную схему.
Все копии в одном здании
Второе по частоте, и понять его проще всего от противного: спросите себя, какое одно событие способно уничтожить сразу и рабочие данные, и все их копии.
Если ответ находится — сервер, аккаунт, дата‑центр, вот она, эта ошибка, и дальше неважно, сколько копий вы на самом деле сделали.
Копия на соседнем диске того же сервера спасает от смерти одного диска и больше ни от чего: сервер сгорел — сгорело всё разом.
Копия на том же гипервизоре не переживёт отказ хоста.
Копия в том же облачном аккаунте не переживёт компрометацию этого аккаунта, а именно аккаунт в современных атаках и берут в первую очередь.
В правилах для этого есть отдельное название — общая судьба.
Не «что‑то могло сломаться», а конкретное событие, которое одним махом забирает и данные, и способ их вернуть.
Атаки последних лет строятся на этой логике.
Сначала атакующий тихо изучает окружение и находит все места, где лежат копии.
Потом, только убедившись, что восстанавливаться будет неоткуда, запускает шифрование рабочих систем.
Сколько именно времени уходит на разведку, я по открытым отчётам об инцидентах судить не берусь, цифры там сильно расходятся.
А вот порядок действий везде один и тот же:
-
Сначала копии.
-
Потом продуктив.
В общем, есть требования к неизменяемой копии. Неизменяемость означает, что данные записываются один раз и не могут быть изменены или удалены в течение заданного срока.
Включая ту учётную запись, которая их записала, это как раз главное.
aws s3api put-object-lock-configuration
--bucket backups-prod
--object-lock-configuration '{
"ObjectLockEnabled": "Enabled",
"Rule": {"DefaultRetention": {"Mode": "COMPLIANCE", "Days": 30}}
}'
Режим COMPLIANCE отличается от GOVERNANCE тем, что снять блокировку не может никто, включая владельца корневого аккаунта.
Настроенная блокировка и работающая блокировка — разные утверждения, и проверить второе можно только попыткой удаления. Не с обычных прав, а с самых широких, какие вообще есть в аккаунте:
aws s3api delete-object --bucket backups-prod --key db-2026-08-20.dump
An error occurred (AccessDenied) when calling the DeleteObject operation:
User: arn:aws:iam::111111111111:root is not authorized to perform this operation
Если вместо этой ошибки объект удалился — конфигурация не применилась, приоритет забрала политика бакета, или блокировка стоит в режиме GOVERNANCE, который root как раз обходит. Р
Зелёный монитор ни о чём не говорит
Мониторинг обычно смотрит на код возврата задания. Отработало без ошибок — зелёный. При этом сломаться могло что угодно после этой проверки.
Посмотрим на три вещи, за которыми стоит следить отдельно.
-
Размер копии в сравнении со вчерашней. Резкое падение означает, что дамп снялся не полностью или база опустела. Резкий рост — что кто‑то залил мусор либо поменялась схема.
YESTERDAY=$(stat -c%s /backup/pg/db-$(date -d yesterday +%F).dump)
TODAY=$(stat -c%s /backup/pg/db-$(date +%F).dump)
DIFF=$(( (TODAY - YESTERDAY) * 100 / YESTERDAY ))
if [ "${DIFF#-}" -gt 20 ]; then
echo "размер копии изменился на ${DIFF}% — проверить" >&2
fi
-
Возраст самой свежей копии. Задание может не падать, а просто не запускаться: планировщик отключили, таймер не включился после перезагрузки, узел вывели из кластера на обслуживание и забыли вернуть.
Проверить, запускался ли таймер systemd вообще, можно одной командой, и это первое, что стоит посмотреть, если подозрение уже есть:
systemctl list-timers pg-backup.timer
NEXT LEFT LAST PASSED UNIT
Sat 2026-08-21 03:00:00 UTC 4h 12min n/a n/a pg-backup.timer
Строка LAST n/a — таймер существует, но ни разу не срабатывал. Часто это следствие того, что юнит включили командой enable, забыв про --now, и он подхватится только при следующей перезагрузке узла, которая может не случиться месяцами.
Возраст самой свежей копии удобнее держать в метриках постоянно. Node exporter умеет читать значения из текстового файла, который вы обновляете сами после каждого прогона:
cat <<EOF > /var/lib/node_exporter/textfile_collector/backup.prom
# HELP backup_last_success_timestamp Unix-время последнего успешного бэкапа
# TYPE backup_last_success_timestamp gauge
backup_last_success_timestamp $(date +%s)
EOF
Дальше в Prometheus остаётся один запрос, который сравнивает это время с текущим и превращает молчание задания в честный алерт:
time() - backup_last_success_timestamp > 26 * 3600
Двадцать шесть часов вместо суток — запас на случай, если ночное окно один раз сдвинулось на час из‑за нагрузки. Алерт при этом реагирует не на ошибку задания, а на факт, что бэкапа давно не было, а это то, что нас интересует, независимо от причины.
-
И целостность архива. Дамп PostgreSQL проверяется чтением оглавления, а это заметно дешевле полного восстановления:
pg_restore --list /backup/pg/db-$(date +%F).dump > /dev/null
|| echo "архив битый" >&2
Проверка читает только заголовок и содержание, так что её можно вешать сразу после снятия копии, не дожидаясь ночного окна.
Для копий, которые едут в объектное хранилище, чтения оглавления мало, файл мог побиться уже при передаче. Здесь спасает контрольная сумма, посчитанная сразу после создания дампа и сохранённая отдельно от самого файла:
sha256sum /backup/pg/db-$(date +%F).dump > /backup/pg/db-$(date +%F).sha256
aws s3 cp /backup/pg/db-$(date +%F).dump s3://backups-prod/
aws s3 cp /backup/pg/db-$(date +%F).sha256 s3://backups-prod/
А перед восстановлением сумму пересчитывают и сравнивают с сохранённой:
aws s3 cp s3://backups-prod/db-$(date +%F).dump .
sha256sum -c db-$(date +%F).sha256 || echo "файл повреждён при хранении или передаче" >&2
Если инструмент резервного копирования не самописный, а готовый — restic или borg, та же задача решена штатной командой, которая заодно проверяет дедупликацию и внутренние индексы:
restic check --read-data-subset=10%
Полная проверка --read-data без указания подмножества выглядит адекватнее, но перекачивает весь архив целиком, и на терабайтных репозиториях это отдельная по стоимости операция.
Час на восстановление, который оказался сутками
Про эти цифры обычно вспоминают на презентации проекта, а потом благополучно забывают до самой аварии.
Копия раз в сутки — значит, в худшем случае пропадёт рабочий день, и это все понимают заранее. А вот сколько времени займёт само восстановление, почти никто не считает заранее, хотя посчитать это можно ровно тем же квартальным прогоном, который мы уже разбирали.
Разрыв между обещанным сроком и тем, что выходит на деле, обнаруживается в двух местах.
Первое — скачивание из холодного хранилища.
Данные лежат в дешёвом классе, откуда их не выгрузишь мгновенно, а узнают об этом ровно тогда, когда счёт уже на минуты.
У Glacier восстановление файла — отдельная операция с тремя уровнями срочности, и у каждого своя цена и своё ожидание:
aws s3api restore-object
--bucket backups-cold
--key db-2025-01-15.dump
--restore-request '{"Days": 1, "GlacierJobParameters": {"Tier": "Expedited"}}'
Ускоренный уровень обещает единицы минут, стандартный — часы, объёмный — до полутора суток. Цена растёт в обратную сторону: ускоренный тариф на порядок дороже объёмного за тот же гигабайт.
Экономить на хранении холодных копий разумно, но если план на бумаге написан «выгрузим за пять минут», а настроен на самый дешёвый уровень — эти пять минут превратятся в полтора суток ожидания.
И это ещё без самой передачи. Объект вернулся из архива — теперь его нужно скачать.
Терабайтный архив по гигабитному каналу качается часа два, если канал весь ваш и ничем больше не занят. В аварии он редко бывает свободен.
Второе больное место — накат логического дампа на большую базу.
Дамп разворачивается медленно: строчка за строчкой, потом заново строятся индексы. Для базы в сотни гигабайт при паре часов на восстановление одна ночная копия попросту не успевает, и никакими настройками это не ускорить. Нужны физические копии и непрерывная архивация журналов:
pg_basebackup -h primary -D /backup/base -X stream -c fast -P
Потоковая передача журналов тут обязательная часть, без неё копия окажется несогласованной, если во время снятия шли записи.
База поднялась, а сервис не работает
Дамп развернулся, таблицы на месте, счётчик из первого скрипта показывает нужное число заказов. Восстановление прошло — можно закрывать тикет.
А приложение при первом же запуске падает с ошибкой, которую вы за месяц ни разу не видели.
FATAL: role "app_readonly" does not exist
Тако/й роли действительно нет.
+,92 И не будет, потому что pg_dump в обычном режиме сохраняет только содержимое одной базы — таблицы, данные, индексы, представления. Роли и права живут на уровне кластера СУБД, а не базы, и в дамп одной базы попросту не попадают.
pg_dump --dbname=orders --format=custom > orders.dump
pg_dumpall --globals-only > globals.sql
Второй командой почему‑то пользуются заметно реже первой, хотя без неё восстановленная база не узнаёт ни одного пользователя, кроме postgres.
При восстановлении порядок важен: сперва накатывают глобальные объекты, потом сам дамп.
psql -f globals.sql
pg_restore --dbname=orders orders.dump
-
Роли — только первая находка в этом разделе, и, пожалуй, самая безобидная: её хотя бы видно сразу по тексту ошибки. Дальше список идёт по нарастанию скрытности.
-
Секреты и сертификаты — если они не в базе, а в отдельном хранилище, копия базы их не затронет вообще.
-
Файлы, которые пользователи когда‑то загрузили и которые лежат на диске рядом с приложением, а не в объектном хранилище, — тоже мимо любого дампа СУБД.
-
Состояние очередей — сообщения, которые не успели разобрать на момент аварии, восстановленная база не помнит и не обязана.
Отдельная категория — вся инфраструктура вокруг самого приложения: правила фильтрации трафика, записи DNS, настройки балансировщика, переменные окружения в оркестраторе.
Если это описано кодом и лежит в репозитории рядом с приложением — восстанавливается тем же способом, что и всё остальное.
Если собиралось руками через консоль провайдера — восстанавливать придётся по памяти, а память эта сейчас, скорее всего, у человека в отпуске.
Собрать список того, что реально нужно для запуска, а не просто «кажется важным», проще всего явным манифестом — он же потом служит чек‑листом при разборе:
# restore-manifest.yml
required_for_boot:
- name: postgres-data
source: s3://backups-prod/orders.dump
verify: pg_restore --list
- name: postgres-roles
source: s3://backups-prod/globals.sql
verify: grep -q "CREATE ROLE app_readonly" globals.sql
- name: uploaded-files
source: s3://backups-prod/uploads/
verify: aws s3 ls s3://backups-prod/uploads/ | wc -l
- name: app-secrets
source: vault-snapshot-2026-08-20.snap
verify: vault operator raft snapshot inspect
- name: dns-zone
source: route53-export.json
verify: jq '.ResourceRecordSets | length' route53-export.json
Дальше манифест прогоняется скриптом, который не восстанавливает данные сам, а честно говорит, чего не хватает:
#!/usr/bin/env bash
set -Eeuo pipefail
MISSING=0
while IFS= read -r name; do
src=$(yq ".required_for_boot[] | select(.name == "$name") | .source" restore-manifest.yml)
if [ -z "$src" ] || ! aws s3 ls "$src" >/dev/null 2>&1; then
echo "нет источника для: $name" >&2
MISSING=1
fi
done < <(yq '.required_for_boot[].name' restore-manifest.yml)
[ "$MISSING" -eq 0 ] || { echo "чего-то не хватает, смотрите выше" >&2; exit 1; }
Отдельный прогон — сама проверка того, что из этого получается рабочий сервис, а не просто набор развёрнутых файлов.
Задача формулируется иначе, чем обычное восстановление: не «поднять базу», а «поднять сервис целиком в изолированном окружении, имея на руках только копии, без единого файла из живой системы».
docker compose -f drill-compose.yml up -d
sleep 5
curl -sf -X POST http://localhost:8080/login
-d '{"user":"drill@example.com","password":"drill"}'
|| { echo "сервис поднялся, вход не работает" >&2; exit 1; }
Именно на этом шаге всплывает то, что в манифесте не всегда предусмотришь заранее: приложение при старте лезет за курсом валют во внешний сервис и без него не проходит проверку готовности, хотя к резервным копиям это отношения не имеет вовсе.
Чем раньше находится такая зависимость, тем дешевле она обходится — на плановом прогоне это пять минут разговора с разработчиками, посреди настоящей аварии — ещё один час простоя, который никто не планировал.
Напоследок
Если делать одну вещь из всего перечисленного, я бы сделал одно восстановление. Взять свежую копию, развернуть на отдельной машине, засечь время и проверить не факт запуска, а конкретные данные: есть ли вчерашние заказы, сходятся ли остатки, работает ли вход пользователя. То, что выяснится за эти два часа, обычно полезнее любого аудита схемы.
Дальше три вещи по убыванию отдачи.
-
Неизменяемая копия — от неё зависит, останется ли у вас вообще что восстанавливать.
-
Мониторинг возраста последней копии вместо кода возврата задания — молчащее задание опаснее падающего.
-
И записанная процедура, проверенная человеком, который её не писал.
Цифры по частоте проверок и по срокам восстановления, которые я привёл, — отраслевые ориентиры из рекомендаций, а не измерения на конкретной инфраструктуре.
У вас они выйдут другими. Собственно, поэтому единственная надёжная проверка — своя, на своих данных и своём железе.

Даже хорошо настроенный бэкап не гарантирует восстановление системы, если заранее не проверить весь путь — от хранения копии до запуска рабочего сервиса.
Разобраться, как строить устойчивую инфраструктуру, искать слабые места и готовиться к сбоям, можно на открытых уроках:
-
23 сентября, 20:00. «Узнайте, как использовать Patroni для управления высокодоступными кластерами PostgreSQL». Записаться
-
21 сентября, 20:00. «Типовые задачи с RAID‑массивами: создание, эксплуатация, перенос данных и восстановление». Записаться
-
14 октября, 20:00. «ИИ для мониторинга: что Prometheus и Grafana могут рассказать агенту». Записаться
Полный список бесплатных уроков сентября по инфраструктуре смотрите в дайджесте.
Автор: badcasedaily1


