Бэкап, из которого ни разу не восстанавливались — это не бэкап

У резервного копирования есть свойство, которое отличает его от почти всего остального в инфраструктуре. Проверить, работает ли оно, можно ровно одним способом — восстановиться. Всё остальное проверяет что‑то другое.
Ситуация тем неприятнее, что узнаёте вы об этом в единственный день, когда копия понадобилась.
Поэтому в статье рассмотрим пять мест, где схема резервного копирования разваливается.
Ноль в конце формулы
Начну с главного, потому что остальные пять — его частные случаи.
Правило 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.dumpAn 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.timerNEXT 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 могут рассказать агенту». Записаться
Полный список бесплатных уроков сентября по инфраструктуре смотрите в дайджесте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.