Bash: коды возврата и set -euo pipefail
Написал скрипт: удалить каталог по пути из переменной. В имени переменной опечатка, значение пустое. Скрипт отработал молча и вернул код 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, остальные разбираются в тренажёре.
- В скрипте с set -e строка curl -sf "$URL" | tee /tmp/resp.json. curl упал, скрипт поехал дальше. Почему?A)Код пайплайна берётся от последней команды — от teeB)Флаг -f у curl подавляет и вывод ошибки, и код возвратаC)set -e не действует на строки с перенаправлением выводаD)tee перехватил ошибку и вернул пустой файл вместо кода
показать ответ и разбор
+A)Код пайплайна берётся от последней команды — от tee// разбор: Статус пайплайна в bash — это статус последней команды. tee на пустом входе завершился нулём, set -e ничего плохого не увидел, и в файле осталась пустота, которую скрипт понесёт дальше. Лечится set -o pipefail (код станет первым ненулевым) или разбором массива PIPESTATUS сразу после пайплайна.
- В функции написано 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) едет дальше как ни в чём не бывало, а переменная остаётся пустой.
- Скрипт создаёт временный файл и должен удалять его при любом выходе, включая ошибку и прерывание. Как надёжнее?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, если нужна особая реакция на прерывание.
- В скрипте с 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 с явной веткой, а не полагайся на падение.
- Скрипт-обёртка на 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.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.