Надёжные скрипты: ошибки, сигналы, отладка
Предыдущие темы показали, как писать простые сценарии 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 | процесс убит сигналом N | 130 при 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 -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 не должна менять код выхода, если только это не сделано намеренно. Поэтому функция очистки не вызывает 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.
Литература
- Wooledge G. Bash FAQ и Bash Pitfalls. — https://mywiki.wooledge.org/BashPitfalls
- Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
- Google. Shell Style Guide. — https://google.github.io/styleguide/shellguide.html
- 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
- Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
- Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
- ShellCheck — инструмент статического анализа shell-скриптов. — https://www.shellcheck.net/
- The Linux man-pages project. — https://man7.org/linux/man-pages/