Пишем корректные bash-скрипты: обработка ошибок, строгие режимы и безопасные переменные
Покажем best practices: set -euo pipefail, ловля ошибок, корректное quoting и проверка входных параметров. Разберём мини-проект с устойчивым поведением.
Содержание
Пишем корректные bash-скрипты: обработка ошибок, строгие режимы и безопасные переменные
Bash часто воспринимают как «язык для склеивания команд», но в реальных системах скрипты становятся полноценными компонентами: они выполняются в CI, разруливают релизы, миграции и бэкапы, обрабатывают входные данные от пользователей и автоматизированных процессов. И вот тут всплывает классическая проблема: скрипт может молча сделать не то, потому что ошибка не была замечена, переменная оказалась пустой, путь содержит пробелы, а конвейер оборвался не там, где ожидалось.
Хорошая новость: устойчивое поведение bash-скриптов достигается набором практик, которые давно считаются best practices в экосистеме Linux. В этой статье разберём их системно: строгие режимы (set -euo pipefail), предсказуемая обработка ошибок, правильные quoting, безопасная работа с переменными, валидация аргументов и построение мини-проекта “по-взрослому”.
Почему bash-скрипты “ломаются” и что именно нужно исправить
Типовые источники проблем в bash:
-
Отсутствие остановки на ошибках
- Команда завершилась ненулевым кодом, но скрипт продолжил работу.
- В конвейерах (
cmd1 | cmd2) ошибкаcmd1может остаться незамеченной, еслиcmd2успешно завершается.
-
Неинициализированные переменные
- Переменная не задана — bash подставляет пустую строку или интерпретирует выражения иначе, чем ожидается.
- Например,
rm -rf "$path"с пустым$pathпревращается в опасную операцию.
-
Неправильный
quoting- Пути и строки с пробелами/табами/символами интерпретируются как несколько аргументов.
- В худшем случае — подмена данных при подстановках и расширениях.
-
Отсутствие проверок входных параметров
- Скрипт ожидает аргументы определённого формата, но не валидирует их.
- Ошибка обнаруживается поздно, в середине выполнения, когда уже есть частичные изменения.
-
Сложные сценарии и отсутствие логирования
- Ошибка произошла внутри функции/конвейера, но понять контекст сложно.
- Без корректных хендлеров (
trap) тяжело обеспечить очистку временных файлов и корректный выход.
Решение — дисциплина: строгие режимы, явная политика обработки ошибок, централизованный вывод диагностик и предсказуемая работа с данными.
Строгие режимы bash: set -euo pipefail и их реальное значение
Начнём с основы, которую часто копируют из статей. Но важно понимать, что именно она делает.
set -e: “останавливаться на ошибках”, но не всегда так, как ожидают
set -e заставляет bash завершаться при неуспешном выполнении простых команд. Однако есть нюансы: в некоторых конструкциях bash намеренно не включает “авто-выход”, чтобы не ломать логику условий. Например, в if cmd; then ... fi ошибка cmd не приводит к немедленному завершению — это ожидаемо, потому что ошибка используется как условие.
Практический вывод: set -e полезен как “предохранитель”, но не заменяет явную проверку там, где вы хотите особую семантику.
set -u: запрет на неинициализированные переменные
set -u (или nounset) делает попытку использовать несуществующую переменную ошибкой. Это значительно снижает количество “молчаливых” багов, когда забыли задать аргумент или переменную.
Например, без -u выражение вроде:
echo "$version"→ напечатает пусто, и скрипт может пойти дальше.rm -rf "$dir"при пустом$dir— потенциально катастрофично.
С -u вы получите быстрый фейл и сможете отлавливать ошибку ближе к источнику.
set -o pipefail: в конвейерах важна первая ошибка
По умолчанию bash возвращает код последней команды в конвейере. При cmd1 | cmd2 вы можете получить успешный код даже если cmd1 упал.
set -o pipefail изменяет поведение: конвейер возвращает ненулевой код, если любая команда в конвейере завершилась с ошибкой (точнее — возвращается код последней неуспешной команды).
Практический вывод: pipefail — обязательный минимум для скриптов, где конвейеры используются для фильтрации/преобразований данных.
Политика ошибок: единая функция, trap и контролируемый выход
Строгие режимы — фундамент. Но чтобы скрипт был действительно устойчивым, нужно контролировать контекст ошибки: где она произошла, какой код возврата, какие параметры были использованы, что требуется очистить.
Базовый шаблон: обработчик выхода + очистка
В bash удобно использовать trap на EXIT и/или ERR.
trap ... EXIT— сработает при любом завершении скрипта.trap ... ERR— срабатывает на ошибке команд при включённомset -e(с нюансами), позволяя зафиксировать диагностику.
Рассмотрим рабочий каркас:
#!/usr/bin/env bash
set -euo pipefail
# Локализация временных артефактов
tmpdir=""
cleanup() {
# Безопасная очистка только если директория реально создана
if [[ -n "${tmpdir}" && -d "${tmpdir}" ]]; then
rm -rf -- "${tmpdir}"
fi
}
on_err() {
local exit_code=$?
local line_no=${BASH_LINENO[0]:-unknown}
local cmd=${BASH_COMMAND:-unknown}
printf 'ERROR: command failed (exit=%s) at line %s: %s\n' \
"${exit_code}" "${line_no}" "${cmd}" >&2
}
trap cleanup EXIT
trap on_err ERR
Что здесь важно:
tmpdirобъявлен и инициализирован, что предотвращает ошибки из-заset -u.- В
cleanupпроверяем наличие и тип директории. - Диагностика печатает код возврата и строку, что помогает быстро локализовать проблему.
printfвместоecho(уechoесть исторические отличия в поведении, зависящие от реализации).
Безопасные переменные и правильные кавычки: где чаще всего ломается реальная практика
Принцип: всегда цитируйте переменные, которые могут содержать пробелы
Если переменная — это аргумент команды, почти всегда нужно писать:
"$var"вместо$var"${arr[@]}"вместо${arr[@]}
Пример типичной ошибки:
path=/tmp/my dir
rm -rf $path # ❌ разделит на два аргумента: /tmp/my и dir
Правильно:
rm -rf -- "$path" # ✅ один аргумент, и -- защищает от строк вида -rf
-- после опций: защита от “похожих на флаги” значений
Если пользователь передаст путь вида -rf, команда может воспринять его как опцию. Чтобы избежать этого, большинство утилит принимают -- как конец опций:
cp -- "$src" "$dst"
rm -rf -- "$path"
mkdir -p -- "$dir"
Это особенно важно в “утилитарных” скриптах, которые могут быть использованы в разных контекстах.
Опасные расширения: globbing и word splitting
Два механизма, которые часто приводят к неожиданностям:
- Word splitting: строка разбивается по пробельным символам, если она не заключена в кавычки.
- Globbing: шаблоны
*,?,[]расширяются по файловой системе.
Кавычки отключают оба эффекта для содержимого переменной.
Валидация входных параметров: строгие контракты вместо “надеемся, что всё хорошо”
Один из самых важных шагов в написании устойчивых скриптов — заранее проверять вход. Для этого стоит:
- Явно задокументировать назначение параметров.
- Проверить количество аргументов.
- Проверить тип/формат (например, “директория существует”, “число в диапазоне”, “файл читаем”).
- Отдельно учесть крайние случаи: пустые строки, слишком длинные значения, неожиданные символы.
Пример: простой валидатор аргументов
Предположим, скрипт должен принимать:
--inputпуть к файлу--outputпуть к выходному файлу--patternшаблон поиска (regex)--min-bytesминимальный размер (число)
Каркас разбора аргументов можно сделать через getopts или вручную. Для гибкости часто делают вручную, но с аккуратной проверкой.
Мини-проект: устойчивый скрипт обработки логов (с тестируемым поведением)
Соберём практический пример. Задача:
- Считать входной лог-файл.
- Отфильтровать строки по регулярному выражению.
- Записать результат в выходной файл.
- Параметр
--min-bytesзадаёт минимальный размер входа, чтобы скрипт не обрабатывал “пустой” лог. - Скрипт должен вести себя предсказуемо при ошибках: корректный код выхода, понятная диагностика, очистка временных файлов.
Требования к поведению
- Если входной файл не существует или недоступен — скрипт завершится с ошибкой до начала работы.
- Если
--min-bytesне число — ошибка. - Если regex не валиден для
grep -E— ошибка должна быть диагностирована. - Временные файлы должны удаляться при любой ошибке.
- Пустые результаты фильтрации — допустимы (например, если нет совпадений), но это должно быть явно отражено в сообщении.
Реализация
#!/usr/bin/env bash
set -euo pipefail
tmpdir=""
cleanup() {
if [[ -n "${tmpdir}" && -d "${tmpdir}" ]]; then
rm -rf -- "${tmpdir}"
fi
}
on_err() {
local exit_code=$?
local line_no=${BASH_LINENO[0]:-unknown}
local cmd=${BASH_COMMAND:-unknown}
printf 'ERROR: exit=%s at line=%s: %s\n' \
"${exit_code}" "${line_no}" "${cmd}" >&2
}
trap cleanup EXIT
trap on_err ERR
usage() {
cat >&2 <<'EOF'
Usage:
logfilter.sh --input <file> --output <file> --pattern <regex> --min-bytes <N>
Notes:
- pattern is used with grep -E
- script writes matches to output
EOF
}
# По умолчанию не задаём значения, чтобы set -u не скрывал ошибки
input=""
output=""
pattern=""
min_bytes=""
# Простейший парсер аргументов
while [[ $# -gt 0 ]]; do
case "$1" in
--input)
input="${2:-}"; shift 2 ;;
--output)
output="${2:-}"; shift 2 ;;
--pattern)
pattern="${2:-}"; shift 2 ;;
--min-bytes)
min_bytes="${2:-}"; shift 2 ;;
-h|--help)
usage; exit 0 ;;
*)
printf 'Unknown argument: %s\n' "$1" >&2
usage
exit 2 ;;
esac
done
# Проверка обязательных параметров
if [[ -z "${input}" || -z "${output}" || -z "${pattern}" || -z "${min_bytes}" ]]; then
printf 'Missing required arguments.\n' >&2
usage
exit 2
fi
# Валидация входного файла
if [[ ! -f "${input}" ]]; then
printf 'Input file does not exist: %s\n' "${input}" >&2
exit 2
fi
if [[ ! -r "${input}" ]]; then
printf 'Input file is not readable: %s\n' "${input}" >&2
exit 2
fi
# Проверка min_bytes: только целое число >= 0
if [[ "${min_bytes}" =~ ^[0-9]+$ ]]; then
: # ок
else
printf '--min-bytes must be a non-negative integer, got: %s\n' "${min_bytes}" >&2
exit 2
fi
# Проверка размера файла
# GNU stat: %s — размер в байтах
# (Если вы работаете на macOS, потребуется другая команда; тут намеренно упрощаем)
file_size="$(stat -c '%s' -- "${input}")"
if (( file_size < min_bytes )); then
printf 'Input is too small: %s bytes < %s bytes\n' "${file_size}" "${min_bytes}" >&2
exit 1
fi
# Создаём tempdir
tmpdir="$(mktemp -d)"
# Здесь можно предварительно проверить корректность regex:
# grep -E проверяет regex и возвращает код.
# Но grep возвращает 1 и при "нет совпадений", поэтому нам нужен тест на синтаксическую ошибку.
# Простой способ: прогнать regex на пустой ввод и смотреть код — синтаксическая ошибка даст код 2.
# Однако поведение grep зависит от версии; ниже — практичный вариант:
if ! printf '' | grep -E -- "${pattern}" >/dev/null; then
# Код 1 = нет совпадений, код 2 = ошибка regex (обычно)
# На разных grep точные коды могут отличаться, поэтому мы проверяем текстом:
# Если regex синтаксически неверен, grep обычно пишет сообщение в stderr.
# Поэтому делаем ещё один проход с перехватом.
if err_msg="$( (printf '' | grep -E -- "${pattern}") 2>&1 >/dev/null; echo $? )" ; then
: # не используем; оставляем ниже другой вариант
fi
fi
# Надёжнее: попытаться сделать фильтрацию и доверять stderr grep.
# Если regex синтаксически неверен — grep вернёт ошибку.
out_tmp="${tmpdir}/out.txt"
# grep: -E расширенные регулярные выражения
# -n не включаем, потому что формат должен остаться исходным
# Важно: set -e + отсутствие совпадений
# grep при отсутствии совпадений возвращает 1 -> set -e немедленно завершит скрипт.
# Поэтому нужно обработать это отдельно.
set +e
grep -E -- "${pattern}" -- "${input}" > "${out_tmp}"
grep_status=$?
set -e
if (( grep_status == 0 )); then
: # совпадения есть
elif (( grep_status == 1 )); then
# нет совпадений — считаем это допустимым исходом
printf 'No matches found for pattern in %s. Writing empty output.\n' "${input}" >&2
else
# код 2 и выше — ошибка
printf 'grep failed with status %s. Check regex/pattern and input.\n' "${grep_status}" >&2
exit "${grep_status}"
fi
# Атомарная запись (сначала во временный, потом переместить)
# Используем mv -f, чтобы избежать проблем с частичной записью в output.
mv -f -- "${out_tmp}" "${output}"
printf 'Done. Output written to %s\n' "${output}" >&2
Что здесь “сделано правильно” (и почему)
1) Строгие режимы включены с самого начала
set -euo pipefail предотвращает большинство типовых тихих ошибок. Но из-за set -e есть важный нюанс с grep: отсутствие совпадений возвращает код 1 и может прервать скрипт. Мы это обрабатываем аккуратно через временное отключение set -e на участке:
set +e- выполнение
grep - анализ
grep_status - возвращение
set -e
Это не “обход строгих режимов”, а точечная настройка семантики: “код 1 для grep означает допустимый кейс”.
2) Все переменные цитируются
"${input}", "${pattern}", "${out_tmp}" — скрипт корректно обрабатывает пробелы и спецсимволы в путях/паттернах (насколько это допускает grep и shell).
3) Проверяется формат --min-bytes
Без этого легко получить неожиданное сравнение в арифметике. С regex ^[0-9]+$ мы гарантируем, что (( file_size < min_bytes )) не превратится в “сравнение с мусором”.
4) Временные файлы очищаются гарантированно
trap cleanup EXIT удаляет tempdir даже при раннем падении на ошибках. Это особенно важно, если скрипт часто запускается в CI или как часть пайплайна.
Типичные ошибки, которые ломают строгие скрипты (и как их избегать)
Ошибка 1: думать, что set -e “всегда” ловит любые проблемы
set -e имеет нюансы внутри условных конструкций, циклов и списков команд. Поэтому для “критичных” операций полезно добавлять явные проверки. Пример:
- если нужна конкретная семантика (“код 1 означает пустой результат”), то надо обрабатывать код.
- если нужна “жёсткая” семантика, то можно позволить
set -e
Комментарии
Пока нет комментариев