Тема 15

Надёжные скрипты: ошибки, сигналы, отладка

Надёжные скрипты: ошибки, сигналы, отладка

Предыдущие темы показали, как писать простые сценарии Bash: переменные, кавычки, условия, циклы и функции. Скрипт, который работает на удачных данных, ещё не готов к эксплуатации. Он должен вести себя правильно, когда имя файла содержит пробел, когда каталог не существует, когда пользователь нажал Ctrl+C посреди работы или когда соседний процесс занял тот же временный файл. Именно в таких ситуациях простой скрипт портит данные или оставляет за собой мусор, а причина обычно скрыта в одной некавыченной переменной или в команде, код возврата которой никто не проверил.

Тема строится вокруг типовых ловушек, которые описаны в руководстве «Bash Pitfalls» 1. Сначала рассмотрим ошибки, возникающие из-за правил разбора командной строки, затем механизмы обработки ошибок: коды возврата, функцию die, опцию set -e и её подводные камни. Далее разберём ловушки trap, временные файлы и сигналы, после чего разбор опций, блокировку от повторного запуска и журналирование. Завершают тему отладка, переносимость сценариев между оболочками и место Bash среди других инструментов.

Каждая мысль подкрепляется командой и её реальным выводом. Команды выполнялись в оболочке Bash 5.1 на Ubuntu 22.04; там, где вывод зависит от среды, это оговорено в тексте.

Типовые ловушки Bash

Большинство ошибок в скриптах связано не с алгоритмом, а с правилами, по которым оболочка превращает строку в аргументы команды. Эти правила применяются до запуска программы, и программа не знает, что произошло. Поэтому ошибку часто видно только тогда, когда файл назван неожиданно или переменная оказалась пустой.

Кавычки и пустая переменная

Оболочка выполняет раскрытие переменных, разбиение на слова по символам из IFS и раскрытие шаблонов. Если результат раскрытия не заключён в двойные кавычки, каждое слово, получившееся после разбиения, становится отдельным аргументом. Пустая строка при этом исчезает совсем. Это главный источник ошибок в скриптах, которые удаляют файлы.

Рассмотрим строку, которая часто встречается в учебных и реальных скриптах: удаление каталога, путь к которому строится из переменной. Если переменная dir не задана или пуста, путь превращается в каталог верхнего уровня, а rm -rf удалит всё содержимое этого каталога. Чтобы не выполнять опасную команду, покажем только то, что оболочка передала бы программе:

