Логирование и обработка ошибок в скриптах администрирования: практическое руководство 2026 | AdminWiki

Логирование и обработка ошибок в скриптах администрирования: практическое руководство 2026

25 сентября 2026 14 мин. чтения
Содержание статьи

Почему скрипты администрирования отказывают молча

Молчаливый отказ выглядит так: скрипт завершился с ненулевым кодом, но никто об этом не узнал. Вывод ушёл в никуда, cron не отправил письмо, в логе нет ни строки. Ночной бэкап может не работать неделю, а выяснится это в момент восстановления данных.

Корневых причин три. Логи не пишутся или пишутся в stdout, который cron проглатывает. Ошибки внутри скрипта не проверяются, поэтому сбой в середине пайпа не останавливает работу. Код возврата никто не читает, и уведомления о сбое нет. Ниже разбираем каждую причину с командой проверки, а затем собираем полный контур: уровни логирования, syslog и journald, logrotate, set -euo pipefail, trap, матрицу кодов возврата, flock, проверку предусловий, идемпотентность, уведомления и отладку по логам.

Примеры рассчитаны на bash 5.x и systemd 250 и новее, отличия в поведении отмечены отдельно. В финале лежит чек-лист, по которому можно прогнать свои скрипты за десять минут.

Три причины, по которым сбой остаётся незамеченным

  1. Вывод уходит в никуда. cron отправляет письмо только тогда, когда у задачи есть вывод. Если в crontab стоит пустой MAILTO или почта падает в локальный spool, который никто не читает, сбой никто не увидит. Проверка: crontab -l | grep -i mailto
  2. Ошибки не проверяются. Без set -euo pipefail код возврата упавшей команды внутри пайпа теряется, скрипт идёт дальше и оставляет систему в промежуточном состоянии. Проверка: grep -n 'set -' /usr/local/bin/mybackup.sh
  3. Код возврата маскируется. Финальный 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 со строкой и кодом, код возврата не равен нулю, уведомление приходит в выбранный канал. Если хотя бы один пункт не выполняется, ловушка или уведомление настроены не полностью.

  1. Строгий режим в первой строке. Проверка: head -5 /usr/local/bin/mybackup.sh
  2. trap ERR для логирования и trap EXIT для очистки. Проверка: grep -n '^trap' /usr/local/bin/mybackup.sh
  3. Логи с уровнями в файл и journald. Проверка: journalctl -t mybackup -n 5 --no-pager
  4. Осмысленные коды возврата. Проверка: grep -n 'exit [0-9]' /usr/local/bin/mybackup.sh
  5. flock от параллельного запуска. Проверка: запустить скрипт дважды подряд и убедиться, что второй экземпляр вернул 5.
  6. Проверка предусловий до изменений. Проверка: временно убрать права на /backup и убедиться, что скрипт вернул 4, ничего не скопировав.
  7. Идемпотентность. Проверка: прогнать скрипт дважды и сравнить список файлов в целевом каталоге.
  8. Ротация логов. Проверка: logrotate -d /etc/logrotate.d/mybackup
  9. Уведомление при сбое. Проверка: systemctl status mybackup.service после тестового сбоя и факт доставки сообщения в канал.
  10. Метрика успеха для мониторинга. Проверка: cat /var/lib/node_exporter/textfile/backup.prom

Начните с первого пункта: добавьте set -Eeuo pipefail и trap ERR с записью в лог в самый нагруженный ночной скрипт. Дальше подключите уведомление, и следующий сбой вы увидите утром в мессенджере, а не в момент восстановления данных. Готовые заготовки для частых задач сбора и очистки собраны в подборке десять bash-скриптов для рутинных задач сисадмина.

Поделиться:
Сохранить гайд? В закладки браузера