Прямой ответ
Bash опасен тем, что молча продолжает после ошибок: команда упала — скрипт бежит дальше и удаляет не то. Лечение — привычка, а не магия: заголовок #!/usr/bin/env bash + set -euo pipefail (падение на ошибке, на неопределённой переменной, на провале внутри пайпа), все подстановки в кавычках ("$var" — word splitting и glob'и ломают скрипты на именах с пробелами), явные проверки там, где set -e не работает (условия, пайпы с || true), и shellcheck в pre-commit — статический анализатор ловит большинство граблей до запуска. Эти пять привычек закрывают 80% отказов скриптов. Остальное — техника: trap для уборки временных файлов, атомарная запись через mv, функции с локальными переменными. И честная граница: скрипт длиннее двухсот строк с логикой и структурами данных — кандидат на Python (аргументы в статье про автоматизацию рутины).
Строгий режим: что именно делает каждая опция
set -e — выход при ненулевом коде возврата команды (без него rm -rf "$DIR/" после провалившегося cd "$DIR" выполняется в текущем каталоге — сюжет из страшных историй). Нюансы, о которые спотыкаются: set -e не срабатывает внутри условий (if cmd; then — ошибка команды здесь ожидаема), в цепочках cmd || other, и в пайпах учитывается только последняя команда — отсюда pipefail. set -u — ошибка на необъявленной переменной ($DIRECTOY с опечаткой — немедленный выход вместо пустой строки, которая в rm -rf "$DIRECTOY/*" была бы катастрофой); легитимные «может быть пустой» — ${VAR:-} с дефолтом. set -o pipefail — пайп падает, если упала любая команда в нём (curl ... | jq — без pipefail провал curl невидим, jq «успешно» обработает пустоту). Дополнительно строгие скрипты добавляют IFS=$'\n\t' (предсказуемый word splitting) — но три первых опции дают основную защиту.
Кавычки и подстановки: источник половины багов
Правило абсолютное: каждая подстановка переменной — в двойных кавычках. rm "$file" работает с «моим файлом.txt»; rm $file — удаляет «мой» и «файлом.txt» отдельно (word splitting), а при пустой переменной — получает аргументы от shell'а. Одиночные кавычки — для литералов без подстановок. Массивы — единственный правильный способ списков (files=(a b "c d"); for f in "${files[@]}" — именно [@] в кавычках; $* и голый $@ ломаются на элементах с пробелами). Подстановки с защитой: ${VAR:-default} (дефолт), ${VAR:?ошибка} (падение с сообщением — для обязательных переменных), ${#arr[@]} (длина). Отдельная мина — парсинг вывода команд построчно: while IFS= read -r line (без IFS= и -r теряются пробелы и backslash'и); и никогда — for line in $(cmd) (word splitting по всем пробелам сразу). Сравнения — [[ ]] (не устаревший [ ]: безопаснее с пустыми строками, поддерживает regex и &&/||).
Коды возврата и обработка ошибок
set -e не заменяет осмысленную обработку: там, где ошибка ожидаема, проверяйте явно — if ! cmd; then ..., cmd || { echo "не удалось" >&2; exit 1; }, сохранение кода cmd; rc=$?. Полезные приёмы: функции-обёртки с ретраями для сетевых команд; trap 'echo "ошибка в строке $LINENO"' ERR (диагностика места падения — с set -e бесценна); trap 'rm -rf "$tmp"' EXIT (уборка временных каталогов при любом выходе — включая Ctrl-C и падение; без trap прерванный скрипт оставляет мусор). Осознанные исключения из строгого режима — локальные и помеченные: set +e; ... ; set -e вокруг блока, где ошибки обрабатываются вручную (или подкоманда с явной проверкой $?).
Атомарность и идемпотентность операций
Скрипты в cron и деплое обязаны переживать прерывание. Атомарная запись файла: пишем во временный рядом с целевым, затем mv tmp target (rename атомарен в пределах ФС — читатель никогда не видит половину файла; > в целевой файл — рваное содержимое при обрыве). Идемпотентность: проверки «уже сделано» ([[ -f flag ]] && exit 0, mkdir -p, grep -q ... || echo ... >> file вместо слепого append) — повторный запуск безопасен (механика cron-задач — в статье про cron). Блокировка от параллельных запусков — flock: exec 9>/var/lock/myjob.lock; flock -n 9 || { echo "уже работает"; exit 0; } — без него два наложившихся cron-запуска портят данные вдвоём. Временные файлы — только mktemp -d (предсказуемые имена в /tmp — и мусор, и дыра безопасности).
Практический сценарий: скрипт ночного бэкапа
Соберём характерный скрипт, в котором все практики работают вместе. Заголовок: строгий режим, ERR-trap с номером строки, flock против наложения, mktemp -d + trap ... EXIT для рабочего каталога. Тело: pg_dump (с set -o pipefail провал дампа не маскируется сжатием) в файл внутри temp-каталога; проверка размера ([[ $(stat -c%s "$dump") -gt 1000 ]] — «пустой дамп» не уезжает в хранилище); mv дампа в каталог бэкапов (атомарная публикация — читатели не видят недописанное); ротация (find ... -mtime +14 -delete); отправка метрики/уведомления. Каждый шаг с сообщением в stderr при провале и ненулевым выходом — cron-почта/алерт получают конкретику. Скрипт идемпотентен (повторный запуск за тот же день перезаписывает дамп того же дня через mv), атомарен (прерванный на середине не оставляет битых файлов в целевом каталоге) и проверяем shellcheck'ом в pre-commit. Шестьдесят строк, ноль молчаливых отказов.
Когда bash — не тот инструмент
Честные границы: структуры данных (ассоциативные массивы bash скудны), сложная строковая логика, арифметика с плавающей точкой (её нет — только целые), кроссплатформенность (bash на macOS — версии 3.2 из-за лицензии; «работает в Linux-контейнере, не работает на маке» — классика), парсинг сложных форматов (JSON — только через jq, XML — не надо вообще). Признаки «пора на Python»: скрипт вырос за двести строк, появилась вложенная логика с флагами, нужны словари/списки структур, ошибки обрабатываются развесистыми if-цепочками. Python-скрипт с argparse и pathlib читается и тестируется как обычный код; bash остаётся там, где он силён — клей для команд, файловые операции, обёртки деплоя на десятки строк.
FAQ
Обязателен ли shellcheck? Для скриптов, живущих в репозитории, — да: он ловит и отсутствие кавычек, и word-splitting, и десятки тонких граблей (SC-коды с объяснениями); в pre-commit/CI — секунды на прогон. Интерактивные однострочники — на ваше усмотрение.
Почему set -e иногда «не работает» и все советуют его бросить?
Он не срабатывает в условиях и левой части ||/&& (by design), и поведение в сложных сценариях (функции в subshell, trap ERR) удивляет. Альтернативная школа — явные проверки каждого кода возврата; практический консенсус — строгий режим плюс явные проверки в критичных местах, и понимание, где set -e бессилен.
sh или bash в shebang?
#!/usr/bin/env bash — если используете массивы, [[ ]], process substitution (всё из этой статьи); /bin/sh — только для POSIX-переносимых скриптов (на Debian sh — это dash, и bash-измы в нём падают). Смешивание — источник «на моей машине работает».
Как безопасно передать переменные в heredoc/SQL/ssh?
Heredoc с кавычками-делимитером (<<'EOF' — без подстановок) против <<EOF (с подстановками) — выбирать осознанно; команды на удалённой машине через ssh — одним аргументом в одинарных кавычках с подстановкой локальных значений через printf, а не склейкой строк (инъекция в собственный shell — реальна и в своих скриптах).
Источники: GNU Bash Manual, BashPitfalls (mywiki.wooledge.org), shellcheck — ссылки в шапке.