dir=""
base=sandbox
printf 'раскрытие: [%s]\n' "$base/$dir/"
echo "команда была бы такой: rm -rf \"$base/$dir/\""
раскрытие: [sandbox//]
команда была бы такой: rm -rf "sandbox//"

Пустая переменная не исчезла бесследно: путь стал каталогом sandbox, и в конце появился лишний символ /. Если бы переменная была пустой в корне, результатом стал бы /. Защита состоит в конструкции ${dir:?}. Если переменная не задана или пуста, оболочка прекращает выполнение команды и выводит сообщение, которое задано после двоеточия:

bash -c 'dir=""; echo "rm -rf \"${dir:?каталог не задан}/\""; echo "дошли до конца"'
echo "код выхода: $?"
bash: line 1: dir: каталог не задан
код выхода: 127

Строка дошли до конца не выведена: оболочка остановилась раньше. Когда переменная задана, конструкция возвращает её значение без изменений:

bash -c 'dir=mydir; echo "${dir:?пусто}/"; echo ok'
echo "код выхода: $?"
mydir/
ok
код выхода: 0

В сочетании с обязательной проверкой каталога эта конструкция закрывает наиболее опасный класс ошибок. Самую опасную команду rm -rf мы уже видели в теме 2; здесь важно правило: любой путь, который строится из переменной и идёт в удаление, должен защищаться от пустого значения.

Разбор вывода ls и цикл for

Вывод программы ls предназначен для чтения человеком. Он не имеет однозначного формата: имена могут содержать пробелы, переносы строк и символы, которые в выводе выглядят как разделители. Конструкция for f in $(ls) разбивает вывод на слова по пробелам и переносам, поэтому файл «отчёт 1.txt» превращается в два элемента, «отчёт» и «1.txt».

Покажем различие на каталоге с двумя файлами, один из которых содержит пробел:

mkdir -p ls-demo && cd ls-demo
touch "мой отчёт.txt" b.txt
for f in $(ls); do echo "[$f]"; done
echo ---
for f in *; do echo "<$f>"; done
[b.txt]
[мой]
[отчёт.txt]
---
<b.txt>
<мой отчёт.txt>

Шаблон * раскрывается самой оболочкой, и каждое имя остаётся одним словом. Программа ls здесь вообще не нужна: глоббинг 5 даёт список файлов напрямую и без лишних процессов. Правило можно сформулировать так: для обхода файлов используется шаблон, а не вывод ls. Та же ловушка возникает в конвейере ls | while read f, где read тоже разбивает строки, а содержимое переменных надо всегда брать в кавычки.

Команда cd без проверки

Команда cd может завершиться с ошибкой, например если каталога нет или нет права на вход в него. Если сценарий продолжает работу без проверки кода возврата, все последующие относительные пути относятся к текущему каталогу, а не к тому, который ожидался. Особенно опасно, когда аргумент cd оказался пустым: cd без аргумента переходит в домашний каталог.

bash -c 'cd /nonexistent/dir; echo "текущий каталог: $PWD"'
echo "код выхода: $?"
bash: line 1: cd: /nonexistent/dir: No such file or directory
текущий каталог: /home/student/lab-15
код выхода: 0

Сценарий продолжил работу в другом каталоге, а код выхода оказался нулевым, потому что последней командой была echo. Исправление состоит в проверке, которая останавливает сценарий в момент ошибки:

bash -c 'cd /nonexistent/dir || { echo "каталог недоступен" >&2; exit 3; }; echo "не будет выведено"'
echo "код выхода: $?"
bash: line 1: cd: /nonexistent/dir: No such file or directory
каталог недоступен
код выхода: 3

Ещё надёжнее работать с абсолютными путями и не менять текущий каталог вовсе. Такой подход принят в эталонном решении лабораторной работы: входной и выходной каталоги раскрываются в абсолютные пути один раз, а дальше используются только они.

Коды возврата, die и set -e

Каждая программа в Unix сообщает результат своей работы одним числом — кодом возврата (англ. exit status). Соглашение простое: ноль означает успех, любое ненулевое значение означает ошибку, и конкретное число обычно говорит о её характере. Оболочка сохраняет код последней команды в переменной $?. Всё управление ошибками в сценариях строится на этом числе, поэтому понимание его смысла необходимо до того, как появятся более сложные механизмы.

Коды возврата и сообщения об ошибках

Важное правило касается того, куда пишутся сообщения. Обычный вывод сценария идёт в стандартный поток вывода (англ. stdout, дескриптор 1), а диагностика — в стандартный поток ошибок (англ. stderr, дескриптор 2), как описано в теме 5. Если сообщение об ошибке попадает в stdout, его можно перепутать с результатом, а перенаправление 2>/dev/null его не скроет. Поэтому все сообщения об ошибках пишутся в stderr.

Для единообразия удобно определить функцию die, которая печатает сообщение в stderr и завершает сценарий с заданным кодом:

#!/usr/bin/env bash
die() { printf '%s: %s\n' "${0##*/}" "$1" >&2; exit "${2:-1}"; }
echo "вывод в stdout: запуск"
die "входной каталог не найден" 3
bash die.sh
echo "код выхода: $?"
bash die.sh 2>/dev/null
echo "код выхода при скрытом stderr: $?"
вывод в stdout: запуск
die.sh: входной каталог не найден
код выхода: 3
вывод в stdout: запуск
код выхода при скрытом stderr: 3

Видно, что перенаправление 2>/dev/null скрыло сообщение, но код выхода сохранился: сценарий сообщает вызывающей стороне о причине через число, а человеку — через текст. Осмысленные коды выхода позволяют скриптам, которые вызывают наш сценарий, различать причины: неверные аргументы, недоступный каталог, занятую блокировку. В таблице приведены соглашения, которыми пользуется эталонное решение лабораторной работы. Числа 1–2 и 126–127 имеют стандартный смысл в оболочке, поэтому собственные коды выбираются из диапазона от 3 и выше.

КодЗначениеПример источника
0успехштатное завершение
1общая ошибка или часть работы не выполненаошибка обработки файла
2ошибка аргументов командной строкинеизвестная опция
3недоступен входной ресурскаталог отсутствует
4уже выполняется другой экземплярблокировка flock занята
126 и 127команда не запустилась или не найденастандартные коды оболочки
128 + Nпроцесс убит сигналом N130 при SIGINT, 143 при SIGTERM

Последняя строка таблицы важна для ловушек: код 130 означает не «ошибку программы», а завершение сигналом SIGINT, номер которого равен двум. Подробнее о сигналах рассказано в разделе о ловушках.

set -e и его подводные камни

Опция set -e требует от оболочки немедленно завершить сценарий, если простая команда вернула ненулевой код. Она кажется универсальным средством, и многие учебные материалы рекомендуют ставить её первой строкой. Однако её поведение тонко и содержит несколько исключений, в которых ошибка тихо проходит мимо. Руководство Bash описывает эти исключения 2, а на практике они оказываются источником самых неприятных ошибок. Поэтому опцию нельзя считать заменой явной проверки кодов возврата.

Сначала убедимся, что опция работает в простейшем случае:

bash -c 'set -e; echo один; false; echo два'
echo "код выхода: $?"
один
код выхода: 1

Строка «два» не выведена: сценарий остановился на false. Теперь рассмотрим четыре ситуации, в которых ошибка не останавливает сценарий. Каждый пример выполнен в той же оболочке.

Первая ситуация — команда в условии if. Оболочка не завершает сценарий при ошибке проверки условия, потому что сама ошибка и есть предмет проверки:

bash -c 'set -e; if false; then echo x; fi; echo "после if"'
echo "код выхода: $?"
после if
код выхода: 0

Вторая ситуация — левая часть списка && или ||. Здесь ошибка тоже допустима, поскольку дальнейшее решение принимает сам оператор:

bash -c 'set -e; false && echo x; echo "после &&"'
echo "код выхода: $?"
после &&
код выхода: 0

Третья ситуация — функция, вызванная внутри условия. Подавление set -e распространяется на всё тело функции, даже если ошибка произошла в её середине:

cat > d.sh <<'EOF'
#!/usr/bin/env bash
set -e
f() { false; echo "внутри f после false"; }
if f; then echo "f ok"; else echo "f failed"; fi
echo "конец"
EOF
bash d.sh
echo "код выхода: $?"
внутри f после false
f ok
конец
код выхода: 0

Строка «внутри f после false» выведена, то есть функция продолжила работу после ошибки, а условие сочло её успешной. Четвёртая ситуация — последняя команда в конвейере. Код конвейера равен коду его последней команды, поэтому ошибка в начале конвейера не учитывается:

bash -c 'set -e; false | cat; echo "конвейер пройден"'
echo "код выхода: $?"
конвейер пройден
код выхода: 0

Исправляет эту ловушку опция pipefail, которая делает код конвейера ненулевым, если ненулевой код вернула любая его команда:

bash -c 'set -eo pipefail; false | cat; echo "не будет выведено"'
echo "код выхода: $?"
код выхода: 1

Однако даже с pipefail остаётся подвох с объявлением переменной. Конструкция local v=$(false) объявляет переменную и присваивает ей результат подстановки в одной команде. Код возврата local равен нулю, и ошибка подстановки теряется:

bash -c 'set -e; f() { local v=$(false); echo "v=[$v] после local"; }; f; echo конец'
echo "код выхода: $?"
v=[] после local
конец
код выхода: 0

Лечится это раздельным объявлением и присваиванием, после которого ошибка снова видна:

bash -c 'set -e; f() { local v; v=$(false); echo "не будет выведено"; }; f; echo конец'
echo "код выхода: $?"
код выхода: 1

Общее правило можно сформулировать так: set -e не заменяет проверку там, где ошибка ожидаема или где результат проходит через другую команду. Для важных операций пишут явную проверку. Схема на рисунке собирает ситуации, которые надо знать наизусть.

Ситуации, в которых set -e останавливает сценарий, и ситуации, в которых ошибка проходит незамеченной
Ситуации, в которых set -e срабатывает и в которых не срабатывает

Опция выглядит простой, но её поведение зависит от контекста вызова, и это делает её ненадёжной сама по себе. Поэтому в лабораторной работе предлагается использовать явные проверки там, где ошибка важна, а set -e рассматривать как дополнительную страховку.

set -u и pipefail

Опция set -u делает обращение к неустановленной переменной ошибкой. Без неё опечатка в имени переменной тихо даёт пустую строку, и эта пустота потом превращается в путь вида /*, рассмотренный выше. С ней оболочка сообщает о проблеме сразу:

bash -c 'set -u; echo "x=$undefined_var"'
echo "код выхода: $?"
bash: line 1: undefined_var: unbound variable
код выхода: 127

Опция set -u полезна, но у неё есть исключение: в старых версиях Bash обращение "${arr[@]}" к пустому индексированному массиву вызывает ту же ошибку. Поэтому при работе с массивами её стоит проверять на реальных данных. Сочетание set -Eeuo pipefail часто используют как отправную точку, однако, как видно из примеров выше, оно не избавляет от необходимости думать о каждой проверке.

Ловушки trap, mktemp и сигналы

Сценарий часто создаёт временные файлы, держит открытые ресурсы и меняет состояние системы. Если он завершается посреди работы — из-за ошибки, Ctrl+C или по команде kill — эти следы остаются. Механизм, который решает эту задачу, — ловушки (англ. traps) — позволяет выполнить заданный код при наступлении определённого события. Команда trap принимает код или имя функции и список событий. Ловушки описаны в справочнике Bash 2, а поведение сигналов в Linux подробно изложено в книге Керриска 6.

mktemp и очистка

Временный файл следует создавать командой mktemp, которая выбирает уникальное имя и сразу создаёт файл с правами только для владельца. Фиксированное имя вроде /tmp/job.tmp опасно по двум причинам. Во-первых, два экземпляра сценария используют один файл и портят данные друг друга. Во-вторых, злоумышленник может заранее создать символическую ссылку с этим именем и заставить сценарий перезаписать произвольный файл пользователя. Тема 2 рассказывала о символических ссылках; здесь важно лишь то, что уникальное имя от mktemp защищает от этого сценария.

t=$(mktemp)
echo "создан: $t"
ls -l "$t"
rm -f -- "$t"
создан: /tmp/tmp.ArNpPUNO1z
-rw------- 1 student student 0 окт 12 13:40 /tmp/tmp.ArNpPUNO1z

Права -rw------- означают, что файл доступен только владельцу. Для нескольких файлов удобнее создать временный каталог командой mktemp -d и складывать всё внутри него: тогда очистка сводится к удалению одного каталога. Удаляя его, полезно всегда использовать конструкцию с обязательной переменной, которая не даст удалить лишнее: rm -rf -- "${work:?}".

trap на EXIT, ERR, INT и TERM

Пусть сценарий создаёт временный файл, а затем может завершиться четырьмя способами: штатно, из-за ошибки команды, по Ctrl+C или по сигналу TERM. Сигнал EXIT срабатывает при любом завершении сценария, поэтому очистку разумно ставить именно на него. Сигнал ERR срабатывает при ошибке команды (с опцией set -E ловушка наследуется функциями). Сигналы INT и TERM приходят извне. Следующий сценарий собран из всех четырёх ловушек и показывает порядок их срабатывания:

#!/usr/bin/env bash
# Иллюстрация жизненного цикла скрипта с ловушками
set -Eeuo pipefail

log() { printf '%s\n' "$*"; }

tmp=$(mktemp)
log "создан временный файл: $tmp"

cleanup() {
    local status=$?
    log "EXIT: код=$status, удаляю $tmp"
    rm -f -- "$tmp"
}
on_err() { log "ERR: строка $1, команда «$2», код $?"; }
on_int() { log "INT: получен SIGINT"; exit 130; }
on_term() { log "TERM: получен SIGTERM"; exit 143; }

trap cleanup EXIT
trap 'on_err "$LINENO" "$BASH_COMMAND"' ERR
trap on_int INT
trap on_term TERM

mode=${1:-ok}
case $mode in
    ok)    log "работа без ошибок" ;;
    fail)  log "имитация ошибки"; false ;;
    slow)  log "долгая работа, ждём сигнала"; sleep 30 ;;
esac
log "конец основной части"

Запустим сценарий в штатном режиме и с ошибкой:

bash lifecycle.sh ok; echo "код выхода: $?"
bash lifecycle.sh fail; echo "код выхода: $?"
создан временный файл: /tmp/tmp.RDyGA6QL5I
работа без ошибок
конец основной части
EXIT: код=0, удаляю /tmp/tmp.RDyGA6QL5I
код выхода: 0
создан временный файл: /tmp/tmp.Z5nD0bSre1
имитация ошибки
ERR: строка 27, команда «false», код 1
EXIT: код=1, удаляю /tmp/tmp.Z5nD0bSre1
код выхода: 1

В обоих случаях сигнал EXIT удалил временный файл, а ловушка ERR сообщила строку и команду, которые вызвали ошибку. Порядок важен: сначала срабатывает ERR, затем EXIT, потому что ошибка приводит к завершению и запускает ловушку выхода. Ловушку EXIT читает переменная $?, поэтому в функции очистки её сохраняют в локальную переменную сразу в первой строке.

Ловушки INT и TERM завершаются командой exit с конвенциональными кодами 130 и 143, и EXIT срабатывает уже после них. Схема на рисунке показывает этот жизненный цикл целиком.

Жизненный цикл сценария с ловушками EXIT, ERR, INT и TERM
Порядок срабатывания ловушек в сценарии с временным файлом

Важная деталь: ловушка на EXIT не должна менять код выхода, если только это не сделано намеренно. Поэтому функция очистки не вызывает exit, а только удаляет файлы.

Сигналы и Ctrl+C

Сигнал — это асинхронное уведомление процессу, которое может прийти в любой момент работы. Нажатие Ctrl+C в терминале посылает SIGINT всей группе процессов переднего плана, а команда kill без указания сигнала посылает выбранному процессу SIGTERM. Сигнал SIGKILL нельзя перехватить: процесс завершается ядром, и никакие ловушки не выполняются 6. Поэтому очистку, которая обязана выполниться, нельзя полностью перенести на ловушку, если процессу может прийти SIGKILL. Это ограничение фундаментальное, и о нём полезно помнить, выбирая место для хранения состояния.

Проверим поведение сценария на сигналах. Поскольку в этой среде нет терминала, сигнал отправлен через небольшую программу на Python. Она запускает сценарий в отдельной группе процессов и через секунду посылает сигнал всей группе, то есть и самому Bash, и выполняющейся в нём команде sleep. Именно так поступает терминал при нажатии Ctrl+C:

python3 ctrlc.py lifecycle.sh SIGINT
python3 ctrlc.py lifecycle.sh SIGTERM

Программа ctrlc.py и сценарий lifecycle.sh лежат в каталоге examples архива окружения темы; команды выполняются из этого каталога.

создан временный файл: /tmp/tmp.ArNpPUNO1z
долгая работа, ждём сигнала
INT: получен SIGINT
EXIT: код=130, удаляю /tmp/tmp.ArNpPUNO1z
код выхода процесса: 130
файлов /tmp/tmp.* после завершения: 0
создан временный файл: /tmp/tmp.L2sgJ5NXtT
долгая работа, ждём сигнала
Terminated
TERM: получен SIGTERM
EXIT: код=143, удаляю /tmp/tmp.L2sgJ5NXtT
код выхода процесса: 143
файлов /tmp/tmp.* после завершения: 0

В обоих случаях файл удалён, а код выхода равен 128 плюс номер сигнала. Строку Terminated печатает Bash: он сообщает, что команда sleep завершена сигналом TERM.

Важная тонкость: Bash выполняет ловушку не в момент прихода сигнала, а после того, как завершится текущая команда переднего плана. Если послать SIGINT только процессу Bash командой kill -INT PID, команда sleep 30 продолжит работу, и сообщение «INT: получен SIGINT» появится лишь через 30 секунд. Ctrl+C в терминале и программа ctrlc.py посылают сигнал всей группе, поэтому sleep прерывается сразу и ловушка срабатывает примерно через секунду. Есть ещё одна тонкость. Когда команда запущена в фоне знаком & в неинтерактивной оболочке, она получает SIGINT и SIGQUIT в игнорируемом состоянии. Это видно в поле SigIgn статуса процесса: значение 0000000000000006 означает, что сигналы номер 2 (INT) и номер 3 (QUIT) игнорируются:

bash -c 'sleep 3 & echo "$(grep SigIgn /proc/$!/status)"; wait'
SigIgn:	0000000000000006

Сценарий, который сам установил ловушку INT, эту ситуацию обходит, но простой команде, запущенной в фоне, Ctrl+C не повредит. Это поведение стоит учитывать при запуске сценариев в фоне под nohup и &, как описано в теме 7.

Опции, справка, блокировка и журнал

Сценарий, который принимает аргументы, должен разбирать их предсказуемо и сообщать о неправильном вводе так же ясно, как о внутренних ошибках. Для этого в Bash используется встроенная команда getopts. Кроме разбора опций, сценарий должен предоставлять справку, защищать себя от параллельного запуска и оставлять след в системном журнале.

getopts, usage и -h

Встроенная команда getopts читает опции в стиле POSIX: короткие опции с одним символом, объединяемые в группу (-vi file), и опции с аргументом. Строка :i:o:vh означает: опции -i и -o принимают аргумент, -v и -h — флаги без аргумента. Ведущее двоеточие в начале строки переводит getopts в режим, где ошибки обрабатывает сам сценарий, а не выводит своё сообщение. Переменная OPTARG содержит аргумент, OPTIND указывает, с какого слова продолжать разбор.

Сценарий ниже принимает опции, печатает справку по -h и корректно реагирует на ошибки в опциях:

#!/usr/bin/env bash
set -euo pipefail
usage() {
    cat <<USAGE
Использование: ${0##*/} [-v] [-o КАТАЛОГ] -i ФАЙЛ
  -i ФАЙЛ      входной файл (обязательно)
  -o КАТАЛОГ   каталог результатов (по умолчанию ./out)
  -v           подробный вывод
  -h           показать справку и выйти
