Типичные подводные камни при написании Bash-скриптов и как их избежать

Избегайте распространенных ошибок в Bash-скриптах с помощью безопасной обработки ошибок, правильного использования кавычек, массивов, ловушек и разбора аргументов.

Распространенные ошибки в Bash-скриптах и как их избежать

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

Вам не нужно делать каждый скрипт сложным. Но вам нужно заключать подстановки в кавычки, осознанно проверять сбои и тестировать с именами, содержащими пробелы.

Устанавливайте безопасные значения по умолчанию с умом

Многие скрипты начинаются с:

#!/usr/bin/env bash
set -euo pipefail

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

  • set -e завершает скрипт при сбое простой команды, за исключением случаев, таких как проверки if, части списков && и ||, и некоторые подстановки команд.
  • set -u завершает скрипт при раскрытии неопределенной переменной.
  • set -o pipefail делает конвейер неудачным, если любая команда в конвейере завершается с ошибкой, а не только последняя.

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

if ! grep -q "ready" status.txt; then
  echo "сервис еще не готов"
  exit 1
fi

Заключайте подстановки переменных в кавычки

Незаключенные в кавычки переменные — самая распространенная ошибка в Bash. Bash выполняет разбиение на слова и раскрытие шаблонов для незаключенных в кавычки подстановок, поэтому путь типа release notes/*.txt может стать несколькими аргументами или сопоставиться с файлами, которые вы не планировали.

file="release notes.txt"

# Плохо: ломается, потому что значение разбивается на два слова.
rm $file

# Хорошо: передает один точный аргумент.
rm -- "$file"

Используйте -- перед именами файлов, контролируемыми пользователем, когда команда это поддерживает. Это предотвращает интерпретацию имени файла, такого как -rf, как опции.

Используйте массивы для списков аргументов

Не храните команду с аргументами в одной строке и затем не выполняйте ее. Кавычки становятся хрупкими очень быстро.

# Плохо
flags="-a --exclude node_modules"
rsync $flags "$src" "$dest"

# Хорошо
flags=(-a --exclude "node_modules")
rsync "${flags[@]}" "$src" "$dest"

Массивы сохраняют границы аргументов. Это важно, когда аргумент содержит пробелы, символы подстановки или значения, начинающиеся с дефиса.

Предпочитайте $(...) обратным кавычкам

Обратные кавычки трудно вкладывать и легко неправильно прочитать. Используйте $(...) для подстановки команд.

current_branch="$(git rev-parse --abbrev-ref HEAD)"
echo "сборка ветки: $current_branch"

Держите подстановки команд в кавычках, если вы намеренно не хотите разбиения на слова.

Читайте файлы без потери данных

Этот шаблон выглядит безобидно, но ломается на пробелах и может исказить обратные слеши:

for line in $(cat hosts.txt); do
  echo "$line"
done

Вместо этого читайте файлы с помощью while IFS= read -r.

while IFS= read -r host; do
  echo "проверка $host"
done < hosts.txt

IFS= сохраняет начальные и конечные пробелы. -r предотвращает интерпретацию escape-последовательностей с обратным слешем.

Обрабатывайте временные файлы с помощью mktemp и trap

Жестко заданные временные пути могут конфликтовать с другим процессом или оставлять устаревшие файлы. Создайте уникальный путь и очистите его при выходе.

tmp_file="$(mktemp)"
cleanup() {
  rm -f "$tmp_file"
}
trap cleanup EXIT

printf '%s\n' "рабочие данные" > "$tmp_file"

Для каталогов используйте mktemp -d и удалите каталог в вашей функции очистки.

Разбирайте опции с помощью getopts

Ручной разбор аргументов часто упускает из виду граничные случаи. Для коротких опций встроенного getopts в Bash обычно достаточно.

verbose=false
output=""

while getopts ":vo:" opt; do
  case "$opt" in
    v) verbose=true ;;
    o) output="$OPTARG" ;;
    :)
      echo "Опция -$OPTARG требует аргумента" >&2
      exit 2
      ;;
    \?)
      echo "Неизвестная опция: -$OPTARG" >&2
      exit 2
      ;;
  esac
done
shift "$((OPTIND - 1))"

getopts обрабатывает короткие флаги, такие как -v и -o file. Если вашему скрипту нужны длинные опции, такие как --output, напишите тщательный парсер или используйте язык с более мощной библиотекой разбора аргументов.

Проверяйте команды, которые могут завершиться с ошибкой

Не предполагайте, что команда сработала, потому что она что-то вывела. Проверяйте важные операции перед использованием их вывода.

if ! archive="$(tar -czf app.tar.gz app 2>&1)"; then
  echo "архивация не удалась: $archive" >&2
  exit 1
fi

Для конвейеров включите pipefail, когда сбой в середине должен привести к сбою всего конвейера.

set -o pipefail
journalctl -u api.service | grep -i "error"

Без pipefail статус конвейера обычно берется от последней команды.

Избегайте Bash, когда важна переносимость

Если ваш скрипт использует массивы, [[ ... ]], mapfile или pipefail, это скрипт Bash. Начинайте его с:

#!/usr/bin/env bash

Если вам нужна переносимость POSIX sh, избегайте функций, специфичных для Bash, и тестируйте с оболочкой, которую использует ваша целевая система. Не пишите скрипт Bash с #!/bin/sh и не надейтесь, что он будет везде вести себя одинаково.

Вывод

Самый быстрый способ улучшить ваши Bash-скрипты — тестировать их с грязным вводом: пробелы в именах файлов, отсутствующие переменные, пустые файлы и сбоящие команды. Заключайте подстановки в кавычки, используйте массивы для списков аргументов, очищайте временные файлы с помощью trap и делайте пути ошибок явными. Ваше будущее я потратит меньше времени на отладку скриптов, которые работали только с идеальным вводом.