Скрипт делает дамп базы, жмёт его в 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}. Если развалилось небезобидно — поздравляю, нашёл баг, который мог рвануть в самый неподходящий момент.