USAGE
}
in= out=./out verbose=0
while getopts ':i:o:vh' opt; do
    case $opt in
        i) in=$OPTARG ;;
        o) out=$OPTARG ;;
        v) verbose=1 ;;
        h) usage; exit 0 ;;
        :) echo "${0##*/}: опция -$OPTARG требует аргумент" >&2; usage >&2; exit 2 ;;
        \?) echo "${0##*/}: неизвестная опция -$OPTARG" >&2; usage >&2; exit 2 ;;
    esac
done
shift $((OPTIND - 1))
echo "in=$in out=$out verbose=$verbose rest=$*"

Запустим его с разными аргументами:

bash opts.sh -h; echo "код выхода: $?"
bash opts.sh -x 2>&1; echo "код выхода: $?"
bash opts.sh -i 2>&1; echo "код выхода: $?"
bash opts.sh -vi data.txt -o /tmp/x extra1 extra2; echo "код выхода: $?"
Использование: opts.sh [-v] [-o КАТАЛОГ] -i ФАЙЛ
  -i ФАЙЛ      входной файл (обязательно)
  -o КАТАЛОГ   каталог результатов (по умолчанию ./out)
  -v           подробный вывод
  -h           показать справку и выйти
код выхода: 0
opts.sh: неизвестная опция -x
Использование: opts.sh [-v] [-o КАТАЛОГ] -i ФАЙЛ
  -i ФАЙЛ      входной файл (обязательно)
  -o КАТАЛОГ   каталог результатов (по умолчанию ./out)
  -v           подробный вывод
  -h           показать справку и выйти
