Production preamble: set -euo pipefail и IFS
Bash по умолчанию — это очень прощающий язык. Если команда упала — он молча продолжает дальше. Если переменная не определена — она просто пустая строка. Если в pipeline сломалась первая команда из пяти — exit code последней команды (часто 0) скрывает проблему. На рабочей машине Junior пишет скрипт, тестирует — вроде работает. Заливает в Airflow, скрипт «работает» — но скрытно пропускает половину файлов. Через неделю аналитик жалуется, что данные неполные. День разборок — bug найден в скрипте, который написан был «как у всех в интернете».
В этом уроке учимся писать bash, который громко падает при первой же проблеме. Это занимает одну строку — preamble set -euo pipefail; IFS=$'\n\t'. Каждый production-скрипт в нашей команде, в Airflow, в CI/CD должен начинаться именно так. Junior, который ставит этот preamble автоматически, отличается от того, кто этого не делает, как зрячий от слепого.
Дефолтное поведение bash — почему оно опасно
Запустим простой скрипт без preamble:
#!/bin/bash
# fetch.sh — скачать данные, обработать, отправить дальше
curl --max-time 5 https://api.example.com/users > users.json
cat users.json | jq '.data' > processed.json
aws s3 cp processed.json s3://my-bucket/output/
echo "Done!"
Сценарий: API недоступен, curl упал с exit code 28 (timeout). Что произойдёт?
Каждая команда выполняется независимо. Ошибка не останавливает скрипт.
В логах будет одна строка stderr от jq и Done! в stdout. Airflow увидит success, task пройдёт зелёным. Через сутки в Snowflake — кривые данные. Это и есть silent failure — самая дорогая категория багов в data engineering.
set -e: упади на первой же ошибке
Опция -e (или set -o errexit) меняет правила: если любая команда возвращает ненулевой exit code — скрипт сразу завершается с этим же exit code.
#!/bin/bash
set -e
curl --max-time 5 https://api.example.com/users > users.json
# Если curl упал — скрипт здесь и закончится с exit 28
cat users.json | jq '.data' > processed.json
aws s3 cp processed.json s3://my-bucket/output/
echo "Done!"
Теперь при падении curl ни jq, ни aws s3 cp, ни echo не выполнятся. Airflow увидит task failure, retry policy сработает, человек получит alert. Это то, что нужно.
Тонкости и исключения set -e
set -e не идеален. Есть случаи, когда команда упала, но скрипт продолжает работать:
Самый коварный случай — pipeline:
set -e
curl https://broken-api.com/data | jq '.users' > out.json
# curl упал, но jq получил пустой stdin и выдал 'null' с exit 0
# Скрипт продолжает работать с out.json содержащим строку 'null'
echo "Continues here..."
Решение — добавить pipefail.
set -o pipefail: pipeline падает, если упала любая команда внутри
По умолчанию exit code pipeline = exit code последней команды. Опция pipefail меняет это: если любая команда в pipeline упала, exit code = exit code первой упавшей.
set -e
set -o pipefail
curl https://broken-api.com/data | jq '.users' > out.json
# Теперь pipeline возвращает exit code 28 от curl
# set -e останавливает скрипт
В сочетании set -e и pipefail дают нам громкое падение на любой проблеме в pipeline.
set -u: undefined переменная это ошибка
Default bash считает, что необъявленная переменная — это пустая строка. Опасно:
#!/bin/bash
# delete_logs.sh — удаляет логи из директории
LOG_DIR="/var/log/myapp"
rm -rf "$LOG_DIR/" # норм
# Опечатка в имени переменной:
rm -rf "$LOGDIR/" # LOGDIR не определён, превращается в ""
# Команда становится: rm -rf "/"
# rm -rf / в современных Linux требует --no-preserve-root, но другие команды нет
Опция -u (или set -o nounset) превращает обращение к необъявленной переменной в фатальную ошибку:
#!/bin/bash
set -u
LOG_DIR="/var/log/myapp"
rm -rf "$LOGDIR/"
# bash: LOGDIR: unbound variable
# Exit code 1, скрипт упал
Дефолты для опциональных переменных
Иногда переменная намеренно опциональна (например, --dry-run flag через env). С set -u нужно явно указать дефолт:
#!/bin/bash
set -euo pipefail
# Опциональная переменная с дефолтом:
DRY_RUN="${DRY_RUN:-false}"
# Шорткат: если переменная не задана, используем "false"
# ${VAR:-default} работает и без set -u, но с set -u — ОБЯЗАТЕЛЬНО для опциональных
#!/bin/bash
set -euo pipefail
# Обязательные:
INPUT_FILE="${1:?Usage: $0 <input.csv>}"
# Опциональные с дефолтами:
OUTPUT_DIR="${OUTPUT_DIR:-/tmp/processed}"
LOG_LEVEL="${LOG_LEVEL:-INFO}"
# Условные:
DEBUG_FLAG="${DEBUG:+--verbose}"
# Если DEBUG задан — DEBUG_FLAG="--verbose", иначе пустая строка
mycommand $DEBUG_FLAG --input "$INPUT_FILE"
IFS: защита от word-splitting сюрпризов
IFS — Internal Field Separator. Bash использует его при word splitting (разбиении строк на токены). Дефолт — пробел, табуляция, перевод строки. Это значит: если в имени файла пробел, bash считает это двумя разными аргументами.
Классический пример катастрофы:
#!/bin/bash
# Дефолтный IFS — для каждого файла запустить обработку
for f in $(ls /data); do
process "$f"
done
Если в /data есть файл User Data 2026.csv — этот код вызовет:
process User
process Data
process 2026.csv
Три ошибки вместо одной обработки. И никакого set -e не спасёт, потому что технически ошибки нет — каждый вызов process выполнится.
Решение: IFS=$‘\n\t’
В preamble мы устанавливаем IFS=$'\n\t' — только перевод строки и табуляция как разделители. Пробелы перестают разбивать строки:
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
# Теперь:
for f in $(ls /data); do
process "$f" # "User Data 2026.csv" пройдёт как один аргумент
done
Использование for f in $(ls /data) — всё равно плохая практика, даже с правильным IFS. Парсинг вывода ls ломается на newlines в именах файлов (да, это легальные имена). Правильный паттерн через find ... -print0 | xargs -0 или find ... -exec. Подробнее — в уроке про trap (03) и в capstone-лабе.
$‘\n\t’ — что это за синтаксис
$'...' — это ANSI-C quoting в bash. Внутри обрабатываются escape-последовательности: \n — newline, \t — tab, \\ — backslash, \xHH — hex-байт. Без $ (просто '...') escape-последовательности остаются буквальными символами.
echo 'hello\nworld' # выведет: hello\nworld
echo $'hello\nworld' # выведет:
# hello
# world
Все вместе: production preamble
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
# Дальше — твой скрипт
Это должно быть в каждом production-bash-скрипте. Запоминаем как мантру.
Каждая опция отвечает за свой класс багов.
Когда отключать (намеренно)
Иногда set -e мешает. Например, мы хотим проверить exit code и среагировать:
set -euo pipefail
# Проверить, есть ли пользователь в системе:
if id "deploy_user" &>/dev/null; then
echo "User exists"
else
echo "User missing, creating..."
useradd deploy_user
fi
Здесь id может упасть (пользователя нет) — это нормально и обработано через if. set -e не сработает, потому что команда в условии.
Если нужно временно выключить -e для блока:
set +e # выключить errexit
some_command_that_might_fail
EXIT_CODE=$?
set -e # включить обратно
if [ $EXIT_CODE -ne 0 ]; then
echo "Command failed with $EXIT_CODE, handling..."
fi
Альтернатива — cmd || true:
# Игнорировать ошибку этой конкретной команды:
rm -f /tmp/maybe_exists.txt || true
# Игнорировать ошибку и сохранить код:
some_command || EXIT_CODE=$?
GitHub Actions workflow для DE: bash в CI/CD
Реалистичный пример: production-grade fetch
Соберём всё вместе на DE-задаче:
#!/bin/bash
# fetch_users.sh — fetch users from API, validate, upload to S3
set -euo pipefail
IFS=$'\n\t'
# Обязательные аргументы:
API_TOKEN="${API_TOKEN:?API_TOKEN env var required}"
S3_BUCKET="${1:?Usage: $0 <s3-bucket>}"
# Опциональные:
OUTPUT_DIR="${OUTPUT_DIR:-/tmp/fetch_users_$$}"
LOG_LEVEL="${LOG_LEVEL:-INFO}"
mkdir -p "$OUTPUT_DIR"
# Шаг 1: fetch
curl --max-time 30 --fail \
-H "Authorization: Bearer $API_TOKEN" \
https://api.example.com/users \
> "$OUTPUT_DIR/users.json"
# Шаг 2: validate JSON через jq (упадёт если невалидный)
jq -e 'type == "array" and length > 0' "$OUTPUT_DIR/users.json" > /dev/null
# Шаг 3: upload
aws s3 cp "$OUTPUT_DIR/users.json" "s3://$S3_BUCKET/raw/$(date +%Y-%m-%d)/users.json"
echo "OK: uploaded $(jq 'length' < "$OUTPUT_DIR/users.json") records"
Что мы получили:
set -euo pipefail— любая ошибка останавливает скрипт.--failу curl — HTTP 4xx/5xx тоже триггерят non-zero exit (без--failcurl возвращает 0 на HTTP errors).jq -e— strict mode, упадёт на невалидный JSON.- Обязательные/опциональные аргументы через
${:?}и${:-}.
Этот скрипт либо успешно отрабатывает (exit 0, файл в S3), либо громко падает (exit != 0, понятный stderr) — третьего не дано.
Подводные камни и FAQ
local var=$(cmd) маскирует exit code
function get_data() {
local data
data=$(curl https://api.example.com) # set -e сработает на curl fail
# ВНО:
local data2=$(curl https://api.example.com) # set -e НЕ сработает!
# Потому что local — это команда, она возвращает exit 0 даже если $() упало
}
Правило: сначала объяви local, потом присваивай:
local data
data=$(curl https://api.example.com)
errexit не наследуется в functions внутри () подоболочек
set -e
my_func() {
false # упадёт, скрипт завершится
echo "никогда"
}
my_func # тут set -e работает
# Но:
( my_func; echo "this runs" ) # subshell, могут быть нюансы
Знать тонкости важно, но как Junior просто всегда ставь preamble и пиши без хитростей — тогда set -e будет работать предсказуемо.
Bash 5.3 (2025) и errexit для условных конструкций
В Bash 5.3 поведение set -e для редких конструкций было дополнительно стабилизировано. Большая часть «странностей» из старых статей устарела. Но базовое правило не меняется: ставь preamble, тестируй скрипт, используй shellcheck (про него — в уроке 05).
Cross-links на следующие модули
- Урок 02 (массивы) — почему quoting
"${arr[@]}"критичен с включённым IFS. - Урок 03 (trap) — cleanup при падении на
set -e. - Урок 05 (debugging) —
shellcheckловит 90% проблем, которыеset -eне успевает. - Capstone (модуль 20) — preamble обязателен в любом скрипте, который мы будем писать.
Попробуй сам
- Создай файл
bad.sh:
#!/bin/bash
ls /nonexistent_dir
echo "Я печатаюсь даже после ошибки!"
Запусти. Увидишь, что echo выполнился, и exit code 0.
-
Добавь
set -eв начало (после shebang). Запусти.echoтеперь не выполняется, exit code 2. -
Создай
pipe.sh:
#!/bin/bash
set -e
false | true
echo "После pipeline"
Запусти — echo отработает. Добавь set -o pipefail. Запусти — echo не отработает.
- Создай
undef.sh:
#!/bin/bash
set -u
echo "$UNDEFINED_VAR"
Запусти — скрипт упадёт с UNDEFINED_VAR: unbound variable. Замени $UNDEFINED_VAR на ${UNDEFINED_VAR:-default} — увидишь “default”.
- Создай
ifs.sh:
#!/bin/bash
mkdir -p /tmp/ifs-test
touch '/tmp/ifs-test/file with spaces.txt'
for f in $(ls /tmp/ifs-test); do
echo "Got: $f"
done
Запусти — увидишь три строки (“Got: file”, “Got: with”, “Got: spaces.txt”). Добавь IFS=$'\n\t' перед циклом. Запусти — увидишь одну строку.