Модуль 07 — Надёжные скрипты

Скрипт, который при ошибке останавливается, убирает за собой и говорит, что случилось

Прогресс курса Модуль 7 из 9

Что вы освоите в этом модуле

01

Почему bash продолжает после ошибки

По умолчанию bash, встретив ошибку, пишет сообщение и выполняет следующую строку. Для интерактивной работы это удобно, для скрипта — опасно:

#!/bin/bash
cd /nonexistent/backup
echo "удаляю старые копии в $(pwd)"
$ bash noe.sh
noe.sh: строка 2: cd: /nonexistent/backup: Нет такого файла или каталога
удаляю старые копии в /home/student

Будь вместо echo команда rm -rf *, она удалила бы содержимое домашнего каталога. В модуле 03 мы ставили || exit 1 после опасных команд; строгий режим делает это для всех сразу.

02

set -euo pipefail

Вторая строка почти каждого рабочего скрипта:

#!/bin/bash
set -euo pipefail
ОпцияЧто делаетОт чего защищает
-eОстановить скрипт, если команда завершилась ошибкойПродолжение после неудачного шага
-uОшибка при обращении к незаданной переменнойОпечатки в именах: $bakup_dir вместо $backup_dir
-o pipefailКод конвейера — код последней упавшей команды, а не последней в цепочкеОшибки в середине a | b | c
$ bash e.sh                     # тот же скрипт с set -euo pipefail
e.sh: строка 3: cd: /nonexistent/backup: Нет такого файла или каталога
$ echo $?
1