код выхода: 2
opts.sh: опция -i требует аргумент
Использование: opts.sh [-v] [-o КАТАЛОГ] -i ФАЙЛ
  -i ФАЙЛ      входной файл (обязательно)
  -o КАТАЛОГ   каталог результатов (по умолчанию ./out)
  -v           подробный вывод
  -h           показать справку и выйти
код выхода: 2
in=data.txt out=/tmp/x verbose=1 rest=extra1 extra2
код выхода: 0

Обратим внимание на несколько деталей. Справка выводится через «здесь-документ» (англ. here-document), поэтому её текст легко читается в исходнике. Ошибки опций уходят в stderr и сопровождаются кодом 2, как условлено выше. Группа -vi data.txt разобрана правильно: флаг -v и опция -i с аргументом в одном слове. Остальные аргументы попали в $* после сдвига на OPTIND. Справка по -h должна выводиться до любых проверок, иначе пользователь, которому нужна подсказка, получит сообщение об ошибке вместо неё.

Справку полезно поддерживать в одном месте: её текст должен совпадать с реальными опциями. Расхождение между справкой и поведением сценария — частая причина неправильного использования, поэтому в лабораторной работе предлагается проверить каждую опцию, перечисленную в usage.

flock от повторного запуска

Регулярный сценарий, например задача по расписанию, может запуститься второй раз, пока первый экземпляр ещё работает. Два экземпляра, пишущих в один файл отчёта, испортят его. Чтобы этого не допустить, сценарий берёт эксклюзивную блокировку на файл командой flock 8. Блокировка привязана к открытому дескриптору файла и снимается автоматически при завершении процесса, в том числе аварийном, поэтому «зависшего» файла блокировки не возникает. Это важное отличие от подхода с созданием файла-флага, который после падения сценария остаётся навсегда.

