Learning Platform
Глоссарий Troubleshooting
Урок 19.01 · 22 мин
Средний
Bashset -epipefailIFSProductionDefensive scripting

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). Что произойдёт?

Что происходит без set -e (default bash)

Каждая команда выполняется независимо. Ошибка не останавливает скрипт.

curlУпал с exit code 28 (timeout). users.json создан, но он пустой (0 байт). Bash продолжает дальше
jqПолучает пустой stdin, выдаёт ошибку 'parse error: invalid numeric literal'. processed.json остаётся либо пустой, либо со старым содержимым
aws s3 cpЗаливает пустой/старый файл в S3. Downstream считает что всё OK, и обрабатывает мусор
echo Done!Печатается всегда. Airflow видит exit 0 и считает task succeeded. SILENT FAILURE

В логах будет одна строка 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 не идеален. Есть случаи, когда команда упала, но скрипт продолжает работать:

Когда set -e НЕ срабатывает
Команды в условииif grep foo file; then ... fi — grep может упасть, но это часть условного оператора. Это by design: проверка успешности — это нормальное использование exit code
cmd && cmd2В составе && или || exit code это часть логики, set -e не сработает
cmd | cmd2В pipeline без pipefail exit code берётся от ПОСЛЕДНЕЙ команды. Если первая упала, а вторая прошла — exit 0
cmd || trueЯвный override. Используется когда мы намеренно игнорируем ошибку. Это легально и читаемо
(...) subshellSubshell с собственным set state. set -e ВНУТРИ subshell применяется к нему отдельно
function() returnВнутри функции, вызванной в условии — 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 останавливает скрипт
pipeline: с pipefail vs без
Без pipefailcurl exit 28 -> jq exit 0 -> pipeline exit 0. set -e не сработает
exit code
0Скрипт думает что всё хорошо. Silent failure
С pipefailcurl exit 28 -> jq exit 0 -> pipeline exit 28 (от первого упавшего). set -e сработает
exit code
28Скрипт упадёт с осмысленным кодом ошибки. Airflow retry, alert, человек разбирается

В сочетании 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 — ОБЯЗАТЕЛЬНО для опциональных
Patterns для опциональных переменных
`${VAR:-def}`Если VAR не задан ИЛИ пуст — вернуть 'def'. Не присваивает VAR. Самый частый паттерн
`${VAR:=def}`Если VAR не задан/пуст — присвоить 'def' и вернуть. После — VAR=def
`${VAR:?err}`Если VAR не задан/пуст — упасть с сообщением 'err'. Для обязательных аргументов
`${VAR:+sub}`Если VAR задан и не пуст — вернуть 'sub'. Иначе пустую строку. Условная подстановка
#!/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
WARNING

Использование 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-скрипте. Запоминаем как мантру.

Preamble Anatomy

Каждая опция отвечает за свой класс багов.

set -eerrexit — падать на первой ошибке. Защита от silent failures. Без него bash продолжает после fail
set -unounset — undefined переменная = error. Защита от опечаток типа $LOGDIR вместо $LOG_DIR
set -o pipefailpipefail — exit code pipeline = первый non-zero. Защита от маскировки ошибок в pipe
IFS=$'\\n\\t'Только newline и tab разделяют слова. Защита от word-splitting на пробелы в filenames/values

Когда отключать (намеренно)

Иногда 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"

Что мы получили:

  1. set -euo pipefail — любая ошибка останавливает скрипт.
  2. --fail у curl — HTTP 4xx/5xx тоже триггерят non-zero exit (без --fail curl возвращает 0 на HTTP errors).
  3. jq -e — strict mode, упадёт на невалидный JSON.
  4. Обязательные/опциональные аргументы через ${:?} и ${:-}.

Этот скрипт либо успешно отрабатывает (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).


  • Урок 02 (массивы) — почему quoting "${arr[@]}" критичен с включённым IFS.
  • Урок 03 (trap) — cleanup при падении на set -e.
  • Урок 05 (debugging)shellcheck ловит 90% проблем, которые set -e не успевает.
  • Capstone (модуль 20) — preamble обязателен в любом скрипте, который мы будем писать.

Попробуй сам

  1. Создай файл bad.sh:
#!/bin/bash
ls /nonexistent_dir
echo "Я печатаюсь даже после ошибки!"

Запусти. Увидишь, что echo выполнился, и exit code 0.

  1. Добавь set -e в начало (после shebang). Запусти. echo теперь не выполняется, exit code 2.

  2. Создай pipe.sh:

#!/bin/bash
set -e
false | true
echo "После pipeline"

Запусти — echo отработает. Добавь set -o pipefail. Запусти — echo не отработает.

  1. Создай undef.sh:
#!/bin/bash
set -u
echo "$UNDEFINED_VAR"

Запусти — скрипт упадёт с UNDEFINED_VAR: unbound variable. Замени $UNDEFINED_VAR на ${UNDEFINED_VAR:-default} — увидишь “default”.

  1. Создай 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' перед циклом. Запусти — увидишь одну строку.


Проверка знанийKnowledge check
Junior пишет скрипт, который качает данные через curl, потом обрабатывает через jq, потом загружает в S3. На preview-environment всё работает. На production иногда возникают 'неполные данные' в S3, но скрипт всегда показывает exit 0. Какой preamble нужен и почему?
ОтветAnswer
Preamble: #!/bin/bash + set -euo pipefail + IFS=$'\n\t'. Разбор: 1) set -e — если curl упадёт (timeout, 5xx), скрипт остановится, не пойдёт дальше обрабатывать пустой файл. 2) set -o pipefail — критично! В pipeline curl ... | jq ... без pipefail exit code = exit code jq (часто 0 даже на пустом stdin). Если curl упал, а jq не упал — без pipefail скрипт думает, что всё OK. С pipefail exit code pipeline = exit code первой упавшей команды. 3) set -u — защищает от опечаток в именах env-переменных (\$API_TOKEN vs \$APITOKEN), которые ведут к пустым значениям в curl запросах. 4) IFS=$'\n\t' — если в обработке участвуют имена файлов/значения с пробелами (CSV-данные часто содержат пробелы), дефолтный IFS разобьёт их на части. Дополнительно: для curl нужно --fail (без него HTTP 5xx возвращает 0), для jq — -e (exit 1 если результат null/false). Это и есть разница между скриптом, который "иногда работает" и production-grade скриптом.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 5. Что произойдёт в скрипте `set -e; false | true; echo hi` без опции pipefail?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5