Опечатка в имени переменной без -u превращает rm -rf "$bakup_dir/"* в rm -rf /*. С -u:

$ bash u.sh
u.sh: строка 4: bakup_dir: не заданы границы переменной

А без pipefail конвейер «успешен», даже если его начало упало:

grep ERROR /nonexistent.log | wc -l       # код 0 — wc отработал
set -o pipefail
grep ERROR /nonexistent.log | wc -l       # код 2 — от grep
03

Где set -e не срабатывает

Строгий режим — страховочная сетка с дырами. Их нужно знать, чтобы не думать «у меня же set -e».

#!/bin/bash
set -euo pipefail
if grep -q nobody_xyz /etc/passwd; then echo найден; else echo "не найден — скрипт продолжает"; fi
count=$(grep -c nobody_xyz /etc/passwd || true)
echo "count=$count"
f() { local out=$(false); echo "local скрыл ошибку"; }
f
n=0
(( n++ ))
echo "сюда не дойдём"
$ bash pit.sh; echo "код $?"
не найден — скрипт продолжает
count=0
local скрыл ошибку
код 1
СлучайЧто происходитКак быть
Команда в условии if, while, перед && / ||Ошибка — это ответ «нет», скрипт продолжает. Так и задуманоПользоваться этим: grep -q в if
grep ничего не нашёлКод 1 остановит скрипт|| true, если «не найдено» — нормальный исход
local x=$(cmd)Код local (успех) скрывает ошибку cmdРаздельно: local x; x=$(cmd) — ShellCheck предупредит (SC2155)
(( n++ )) при n = 0Выражение равно 0 — код 1, скрипт остановитсяn=$((n + 1)) или (( ++n ))
[[ -n $x ]] && cmd последней строкой функцииПри пустом $x функция вернёт 1 — и это станет кодом скрипта, если функция вызвана в trap … EXITif [[ -n $x ]]; then cmd; fi
04

trap: убрать за собой

trap 'команды' СОБЫТИЕ выполняет команды при событии. EXIT срабатывает при любом завершении скрипта — успешном, по ошибке set -e, по exit, по Ctrl+C. Это место для удаления временных файлов и снятия блокировок.

#!/bin/bash
set -euo pipefail
tmp=$(mktemp)
trap 'rm -f "$tmp"; echo "удалён $tmp" >&2' EXIT
echo "работаю с $tmp"
date > "$tmp"
false                              # ошибка: скрипт остановится
echo "не выполнится"
$ bash tr.sh
работаю с /tmp/tmp.QRItpyxVyX
удалён /tmp/tmp.QRItpyxVyX

mktemp создаёт файл с уникальным случайным именем и правами 600, mktemp -d — каталог. Не придумывайте имена вида /tmp/backup.tmp: два запуска скрипта затрут файлы друг друга, а предсказуемое имя в общем /tmp — известный способ атаки через символическую ссылку.

ERR срабатывает при ошибке команды — удобно, чтобы сообщить, где именно упал скрипт. Внутри функций ловушка ERR по умолчанию не действует; чтобы действовала, к строгому режиму добавляют -E: set -Eeuo pipefail.

trap 'echo "ошибка в строке $LINENO: $BASH_COMMAND (код $?)" >&2' ERR
$ bash err.sh
начало
cp: невозможно открыть '/etc/shadow' для чтения: Отказано в доступе
ошибка в строке 5: cp /etc/shadow /tmp/x (код 1)
05

Журнал скрипта

Скрипт, работающий по расписанию, никто не видит. Всё, что он делал, должно остаться в журнале с датой и уровнем важности.

log_file=/var/log/backup.log
log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$1" "${*:2}" | tee -a "$log_file" >&2; }
log INFO "запуск"
log ERROR "нет места на диске"
$ cat /var/log/backup.log
2026-10-14 22:05:34 [INFO] запуск
2026-10-14 22:05:34 [ERROR] нет места на диске

Другой вариант — писать в общий журнал systemd командой logger: не нужно думать о правах на файл и его ротации, а читать можно через journalctl с фильтрами по времени:

$ logger -t backup "проверка записи в журнал"
$ sudo journalctl -t backup -n 1
окт 14 22:05:34 debian backup[954277]: проверка записи в журнал

-t — метка, по которой потом ищут; -p err — уровень важности. Файл журнала в /var/log со временем растёт — для него настраивают ротацию (/etc/logrotate.d/), журнал systemd ограничен сам.

06

flock и timeout

Резервное копирование запускается каждый час и однажды идёт дольше часа. Второй экземпляр стартует поверх первого, оба пишут в одни файлы, диск забивается. Защита — блокировка файлом через flock:

#!/bin/bash
set -euo pipefail
exec 9>/run/lock/backup.lock         # открыть файл блокировки на дескрипторе 9
if ! flock -n 9; then
    echo "уже запущен другой экземпляр, выхожу" >&2
    exit 1
fi
echo "работаю (PID $$)"
sleep 3
$ bash lock.sh & sleep 0.5; bash lock.sh
работаю (PID 954270)
уже запущен другой экземпляр, выхожу

Блокировку снимает ядро, когда процесс завершается любым способом, даже по kill -9, — в отличие от самодельного «файла-флага», который останется после аварии и заблокирует все следующие запуски.

Внешние команды, которые могут зависнуть (сеть, удалённые диски), ограничивают по времени: timeout 300 rsync … завершит команду через 5 минут с кодом 124.

07

Отладка

bash -n скрипт проверяет синтаксис, ничего не выполняя. bash -x скрипт выполняет и печатает каждую команду после подстановок — видно, какие значения на самом деле получили переменные:

$ bash -n bad.sh
bad.sh: строка 3: синтаксическая ошибка: неожиданный конец файла
$ PS4='+ ${LINENO}: ' bash -x dbg.sh
+ 2: name=мир
+ 3: for i in 1 2
+ 4: echo 'привет, мир (1)'
привет, мир (1)
+ 3: for i in 1 2
+ 4: echo 'привет, мир (2)'
привет, мир (2)

PS4 задаёт префикс строк трассировки — номер строки помогает найти место. Трассировку можно включить для части скрипта: set -x … set +x.

Трассировка печатает значения переменных, в том числе пароли и токены. Не оставляйте set -x в скриптах, которые работают с секретами, и не выкладывайте их вывод в чаты и задачи.
08

Шаблон рабочего скрипта

#!/bin/bash
# имя — что делает. Запуск: имя [-n] АРГУМЕНТЫ
set -Eeuo pipefail

readonly LOG_TAG=имя
log()  { logger -t "$LOG_TAG" -p "user.$1" -- "${*:2}"; echo "[$1] ${*:2}" >&2; }
die()  { log err "$*"; exit 1; }

exec 9>"/run/lock/$LOG_TAG.lock"
flock -n 9 || die "уже запущен"

workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
trap 'log err "строка $LINENO: $BASH_COMMAND"' ERR

log info "старт"
# … работа …
log info "готово"

Последнее свойство надёжного скрипта — идемпотентность: повторный запуск после сбоя доводит работу до конца, а не ломает сделанное. mkdir -p вместо mkdir, замена строки вместо дописывания, проверка «уже сделано?» перед каждым шагом, который нельзя повторять.

Ключевые выводы модуля

Запомните главное

  • set -euo pipefail — во второй строке; помните исключения: условия, local x=$(…), (( n++ ))
  • Временные файлы — mktemp, удаление — в trap … EXIT
  • Журнал с датой и уровнем — в файл через tee -a или в journald через logger -t
  • flock -n — защита от двойного запуска, timeout — от зависания
  • bash -n — синтаксис, bash -x с PS4 — трассировка; секреты в трассировку не пускать
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 06: Текст Все модули Модуль 08: Расписание