(flock -n demo.lock -c 'sleep 3; echo "первый экземпляр завершён"') &
sleep 0.5
flock -n demo.lock -c 'echo "второй получил блокировку"'
echo "код выхода второго: $?"
wait
flock -n demo.lock -c 'echo "теперь свободно"'
echo "код выхода: $?"
код выхода второго: 1
первый экземпляр завершён
[1]+  Done                    ( flock -n demo.lock -c 'sleep 3; echo "первый экземпляр завершён"' )
теперь свободно
код выхода: 0

Строка Done выводит оболочка, когда фоновое задание завершилось; строка «первый экземпляр завершён» — вывод самого задания. Второй экземпляр не получил блокировку и вернул код 1, а после завершения первого блокировка освободилась.

Опция -n означает «не ждать»: если блокировка занята, команда сразу возвращает код 1. Сценарий в лабораторной работе использует этот режим и возвращает собственный код 4, когда другой экземпляр уже работает. Для сценария, который держит дескриптор блокировки на всё время работы, в эталонном решении применяется запись exec 9>файл, а затем flock -n 9: дескриптор 9 открыт до завершения сценария и закрывается вместе с ним.

logger

Журнал событий в Linux ведётся службами syslog или systemd-journald 8. Сценарий может записывать в журнал важные события, например начало и конец работы, результат и код возврата. Для этого служит команда logger. Её поведение в нашей среде заслуживает внимания, потому что оно показывает, как отсутствие службы журнала может остаться незамеченным:

logger -t demo-script "тестовое сообщение"
echo "код выхода: $?"
logger -s -t demo-script "проверка"
echo "код выхода: $?"
код выхода: 0
logger: socket /dev/log: No such file or directory
код выхода: 1

