Почему скрипты администрирования отказывают молча
Молчаливый отказ выглядит так: скрипт завершился с ненулевым кодом, но никто об этом не узнал. Вывод ушёл в никуда, cron не отправил письмо, в логе нет ни строки. Ночной бэкап может не работать неделю, а выяснится это в момент восстановления данных.
Корневых причин три. Логи не пишутся или пишутся в stdout, который cron проглатывает. Ошибки внутри скрипта не проверяются, поэтому сбой в середине пайпа не останавливает работу. Код возврата никто не читает, и уведомления о сбое нет. Ниже разбираем каждую причину с командой проверки, а затем собираем полный контур: уровни логирования, syslog и journald, logrotate, set -euo pipefail, trap, матрицу кодов возврата, flock, проверку предусловий, идемпотентность, уведомления и отладку по логам.
Примеры рассчитаны на bash 5.x и systemd 250 и новее, отличия в поведении отмечены отдельно. В финале лежит чек-лист, по которому можно прогнать свои скрипты за десять минут.
Три причины, по которым сбой остаётся незамеченным
- Вывод уходит в никуда. cron отправляет письмо только тогда, когда у задачи есть вывод. Если в crontab стоит пустой MAILTO или почта падает в локальный spool, который никто не читает, сбой никто не увидит. Проверка: crontab -l | grep -i mailto
- Ошибки не проверяются. Без set -euo pipefail код возврата упавшей команды внутри пайпа теряется, скрипт идёт дальше и оставляет систему в промежуточном состоянии. Проверка: grep -n 'set -' /usr/local/bin/mybackup.sh
- Код возврата маскируется. Финальный exit 0, ловушка без проброса кода, отсутствие уведомления: для планировщика это успех. Проверка: запустить скрипт вручную и посмотреть echo $?
Что считается «предсказуемым» скриптом в 2026
- Пишет структурированный лог с уровнями и таймстампом, а не свободный текст в stdout.
- Возвращает осмысленный код выхода, по которому понятно, что именно сломалось.
- Ловит ошибки и освобождает ресурсы через trap: временные каталоги, файлы, блокировки.
- Безопасен при повторном запуске: второй прогон не создаёт дублей и не портит данные.
- Отправляет уведомление при сбое, вместо надежды на то, что кто-то откроет лог.
Этот набор ляжет в основу чек-листа в конце статьи. Чем скрипт отличается от ad-hoc команды и когда пора уходить к конфигурационным менеджерам, разбираем в материале про скрипты администрирования и конфигурационные менеджеры.
Логирование в bash-скриптах: уровни, форматы и запись в syslog/journald
Скрипт, запущенный из cron или systemd, не имеет терминала. Всё, что он печатает в stdout, уходит в почту, в journal или просто исчезает. Поэтому рабочий лог пишите в файл и в системный журнал, а stdout оставляйте для данных, которые читают другие программы.
Четырёх уровней хватает почти всегда: DEBUG для трассировки, INFO для этапов работы, WARN для восстановимых отклонений, ERROR для сбоя. Уровень задавайте переменной окружения, чтобы включать подробность без правки кода.
Готовая функция логирования с уровнями
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_NAME="$(basename "$0" .sh)"
LOG_FILE="${LOG_FILE:-/var/log/${SCRIPT_NAME}.log}"
LOG_LEVEL="${LOG_LEVEL:-INFO}"
level_rank() {
case "$1" in
DEBUG) echo 10 ;;
INFO) echo 20 ;;
WARN) echo 30 ;;
ERROR) echo 40 ;;
*) echo 20 ;;
esac
}
syslog_priority() {
case "$1" in
DEBUG) echo debug ;;
INFO) echo info ;;
WARN) echo warning ;;
ERROR) echo err ;;
*) echo notice ;;
esac
}
log() {
local level="$1"; shift
local msg="$*" ts line
[ "$(level_rank "$level")" -ge "$(level_rank "$LOG_LEVEL")" ] || return 0
ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
line="$ts $(printf '%-5s' "$level") [$SCRIPT_NAME] pid=$$ $msg"
printf '%s\n' "$line" >>"$LOG_FILE"
printf '%s\n' "$line" >&2
logger -t "$SCRIPT_NAME" -p "user.$(syslog_priority "$level")" -- "$msg"
}
log INFO "старт бэкапа"
log ERROR "rsync завершился с кодом 23"
Строка лога содержит время в UTC, уровень фиксированной ширины, имя скрипта и PID. Вызов logger -t mybackup задаёт SYSLOG_IDENTIFIER, по которому запись потом ищется в journald, а ключ -p user.err раскладывает уровень по стандартным приоритетам syslog. Фильтр по LOG_LEVEL работает до записи, поэтому DEBUG не засоряет продакшн-лог. Подробный прогон включается одной командой: LOG_LEVEL=DEBUG /usr/local/bin/mybackup.sh
Учтите две особенности. Первая: logger возвращает 0 даже тогда, когда журнал недоступен, поэтому факт доставки проверяйте отдельно командой journalctl -t mybackup -n 1 --no-pager. Вторая: syslog-сообщения ограничены по длине, граница зависит от конфигурации демона, так что дампы и большие выводы пишите в файл, а в журнал отправляйте короткую сводку.
Запись в syslog и journald: logger, systemd-cat, journalctl
Есть три рабочих способа попасть в системный журнал: отправлять отдельные сообщения через logger, отдать весь вывод скрипта в journald через systemd-cat, либо перенаправить stdout и stderr в logger целиком.
logger -t mybackup "бэкап завершён: db-2026-09-25.sql.gz" systemd-cat -t mybackup /usr/local/bin/mybackup.sh /usr/local/bin/mybackup.sh 2>&1 | logger -t mybackup printf 'MESSAGE=rsync error 23\nPRIORITY=3\nSYSLOG_IDENTIFIER=mybackup\n' | logger --journald journalctl -t mybackup --since "1 hour ago" --no-pager journalctl -u mybackup.service -n 50 --no-pager journalctl -t mybackup -o json-pretty -n 1
Вариант с logger --journald принимает нативные поля журнала: MESSAGE, PRIORITY, SYSLOG_IDENTIFIER и любые свои поля в верхнем регистре, например BACKUP_HOST=db-01. Свои поля удобно фильтровать: journalctl -t mybackup BACKUP_HOST=db-01. Имена полей не должны начинаться с подчёркивания, этот префикс systemd оставляет за собой.
Дисковое пространство под журнал ограничивается в /etc/systemd/journald.conf. Настройки вступают в силу после systemctl restart systemd-journald.
# /etc/systemd/journald.conf Storage=persistent SystemMaxUse=500M SystemMaxFileSize=50M SystemKeepFree=1G
Значение Storage=auto хранит журнал в памяти и теряет его при перезагрузке, если каталога /var/log/journal нет. Чтобы включить постоянное хранилище, создайте каталог и примените шаблоны: mkdir -p /var/log/journal и systemd-tmpfiles --create --prefix /var/log/journal.
Ротация логов скриптов через logrotate
Файловый лог без ротации однажды займёт весь раздел, и следующий сбой случится уже из-за нехватки места. Конфиг для логов скрипта кладётся в /etc/logrotate.d/.
/var/log/mybackup.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 root adm
# copytruncate
}
Директива rotate 14 оставляет две недели истории, delaycompress сжимает всё, кроме самого свежего файла, missingok гасит ошибки при отсутствии лога, а notifempty пропускает пустые файлы. Ключ copytruncate нужен, если процесс держит файл открытым и не умеет переоткрывать его по сигналу. У способа есть окно потери: строки, записанные между копированием и обрезкой, пропадут. Альтернатива для скриптов, которые пишут через logger, проще: файла нет, ротировать нечего.
Проверка конфига без реального вращения: logrotate -d /etc/logrotate.d/mybackup. На современных дистрибутивах ротацию запускает systemd-таймер logrotate.timer, а не cron, поэтому статус смотрите через systemctl list-timers logrotate.timer.
Коды возврата и обработка ошибок: set -euo pipefail, trap и exit codes
Флаги в первой строке скрипта решают большую часть проблем с необнаруженными сбоями, но не все. Разберём, что каждый из них делает и где они молчат.
set -euo pipefail: что реально делает и где не спасает
- -e завершает скрипт при ненулевом коде команды.
- -u считает обращение к необъявленной переменной ошибкой, что ловит опечатки вида $BACKUP_DIRR.
- -o pipefail делает код возврата пайпа равным коду последней неуспешной команды, а не последней в конвейере.
- -E включает наследование ERR-ловушки функциями и подоболочками, без него trap ERR не сработает внутри функции.
Флаг -e не срабатывает в условиях if, while и until, в списках && и ||, при отрицании через ! и в арифметике: выражение ((i++)) при нулевом результате возвращает 1 и может уронить скрипт неожиданно. Пример с пайпом показывает, почему без pipefail сбой источника теряется.
set -euo pipefail
# код возврата всего пайпа равен коду последней команды
false | true; echo "статус пайпа: $?"
# PIPESTATUS хранит коды всех участников
false | true
echo "участники: ${PIPESTATUS[0]} ${PIPESTATUS[1]}"
Для критичных конвейеров проверяйте PIPESTATUS явно или переписывайте логику так, чтобы важная команда стояла последней. Разбор каркаса скрипта с этими флагами, IFS, getopts и mktemp есть в статье про надёжный каркас bash-скрипта и чек-лист ревью.
trap ERR и trap EXIT: логирование и очистка ресурсов
Ловушки закрывают две задачи: записать причину сбоя с контекстом и убрать за собой временные файлы. trap ERR срабатывает на ненулевой код, trap EXIT выполняется всегда, и при успехе, и при падении.
set -Eeuo pipefail
TMPDIR_RUN="$(mktemp -d "/var/tmp/${SCRIPT_NAME}.XXXXXX")"
cleanup() {
rm -rf "$TMPDIR_RUN"
}
on_error() {
local code=$?
log ERROR "сбой: строка $LINENO, код $code, команда: $BASH_COMMAND"
exit "$code"
}
trap cleanup EXIT
trap on_error ERR
Переменные LINENO и BASH_COMMAND дают строку и саму команду, которая упала, а локальная переменная code сохраняет код возврата до вызова log. Если функция ошибки вызывает exit, код уходит наружу без изменений, и планировщик видит реальный результат. Ловушка ERR не переходит в подоболочки и функции без set -E. Тот же приём с откатом частично применённых изменений при деплое разбираем в материале про логирование и откат в скриптах развёртывания.
Матрица кодов возврата для административных скриптов
Единый набор кодов превращает вывод echo $? в диагноз. Ниже соглашение, которое удобно держать во всех скриптах парка машин.
| Код | Значение | Типичный пример |
|---|---|---|
| 0 | Успех | Бэкап создан, контрольная сумма совпала |
| 1 | Общая ошибка | Неожиданный сбой без уточнения |
| 2 | Неверные аргументы | Передан неизвестный флаг |
| 3 | Нет зависимости | В PATH отсутствует rsync или pg_dump |
| 4 | Не выполнено предусловие | Мало места, нет прав на запись, хост недоступен |
| 5 | Блокировка занята | Предыдущий запуск ещё работает |
| 6 | Ошибка внешнего сервиса | API вернул 5xx, реплика не отвечает |
Не занимайте коды выше 125: значения 126, 127 и 128 плюс номер сигнала зарезервированы оболочкой. Возврат понятного кода прост: проверка наличия утилиты завершает скрипт с кодом 3, а не с общим 1.
command -v rsync >/dev/null 2>&1 || { log ERROR "rsync не найден в PATH"; exit 3; }
Идемпотентность и защита от повторного запуска: flock, проверка предусловий, cleanup
Скрипт по таймеру может пересечься сам с собой, если предыдущий запуск затянулся. Второй экземпляр поверх первого ломает данные и логи, поэтому запуск сериализуют.
flock: защита от параллельного запуска
LOCK_FILE="/var/lock/mybackup.lock" exec 9>"$LOCK_FILE" if ! flock -n 9; then log WARN "предыдущий запуск ещё выполняется, выход" exit 5 fi
Флаг -n не ждёт освобождения блокировки и сразу возвращает ошибку, из-за чего скрипт завершается с кодом 5. Вариант flock -w 10 ждёт до десяти секунд, что полезно для задач, которые быстро освобождают ресурс. Файл блокировки удалять вручную не нужно: ядро снимает блокировку при завершении процесса, а сам файл можно оставить на диске. Располагайте lock-файл в /var/lock или /run, а не в каталоге, который очищается другим скриптом.
Проверка предусловий до изменений
Падать рано дешевле, чем на середине копирования. Минимальный набор проверок перед работой: свободное место, доступность источника, наличие бинарников, права на запись, корректность конфигурации.
- Место на целевом разделе: df -Pm /backup | awk 'NR==2 {print $4}'
- Доступность хоста без пароля: ssh -o BatchMode=yes -o ConnectTimeout=5 db-01 true
- Зависимости: command -v rsync pg_dump gzip
- Права: test -w /backup
check_preconditions() {
local missing="" free_mb
for bin in rsync ssh gzip; do
command -v "$bin" >/dev/null 2>&1 || missing="$missing $bin"
done
if [ -n "$missing" ]; then
log ERROR "нет зависимостей:$missing"
return 3
fi
free_mb="$(df -Pm /backup | awk 'NR==2 {print $4}')"
if [ "$free_mb" -lt 5120 ]; then
log ERROR "мало места на /backup: ${free_mb} МБ"
return 4
fi
if ! ssh -o BatchMode=yes -o ConnectTimeout=5 db-01 true; then
log ERROR "источник db-01 недоступен"
return 4
fi
return 0
}
check_preconditions || exit $?
Конструкция check_preconditions || exit $? пробрасывает наружу код 3 или 4 и не запускает основной код при неудаче. Такой же подход с dry-run и проверкой в контейнере применяется в скрипте развёртывания с откатом и протоколом проверки.
Идемпотентность: как сделать повторный запуск безопасным
- Создавайте каталоги через mkdir -p: повторный вызов не падает с ошибкой существования.
- Синхронизируйте каталоги через rsync -a --delete: состояние после второго прогона совпадает с первым.
- Записывайте результат во временный файл и заменяйте целевой через mv: читатель видит либо старую, либо новую версию, но не половину.
- Проверяйте, не сделана ли работа: test -f "/backup/$(date +%F).tar.gz" и выход с кодом 0.
- Создавайте пользователей через id -u user или useradd без падения при существующей учётной записи.
Очистку временных файлов вешайте на trap EXIT, иначе прерванный запуск оставит мусор, который однажды закончится нехваткой inode.
Уведомления о сбоях: cron MAILTO, systemd OnFailure, webhook и мониторинг
Уведомление должно содержать хост, имя скрипта, код возврата и последние строки лога, иначе дежурный всё равно пойдёт в journalctl. Выбор канала зависит от того, чем запускается скрипт.
systemd OnFailure: надёжное уведомление о сбое
Если задача запускается таймером, директива OnFailure вызывает отдельный unit при любом ненулевом коде возврата или падении по сигналу.
# /etc/systemd/system/mybackup.service [Unit] Description=Ночной бэкап базы OnFailure=notify-failure@%n.service [Service] Type=oneshot User=backup ExecStart=/usr/local/bin/mybackup.sh
# /etc/systemd/system/mybackup.timer [Unit] Description=Запуск бэкапа в 03:00 [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true RandomizedDelaySec=300 [Install] WantedBy=timers.target
# /etc/systemd/system/notify-failure@.service [Unit] Description=Уведомление о сбое %i [Service] Type=oneshot ExecStart=/usr/local/bin/notify-failure.sh %i
Скрипт уведомления получает имя упавшего unit в %i, забирает контекст через journalctl -u "$1" -n 20 --no-pager и отправляет его в выбранный канал. Ключ Persistent=true в таймере догоняет пропущенный запуск после выключенной машины, а RandomizedDelaySec разводит одновременные задачи по времени. Проверка состояния: systemctl status mybackup.service и systemctl list-timers mybackup.timer.
cron MAILTO: что настроить, чтобы письма доходили
cron пишет письмо только при наличии вывода у задачи, поэтому скрипт должен что-то печатать в stdout или stderr. Пустой MAILTO отключает почту полностью, а незаданный отправляет её локальному пользователю, где письма обычно никто не читает. Для доставки нужен работающий MTA: postfix, msmtp или аналог.
MAILTO=admin@example.com 0 3 * * * bash -o pipefail -c '/usr/local/bin/mybackup.sh 2>&1 | logger -t mybackup'
Обёртка bash -o pipefail важна: без неё cron видит код возврата logger, то есть всегда 0, и считает задачу успешной. Если письма не нужны, оставляйте отправку через logger, но тогда уведомление о сбое обязан закрывать другой канал.
Webhook и метрики для мониторинга
Мессенджер подходит для быстрых алертов, а метрика закрывает главный сценарий молчаливого отказа: скрипт не запускался вовсе. Адрес вебхука держите в переменной окружения или файле с правами 0600, чтобы не светить токен в коде и в истории команд.
notify_failure() {
local code="$1"
curl -fsS -X POST "$NOTIFY_WEBHOOK" \
--data-urlencode "text=Сбой бэкапа на $(hostname), код ${code}" \
--data-urlencode "log=$(journalctl -u mybackup.service -n 10 --no-pager)" \
>/dev/null || log WARN "вебхук недоступен"
}
# метрика успеха для node_exporter textfile collector
echo "backup_last_success_timestamp_seconds $(date +%s)" \
> /var/lib/node_exporter/textfile/backup.prom.tmp
mv /var/lib/node_exporter/textfile/backup.prom.tmp \
/var/lib/node_exporter/textfile/backup.prom
Правило алерта на отсутствие успеха за сутки выглядит так: time() - backup_last_success_timestamp_seconds > 90000. Это и есть dead man's switch: сигнал приходит не по факту ошибки, а по факту тишины. Проверка метрики вручную: cat /var/lib/node_exporter/textfile/backup.prom
Отладка скриптов по логам: set -x, PS4, BASH_XTRACEFD и контекст ошибки
Разбирать сбой по одной строке ERROR неудобно. Трассировка и контекст запуска сокращают путь до причины, если включать их по требованию и писать в отдельный поток.
set -x и PS4: трассировка без засорения основного лога
Переменная PS4 задаёт префикс трассировки, а BASH_XTRACEFD уводит её в отдельный файловый дескриптор, поэтому основной лог остаётся чистым.
PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
exec 3>/var/log/mybackup.trace
BASH_XTRACEFD=3
set -x
rsync -a --delete /srv/data/ backup@db-01:/backup/data/
set +x
exec 3>&-
unset BASH_XTRACEFD
Дескриптор BASH_XTRACEFD назначайте до включения set -x, иначе трассировка уйдёт в stderr. Включать режим удобно по переменной окружения: if [ "${DEBUG:-0}" = "1" ]; then set -x; fi. Помните, что set -x печатает команды после раскрытия переменных, но не показывает промежуточные значения внутри конвейеров.
Логирование контекста: аргументы, PID, хост, версия
Одна строка с параметрами запуска экономит полчаса поисков. Добавьте к ней correlation id, если скрипт вызывает внешние команды или API.
CORR_ID="$(uuidgen)"
SCRIPT_VERSION="2026.09"
log_context() {
log INFO "host=$(hostname -f) user=${USER:-?} pid=$$ ver=$SCRIPT_VERSION corr=$CORR_ID args=$*"
}
log_context "$@"
Дальше идентификатор передаётся во внешние вызовы и в лог каждого этапа, а поиск по нему занимает секунды: journalctl -t mybackup | grep "$CORR_ID". Для запусков из cron добавьте в контекст имя задачи, а для systemd его подставит journalctl -u.
Чек-лист: как проверить, что ваш скрипт не откажет молча
Как протестировать обработку ошибок
Проверка занимает минуту: возьмите копию скрипта, подмените критичную команду на заведомо падающую и посмотрите на поведение системы. На проде этого делать не нужно.
cp /usr/local/bin/mybackup.sh /tmp/mybackup-test.sh sed -i 's|^rsync |false rsync |' /tmp/mybackup-test.sh bash -x /tmp/mybackup-test.sh; echo "exit=$?"
Ожидаемый результат: скрипт останавливается на подменённой команде, в логе появляется строка ERROR со строкой и кодом, код возврата не равен нулю, уведомление приходит в выбранный канал. Если хотя бы один пункт не выполняется, ловушка или уведомление настроены не полностью.
- Строгий режим в первой строке. Проверка: head -5 /usr/local/bin/mybackup.sh
- trap ERR для логирования и trap EXIT для очистки. Проверка: grep -n '^trap' /usr/local/bin/mybackup.sh
- Логи с уровнями в файл и journald. Проверка: journalctl -t mybackup -n 5 --no-pager
- Осмысленные коды возврата. Проверка: grep -n 'exit [0-9]' /usr/local/bin/mybackup.sh
- flock от параллельного запуска. Проверка: запустить скрипт дважды подряд и убедиться, что второй экземпляр вернул 5.
- Проверка предусловий до изменений. Проверка: временно убрать права на /backup и убедиться, что скрипт вернул 4, ничего не скопировав.
- Идемпотентность. Проверка: прогнать скрипт дважды и сравнить список файлов в целевом каталоге.
- Ротация логов. Проверка: logrotate -d /etc/logrotate.d/mybackup
- Уведомление при сбое. Проверка: systemctl status mybackup.service после тестового сбоя и факт доставки сообщения в канал.
- Метрика успеха для мониторинга. Проверка: cat /var/lib/node_exporter/textfile/backup.prom
Начните с первого пункта: добавьте set -Eeuo pipefail и trap ERR с записью в лог в самый нагруженный ночной скрипт. Дальше подключите уведомление, и следующий сбой вы увидите утром в мессенджере, а не в момент восстановления данных. Готовые заготовки для частых задач сбора и очистки собраны в подборке десять bash-скриптов для рутинных задач сисадмина.