Перейти к содержанию

Написание надёжных Bash-скриптов

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

Как это устроено

Bash выполняет скрипт построчно, по умолчанию игнорируя код завершения каждой команды, кроме последней в конвейере, и трактуя неустановленные переменные как пустые строки, а не как ошибку. Оба поведения по умолчанию неверны почти для любого production-скрипта.

set -euo pipefail

#!/usr/bin/env bash
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 не нашёл совпадений), нужно обрабатывать явно, иначе весь скрипт завершится:

if grep -q "pattern" file.txt; then
  echo "найдено"
else
  echo "не найдено"
fi

Функции и локальные переменные

myfunc() {
  local name="$1"
  echo "Привет, ${name}!"
}
myfunc "Мир"

Вывод:

Привет, Мир!

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:

verbose=1 file=/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.

Практическое задание

  1. Напишите скрипт backup-check.sh, принимающий путь к директории единственным аргументом, используя set -euo pipefail, который завершается с сообщением об ошибке (в stderr) и ненулевым кодом, если директория не существует.
  2. Добавьте функцию с local, считающую количество файлов в этой директории через find, переданный в wc -l, и выводящую это число.
  3. Сознательно сломайте защиту 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 полностью, если только вы не контролируете каждый байт вычисляемой строки.

Связанные статьи

Контрольные вопросы

  1. Что меняет set -o pipefail в определении кода завершения конвейера?
  2. Почему set -u ловит баги, которые пропустил бы один только set -e?
  3. Почему #!/usr/bin/env bash в общем случае предпочтительнее #!/bin/sh для скриптов с bash-специфичным синтаксисом?
  4. В чём практическая разница между local name="$1" и name="$1" внутри функции?