open atlas
↑ К треку
Командная строка CLI · 08 · 04

Надёжные скрипты

set -euo pipefail: -e завершает при ошибке, -u — при необъявленной переменной, -o pipefail перехватывает сбои в конвейерах. trap убирает за собой при любом выходе. Функции с local предотвращают утечку переменных.

CLI Senior ◷ 25 min
Уровень
ОсновыJuniorMiddleSenior

Bash-скрипт без set -euo pipefail — это скрипт, молча продолжающийся после сбоев. Команда, вернувшая код выхода 1, игнорируется. Опечатка в имени переменной раскрывается в пустую строку. Упавшая команда внутри конвейера маскируется успехом последней команды. Скрипт выводит «Готово!» и оставляет за собой наполовину выполненное, повреждённое или перезаписанное состояние. Три флага и один вызов trap полностью меняют картину: bash становится строгим интерпретатором с ранним выходом, который останавливается при первом признаке проблемы и запускает код очистки перед выходом. Это не опциональная полировка — это разница между скриптом, которому доверяют в проде, и скриптом, которому нянчатся.

Цель

После этого урока ты сможешь написать корректную строку shebang, применить set -euo pipefail и объяснить, что делает каждый флаг, зарегистрировать очистку через trap, писать функции с local для предотвращения утечки переменных, и знать три распространённые ловушки set -e.

1

#!/usr/bin/env bash — корректный shebang для переносимых bash-скриптов. Он просит ОС запустить тот bash, который первым встречается в $PATH, а не указывает жёстко /bin/bash (которого нет в некоторых системах — NixOS, ряд контейнеров).

#!/usr/bin/env bash
# Всегда первая строка — никаких пустых строк выше

/bin/sh вызывает системную POSIX-оболочку — это может быть dash, ash или busybox, но не bash. Если скрипт использует [[ ]], массивы или другие bash-специфичные возможности, он обязан использовать #!/usr/bin/env bash. Запуск bash-скрипта под sh даёт загадочные ошибки разбора для конструкций, корректных в bash, но не в POSIX sh.

2

set -e завершает скрипт немедленно, если любая команда вернула ненулевой код выхода. Без него каждая упавшая команда молча игнорируется и выполнение продолжается.

set -e
cp /nonexistent /tmp/   # выходит здесь — cp упал
echo "Это никогда не выполнится"  # пропущено

set -u трактует любую ссылку на незаданную переменную как ошибку и завершает скрипт.

set -u
echo "$UNDEFINED_VAR"   # выходит: bash: UNDEFINED_VAR: unbound variable

Это перехватывает баг с опечаткой в переменной: $TEMP_DIR против $TMPDIR — одна задана, другая нет, и без -u незаданная молча раскрывается в пустую строку, вызывая rm -rf "" или cd "".

set -o pipefail заставляет конвейер упасть, если любая стадия упала, а не только последняя.

set -o pipefail
grep "шаблон" missing_file.txt | wc -l
# Без pipefail: завершается с 0 (wc успешен)
# С pipefail:   завершается с ненулевым (grep упал)

Ставь все три вместе в начале каждого скрипта:

set -euo pipefail
3

trap 'команда_очистки' СИГНАЛ регистрирует команду, которая выполнится при получении сигнала или выходе. Самый полезный сигнал — EXIT: он срабатывает при любом выходе, включая ошибки.

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

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT   # всегда очищается, даже при ошибке

# ... тело скрипта использует $TMPDIR ...
cp important_data.txt "$TMPDIR/"
process "$TMPDIR/important_data.txt"

Без trap сбой на середине оставляет $TMPDIR на диске. С ним bash запускает rm -rf "$TMPDIR" перед завершением процесса — независимо от того, как он завершается: нормально, через exit 1 или из-за необработанной ошибки.

Несколько сигналов: trap 'cleanup' EXIT INT TERM также обрабатывает Ctrl-C (INT) и kill (TERM).

4

Функции инкапсулируют переиспользуемую логику. local предотвращает утечку переменных в область вызывающего.

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

log() {
  local level="$1"
  local msg="$2"
  echo "[$(date +%T)] [$level] $msg" >&2
}

backup_file() {
  local src="$1"
  local dest="${src}.bak.$(date +%s)"
  cp "$src" "$dest"
  log INFO "Скопировано $src в $dest"
}

backup_file /etc/nginx/nginx.conf

Без local переменные level, msg, src, dest — глобальные. Если вызывающая функция тоже использует переменную с именем dest, внутренняя функция перезапишет её. local ограничивает переменную стековым фреймом функции. Это не опциональный стиль — это предотвращение action-at-a-distance багов в скриптах длиннее пары функций.

