сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Linux и bash

Bash: коды возврата и set -euo pipefail

Коды возврата и set -e

Написал скрипт: удалить каталог по пути из переменной. В имени переменной опечатка, значение пустое. Скрипт отработал молча и вернул код 0 - он честно удалил каталог по пути, который собрался из пустого значения. Добавил один флаг, и тот же скрипт остановился на второй строке со словами «unbound variable».

Стержень: три флага закрывают три частые дыры, но у каждого есть исключения, и знание этих исключений отличает того, кто их ловил, от того, кто копировал строку из чужого скрипта.

// Формулировки: «что делает set -euo pipefail?», «почему скрипт не остановился на ошибке?», «как проверять код возврата?»

Три флага и что каждый ловит

Код возврата - число, которое команда оставляет после себя: 0 означает успех, любое другое - ошибку. Скрипт по умолчанию их игнорирует и идёт дальше, что и делает половину аварий тихими.

Флаг остановки на ошибке (set -e) прерывает скрипт на первой неуспешной команде. Проверил: скрипт с командой, возвращающей ошибку, остановился сразу и сам вернул код 1. Флаг запрета пустых переменных (set -u) роняет скрипт при обращении к необъявленной переменной - именно он поймал мою опечатку. Флаг для конвейеров (pipefail) меняет правило подсчёта кода. Конвейер - это цепочка команд, где вывод одной уходит на вход следующей; без этого флага код всего конвейера берётся от ПОСЛЕДНЕЙ команды, и связка «ошибка, а потом успех» даёт общий код 0; с ним та же связка честно даёт 1. Замерил обе.

// Отсюда привычная первая строка прод-скрипта: включить все три сразу. Она не делает скрипт правильным, но убирает три самых частых способа не заметить поломку.

код возврата
число после команды: 0 успех, остальное ошибка
set -e
останавливать скрипт на первой неуспешной команде
pipefail
код конвейера берётся от первой упавшей команды, а не от последней

Где остановка на ошибке не срабатывает

Это и есть суть вопроса на собесе, потому что исключений несколько и они неочевидны. Проверил каждое.

Первое: команда внутри условия. Написал функцию, которая падает, и вызвал её в условии - скрипт спокойно дошёл до конца и вернул 0. Так и задумано: пока команда стоит в условии, её код проверяют, а не считают аварией. Побочный эффект в том, что ВНУТРИ такой функции остановка на ошибке тоже выключается, и падение в её середине никого не остановит. Второе: подстановка команды внутри строки. Присваивание переменной результата упавшей команды скрипт останавливает, а вот та же упавшая команда внутри строки вывода - нет, код теряется. Третье: арифметика. Строка увеличения счётчика с нуля вернула код 1 и уронила скрипт с включённой остановкой - потому что результат выражения оказался нулём, а это для оболочки «ложь».

// Вывод не в том, чтобы заучить список, а в том, чтобы не считать флаги защитой от всего. Там, где результат важен, код возврата проверяют явно: выполнили, посмотрели код, решили. А в конвейерах смотрят массив кодов всех участников, а не один общий.

команда в условии
её падение не считается аварией; внутри неё остановка тоже отключена
подстановка команды
выполнение команды ради её вывода прямо внутри строки

Как падать громко

Скрипт, который упал молча и вернул ноль, хуже, чем скрипт, который не запускался: планировщик - служба, запускающая задачи по расписанию, - считает задачу выполненной, мониторинг молчит, а работа не сделана. Мой пример с опечаткой в имени переменной именно такой - код 0, ничего не произошло, все довольны.

Минимальный набор для прод-скрипта такой. Включить все три флага. Ставить ловушку на выход, которая печатает, на какой строке всё сломалось. Писать сообщения об ошибках в поток ошибок, а не в обычный вывод, иначе они смешаются с полезными данными и уедут в конвейер. И возвращать осмысленный ненулевой код, чтобы вызывающая сторона могла отличить «нечего делать» от «сломалось».

// Отдельно про проверки перед действием. Скрипт, который что-то удаляет или перезаписывает, обязан сначала убедиться, что путь непустой и указывает туда, куда задумано. Это дешёвая страховка ровно от того случая, который я воспроизвёл: пустая переменная превращает путь в корень.

ловушка на выход
команда trap, которая срабатывает при завершении и печатает место падения
поток ошибок
отдельный поток вывода для сообщений о проблемах

Как отвечать: «Что делает set -euo pipefail и от чего он НЕ спасает?»

Три флага закрывают три дыры. Остановка на первой ошибке, падение при обращении к необъявленной переменной и правильный подсчёт кода конвейера - без последнего связка «упало, потом успешно» даёт общий ноль, я это проверял. Но от всего они не спасают, и это важнее самой строки. Команда внутри условия не считается аварией, и внутри такой функции остановка тоже выключена: у меня функция падала на первой строке, а скрипт спокойно доходил до конца. Упавшая команда внутри строки вывода тоже теряет код. И арифметика: увеличение счётчика с нуля возвращает единицу и роняет скрипт с включённой остановкой. Поэтому там, где результат важен, я проверяю код возврата явно, а не полагаюсь на флаги, и обязательно ставлю ловушку, которая печатает строку падения.

Ответ показывает и пользу флагов, и их границы на конкретных случаях. Заученная строка без исключений выдаёт человека, который её скопировал.

На чём валятся

  • Считают set -e полной защитой и не проверяют коды возврата явно.
  • Не знают, что внутри функции, вызванной в условии, остановка на ошибке отключена.
  • Забывают pipefail и получают зелёный конвейер с упавшей первой командой.
  • Пишут ошибки в обычный вывод, и они уезжают дальше по конвейеру как данные.
  • Возвращают ноль при неудаче: планировщик считает задачу выполненной.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 7, остальные разбираются в тренажёре.

  1. #dvo_bash_flow1 / 5
    В скрипте с set -e строка curl -sf "$URL" | tee /tmp/resp.json. curl упал, скрипт поехал дальше. Почему?
    A)Код пайплайна берётся от последней команды — от tee
    B)Флаг -f у curl подавляет и вывод ошибки, и код возврата
    C)set -e не действует на строки с перенаправлением вывода
    D)tee перехватил ошибку и вернул пустой файл вместо кода
    показать ответ и разбор
    +A)Код пайплайна берётся от последней команды — от tee

    // разбор: Статус пайплайна в bash — это статус последней команды. tee на пустом входе завершился нулём, set -e ничего плохого не увидел, и в файле осталась пустота, которую скрипт понесёт дальше. Лечится set -o pipefail (код станет первым ненулевым) или разбором массива PIPESTATUS сразу после пайплайна.

  2. #dvo_bash_flow2 / 5
    В функции написано local ip=$(get_ip), следом проверка if [ $? -ne 0 ]. get_ip падает, а проверка молчит. Почему?
    A)Внутри функции $? хранит код вызова самой функции
    B)Подстановка команд не отдаёт код возврата, только вывод
    C)$? здесь — код команды local, а она отработала успешно
    D)Нужен pipefail: без него код подстановки теряется
    показать ответ и разбор
    +C)$? здесь — код команды local, а она отработала успешно

    // разбор: local, declare, export и readonly — обычные команды, и их собственный код возврата затирает код подстановки. Поэтому объявление и присваивание разносят: local ip; ip=$(get_ip) || return 1. Та же ловушка съедает и set -e — скрипт с local x=$(false) едет дальше как ни в чём не бывало, а переменная остаётся пустой.

  3. #dvo_bash_flow3 / 5
    Скрипт создаёт временный файл и должен удалять его при любом выходе, включая ошибку и прерывание. Как надёжнее?
    A)Достаточно поставить rm в самом конце скрипта
    B)Положиться на то, что /tmp сам очистится при перезагрузке
    C)trap 'rm -f "$tmp"' EXIT — обработчик сработает на любом завершении
    D)Ставить rm после каждой команды, которая может упасть
    показать ответ и разбор
    +C)trap 'rm -f "$tmp"' EXIT — обработчик сработает на любом завершении

    // разбор: trap 'команда' EXIT вешает обработчик, который выполняется при любом завершении скрипта: обычном, по set -e и по перехваченным сигналам. Одно место — гарантированная уборка. Для временных файлов канон: tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT. Отдельно можно ловить INT/TERM, если нужна особая реакция на прерывание.

  4. #dvo_bash_flow4 / 5
    В скрипте с set -e функция check возвращает ошибку внутри if check; then …, но скрипт не падает и идёт дальше. Это баг set -e?
    A)Нет: в условии if команде разрешено падать, set -e там намеренно не действует
    B)Да, set -e вообще не распространяется на функции
    C)Да, set -e ловит только внешние бинарники, но не функции
    D)Нет, потому что внутри функции set -e надо включать заново
    показать ответ и разбор
    +A)Нет: в условии if команде разрешено падать, set -e там намеренно не действует

    // разбор: set -e намеренно не срабатывает, когда команда стоит в проверяемом контексте: условие if/while/until, левые операнды && и ||, отрицание через !. Более того, если функцию вызвать в таком контексте, set -e отключается на всё её тело — классический футган: «внутри check ошибка не остановила». Хочешь проверить и обработать — используй if с явной веткой, а не полагайся на падение.

  5. #dvo_bash_flow5 / 5
    Скрипт-обёртка на bash запускает приложение и стоит PID 1 в контейнере. docker stop висит весь таймаут и роняет контейнер по SIGKILL. Что упустили в обёртке?
    A)Добавить sleep перед завершением, чтобы приложение успело закрыться
    B)Поднять таймаут docker stop — обёртка тут ни при чём
    C)Запускать приложение в фоне через &, тогда сигнал дойдёт
    D)Приложение надо запускать через exec — тогда оно само станет PID 1
    показать ответ и разбор
    +D)Приложение надо запускать через exec — тогда оно само станет PID 1

    // разбор: Шелл-обёртка как PID 1 не пробрасывает SIGTERM дочернему приложению, а у PID 1 нет дефолтных обработчиков сигналов — поэтому docker stop ждёт таймаут и бьёт SIGKILL. Простейшее лечение: exec app "$@" — приложение заменяет процесс шелла, само становится PID 1 и получает SIGTERM. Альтернатива: trap на TERM/INT с пробросом сигнала дочернему PID и wait, либо init вроде tini.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.