Без опции -s команда в этой среде вернула 0 и ничего не вывела, хотя сообщение никуда не попало, потому что сокета /dev/log нет. С опцией -s она сообщила об ошибке и вернула 1. Это наблюдение сделано в WSL, где служба журнала не запущена по умолчанию; в обычной системе с systemd-journald команда работает штатно. Вывод: сценарий, который полагается на журнал как на доказательство выполнения, должен проверять результат logger -s либо дублировать важные сообщения в stderr. В лабораторной работе предлагается записывать в журнал результат каждого запуска с кодом возврата.

Отладка

Отладка сценария отличается от отладки программы на языке с компилятором. Сценарий не компилируется, и синтаксические ошибки обнаруживаются только при разборе, а логические — при запуске на конкретных данных. Поэтому инструменты отладки применяются ступенями: сначала дешёвые проверки без запуска, затем трассировка конкретного запуска. Порядок показан на рисунке.

Ступени отладки сценария от проверки синтаксиса до анализа журнала
Инструменты отладки в порядке возрастания стоимости

bash -n и set -x

Команда bash -n читает сценарий и проверяет его синтаксис, но не выполняет ни одной команды. Это безопасная проверка: она не меняет файлов и не требует данных. Покажем на сценарии с пропущенной закрывающей конструкцией:

printf 'if true; then\n  echo hi\n' > broken.sh
bash -n broken.sh
echo "код выхода: $?"
bash -n opts.sh
echo "код выхода: $?"
broken.sh: line 3: syntax error: unexpected end of file
код выхода: 2
код выхода: 0

Синтаксическая ошибка найдена без запуска, а корректный сценарий прошёл проверку молча. Синтаксис — лишь малая часть ошибок. Логические ошибки видны только при выполнении, и для них служит опция set -x, которая печатает каждую команду перед её выполнением с уже раскрытыми переменными. Это главный инструмент для поиска ошибок вида «значение переменной неожиданно изменилось».

Рассмотрим сценарий, в котором функция по ошибке меняет глобальную переменную, используемую вызывающим кодом:

#!/usr/bin/env bash
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
name=report
f() { name=changed; }
f
echo "$name"
+ trace.sh:4: name=report
+ trace.sh:6: f
+ trace.sh:5: name=changed
+ trace.sh:7: echo changed
changed

Трассировка показывает, где именно значение изменилось: строка 5 внутри функции f. Без трассировки ошибка выглядела бы как загадочное значение changed в конце. Это типичная ситуация с переменными без local: функция видит и меняет всё глобальное пространство имён, и вызывающий код получает неожиданное значение. Исправляется объявлением переменных функции через local.

Опцию set -x можно включить на части сценария, окружив интересующий участок командами set -x и set +x. Это удобнее, чем трассировать сценарий целиком, в котором тысячи строк вывода скрывают нужную.

PS4 и BASH_XTRACEFD

Строки трассировки начинаются с символов из переменной PS4. По умолчанию это + и пробел. Задав PS4 как в примере выше, мы получаем в начале каждой строки имя файла и номер строки, что сразу указывает место в исходнике. Переменная BASH_XTRACEFD направляет трассировку в другой дескриптор, например в файл, чтобы она не смешивалась с обычным выводом:

bash -c 'exec 9>trace.log; BASH_XTRACEFD=9; set -x; x=1; echo $x; set +x'
cat trace.log
1
+ x=1
+ echo 1
+ set +x

Обычный вывод (единица) остался на экране, а трассировка ушла в файл. В отладке длинных сценариев это позволяет анализировать трассировку отдельно и не терять результаты работы.

ShellCheck и порядок проверок

Инструмент ShellCheck 7 выполняет статический анализ сценария и находит типичные ошибки: некавыченные переменные, использование ls для обхода файлов, устаревший синтаксис, неверное сравнение строк. Он не запускает сценарий, а анализирует его текст. Его предупреждения имеют номера вида SC2086, которые можно найти в справочнике на сайте проекта. ShellCheck не всесилен: он не знает, какой каталог на самом деле существует и какие имена файлов будут во входных данных. Поэтому его вывод — необходимое, но не достаточное условие надёжности. Например, для учебного fragile.sh из лабораторной работы ShellCheck 0.8 выдаёт предупреждения SC2164 (cd без проверки результата), SC2086 (некавыченные переменные), SC2012 (обход вывода ls), SC2162 (read без -r) и пару SC2030/SC2031 (переменная изменена в подоболочке конвейера). В лабораторной работе рекомендуется установить ShellCheck и добиться отсутствия замечаний.

Порядок проверок в реальной работе таков: сначала bash -n, затем ShellCheck, затем запуск на тестовых данных с set -x при обнаружении неожиданного поведения, и в конце анализ журнала на реальных запусках. Каждая ступень дешевле предыдущей или отвечает на вопрос, который предыдущая не могла задать.

ShellCheck как обязательная проверка

Пока ShellCheck запускают вручную, о нём легко забыть, особенно когда в сценарий вносится «маленькая» правка. Поэтому в командах разработчиков проверку делают автоматической: сценарий не принимается в общий репозиторий, пока ShellCheck находит в нём замечания. Такая автоматизация строится на уже знакомом механизме — коде возврата. ShellCheck завершается с кодом 0, если замечаний нет, и с кодом 1, если они есть. Рассмотрим учебный сценарий clean.sh:

cat scripts/clean.sh
shellcheck scripts/clean.sh
echo "код: $?"
#!/usr/bin/env bash
# Удаляет старые журналы из каталога, переданного аргументом
dir=$1
cd $dir
rm -f *.log.old
In scripts/clean.sh line 4:
cd $dir
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
   ^--^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean: 
