Скрипт, написанный для себя, прощает многое: автор помнит context, знает порядок запуска и допускает, что при ошибке придётся разбираться вручную. Скрипт для оператора обязан быть честнее: ошибка не имеет права проходить молча, временные файлы не имеют права оставаться после любого исхода, а код возврата не имеет права врать вызывающей стороне. Оболочка bash даёт для этой честности три инструмента: строгий режим из трёх опций, ловушки завершения и дисциплину работы с ресурсами. Статья разбирает их связку, границы защиты строгого режима, идиому очистки через ловушку и скелет скрипта, который не стыдно показать оператору.
Строгий режим оболочки и три его опции
Обычное поведение оболочки либерально: команда завершилась с ошибкой, и сценарий идёт дальше, как будто ничего не произошло. Для интерактивной работы это удобно, для автоматизации это дыра. Строгий режим включается одной строкой сразу после интерпретатора:
#!/usr/bin/env bash
set -euo pipefail
Первая опция требует выхода при ошибке: команда вернула ненулевой код, сценарий останавливается. Вторая запрещает обращение к необъявленным переменным: опечатка в имени переменной перестаёт быть пустой строкой и становится видимым отказом. Третья меняет поведение конвейеров: кодом конвейера становится самый плохой код среди участников, а не код последней команды, и классическая история с успешной последней командой после провала первой перестаёт проходить.
Эффект включения ощущается сразу. Скрипты, годами молча копившие ошибки, начинают останавливаться на первом честном отказе, и первые недели включения строгого режима это недели вскрытия старых болячек. Именно поэтому строгий режим включают в новых скриптах с первой строки, а не приклеивают к легаси задним числом: свежий скрипт сразу пишется в предположении, что любая ошибка видимa.
Идеология трёх опций общая: преобразовать молчаливые проблемы в громкие. Опечатка в переменной, сломанный этап конвейера, отказавшая команда, всё останавливает сценарий там, где проблема возникла, а не там, где её последствия стали заметны.
Границы защиты set -e и честное поведение скрипта
Опция выхода при ошибке умнее, чем кажется, и её границы обязан знать каждый, кто её включает. Команды в условиях не завершают сценарий: проверка существования файла с отрицательным результатом это часть логики, а не авария. Команды после логических связок и команды в отрицании тоже выпадают из защиты, и это правильно, ведь они проверяют условие. Коварнее всего ведёт себя присваивание результата: объявление локальной переменной с командой внутри маскирует код возврата команды, и классическая ловушка выглядит как строка, где в одном объявлении сочетаются локальная переменная и подстановка.
Осознанные проверки заменяют слепую надежду явными сообщениями. Там, где отказ ожидаем и обрабатывается, пишется условие с понятным текстом в поток ошибок и выходом с осмысленным кодом. Там, где участок обязан переживать ошибки, строгий режим временно отключается и включается обратно, и границы такого участка держатся минимальными. Подмена проверки авоськой не проходит ни в одном из режимов, строгий лишь делает авоську громче.
Отдельная история, конвейеры с ранним завершением читателя. Классический пример, поставщик страниц обрывает чтение, когда нашёл нужное, а поставляющая сторона получает сигнал о разрыве канала. С включённой опцией конвейерного кода такой сценарий выглядит отказавшим, хотя он отработал ровно как задуман. Решения два: принять сигнал к сведению и проверять не механически, а по смыслу, либо строить конвейер иначе, без обрыва на середине.
Итог по границам трезвый: строгий режим это страховка от невнимательности, а не замена размышлению. Он не отвечает за логику, он лишь гарантирует, что неожиданный отказ не проскочит бесследно. Скрипты, где каждая проверка осознанная, а каждое исключение из правил помечено комментарием, читаются годами без сюрпризов.
Trap как страховка очистки при любом исходе
Ресурсы сценария, временный каталог, лок-файл, фоновый процесс, создаются в начале и обязаны исчезнуть в конце, независимо от того, чем закончилась работа. Команда очистки в конце основного кода работает только в сказочном сценарии, где ничего не падает. Ловушка завершения закрывает все дороги сразу: она выполняется при выходе из сценария по любой причине, успеху, отказу строгого режима, прерыванию оператором.
tmpdir=$(mktemp -d)
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
Ловушка на выход срабатывает всегда, ловушки на прерывание и завершение добавляют корректный код для сигналов. Функция очистки пишется осторожной: ресурсы могли не создаться, если отказ случился раньше, и удаление несуществующего пути не должно само превращаться в ошибку в момент, когда до него наконец дошла очередь.
Порядок строк в скрипте с ловушками имеет значение. Ресурс создан, ловушка назначена, работа началась: интервал между созданием ресурса и назначением ловушки это окно уязвимости, и его сокращают до нуля, ставя ловушку следующей строкой после создания. Продвинутая идиома переустанавливает ловушку по мере накопления ресурсов, и очистка всегда знает обо всём, что уже создано.
Ловушки работают и на оператора: прерывание сценария клавишами не оставляет за собой мусор из полуобработанных временных каталогов. Мелочь, которую замечают только когда её нет, и именно на таких мелочах держится доверие к сценариям у людей, которые их запускают.
Временные файлы и локи с гарантией удаления
Имена временных файлов не выдумываются руками. Утилита генерации уникальных временных путей решает сразу три задачи: исключает коллизии между параллельными запусками, убирает угадывание и предсказуемость враждебного использования, и делает имена говорящими в списке процессов. Каталог для нескольких файлов создаётся целиком и удаляется одной командой в ловушке.
Лок против параллельного запуска строится на блокировке файла с уникальным именем сценария. Открытый дескриптор удерживается на время работы, попытка захватить блокировку без ожидания возвращает отказ, и сценарий честно сообщает, что предыдущий запуск ещё жив, вместо того чтобы работать вдвоём над одними файлами:
lockfile=/run/backup.lock
exec 200>"$lockfile"
flock -n 200 || { echo "запуск уже идёт" >&2; exit 1; }
Дескриптор закрытие не освобождает до конца процесса, и лок живёт ровно столько, сколько живёт сценарий. Такой лок не забывается в системе и не требует ручной чистки после аварийных завершений, в отличие от лок-файлов, которые создаются и забываются.
Фоновые процессы, если сценарий их порождает, запоминаются по идентификатору сразу после запуска и попадают в функцию очистки. Незавершённые дочерние процессы, оставшиеся после прерывания сценария, это классическая причина загадочной нагрузки, которую никто не может объяснить до первого взгляда в список процессов.
Права доступа завершают гигиену. Временные каталоги создаются с закрытыми правами по умолчанию, содержимое с чувствительными сведениями не лежит в общем для всех месте, и даже короткая жизнь временных файлов не превращается в окно для любопытных.
Коды возврата и сообщения об ошибках для оператора
Код возврата это интерфейс сценария с вызывающей стороной. Ноль означает успех, всё остальное означает разбор, и продуманный сценарий различает свои отказы: одна ситуация, другой. Оператор, запускающий сценарий из планировщика, читает код в журнале запусков, и один и тот же код для успешного завершения с замечаниями и для полного провала лишает его информации.
Сообщение об ошибке пишется в поток ошибок, а не в общий поток, и содержит три вещи: что случилось, где, и что делать. Сравнимые по полезности формулировки выглядят как рекомендации, а не как стенания. Функция завершения с сообщением собирает идиому в одно место:
die() { echo "ОШИБКА: $*" >&2; exit 1; }
Диагностическая дисциплина требует от сценария молчаливого успеха и разговорчивого отказа. Успех в подробном выводе никто не читает, а отказ без подробностей никто не понимает. Отдельная строка вежливости к оператору: итоговая строка о завершении работы с указанием времени и объёма, краткая, как подпись под документом.
Скромный журнал работы завершает дисциплину: сценарий пишет короткие строки о начале и завершении этапов в свой лог, а не только в общий поток. При разборе ночного происшествия такой журнал отвечает на вопросы без воспроизведения запуска, и пять строк вывода экономят полчаса расследования.
Наследование кодов аккуратно оформляется в ловушках: прерывание завершает сценарий со стандартным кодом сигналов, а не с универсальной ошибкой. Планировщики и оболочки оператора различают эти коды, и сценарий, который различает их тоже, экономит минуты разбора каждому, кто его запускает.
Скелет операторного скрипта от заголовка до итога
Скелет, в котором соблюдены все правила статьи, помещается на экран и читается сверху вниз как инструкция:
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
exec 200>/run/nightly.lock
flock -n 200 || die "ночной запуск уже идёт"
# работа сценария: этапы с проверками
# каждый этап либо завершается сам, либо завершает сценарий
echo "готово, время работы: SECONDS секунд"
Структура скелета повторяет порядок статьи намеренно: строгий режим, ресурсы и ловушки, лок, работа, итог. Читатель, который видит скелет впервые, узнаёт в нём чек-лист, и по этому чек-листу можно проверять чужие сценарии на зрелость за минуту.
Параметры сценария проверяются до первой работы: обязательные аргументы встречают проверку с сообщением об использовании, необязательные получают значения по умолчанию. Сценарий, который не начинает работу с неверными входными сведениями, спасает оператора от половины ошибок ещё до их совершения, и ранняя проверка всегда дешевле отказа в середине пути, когда половина работы уже сделана.
Скелет не заменяет работу, он лишь держит каркас. Каждая строка каркаса существует потому, что без неё встречается конкретная авария: молчаливое продолжение после ошибки, брошенные временные каталоги, задвоенные запуски, врущие коды возврата. Каркас не добавляет работы, он лишь ставит рамки, внутри которых работа не расползается.
Рабочие правила написания скриптов для чужих рук
Свод правил, превращающий сценарий из личной записки в инструмент, выглядит так:
- Строгий режим тремя опциями в первой строке после интерпретатора;
- Ловушка очистки назначается строкой, следующей за созданием ресурса;
- Временные пути только через генератор уникальных имён, без выдуманных;
- Ожидаемый отказ завершает сценарий с сообщением в поток ошибок и кодом;
- Лок против параллельных запусков через удерживаемый дескриптор;
- Учебный запуск на тестовых сведениях до передачи сценария оператору.
Правила короткие, потому что за каждым стоит пережитая история. Молчание сценария, доработавшего наполовину, брошенный гигабайтный каталог во временном хранилище, два параллельных запуска, испортившие друг другу выгрузку, всё это не гипотезы, а общеизвестные истории любой команды.
Итоговое суждение о честном сценарии скромное. Оператор не обязан понимать bash, он обязан доверять результату, и доверие складывается из видимых признаков: сценарий либо молчит и делает, либо говорит и останавливается, третьего не бывает. Строгий режим, ловушки и дисциплина кодов возврата это минимальный набор, который делает такое поведение не свойством характера автора, а свойством конструкции, и именно конструкция выдерживает годы, авторы и смены.
Проверка сценария на честность занимает пять минут: намеренная ошибка в середине пути, прерывание руками на третьем этапе, попытка второго запуска поверх живого. Три коротких учения показывают ловушку, коды и лок в действии, и сценарий, прошедший их, действительно можно передавать чужим рукам без ночных звонков.
Мелкая привычка с большим эффектом: шапка сценария с кратким описанием и примером запуска в комментарии. Оператор не открывает тело, чтобы вспомнить назначение файла, а читатель понимает замысел до первой команды. Три строки комментария экономят три объяснения в чат каждые месяцы.