Зачем bash-скриптам надёжность: контекст 2026
Надёжный bash-скрипт для прода собирается из предсказуемого каркаса и явных проверок: shebang #!/usr/bin/env bash, set -euo pipefail, IFS=$'\n\t', обработчик trap на EXIT/INT/TERM, mktemp для временных файлов, кавычки вокруг каждой подстановки переменной и проверка кода возврата там, где от команды зависит результат. Ниже разбираем каждый блок, даём фрагменты для копирования и объясняем, почему строка нужна именно такой.
Спрос на навык виден в требованиях к вакансиям: в описании позиции Linux Administrator и DevOps-инженера перечислены «Good knowledge of Bash/Python, CI/CD, monitoring and logging» и задача «Automating repetitive operational tasks and improving infrastructure reliability». Речь про инфраструктуру масштаба 2 000+ виртуальных машин и 500+ физических серверов: обновления, патчи безопасности, резервное копирование, разбор инцидентов и анализ первопричин. Все эти сценарии обслуживают скрипты (описание вакансии).
Рядом с bash в такой среде работают Ansible, Salt или Chef, а инфраструктура включает OpenStack, Kubernetes, Docker, Nginx, PostgreSQL или MySQL, Kafka, RabbitMQ, системы мониторинга и логирования. Bash остаётся связующим слоем: обёртки вокруг утилит, хуки деплоя, разовые операции, предполётные проверки.
Ненадёжный скрипт в такой среде превращается в источник инцидентов. Незакавыченная переменная удаляет не тот каталог, потерянный код возврата пропускает сбой репликации, фиксированное имя в /tmp открывает гонку. Дальше разбираем структуру и практики, которые закрывают эти риски.
Структура bash-скрипта: каркас, который не ломается
Каркас задаёт поведение всего файла. Этот набор строк ставьте в начало каждого скрипта, который запускается в проде:
#!/usr/bin/env bash
set -euo pipefail
# Разделители для безопасного разбора вывода команд
IFS=$'\n\t'
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="${0##*/}"
usage() {
printf 'Использование: %s [-v] -f PATH\n' "$SCRIPT_NAME" >&2
}
main() {
printf 'script dir: %s\n' "$SCRIPT_DIR"
}
main "$@"
Разбор по строкам:
- shebang #!/usr/bin/env bash берёт bash из PATH, а не из жёсткого /bin/bash. На системах, где bash 5 стоит в нестандартном каталоге, это избавляет от запуска через sh.
- set -e останавливает скрипт при ненулевом коде возврата команды. Оговорки, ломающие ложное чувство безопасности, ниже.
- set -u превращает обращение к необъявленной переменной в ошибку. Опечатка $LOGFLE вместо $LOGFILE больше не станет пустой строкой в аргументе rm.
- set -o pipefail возвращает код последней неуспешной команды конвейера. Без опции берётся код завершающей команды.
- IFS=$'\n\t' убирает пробел из разделителей, поэтому пути с пробелами не распадаются на куски при разборе вывода.
- readonly для констант ловит случайную перезапись переменной прямо во время выполнения.
- Функции main и usage держат логику в одном месте вместо линейного потока на сотню строк.
set -euo pipefail: что реально делает каждая опция
set -e не универсальная защита. Опция не останавливает скрипт в четырёх частых ситуациях: команда стоит в условии (if, while), команда входит в список через && или ||, команда работает внутри подстановки в аргументе или присваивании, код возврата явно переопределён через || true.
Поведение проверяется одной строкой:
bash -c 'set -e; false; echo "not reached"'
Вывода not reached не будет: оболочка завершится на false. А вот так ошибку легко замаскировать:
#!/usr/bin/env bash set -euo pipefail # антипаттерн: сбой rsync проглочен rsync -a /srv/data /backup/ || true printf 'продолжаем, как будто бэкап сделан\n'
Отсюда правило: set -e ловит очевидные сбои, критичные команды проверяйте явно. Как именно, разбираем в разделе про коды возврата.
pipefail меняет цену ошибки в середине конвейера. grep без совпадений возвращает 1, и если он стоит первым в пайпе, без pipefail вы увидите успех:
# код возврата возьмётся у wc, ошибка grep потеряется grep -q "ERROR" /var/log/app.log | wc -l set -o pipefail # пайп вернёт 1, и скрипт остановится grep -q "ERROR" /var/log/app.log | wc -l
Для отладки есть set -x, но в проде его не оставляют: трассировка печатает значения переменных, включая токены и пароли, в stderr и системный журнал. Включайте её условно, а формат подсказки задавайте через PS4:
export PS4='+${BASH_SOURCE}:${LINENO}: '
[[ "${DEBUG:-0}" == "1" ]] && set -x
Логирование и вывод: чтобы инцидент разбирался за минуты
Логи скрипта отвечают на вопрос «что успело выполниться». Данные уходят в stdout, диагностика в stderr, формат единый:
log() {
local level="$1"; shift
printf '%s [%s] %s\n' "$(date -Is)" "$level" "$*" >&2
}
log INFO "старт бэкапа: ${BACKUP_DIR}"
log ERROR "rsync завершился с кодом ${rc}"
Если на серверах собирают журналы через journald или syslog, дублируйте важные события через logger: запись попадёт в общую систему рядом с логами других сервисов.
logger -t backup-script -p user.info "бэкап завершён за ${DURATION}s"
Два запрета. Не пишите в лог секреты и содержимое .env: журнал читают агенты централизованного сбора. Не выводите пароли, токены API и содержимое приватных ключей. Маскируйте значения: printf 'token=%s\n' "${TOKEN:0:4}***".
Обработка аргументов и интерфейс командной строки
getopts разбирает короткие флаги без внешних зависимостей. Шаблон ниже принимает -f (путь), -v (подробный вывод) и -h (справка), проверяет обязательный параметр и валидирует ввод до первой полезной операции:
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Использование: backup.sh -f PATH [-v] [-h]
-f PATH каталог или файл для бэкапа (обязательно)
-v подробный вывод
-h показать эту справку
EOF
}
VERBOSE=0
SOURCE=""
while getopts ":f:vh" opt; do
case "$opt" in
f) SOURCE="$OPTARG" ;;
v) VERBOSE=1 ;;
h) usage; exit 0 ;;
\?) usage; exit 2 ;;
:) printf 'флаг -%s требует аргумент\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
if [[ -z "$SOURCE" ]]; then
printf 'укажите источник через -f\n' >&2
usage >&2
exit 2
fi
if [[ ! -e "$SOURCE" ]]; then
printf 'путь не существует: %s\n' "$SOURCE" >&2
exit 2
fi
Что здесь важно:
- Ведущее двоеточие в getopts ":f:vh" переводит сообщения об ошибках под ваш контроль: неизвестный флаг приходит в ветку \?, забытый аргумент в ветку :.
- shift $((OPTIND - 1)) сдвигает позиционные аргументы, поэтому "$@" содержит только хвост команды. Так дополнительные параметры передаются дальше: ./backup.sh -f /etc -- --dry-run.
- Код exit 2 отделяет неверный вызов от сбоя выполнения (exit 1). В CI это сразу видно по возврату задачи.
- getopts не понимает длинные флаги вида --file или --help. Их разбирают вручную через case по массиву "$@" либо подключают готовый парсер; для внутренних скриптов коротких флагов хватает.
Проверяйте интерфейс на копии: ./backup.sh, ./backup.sh -f /nonexistent, ./backup.sh -z. Все три запуска должны вернуть код 2 и понятное сообщение, а не половину выполненной работы.
Ловушки trap: очистка ресурсов и корректное завершение
trap привязывает функцию к сигналу или к завершению оболочки. Для уборки нужен EXIT: он срабатывает и при нормальном выходе, и при ошибке с set -e.
#!/usr/bin/env bash
set -euo pipefail
TMP_DIR=""
cleanup() {
local rc=$?
[[ -n "$TMP_DIR" && -d "$TMP_DIR" ]] && rm -rf -- "$TMP_DIR"
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
TMP_DIR="$(mktemp -d)"
printf 'рабочий каталог: %s\n' "$TMP_DIR"
- mktemp -d создаёт каталог с уникальным именем и правами 700, поэтому параллельные запуски не пересекутся.
- trap cleanup EXIT выполняет уборку один раз при любом выходе: exit 0, exit 1 или падение команды.
- local rc=$? первой строкой обработчика сохраняет исходный код возврата, иначе его перезапишет rm.
- exit 130 для SIGINT и exit 143 для SIGTERM повторяют соглашение оболочки (128 плюс номер сигнала). Вызывающая сторона, будь то systemd или раннер CI, понимает причину остановки.
- rm -rf -- "$TMP_DIR" выполняется только при непустом значении и существующем каталоге. Проверка не даёт опечатке превратить уборку в удаление /.
Отдельно про ловушку ERR. Она срабатывает при ненулевом коде команды и удобна для централизованного логирования, но подчиняется тем же оговоркам, что set -e: внутри условий if и в списках &&/|| обработчик не вызовется. Уборку ресурсов на ERR вешать не нужно, EXIT покрывает оба случая.
Ограничения trap: при kill -9 (SIGKILL перехватить нельзя) и при жёстком падении хоста обработчик не выполнится. Если скрипт меняет состояние вне временного каталога, пишите в журнал «начал/завершил», чтобы после сбоя понять, на каком шаге остановились. Откат частично применённых изменений и уровни логирования подробно разобраны в статье про скрипт развёртывания для Linux с готовыми примерами.
Проверка кодов возврата и обработка ошибок
Правило: команда, от результата которой зависит дальнейший ход, должна быть проверена. Переменная $? хранит код последней команды, но его перезаписывает любая следующая, включая printf, поэтому считывайте значение сразу.
if rsync -a --delete "$SRC/" "$DST/"; then
log INFO "синхронизация выполнена"
else
rc=$?
log ERROR "rsync failed with code ${rc}"
exit "$rc"
fi
Проверку через if ! command; then применяйте там, где важен сам факт сбоя, а не его код: отрицание обнуляет $? внутри ветки then.
Для конвейеров используйте PIPESTATUS: pipefail сообщает, что пайп упал, но не говорит, какая команда дала сбой.
set -o pipefail
tar -czf - "$SRC" | ssh "$HOST" 'cat > /backup/src.tgz'
codes=("${PIPESTATUS[@]}")
if (( codes[0] != 0 || codes[1] != 0 )); then
printf 'tar=%s ssh=%s\n' "${codes[0]}" "${codes[1]}" >&2
exit 1
fi
Массив PIPESTATUS читается сразу после конвейера: следующая команда его обнулит. При диагностике сбоя выгружайте оба кода в лог, а не только общий результат.
curl возвращает 0 и при HTTP 4xx, и при 5xx: код отражает состояние транспорта, а не ответ сервера. Статус проверяйте отдельно:
http_code=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 --retry 3 "https://example.com/health")
if [[ "$http_code" != "200" ]]; then
printf 'health check failed: HTTP %s\n' "$http_code" >&2
exit 1
fi
Рецепты для выгрузки артефактов из пайплайна, проверки контрольных сумм и повторных попыток при обрывах сети собраны в материале про загрузку файлов через API, CLI и CI/CD.
Безопасная работа с путями и временными файлами
Фиксированное имя в /tmp, например /tmp/backup.tar, создаёт две проблемы. Параллельные запуски перезаписывают файл друг друга. Локальный пользователь заранее создаёт симлинк /tmp/backup.tar на /etc/passwd, и скрипт с правами root пишет по чужому пути. mktemp закрывает оба сценария: имя непредсказуемо, права 600, файл создаётся атомарно.
readonly TMP_ROOT="${TMPDIR:-/tmp}"
WORK_DIR="$(mktemp -d "${TMP_ROOT%/}/backup.XXXXXX")"
readonly WORK_DIR
trap 'rm -rf -- "$WORK_DIR"' EXIT
# запись с суженными правами
( umask 077; : > "$WORK_DIR/stage.tar" )
Правила работы с путями:
- cd -- "$DIR" || exit 1 вместо cd "$DIR". Без проверки скрипт продолжит работу в текущем каталоге и запишет файлы не туда. Флаг -- отделяет путь от опций, если имя начинается с дефиса.
- realpath -e "$DIR" нормализует путь и вернёт ошибку, если объекта нет. Это нужно при сравнении путей между собой.
- Проверяйте тип объекта до операции: [[ -d "$DIR" ]] || exit 1, [[ -f "$FILE" ]] || exit 1. Так rm -rf не получит пустую строку.
- Оборачивайте подстановки в кавычки: "$var" вместо $var. Без кавычек путь с пробелом распадётся на несколько аргументов (word splitting), а символы * и ? раскроются в имена файлов.
- Читайте переменные с дефолтом: "${BACKUP_DIR:-/var/backups}". Для обязательных значений подходит "${HOST:?HOST не задан}": скрипт остановится с понятным текстом.
- Ставьте umask 077 в начале скрипта, если он создаёт файлы с чувствительными данными.
Типичные ошибки и антипаттерны в bash-скриптах
Большая часть аварий от скриптов приходит не из сложной логики, а из базовых конструкций. Таблица ниже покрывает случаи, которые чаще всего всплывают на ревью:
| Как не надо | Как правильно | Что ломается |
|---|---|---|
| rm -rf $DIR | rm -rf -- "${DIR:?DIR не задан}" | Пустая или незакавыченная переменная удаляет лишний каталог |
| for f in $(ls *.log) | for f in *.log; do ...; done или find . -name '*.log' -print0 | xargs -0 | Имена с пробелами и переносами строк разбивают список на части |
| echo $var | printf '%s\n' "$var" | Word splitting, раскрытие * и ?, значения вида -n трактуются как опции |
| cd $DIR | cd -- "$DIR" || exit 1 | Скрипт молча продолжает работу в прежнем каталоге |
| result=`cmd` | result=$(cmd) | Вложенные обратные кавычки нечитаемы, экранирование ломается |
| if [ $count == 5 ] | if (( count == 5 )) или [ "$count" -eq 5 ] | Строковое сравнение чисел, падение на пустом значении |
| cmd | tail -n 1 | set -o pipefail или проверка PIPESTATUS | Ошибка первой команды конвейера теряется |
| trap 'rm -rf $TMP' EXIT | trap 'rm -rf -- "${TMP:?}"' EXIT | Пустая переменная превращает уборку в удаление корня |
Отдельный класс ошибок проявляется только на реальных данных: пробелы в путях, кириллица в именах, длинные списки файлов. Проверяйте скрипт на копии рабочего каталога, а не на тестовом наборе из латиницы без пробелов.
Статический анализ ловит часть проблем до запуска. shellcheck находит незакавыченные переменные, бесполезные подстановки и неверные сравнения, а встроенная проверка синтаксиса bash -n отсекает битый файл ещё до выкатки:
bash -n ./backup.sh shellcheck -S warning -x ./backup.sh
Запускайте обе команды в pre-commit хуке или на шаге линтера в CI: тогда замечания не доходят до прода.
Чек-лист перед выкаткой скрипта в прод
Список проверок для ревью merge request и для самопроверки перед запуском на боевом хосте:
- Shebang #!/usr/bin/env bash и права на файл 750, владелец root или сервисный пользователь, setuid снят.
- set -euo pipefail и IFS=$'\n\t' стоят в начале файла.
- bash -n проходит без ошибок, shellcheck -S warning не выдаёт замечаний или они осознанно разобраны.
- Каждая подстановка переменной в кавычках; для обязательных значений используется ${VAR:?}.
- Обработчик trap на EXIT (при необходимости INT и TERM) удаляет временные файлы, созданные через mktemp с правами 600/700.
- Коды возврата критичных команд и PIPESTATUS проверяются явно, сбои не замаскированы через || true.
- Логи с уровнем и timestamp пишутся в stderr или через logger; секретов, токенов и содержимого .env в логах нет.
- Идемпотентность: повторный запуск не ломает состояние. Проверка существования каталогов, mkdir -p, флаги --partial, отдельный флаг подтверждения для разрушительных шагов.
- Есть режим пробного прогона или переменная DRY_RUN, сценарий проверен в контейнере или на копии прода.
- Ошибки вызова возвращают код 2, сбои выполнения код 1, успех 0. Возвраты читаются в CI.
- Секреты приходят из окружения, systemd credentials или файла с правами 600, а не лежат в теле скрипта.
Пункты про идемпотентность и проверку восстановления стоит закрывать сквозным тестом: например, прогнать бэкап и убедиться, что архив разворачивается. Готовые примеры снапшотов, мониторинга и интеграции с Prometheus, Alertmanager, Zabbix и Jira собраны в статье про автоматизацию резервного копирования и восстановления.
Когда bash недостаточно: место скриптов среди Ansible, Salt и Chef
bash силён в связующем коде: обёртки вокруг утилит, деплой-хуки, разовые операции, локальные проверки на одной машине. Управление состоянием на множестве машин отдавайте Ansible, Salt или Chef: в требованиях к Linux-администраторам и DevOps-инженерам эти инструменты перечислены отдельным пунктом рядом с bash (описание вакансии).
Сравните два подхода на одной задаче. Обход 500 серверов циклом с ssh в bash хрупок: повторный прогон даёт другой результат, частичные сбои не учитываются, логика обрастает вложенными циклами и таймаутами. Плейбук с описанием желаемого состояния даёт идемпотентность: если настройка уже применена, второй запуск ничего не меняет.
Контейнерные сценарии подчиняются похожему правилу. Инфраструктура включает OpenStack, Kubernetes, Docker, Nginx, PostgreSQL или MySQL, Kafka, RabbitMQ, системы мониторинга и логирования. Если операция выполняется на каждой ноде регулярно, оформляйте её как CronJob, DaemonSet или systemd timer с unit-файлом, а bash-скрипт оставляйте телом задачи: так видны расписание, статус последнего запуска и возвраты.
Общее правило выбора: повторяемая задача с состоянием уходит в инструмент конфигурации, разовая операция или обёртка остаётся в bash. Если задача касается регулярных проверок безопасности и анализа логов, готовые схемы с cron, systemd timers и интеграцией в CI/CD разобраны в материале про автоматизацию аудита безопасности.