cd "$dir" || exit


In scripts/clean.sh line 5:
rm -f *.log.old
      ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.

For more information:
  https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
  https://www.shellcheck.net/wiki/SC2035 -- Use ./*glob* or -- *glob* so name...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
код: 1

Это ловушка из раздела «Команда cd без проверки»: если каталог не существует, rm удалит журналы в текущем каталоге. Каждое замечание имеет уровень важности: error, warning, info или style. Для программ, которые читают результат проверки автоматически, удобнее однострочный формат -f gcc (такой же, как у компилятора GCC: файл, строка, столбец, сообщение), а опция -S задаёт минимальный уровень, о котором стоит сообщать:

shellcheck -f gcc scripts/clean.sh
shellcheck -S error scripts/clean.sh
echo "код: $?"
scripts/clean.sh:4:1: warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails. [SC2164]
scripts/clean.sh:4:4: note: Double quote to prevent globbing and word splitting. [SC2086]
scripts/clean.sh:5:7: note: Use ./*glob* or -- *glob* so names with dashes won't become options. [SC2035]
код: 0

С -S error проверка прошла: ошибок уровня error в сценарии нет, а предупреждение SC2164 отброшено. Пример показывает, что снижать порог нужно осторожно: именно это предупреждение описывает опасную ошибку.

Иногда замечание ShellCheck неприменимо: автор сознательно оставил переменную без кавычек, потому что она должна разбиться на слова. Отключать проверку целиком в этом случае неправильно. Вместо этого перед строкой ставится директива с номером конкретного замечания, а рядом — комментарий, объясняющий решение:

cat scripts/run.sh
shellcheck scripts/run.sh
echo "код: $?"
#!/usr/bin/env bash
opts="-a -v"
# Опции должны разбиться на отдельные слова, кавычки здесь не нужны
# shellcheck disable=SC2086
rsync $opts src/ dst/
код: 0

Директива действует только на следующую команду. Правила для всего проекта записываются в файл .shellcheckrc в корне каталога: ShellCheck ищет его, поднимаясь от каталога сценария вверх. Например, строки shell=bash и disable=SC2035 задают оболочку для файлов без строки #! и отключают замечание SC2035 во всех сценариях проекта.

Где именно запускать проверку, зависит от того, как хранится код. Сценарии обычно хранят в системе контроля версий Git, которая запоминает каждое сохранённое изменение — фиксацию (англ. commit). Git умеет перед каждой фиксацией запускать сценарий-перехватчик (англ. hook) .git/hooks/pre-commit, и если тот вернул ненулевой код, фиксация отменяется. Перехватчик на Bash, который проверяет только добавляемые в фиксацию сценарии, занимает несколько строк:

cat .git/hooks/pre-commit
#!/usr/bin/env bash
# Не даёт зафиксировать скрипты, в которых ShellCheck нашёл замечания
mapfile -t files < <(git diff --cached --name-only --diff-filter=ACM -- '*.sh')
[ "${#files[@]}" -eq 0 ] && exit 0
shellcheck "${files[@]}"

Команда git diff --cached --name-only перечисляет файлы, подготовленные к фиксации, а код возврата перехватчика равен коду shellcheck. Попытка зафиксировать сценарий с обходом вывода ls отклоняется:

git add scripts/list.sh
git commit -m 'Список файлов'
echo "код: $?"
In scripts/list.sh line 2:
for f in $(ls *.txt); do echo $f; done
         ^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
              ^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
                              ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean: 
for f in $(ls *.txt); do echo "$f"; done

For more information:
  https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
  https://www.shellcheck.net/wiki/SC2035 -- Use ./*glob* or -- *glob* so name...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
код: 1

Перехватчик живёт только в копии репозитория конкретного разработчика, и его можно обойти опцией git commit --no-verify. Поэтому в командной работе ту же проверку повторяют на сервере непрерывной интеграции (англ. continuous integration, CI): после каждой отправки изменений в общий репозиторий сервис (GitHub Actions, GitLab CI и подобные) запускает заданные команды в чистой виртуальной машине и отмечает изменение как непрошедшее, если хотя бы одна из них вернула ненулевой код. Описание задания в GitHub Actions — короткий файл YAML .github/workflows/shellcheck.yml:

name: shellcheck
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: sudo apt-get install -y shellcheck
      - run: shellcheck -f gcc scripts/*.sh

Устройство сервисов CI выходит за рамки курса, но главное в этом файле уже знакомо: последняя строка — та же команда, что запускалась в терминале, и решение «прошло или нет» принимается по её коду возврата. Хорошее правило — держать одинаковую команду проверки в перехватчике и в CI, тогда автор узнаёт о замечании до отправки, а сервер лишь гарантирует, что проверку никто не пропустил.

Переносимость и стиль

Сценарии часто переносят между системами, и не всегда известно, какая оболочка будет вызвана. Этот раздел объясняет различие между Bash и POSIX sh, показывает ловушки переносимости и напоминает основные правила хорошего стиля, которые описаны в руководстве Google 3.

POSIX sh и dash

Стандарт POSIX описывает язык командной оболочки 4, то есть набор конструкций, которые должны работать в любой совместимой оболочке. Bash содержит много расширений сверх этого стандарта: массивы, [[ ]], подстановку регистра ${v^^} и другие. Сценарий, который начинается с #!/bin/sh, рассчитывает на POSIX-совместимую оболочку, а в Ubuntu /bin/sh указывает на dash, более простую реализацию.

Проверим, что именно вызывается как /bin/sh в этой системе:

readlink -f /bin/sh
ls -l /bin/sh
/usr/bin/dash
lrwxrwxrwx 1 root root 4 Jan  7  2025 /bin/sh -> dash

Теперь попробуем три типичных башизма в dash:

dash -c '[[ 1 -eq 1 ]] && echo ok'
echo "код выхода: $?"
dash -c 'a=(x y); echo ${#a[@]}'
echo "код выхода: $?"
dash -c 'v=abc; echo "${v^^}"'
echo "код выхода: $?"
dash: 1: [[: not found
код выхода: 127
dash: 1: Syntax error: "(" unexpected
код выхода: 2
dash: 1: Bad substitution
код выхода: 2

Все три конструкции работают в Bash и ломаются в dash. Поведение [[ особенно коварно: в dash это просто неизвестная команда с кодом 127, которую условие if воспримет как ложь, если не проверять код явно. Переносимая версия той же проверки использует [ и POSIX-операторы:

dash -c 'if [ -d /tmp ] && [ "x" = "x" ]; then echo переносимо; fi'
echo "код выхода: $?"
переносимо
код выхода: 0

Есть и тонкость с echo. Поведение echo с опциями различается между оболочками, и dash выводит флаг -e как обычный текст:

dash -c 'echo -e "a\tb"'
-e a	b

Поэтому в переносимых сценариях вместо echo с нестандартными опциями используют printf, которое ведёт себя одинаково везде. Правило выбора простое: если в начале стоит #!/bin/sh, сценарий должен проверяться в dash; если #!/usr/bin/env bash, можно пользоваться расширениями Bash, но тогда и запускать его надо через Bash, а не через sh.

Google Shell Style Guide: ключевые правила

Руководство Google по стилю для shell 3 содержит короткий набор правил, которые делают сценарии читаемыми и устойчивыми. Из них наиболее важны следующие. Сценарии, которые длиннее нескольких сотен строк, или требуют сложных структур данных, или выполняют большую работу с файлами, следует писать на другом языке. Имена функций и переменных записываются строчными буквами с подчёркиванием, а константы и экспортируемые переменные — заглавными. Переменные объявляются с local в функциях. Все переменные, содержащие пути или пользовательский ввод, берутся в двойные кавычки. Вывод ошибок идёт в stderr. Для обхода файлов используют шаблоны, а не ls. Строку справки удобно иметь в каждом сценарии, принимающем опции.

Руководство также рекомендует использовать [[ ]] вместо [ ] в сценариях, которые явно написаны на Bash. Это согласуется с тем, что сказано выше о переносимости: выбор оболочки должен быть осознанным, и в шебанге нужно указать ту, для которой написан сценарий.

Когда переходить на Python

Bash хорошо справляется с конвейерами, вызовами программ и обработкой текста построчно. Однако при росте сценария проявляются его слабые стороны: сложные структуры данных неудобны, обработка ошибок многословна, отсутствуют полноценные модули и тесты, а поведение зависит от тонкостей разбора строк. Признаками, что пора переходить на другой язык, служат необходимость работать с вложенными структурами данных, разбирать JSON или XML, писать многословную обработку ошибок с повторными попытками, писать тесты и поддерживать сценарий больше года.

Python, который может быть уже знаком по другим курсам, решает эти задачи естественно: у него есть типы, исключения, стандартная библиотека для путей, процессов и JSON, а тесты пишутся средствами unittest. Сценарий на Python не заменяет Bash, а дополняет его: командные строки, обвязки и короткие конвейеры остаются за Bash, а логика обработки данных переходит на Python. В лабораторной работе сравнивается обработка одного и того же набора файлов на Bash и на Python, чтобы показать, где именно Bash становится неудобным.

Итоги темы

Надёжный сценарий отличается от работающего тем, что ведёт себя предсказуемо в неудачных ситуациях. Ловушки разбора командной строки — некавыченные переменные, вывод ls, cd без проверки — закрываются кавычками, шаблонами и явными проверками. Коды возврата и сообщения в stderr дают вызывающей стороне и человеку информацию о причине ошибки. Опция set -e полезна, но её исключения требуют явных проверок в тех местах, где ошибка важна. Временные файлы создаются через mktemp, а очистка выполняется ловушкой EXIT, которая срабатывает и при ошибке, и при сигналах INT и TERM.

Сценарий, который принимает опции, должен разбирать их через getopts, печатать справку и возвращать код 2 при ошибке ввода. Блокировка flock защищает от параллельных запусков, а журнал фиксирует результаты. Отладка начинается с bash -n, продолжается ShellCheck и трассировкой set -x с понятным PS4, а переносимость проверяется в dash, если сценарий претендует на роль /bin/sh. Чтобы проверка ShellCheck не зависела от памяти человека, её запускают автоматически: перехватчиком pre-commit в репозитории Git и заданием в сервисе непрерывной интеграции (CI), которое нельзя обойти опцией --no-verify.

Тема подводит итог разделу о программировании на Bash. Следующая тема, «Автоматизация: архивы, резервное копирование, планировщики», использует эти приёмы на практике: резервное копирование должно быть надёжным, блокироваться от повторного запуска, очищать временные данные и оставлять журнал, а запускаться по расписанию через cron или таймер systemd.

Литература

  1. Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
  2. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  3. Google. Shell Style Guide. — https://google.github.io/styleguide/shellguide.html
  4. The Open Group. The Open Group Base Specifications Issue 7 (IEEE Std 1003.1-2017), Shell Command Language. — https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
  5. Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
  6. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  7. ShellCheck — инструмент статического анализа shell-скриптов. — https://www.shellcheck.net/
  8. The Linux man-pages project. — https://man7.org/linux/man-pages/