Функции возвращают коды выхода как команды. return 0 — успех; return N — ошибка. Код выхода последней команды — это код выхода функции, если явно не вызывается return.

5

Три распространённые ловушки set -e, подстерегающие опытных инженеров.

Ловушка 1: команды в условиях if освобождены от действия -e. set -e не срабатывает, когда команда используется непосредственно как условие.

set -e
if grep -q "шаблон" file.txt; then echo "найдено"; fi  # grep код 1 = не найдено, без abort
grep -q "шаблон" file.txt   # ЭТО вызовет abort при коде 1

Ловушка 2: последняя команда в конвейере определяет pipefail. С pipefail код выхода конвейера — это самый правый ненулевой код выхода. Если несколько стадий упали, сообщается только о последнем ненулевом.

Ловушка 3: local сама является командой с кодом выхода. Это тонкая ловушка:

set -e
get_value() {
  local val=$(failing_command)   # НЕВЕРНО: local возвращает 0, даже если $() упал
  local val
  val=$(failing_command)         # ПРАВИЛЬНО: объявление отдельно от присваивания
}

local val=$(cmd) вычисляет cmd, присваивает val, затем local выполняется и возвращает 0 независимо от результата — проглатывая ошибку. Сначала объяви local, затем присваивай отдельной строкой.

Разбор примера

Скелет production-grade скрипта деплоя.

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

DEPLOY_DIR="/var/app"
RELEASE_DIR="${DEPLOY_DIR}/releases/$(date +%Y%m%d_%H%M%S)"
CURRENT_LINK="${DEPLOY_DIR}/current"

log()  { echo "[$(date +%T)] $*" >&2; }
die()  { log "ОШИБКА: $*"; exit 1; }

cleanup() {
  local code=$?
  if [[ $code -ne 0 ]]; then
    log "Деплой провалился (код $code), откатываемся"
    [[ -L "$CURRENT_LINK" ]] && log "Символическая ссылка current цела"
    rm -rf "$RELEASE_DIR" 2>/dev/null || true
  fi
}
trap cleanup EXIT

[[ $# -eq 1 ]] || die "Использование: $0 <архив>"
[[ -f "$1"  ]] || die "Файл не найден: $1"

mkdir -p "$RELEASE_DIR"
tar -xzf "$1" -C "$RELEASE_DIR"
ln -sfn "$RELEASE_DIR" "$CURRENT_LINK"
log "Деплой завершён: $RELEASE_DIR"

Каждая переменная — local внутри функций. trap cleanup EXIT выполняется при любом сбое. set -euo pipefail гарантирует немедленный abort при ошибке tar или ln вместо создания наполовину задеплоенного релиза. die() централизует вывод ошибок.

Почему это работает

На macOS bash 3.2 set -e, set -u и set -o pipefail работают корректно. Специфика macOS: local val=$(cmd) — та же ловушка, то же решение, что и на Linux. Дополнительно: mktemp -d на macOS не требует аргумента с шаблоном (работает просто mktemp -d), тогда как GNU mktemp принимает и шаблон. Форма mktemp -d работает переносимо на обеих системах; mktemp -d /tmp/prefix.XXXXXX — только на Linux.

Частая ошибка

Опасный шаблон: set -e в комбинации с cd, за которым следует rm -rf. Если cd "$TARGET_DIR" провалится (неверный путь, права доступа) и set -e отсутствует, следующий rm -rf * выполнится в том каталоге, где скрипт находится сейчас — и, возможно, удалит не то дерево. С set -e скрипт завершается на упавшем cd. Форма с «поясом и подтяжками»: cd "$TARGET_DIR" || { echo "cd провалился" >&2; exit 1; } — явная даже при наличии set -e, потому что делает намерение очевидным для следующего читателя.

Проверь себя
Викторина

В скрипте с set -euo pipefail ты пишешь: local result=$(failing_command). Что происходит?

Итог

#!/usr/bin/env bash берёт корректный bash из $PATH вместо жёсткого указания /bin/bash. set -e завершает скрипт при ненулевом коде выхода; set -u — при обращении к незаданной переменной; set -o pipefail делает сбои в конвейере видимыми — используй все три, всегда. trap 'cleanup' EXIT гарантирует выполнение кода очистки независимо от способа завершения скрипта. local ограничивает переменные функции, предотвращая межфункциональное заражение. Ловушка local val=$(cmd): объявляй local и присваивай отдельно, чтобы set -e мог перехватить ошибку команды. Эти четыре практики превращают bash из хрупкой последовательности команд в надёжный, проверяемый инструмент автоматизации.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.