Мониторинг бэкапов: как узнать, что копия не создалась
Cron-задача бэкапа стоит в расписании уже полгода, писем об ошибках не было — значит, всё в порядке? На практике «не было жалоб» и «бэкап реально создался» — два разных факта, и разницу между ними обычно обнаруживают в худший момент: когда диск сервера умер, а восстанавливаться оказывается не из чего. Дальше — как мониторить сам процесс резервного копирования, чтобы сбой был виден в течение часов, а не всплывал через год молчания.
Содержание
- Антипаттерн «настроили и забыли»
- Код завершения — единственный факт, которому можно верить
- Свежесть и размер копии: бэкап может «успешно» завершиться пустым
- Отсутствие запуска — тоже сигнал, а не тишина
- Мониторинг мониторинга: алерт должен жить отдельно от того, что он проверяет
- Практическая сборка: cron + внешний heartbeat + проверка размера в одном скрипте
Антипаттерн «настроили и забыли»
Типичная история: администратор добавляет строку в crontab, один раз проверяет вручную, что архив создался, и больше к этому не возвращается. Дальше проходят месяцы, и задача бэкапа тихо перестаёт работать — по одной из десятка обычных причин:
- на диске закончилось место под новую копию (старые архивы не ротировались, либо выросла база данных);
- у целевого хранилища сменились права доступа или истёк токен/ключ;
- сетевое хранилище (NFS, S3-совместимый бакет, удалённый SSH-хост) стало недоступно, и скрипт молча пишет в пустую локальную папку вместо примонтированной;
- путь к данным изменился после миграции приложения, а бэкап-скрипт продолжает архивировать старую (уже пустую) директорию;
- пакет обновился, изменился формат вызова утилиты, и скрипт падает на incompatibe-флаге.
Cron по умолчанию не считает своей задачей сообщать вам об ошибках — он в лучшем случае пишет stdout/stderr в системную почту, которая на большинстве VPS вообще никуда не долетает, потому что локальный MTA не настроен. В итоге задача может «выполняться» месяцами, ничего не создавая, и это не будет замечено ни разу — ровно до того момента, когда бэкап понадобится по-настоящему. Разбор именно такого случая с реальными числами есть в статье «Бэкапы шли год и оказались нерабочими» — там показано, насколько долго система может выглядеть исправной, не будучи такой на самом деле.
Важно отделить две разные задачи мониторинга, которые часто путают:
- Проверка содержимого уже готовых бэкапов — можно ли из них реально восстановиться, не побит ли архив, разворачивается ли база из дампа. Это отдельная, не менее важная тема, которая разобрана в статьях «Как проверить, что бэкап рабочий» и в репетиции восстановления по сценарию учений.
- Мониторинг самого факта успешного выполнения задачи бэкапа — запустилась ли она вообще, завершилась ли без ошибки, создала ли файл ожидаемого размера. Это тема этой статьи, и без неё первая проверка бессмысленна: если бэкапа за сегодня попросту нет, проверять в нём нечего.
Код завершения — единственный факт, которому можно верить
Первое и самое дешёвое, что стоит сделать — явно проверять exit code задачи бэкапа, а не полагаться на отсутствие письма. У большинства инструментов резервного копирования код завершения информативен: например, borgbackup возвращает 0 при полном успехе, 1 — если были предупреждения (файл изменился во время чтения, часть файлов пропущена из-за прав доступа), и 2 и выше — при реальной ошибке. Это разделение стоит сохранять, а не сводить к «упало / не упало».
Пример обёртки для запуска через cron:
#!/usr/bin/env bash
set -uo pipefail
LOG=/var/log/backup-borg.log
TG_TOKEN="123456:AAExampleTokenHere"
TG_CHAT_ID="-100123456789"
borg create --stats --compression zstd \
ssh://backup-storage/./repo::'{hostname}-{now:%Y-%m-%d}' \
/var/lib/app-data \
>>"$LOG" 2>&1
STATUS=$?
alert() {
curl -fsS -m 10 --retry 3 \
-X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-d chat_id="${TG_CHAT_ID}" \
-d text="$1" >/dev/null
}
if [ "$STATUS" -ge 2 ]; then
alert "Backup FAILED on $(hostname): exit code ${STATUS}. См. ${LOG}"
exit "$STATUS"
elif [ "$STATUS" -eq 1 ]; then
alert "Backup on $(hostname) завершился с предупреждениями (код 1) — проверьте лог"
fi
Ключевой момент: отсутствие письма об ошибке — не то же самое, что подтверждение успеха. Если задача вообще не запустилась (например, сам cron не стартовал из-за сбоя systemd, или скрипт упал на этапе, предшествующем логированию), никакого сообщения об ошибке не будет — просто потому, что код, который должен был его отправить, не выполнился. Именно поэтому одной проверки «была ли ошибка» недостаточно — нужен ещё явный сигнал об успехе (см. следующий раздел про пропущенный запуск) и независимая от сервера точка контроля, а не только логика внутри самого скрипта.
Для systemd-таймеров есть более надёжный встроенный механизм — юнит OnFailure=:
# /etc/systemd/system/backup-db.service
[Unit]
Description=Database backup
OnFailure=backup-alert@%n.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-db.sh
Такой юнит гарантированно запустит backup-alert@backup-db.service.service при любом ненулевом коде завершения основного сервиса — это надёжнее, чем полагаться на то, что сам скрипт «не забудет» вызвать alert в каждой ветке ошибок.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSСвежесть и размер копии: бэкап может «успешно» завершиться пустым
Exit code 0 не гарантирует, что архив пригоден к использованию. Реальные сценарии, когда задача рапортует об успехе, а бэкап бесполезен:
pg_dumpподключился не к той базе (например, после смены переменных окружения указывает на пустую тестовую БД) и честно выгрузил пустой дамп — код завершения0;- диск закончился в процессе записи архива, но обёртка перехватила это как «штатное завершение» без явной проверки;
- изменившийся путь к данным (см. антипаттерн выше) приводит к архивированию пустой директории — архив создаётся, весит несколько килобайт, exit code
0.
Поэтому вторая обязательная проверка — не только факт наличия файла, но его актуальность по времени и разумный размер. Пример проверки для дампа базы:
BACKUP_FILE="/backups/db-$(date +%F).sql.gz"
MIN_SIZE_BYTES=$((50 * 1024 * 1024)) # ориентир — подберите под размер своей базы
MAX_AGE_MINUTES=180 # ожидаем свежий файл не старше 3 часов
if [ ! -f "$BACKUP_FILE" ]; then
echo "ALERT: файл бэкапа отсутствует: $BACKUP_FILE"
exit 1
fi
ACTUAL_SIZE=$(stat -c%s "$BACKUP_FILE")
FILE_MTIME=$(stat -c%Y "$BACKUP_FILE")
AGE_MIN=$(( ( $(date +%s) - FILE_MTIME ) / 60 ))
if [ "$ACTUAL_SIZE" -lt "$MIN_SIZE_BYTES" ]; then
echo "ALERT: бэкап подозрительно мал — ${ACTUAL_SIZE} байт (ожидалось от ${MIN_SIZE_BYTES})"
exit 1
fi
if [ "$AGE_MIN" -gt "$MAX_AGE_MINUTES" ]; then
echo "ALERT: файл бэкапа старше ожидаемого — ${AGE_MIN} минут"
exit 1
fi
echo "OK: ${BACKUP_FILE}, ${ACTUAL_SIZE} байт, возраст ${AGE_MIN} мин"
Для дедуплицирующих инструментов (borg, restic, kopia) сырой размер файла на диске мало о чём говорит — репозиторий растёт неравномерно за счёт дедупликации. Здесь полезнее сравнивать не абсолютный размер, а метрики самой утилиты: borg info и restic stats --mode raw-data показывают «оригинальный размер данных за этот запуск» — и вот это число стоит сравнивать со скользящим средним за последние N запусков. Если текущий запуск архивировал, скажем, вдвое меньше данных, чем в среднем за последнюю неделю — это повод насторожиться и проверить, не пропал ли из бэкапа целый каталог. Жёсткого порога тут нет, это ориентир, который стоит откалибровать под конкретную нагрузку — у кого-то естественные колебания больше, у кого-то меньше.
Отсутствие запуска — тоже сигнал, а не тишина
Третий и самый недооценённый случай: задача не завершилась с ошибкой, а вообще не стартовала. Cron-демон не запустился после перезагрузки, systemd-таймер оказался задизейблен после обновления, кто-то случайно закомментировал строку в crontab при правке соседней задачи. Все проверки кода завершения из первого раздела в этом случае молчат — им попросту нечего проверять.
Практический способ ловить именно такие пропуски — паттерн dead man's switch: задача бэкапа при успешном завершении «пингует» внешний сервис, а сервис сам следит за тем, чтобы пинг приходил регулярно, и поднимает тревогу, если пинг не пришёл вовремя. Разница с обычным мониторингом принципиальна: отслеживается не событие «пришла ошибка», а событие «не пришло подтверждение», что и покрывает случай полного незапуска задачи. Подробный разбор такого подхода на конкретном сервисе есть в статье «healthchecks.io: мониторинг cron-задач».
Минимальный пример на healthchecks.io:
# /etc/cron.d/backup-db
0 3 * * * root /usr/local/bin/backup-db.sh \
&& curl -fsS -m 10 --retry 5 https://hc-ping.com/<uuid> >/dev/null \
|| curl -fsS -m 10 --retry 5 https://hc-ping.com/<uuid>/fail >/dev/null
При настройке проверки в самом сервисе указывается ожидаемый период (например, «раз в сутки») и grace-период (например, «плюс 2 часа на случай задержки») — если пинг не пришёл в это окно, сервис отправляет алерт сам, независимо от того, что происходит на вашем сервере. Это работает и для отдельных этапов: можно завести отдельную проверку на «бэкап запустился», отдельную на «бэкап завершился успешно», и по расхождению между ними понять, на каком именно шаге застряла задача.
Мониторинг мониторинга: алерт должен жить отдельно от того, что он проверяет
Здесь важный практический принцип, который легко упустить: если вся логика проверки и оповещения выполняется на том же сервере, который бэкапится, то полный отказ этого сервера (умер диск, отключилось питание, пропала сеть) одновременно убивает и данные, и механизм, который должен был об этом сообщить. Скрипт, который «при ошибке отправляет email через локальный Postfix», бесполезен в сценарии, когда сервер целиком недоступен — email просто некому отправить.
Правило простое: система мониторинга бэкапов должна быть независимой от системы, которую она проверяет, и алерты должны идти через внешний канал, способный сработать даже при полной недоступности основного сервера. На практике это означает:
- пинг уходит НА внешний сервис (healthchecks.io, cronitor или аналог), а не наоборот — тогда отсутствие пинга сервис обнаруживает сам, без участия упавшего сервера;
- финальное уведомление (Telegram, email, SMS) отправляется тем внешним сервисом, а не самим бэкап-скриптом — если сервер недоступен, скрипту всё равно нечем будет отправить это уведомление;
- если хочется полностью независимого self-hosted решения — держите его на отдельном небольшом VPS, физически и по провайдеру не связанном с основным сервером; это не обязательно дорого, для одной лишь роли «принимать heartbeat и слать алерты» хватает минимальной конфигурации;
- не полагайтесь на мониторинг, который смотрит только «изнутри» того же сервера и никогда не проверяется снаружи — это тот же антипаттерн «настроили и забыли», только на уровне самого мониторинга.
Экономия на отдельном внешнем канале обычно не окупается: цена ошибки — узнать об отсутствии бэкапов не за час, а в момент, когда восстанавливать уже нечего.
Практическая сборка: cron + внешний heartbeat + проверка размера в одном скрипте
Ниже — рабочий каркас, который объединяет все три уровня проверки: код завершения, свежесть/размер результата и внешний heartbeat, который не зависит от состояния самого сервера.
#!/usr/bin/env bash
set -uo pipefail
REPO="ssh://backup-storage/./repo"
LOG=/var/log/backup-borg.log
HC_URL="https://hc-ping.com/<uuid>" # внешний heartbeat
TG_TOKEN="123456:AAExampleTokenHere"
TG_CHAT_ID="-100123456789"
MIN_ORIGINAL_SIZE_MB=2000 # ориентир под вашу нагрузку
alert() {
curl -fsS -m 10 --retry 3 \
-X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \
-d chat_id="${TG_CHAT_ID}" -d text="$1" >/dev/null
}
# сигнал "задача стартовала" — отдельный /start-эндпоинт хелсчека
curl -fsS -m 10 "${HC_URL}/start" >/dev/null
borg create --stats --json \
"${REPO}::{hostname}-{now:%Y-%m-%d}" \
/var/lib/app-data \
>"${LOG}.json" 2>>"$LOG"
STATUS=$?
if [ "$STATUS" -ge 2 ]; then
alert "Backup FAILED on $(hostname), код ${STATUS}"
curl -fsS -m 10 "${HC_URL}/fail" >/dev/null
exit "$STATUS"
fi
ORIGINAL_MB=$(jq -r '.archive.stats.original_size / 1024 / 1024 | floor' "${LOG}.json" 2>/dev/null || echo 0)
if [ "${ORIGINAL_MB}" -lt "${MIN_ORIGINAL_SIZE_MB}" ]; then
alert "Backup on $(hostname) подозрительно мал: ${ORIGINAL_MB} MB (ожидалось от ${MIN_ORIGINAL_SIZE_MB})"
curl -fsS -m 10 "${HC_URL}/fail" >/dev/null
exit 1
fi
# всё в порядке — подтверждаем успех внешнему сервису
curl -fsS -m 10 --retry 5 "${HC_URL}" >/dev/null
echo "OK: ${ORIGINAL_MB} MB, exit ${STATUS}"
Такая связка закрывает все три случая из статьи разом: если задача упадёт с ошибкой — сработает alert из кода завершения; если она не запустится вовсе (cron не стартовал, сервер лёг) — heartbeat на hc-ping.com не придёт вовремя, и внешний сервис поднимет тревогу сам; если задача формально отработает, но создаст подозрительно маленький архив — сработает проверка ORIGINAL_MB. Telegram здесь используется как дополнительный, быстрый канал, а не единственный — основной независимый контроль по-прежнему держит внешний heartcheck-сервис, у которого есть собственное расписание и собственное понятие «пинг не пришёл».
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Разве не достаточно письма от cron при ошибке?
Нет. Во-первых, локальная почта на большинстве VPS никуда не доставляется без отдельной настройки. Во-вторых, письмо приходит только если задача вообще запустилась и упала с ошибкой — если она не стартовала совсем, письма не будет, а бэкапов тоже.
Как понять, что бэкап не просто «создался», а рабочий?
Это отдельный, следующий уровень проверки — периодически пробное восстановление или валидация содержимого архива. Мониторинг из этой статьи ловит факт «задача выполнилась и создала копию разумного размера»; рабочесть самой копии для восстановления разбирается в «Как проверить, что бэкап рабочий».
Что если сервер с бэкапами сам полностью недоступен — кто тогда пришлёт алерт?
Именно поэтому проверка должна идти через независимый внешний сервис (heartbeat/dead man's switch), который сам замечает отсутствие пинга и сам инициирует оповещение — а не ждёт, что упавший сервер что-то отправит.
Нужен ли обязательно платный внешний сервис?
Нет, есть бесплатные тарифы у большинства heartbeat-сервисов для небольшого числа проверок, либо можно поднять собственный self-hosted вариант на отдельном недорогом VPS — важно только, чтобы он физически не зависел от основного сервера.
Как выбрать порог «подозрительно маленького» размера?
Это не универсальное число — возьмите средний размер бэкапа за последние несколько недель и установите порог заметно ниже этого среднего (например, 50-60%), а не гадайте наугад; для дедуплицирующих инструментов сравнивайте «оригинальный размер данных», а не размер файла на диске.
Что делать с предупреждениями (exit code 1 у borg и подобных), а не только с ошибками?
Не игнорировать их молча: логировать и присылать отдельным, менее срочным уведомлением, чтобы отличать «часть файлов пропущена из-за прав» от полноценного сбоя, но при этом не терять сигнал.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →