Bash, почему set -euo pipefail?

Скрипт делает дамп базы, жмёт его в gzip, кладёт в S3. В кроне отрабатывает каждую ночь, exit code 0, бэкапы копятся. Через полгода база падает, лезешь восстанавливаться — а в архивах пусто. Все 180 файлов. По нулям.

Так выглядит сценарий, который set -e пропускает молча. Эта статья — про три флага, которые стоит ставить в начало почти любого bash-скрипта, и про то, почему по отдельности они дырявые.

Что делает set -e

Базовое поведение bash: команда упала — скрипт идёт дальше. set -e (она же set -o errexit) говорит «выйти при первой ненулевой команде».

#!/bin/bash
set -e

cp non-existent-file /tmp/
echo "this line never runs"

Без set -e второй echo отработает, и скрипт вернёт 0. С set -e — выход на cp с кодом 1.

Звучит просто, но set -e имеет репутацию флага с подвохами. Главных два.

Первое — команды в условиях не считаются ошибкой. Это нужно, иначе любой if grep -q foo file ронял бы скрипт при отсутствии совпадения. Полезно, но иногда контринтуитивно:

set -e
if check_something; then
    do_work    # если check_something вернёт 1, сюда не зайдём — но скрипт не упадёт
fi
echo "continuing"  # выполнится

Второе — пайпы. Об этом ниже целый раздел, там и зарыта основная боль.

Документация bash честно предупреждает: errexit поведение исторически сложилось довольно странно, и есть целая страница в BashFAQ с разбором, почему многие опытные шелл-программисты set -e не любят. Но для типовых скриптов «сделай шаг, сделай ещё шаг, упади если что-то пошло не так» — это разумный дефолт.

set -u: ловим опечатки в переменных

Без -u обращение к незаданной переменной возвращает пустую строку. Молча. Это особенность шелла, унаследованная из 70-х, и в большинстве случаев она работает против тебя.

Классика жанра:

#!/bin/bash
BACKUP_DIR="/var/backups/db"
# ...сто строк кода...
rm -rf "$BACUP_DIR/old"   # опечатка в имени

Без -u: $BACUP_DIR развернётся в пустую строку, команда станет rm -rf /old. Если /old существует — поздравляю, у тебя интересная ночь. Если не существует — ошибка rm, set -e сработает, но переменная-то всё равно с опечаткой, и в следующий раз ситуация повторится.

С set -u (она же nounset) скрипт падает сразу на строке с опечаткой: BACUP_DIR: unbound variable. Сразу видно, где именно.

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

set -u

# упадёт, если $1 не передан
NAME="$1"

# не упадёт — есть дефолт
NAME="${1:-default}"

# не упадёт — проверка существования
if [ -n "${OPTIONAL_VAR:-}" ]; then
    use_var "$OPTIONAL_VAR"
fi

Конструкция ${VAR:-} означает «значение VAR, а если не задано — пустая строка». Под -u это безопасный способ проверить переменную.

Отдельная грабля — массивы. В bash до версии 4.4 обращение к пустому массиву "${arr[@]}" под set -u падало. С 4.4 починили, но если скрипт должен работать на старых системах (RHEL 7, macOS со стандартным bash 3.2) — стоит писать "${arr[@]+"${arr[@]}"}". Уродливо, но работает.

set -o pipefail: главная причина, по которой я пишу эту статью

Вот сценарий, с которого начали. Скрипт бэкапа:

#!/bin/bash
set -e

mysqldump --all-databases | gzip > /backup/full-$(date +%F).sql.gz
aws s3 cp /backup/full-$(date +%F).sql.gz s3://my-backups/

Exit code пайплайна a | b | c в bash по умолчанию равен exit code последней команды. Только последней. Если mysqldump упал — например, кончилось место в /tmp, или сменили пароль, или сервер прилёг — gzip всё равно отработает успешно. Он же получил какой-то ввод (пусть и пустой или обрезанный) и честно его сжал. Exit code пайпа = 0. set -e доволен. aws s3 cp тоже доволен, файл-то есть. В S3 уезжает архив с обрезанным или вообще пустым дампом.

И так каждую ночь.

set -o pipefail меняет логику: exit code пайпа становится равен коду первой упавшей команды. Если упал mysqldump — пайп возвращает его код, set -e срабатывает, скрипт падает, в идеальном мире — приходит алерт.

Проверить разницу можно одной строкой:

$ false | true; echo $?
0

$ set -o pipefail
$ false | true; echo $?
1

Без pipefail ошибка false теряется. С ним — всплывает.

Это не теоретическая проблема. Истории про «бэкапы не восстанавливаются» в большинстве своём — про отсутствие pipefail где-нибудь в цепочке. Достаточно одного скрипта.

Зачем все три вместе

Каждый флаг закрывает свой класс ошибок:

  • -e ловит падения отдельных команд
  • -u ловит опечатки в именах переменных и забытые экспорты
  • -o pipefail ловит ошибки внутри пайпов

Они не пересекаются. Можно поставить только -e и считать, что защита есть — но скрипт с дампом базы через пайп будет продолжать тихо отгружать пустые архивы. Можно поставить -eu — но всё та же проблема с пайпами останется.

Канонический «безопасный заголовок» для bash-скрипта выглядит так:

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

Последняя строка — отдельная тема (защита от пробелов в именах файлов при словарных циклах), но set -euo pipefail идёт в комплекте практически всегда. Это рекомендация Aaron Maxwell, известная как «unofficial bash strict mode», и за десять лет она прижилась как стандарт де-факто.

Что меняется на практике

Возьмём типичный скрипт деплоя — до и после.

Было:

#!/bin/bash

VERSION=$(git describe --tags)
docker build -t myapp:$VERSION .
docker push myapp:$VERSION
ssh deploy@prod "docker pull myapp:$VERSION && docker-compose up -d"

Что тут может пойти не так:

  • git describe упадёт (нет тегов) — $VERSION пустая, тег станет myapp:, docker build отработает с тегом myapp:latest. Сюрприз.
  • Опечатка в VERSION (например, docker push myapp:$VERION) — пуш с пустым тегом, та же история.
  • docker build упал — docker push всё равно попробует пушить старый локальный образ.

С set -euo pipefail:

#!/bin/bash
set -euo pipefail

VERSION=$(git describe --tags)         # упал? Скрипт падает здесь.
docker build -t "myapp:$VERSION" .     # упал build — дальше не пойдём
docker push "myapp:$VERSION"
ssh deploy@prod "docker pull myapp:$VERSION && docker-compose up -d"

Опечатка в $VERION теперь даёт явную ошибку про unbound variable, а не молча пушит пустой тег.

Подводные камни, про которые стоит знать

set -e и арифметика. Это место, где люди чаще всего обжигаются:

set -e

# счётчик увеличивается до 1 — вернёт 0 (успех)
# счётчик увеличивается до 0 — вернёт 1, и скрипт упадёт
((counter++))

Постфиксный инкремент возвращает старое значение. Если оно было 0 — exit code 1, скрипт падает. Лечится через ((counter++)) || true или counter=$((counter + 1)).

set -e не работает внутри функций, вызванных в условиях. Тонкость, на которую жалуется тот самый BashFAQ:

set -e

my_func() {
    false        # должно уронить функцию
    echo "but I still run"
}

if my_func; then   # внутри условия errexit не действует
    echo "ok"
fi

false не уронит функцию, потому что она вызвана в контексте if. Не сказать, что это часто встречается, но знать стоит.

pipefail не отменяет SIGPIPE. Классический случай:

set -euo pipefail
generate_huge_output | head -n 10

head прочитает 10 строк и закроет stdin. generate_huge_output получит SIGPIPE и завершится с кодом 141. pipefail это засечёт, скрипт упадёт. Хотя по смыслу всё было правильно. Лечится либо || true в конце пайпа, либо передачей через временный файл, либо точечным отключением:

set +o pipefail
generate_huge_output | head -n 10
set -o pipefail

Альтернатива всему этому — переписать на Python. Если скрипт разрастается до пары сотен строк, ловит несколько типов ошибок, имеет нетривиальную логику ветвления — bash перестаёт окупаться. subprocess.run(..., check=True) плюс нормальные исключения дают то же самое без танцев с ${VAR:-} и || true.

Что делать прямо сейчас

Открыть любой свой долгоживущий bash-скрипт. Посмотреть на первую строку.

Если там просто #!/bin/bash без set — добавить set -euo pipefail сразу под шебангом. Прогнать на тестовых данных. Скорее всего, что-то развалится в неожиданном месте — это и есть ошибки, которые скрипт раньше прятал.

Особенно — скрипты, которые делают пайпы с критичными данными: бэкапы баз, выгрузки логов, обработка платёжных файлов. Там pipefail нужен в первую очередь.

Если что-то развалилось безобидно (типа того инкремента счётчика) — точечно лечится || true или ${VAR:-default}. Если развалилось небезобидно — поздравляю, нашёл баг, который мог рвануть в самый неподходящий момент.

Оставить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *