Написание надёжных Bash-скриптов¶
Большинство скриптов в стиле «у меня работает» ломаются при первом же неожиданном вводе, пустой переменной или команде, которая молча завершается с ошибкой. Эта статья — о практиках, которые отличают скрипт, падающий громко и безопасно, от скрипта, который портит данные в 3 часа ночи.
Как это устроено¶
Bash выполняет скрипт построчно, по умолчанию игнорируя код завершения каждой команды, кроме последней в конвейере, и трактуя неустановленные переменные как пустые строки, а не как ошибку. Оба поведения по умолчанию неверны почти для любого production-скрипта.
set -euo pipefail¶
set -e— немедленно завершить работу, если любая команда вернула ненулевой код (с некоторыми исключениями: команды в условииif, или после&&/||, освобождены от этого, поскольку их неуспех явно проверяется).set -u— трактовать обращение к неустановленной переменной как ошибку, вместо тихой подстановки пустой строки. Это сразу ловит опечатки вроде$FIELвместо$FILE.set -o pipefail— в конвейере (cmd1 | cmd2) итоговый код завершения становится кодом последней (самой правой) неуспешной стадии, а не только последней команды. Без этогоgrep pattern missingfile.txt | sortможет молча провалиться — ошибкаgrepпоглощается, потому чтоsort(который вообще не видит никакой ошибки) и определяет итоговый код конвейера.
Эту комбинацию иногда называют «bash strict mode». Это не серебряная пуля — команду, которая ожидаемо может завершиться неуспехом (например, grep не нашёл совпадений), нужно обрабатывать явно, иначе весь скрипт завершится:
Функции и локальные переменные¶
Вывод:
local важен: без него name стал бы глобальной переменной, видимой (и перезаписываемой) любой другой функцией скрипта — частый источник ошибок в скриптах, выросших за пределы пары десятков строк.
Разбор аргументов¶
Цикл while/case — стандартный способ разбора флагов без внешних зависимостей:
#!/usr/bin/env bash
set -euo pipefail
verbose=0
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) verbose=1; shift ;;
-f|--file) file="$2"; shift 2 ;;
*) echo "неизвестный аргумент: $1" >&2; exit 1 ;;
esac
done
echo "verbose=$verbose file=${file:-none}"
Запуск ./script.sh -v -f /etc/hosts:
Обратите внимание на ${file:-none} — это подстановка значения по умолчанию: если file так и не была установлена (флаг -f не передали), получится буквальная строка none вместо ошибки под set -u.
Различия Ubuntu/Debian и RHEL-подобных систем¶
Сам bash ведёт себя одинаково во всех дистрибутивах — различия в том, на что указывает /bin/sh. В Debian/Ubuntu /bin/sh — это dash, минимальный POSIX-shell, который не поддерживает bash-измы вроде [[ ]], массивов или подстановки процессов. В RHEL-подобных системах /bin/sh обычно сам bash (в ограниченном POSIX-совместимом режиме). Всегда указывайте шебанг #!/usr/bin/env bash, а не #!/bin/sh, если только вы сознательно не написали чисто POSIX sh-синтаксис и не протестировали его конкретно с dash.
Практическое задание¶
- Напишите скрипт
backup-check.sh, принимающий путь к директории единственным аргументом, используяset -euo pipefail, который завершается с сообщением об ошибке (в stderr) и ненулевым кодом, если директория не существует. - Добавьте функцию с
local, считающую количество файлов в этой директории черезfind, переданный вwc -l, и выводящую это число. - Сознательно сломайте защиту
pipefail, передав вwc -lнесуществующую команду, понаблюдайте за (неверным) кодом завершения безpipefail, затем вернитеset -o pipefailи понаблюдайте за исправленным кодом.
Как проверить, что всё сделано правильно¶
./backup-check.sh /nonexistent; echo $?должен вывести в stderr сообщение в духе «такой директории нет» и итоговый ненулевой код (1или выбранный вами), не0.nonexistentcmd | wc -l; echo $?безpipefailвыведет0(неверно —wc -lзавершился успешно, хотяnonexistentcmdупал); сset -o pipefail— выведет127(или1, реальный код упавшей стадии).
Типичные ошибки¶
- Неэкранированные подстановки переменных:
rm -rf $DIR/*вместоrm -rf "${DIR}"/*. ЕслиDIRне задана или содержит пробелы/глобы, это может развернуться в нечто гораздо более разрушительное, чем задумано. Всегда заключайте подстановки переменных в кавычки, если вам специально не нужно разбиение по словам.rm -rf "${DIR:?}"/*прерывает скрипт, еслиDIRпуста или не задана, даже безset -u. - Проверка
$?слишком поздно.$?отражает код завершения непосредственно предыдущей команды — вставкаechoдля отладки между командой и проверкой молча ломает это. - Использование
[ ]вместо[[ ]]для чего угодно сложнее простейших POSIX-сравнений.[[ ]]избегает сюрпризов с разбиением слов/глобингом и напрямую поддерживает&&/||/сопоставление по регулярным выражениям.
⚠ Опасные команды¶
- Любой скрипт с
rm -rf "$VAR", где$VARможет быть пустой или неустановленной. В сочетании сset -uнеустановленная переменная остановит скрипт (это хорошо) — но безset -u,rm -rf "$VAR"/с пустой$VARстановитсяrm -rf /, удаляя корневую файловую систему (блокируется современнымrmдля буквального пути/, но не для$VAR/subdir, разворачиваемого неожиданно). - Скрипты, выполняющие
evalнад вводом пользователя — прямой путь к произвольному выполнению кода; избегайтеevalполностью, если только вы не контролируете каждый байт вычисляемой строки.
Связанные статьи¶
Контрольные вопросы¶
- Что меняет
set -o pipefailв определении кода завершения конвейера? - Почему
set -uловит баги, которые пропустил бы один толькоset -e? - Почему
#!/usr/bin/env bashв общем случае предпочтительнее#!/bin/shдля скриптов с bash-специфичным синтаксисом? - В чём практическая разница между
local name="$1"иname="$1"внутри функции?