Bash-скрипты для администрирования Linux: структура, set -euo pipefail и чек-лист 2026 | AdminWiki

Bash-скрипты для администрирования Linux: структура, set -euo pipefail и чек-лист 2026

24 сентября 2026 13 мин. чтения

Зачем 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 $DIRrm -rf -- "${DIR:?DIR не задан}"Пустая или незакавыченная переменная удаляет лишний каталог
for f in $(ls *.log)for f in *.log; do ...; done или find . -name '*.log' -print0 | xargs -0Имена с пробелами и переносами строк разбивают список на части
echo $varprintf '%s\n' "$var"Word splitting, раскрытие * и ?, значения вида -n трактуются как опции
cd $DIRcd -- "$DIR" || exit 1Скрипт молча продолжает работу в прежнем каталоге
result=`cmd`result=$(cmd)Вложенные обратные кавычки нечитаемы, экранирование ломается
if [ $count == 5 ]if (( count == 5 )) или [ "$count" -eq 5 ]Строковое сравнение чисел, падение на пустом значении
cmd | tail -n 1set -o pipefail или проверка PIPESTATUSОшибка первой команды конвейера теряется
trap 'rm -rf $TMP' EXITtrap '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 разобраны в материале про автоматизацию аудита безопасности.

Поделиться:
Сохранить гайд? В закладки браузера