Почему скрипт развёртывания без обработки ошибок - это мина под продом
Bash по умолчанию игнорирует неудачную команду. Если nginx -t вернёт код 1, интерпретатор спокойно перейдёт к следующей строке и продолжит работу. Прод получит частично применённые изменения, а инженер узнает об этом от дежурного, а не из лога. Управляемость дают три вещи, которые стоит настроить до первого боевого запуска: строгий режим set -euo pipefail, trap-обработчик с функцией отката и идемпотентные шаги, безопасные для повторного запуска.
Логика простая и выстроена по приоритету. Сначала скрипт должен останавливаться на первой непойманной ошибке. Затем он должен уметь вернуть систему к состоянию до запуска. И только потом есть смысл говорить о логах: логи без отката лишь описывают катастрофу, а откат без логов оставляет непонятной причину.
Классический пример. Скрипт копирует новые конфиги, перезапускает сервис и падает на этапе миграции БД. Сервис уже работает с новым конфигом против старой схемы, отдаёт 500 и пишет в лог ошибки подключения. Откатывать конфиг некому: trap не настроен, код возврата миграции никто не проверял, а в логе деплоя нет ни имени упавшего шага, ни кода возврата.
Типичные сценарии сбоя на середине развёртывания
- Обрыв SSH-сессии или срабатывание таймаута: SIGHUP убивает скрипт на середине копирования, часть файлов обновлена, часть осталась старой.
- Нехватка места на диске: rsync или tar возвращают ненулевой код, архив распакован наполовину, временные файлы никто не убрал.
- Синтаксическая ошибка в конфиге Nginx: reload падает, а сервис уже останавливали или частично перечитал конфигурацию.
- Недоступность внешнего API или реестра образов: docker pull зависает и падает по таймауту, обновлена только часть контейнеров.
- Конфликт версий пакетов: apt-get или dnf прерывается на середине транзакции, пакет остаётся в состоянии half-configured.
- Ошибка в миграции схемы БД: часть ALTER-запросов применена, часть нет, приложение старой версии уже не совместимо с новой схемой.
Общая черта всех этих сбоев одна: система остаётся в состоянии, которого не было ни до запуска, ни после успешного деплоя. Без trap скрипт завершится с ненулевым кодом, но ни одного шага назад не сделает.
Цена неопределённого состояния: простой, потеря данных, ручной разбор
Пока сервис отдаёт 500, а в логе деплоя пусто, дежурный восстанавливает картину по косвенным признакам: смотрит время изменения файлов, историю systemctl, вывод git log на сервере. Каждый такой шаг - это минуты простоя, потраченные не на починку, а на ответ на вопрос «что вообще произошло».
Посчитайте свою цену заранее: возьмите последние 10 постмортемов и отметьте, в скольких из них причину нашли бы за минуту, если бы скрипт писал код возврата и имя упавшего шага. На практике разбор чаще всего упирается именно в поиск точки, после которой состояние поехало. Часть таких сценариев закрывается тем же набором привычек, что и четыре ошибки системных администраторов, которые чаще всего приводят к простоям: неполные бэкапы, изменения в проде без ревью, шумный мониторинг и лишние права.
Разница в трудозатратах измеримая. Автоматический откат и структурированный лог сокращают восстановление до одного запуска rollback и одной команды поиска по логу. Без них восстановление превращается в ручную реконструкцию: снять бэкап, сверить конфиги, понять, какая миграция применилась, и только затем запускать починку.
set -euo pipefail: базовый каркас надёжного скрипта
Каркас ставят сразу после shebang, до первой рабочей команды:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
LOG_FILE=/var/log/deploy/deploy.log
Разберём вклад каждой опции.
- set -e завершает скрипт, когда команда возвращает ненулевой код. Без этого флага вы обязаны проверять код возврата после каждого вызова вручную.
- set -u выбрасывает ошибку при обращении к необъявленной переменной. Опечатка вида ${BACKUP_DIRR} вместо ${BACKUP_DIR} иначе превратится в пустую строку и тихо сломает путь.
- set -o pipefail меняет код возврата пайпа: без него это статус последней команды, а с ним - статус последней (самой правой) команды, завершившейся с ненулевым статусом, либо ноль, если все команды успешны. Это поведение описано в man-странице bash(1).
Проверка разницы на живом примере. Выполните false | tee /tmp/x; echo $? без pipefail: увидите 0, потому что tee завершился успешно, а падение первой команды потерялось. Добавьте set -o pipefail и повторите: получите код 1. Так же проверяется поведение любого шага: запустите команду и сразу посмотрите echo $?, если сомневаетесь, что она вернула.
Как set -euo pipefail влияет на код возврата и поток выполнения
Опция -e работает не везде, и это важно понимать, чтобы не построить ложную защиту. Bash приостанавливает действие errexit для команд в условиях (if, while, until), для команд в списках && и ||, кроме команды после последнего оператора, для команд в пайпе, кроме последней, и для команд, запущенных с отрицанием через !. В этих местах ненулевой код - нормальная часть логики, и Bash отключает автоматический выход. Полный список исключений разобран в BashFAQ/105 и в разборе строгого режима Bash.
Ещё один подводный камень: подоболочки, создаваемые подстановкой команд, сбрасывают set -e, если не установлен inherit_errexit (доступен с Bash 4.4). Bash по умолчанию очищает errexit в подоболочке, создаваемой для подстановки команд, хотя сама подстановка может вернуть ненулевой статус родителю. Если шаг вызывает $(...) и внутри падает, полагаться только на -e рискованно. Явная проверка результата надёжнее любого неявного поведения.
Обработка кодов возврата в условных конструкциях и циклах
Там, где ненулевой код ожидаем, проверяйте его сами. Хорошая привычка - конструкция с отрицанием, которая понятна и с включённым set -e:
if ! systemctl is-active --quiet nginx; then
log WARN "nginx не запущен, стартуем"
systemctl start nginx
fi
Поиск по файлу даёт код 1, когда совпадений нет, и скрипт с set -e не прервётся, потому что grep стоит в условии. Для циклов чтения файла используйте while IFS= read -r line; do ... done < hosts.txt. Если шаг обязан завершиться успешно, пишите проверку явно: сравните код возврата с нулём и вызовите откат, не надеясь на неявное поведение интерпретатора. Полный разбор каркаса с аргументами, конфигами, функциями и откатом собран в статье про скрипт развёртывания для Linux-сервера с готовыми примерами.
trap-обработчики: как поймать сбой и откатить изменения
Синтаксис простой: trap 'обработчик' список-сигналов. Обработчик - это имя функции, а не набор команд в строке, иначе цитирование и переменные превратятся в источник ошибок. Внутри trap-обработчика EXIT статус, с которым оболочка завершается, доступен через $? как первая команда обработчика, и его следует сохранить до выполнения других команд, пока его не перезаписала другая команда. Этот приём разобран в разборе trap: EXIT, cleanup, signals и ERR.
Рабочая схема из двух обработчиков: один ловит ошибку и запускает откат, второй гарантированно убирает за собой при любом выходе.
STATE_DIR=/var/lib/deploy/$(date -u +%Y%m%dT%H%M%SZ)
on_error() {
local code=$?
log ERROR "шаг ${CURRENT_STEP} упал с кодом ${code}"
rollback
exit "${code}"
}
on_exit() {
local code=$?
rm -rf "${TMP_DIR}"
flock -u 200
log INFO "скрипт завершён с кодом ${code}"
}
trap on_error ERR
trap on_exit EXIT
Переменная CURRENT_STEP обновляется перед каждым блоком работ. Тогда в логе видно не абстрактное «скрипт упал», а конкретную точку: миграция, перезапуск сервиса, выкладка статики. Функция rollback должна быть идемпотентной и не падать: оберните её команды в проверки существования файлов и допустимости отката, а её собственные ошибки логируйте отдельно, иначе откат убьёт исходную диагностику.
Шаблон trap-обработчика для отката частично применённых изменений
Откат возможен только там, где заранее сохранено состояние. Минимальный набор: копия конфигов до перезаписи, дамп БД до миграции, список файлов, которые шаг создаёт. Копию делают до изменения, а не после:
cp -a /etc/nginx/nginx.conf "${STATE_DIR}/nginx.conf.bak"
mysqldump --single-transaction app > "${STATE_DIR}/app.sql"
rsync -a --delete target/ /var/www/app/
Функция rollback восстанавливает из этих артефактов: возвращает конфиг, откатывает миграцию или разворачивает дамп, перезапускает сервис, снимает временные файлы. Для сложных систем, где шагов десятки, самописный Bash быстро становится неподъёмным. В Ansible тот же сценарий описывается блоками block, rescue и always: в rescue идёт откат, в always - очистка и финальный лог, и это заметно читаемее, чем вложенные обработчики в шелле.
Разница между trap на ERR, EXIT и сигналами INT/TERM
| Сигнал | Когда срабатывает | Что делать в обработчике |
|---|---|---|
| ERR | Команда вернула ненулевой код при включённом set -e; trap на ERR выполняется перед выходом оболочки | Записать шаг и код возврата, вызвать откат |
| EXIT | Скрипт завершается любым способом | Убрать временные файлы, снять блокировку, записать финальный статус |
| INT | Ctrl+C или SIGINT от вызывающего процесса | Прервать текущий шаг, откатить, выйти с кодом 130 |
| TERM | kill или остановка юнита systemd | Доиграть критичный шаг до конца или откатить, выйти |
Комбинируйте ERR и EXIT: первый сигнализирует именно об ошибке, второй страхует от всех остальных путей выхода, включая ручной exit и срабатывание set -u. Учитывайте ограничение: trap на ERR следует многим из тех же исключений, что и errexit, и не срабатывает во всех контекстах, где можно было бы ожидать семантику «catch» - в частности, в условиях и в пайпах без pipefail. Кроме того, pipefail меняет общий код возврата пайпа при сбое более ранней стадии, но это не означает, что каждая упавшая команда в пайпе сама по себе запускает trap. Поэтому критичные шаги всё равно проверяйте явно.
Идемпотентность шагов: как безопасно перезапускать развёртывание
Идемпотентный шаг можно выполнять сколько угодно раз с одинаковым результатом. Для скрипта деплоя это означает, что повторный запуск после сбоя либо доводит систему до нужного состояния, либо ничего не меняет. Идемпотентность также упрощает откат: каждый шаг знает, что именно он создал, и может вернуть прежнее состояние без побочных эффектов.
Принцип один: перед изменением проверьте текущее состояние и либо пропустите действие, либо выполните его. Правило легко запомнить в виде чек-листа к каждому шагу: что я проверяю, что я меняю, что будет при повторном запуске.
Проверка состояния перед изменением: примеры для файлов, сервисов и пакетов
- Файл: test -f /etc/nginx/nginx.conf || cp nginx.conf /etc/nginx/, а для уже существующего файла сравнивайте хеш и обновляйте только при различии.
- Каталог: mkdir -p /var/www/app, ключ -p не ругается на существующий путь.
- Сервис: systemctl is-active --quiet nginx || systemctl start nginx, а для автозапуска systemctl is-enabled nginx || systemctl enable nginx.
- Пакет: dpkg -l nginx | grep -q '^ii' || apt-get install -y nginx, в семействе RPM аналогично проверяется rpm -q.
- Пользователь: id -u deploy >/dev/null 2>&1 || useradd -m deploy, без ключа -m повторный вызов с другим набором аргументов может сломать домашний каталог.
- Симлинк: создавайте через ln -sfn, тогда повторный запуск просто перезапишет ссылку.
Разделяйте изменение и проверку результата. После старта сервиса убедитесь, что он действительно поднялся: systemctl is-active возвращает успех сразу после запуска процесса, а порт может открыться через секунды. Добавьте ожидание с ограничением: timeout 30 bash -c 'until curl -fsS http://127.0.0.1:8080/health; do sleep 1; done'. Если health-check не прошёл, это повод вызвать откат, а не продолжать деплой.
Использование блокировок для предотвращения параллельных запусков
Два одновременных запуска деплоя дают самые неприятные отказы: они перезаписывают файлы друг друга и откатывают чужие шаги. Блокировка на файле через flock закрывает вопрос в три строки:
exec 200>/var/lock/deploy.lock
flock -n 200 || { log FATAL 'другой деплой уже идёт'; exit 1; }
Ключ -n означает «не ждать»: скрипт падает сразу, если блокировка занята. Дескриптор 200 живёт, пока жив процесс, поэтому отдельно снимать файл не нужно. Для юнитов systemd тот же эффект даёт ExecStart с поддержкой блокировки или Type=oneshot в связке с ConditionPathExists на файл-флаг. В декларативных инструментах блокировки и порядок шагов берут на себя Terraform, Ansible и Kubernetes: state-файл и контроллер не дадут двум операциям применить конфликтующие изменения.
Логирование в скриптах развёртывания: уровни, форматы, ротация
Лог деплоя читают в момент аварии, поэтому он должен отвечать на три вопроса без дополнительных раскопок: какой шаг выполнялся, какую команду запускали, какой код возврата получили. Формат с временной меткой в UTC, уровнем, именем скрипта, номером строки и текстом сообщения покрывает почти все ситуации.
log() {
local level=$1; shift
printf '%s [%s] %s:%s - %s\n' "$(date -u +%FT%TZ)" "$level" "$(basename "$0")" "${BASH_LINENO[0]}" "$*" | tee -a "$LOG_FILE" >&2
}
Запись идёт одновременно в файл и в stderr: файл остаётся для разбора, stderr попадает в journald или в лог CI-системы. Вызов выглядит так: log ERROR "nginx -t вернул код ${code}", а строка на выходе читается как 2026-09-24T08:14:07Z [ERROR] deploy.sh:118 - nginx -t вернул код 1.
Уровни логирования и что писать на каждом
| Уровень | Что попадает в лог | Пример сообщения |
|---|---|---|
| INFO | Начало и конец шагов, успешные операции | шаг 3/7: выкладка статики завершена |
| WARN | Некритичные отклонения, обойдённые проблемы | файл уже существует, обновление пропущено |
| ERROR | Команда вернула ненулевой код, но скрипт продолжает работу | curl вернул код 7, повтор через 5 с |
| FATAL | Скрипт прерывается, запущен откат | миграция не применилась, откат начат |
Пароли, токены, приватные ключи, содержимое .env и строки подключения к БД в лог не пишут. Маскируйте такие значения до записи или не передавайте их в шаблон сообщения. Критерии, что фиксировать стоит, а что превратит лог в шум, разобраны в отдельном материале про критерии выбора событий для логирования: там же есть шаблоны политик для продакшена.
Ротация логов: logrotate и journald
Без ротации файл деплоя однажды займёт весь раздел, и следующий релиз упадёт из-за нехватки места. Конфиг logrotate в /etc/logrotate.d/deploy выглядит так:
/var/log/deploy/*.log {
daily
rotate 7
compress
missingok
notifempty
create 0640 root adm
}
Проверить конфиг до применения можно командой logrotate -d /etc/logrotate.d/deploy, принудительный прогон - ключом -f. Если скрипт запускается как юнит systemd, логи уходят в journald, и там ограничивают объём через SystemMaxUse=500M в /etc/systemd/journald.conf: этот параметр задаёт верхний лимит дискового пространства, которое journald может занять для system-wide журнала, и работает вместе с SystemKeepFree - journald старается соблюдать оба ограничения, удаляя старые сегменты журнала. Разбор этих параметров есть в материале про хранение, ротацию и форвардинг логов journald и syslog. Без такого лимита журнал растёт до процентов от размера диска.
Централизованное логирование: отправка логов в ELK, Loki или rsyslog
Логи на десяти серверах бесполезны по отдельности: причина сбоя почти всегда лежит на стыке машин. Собрать записи в одно место можно без установки тяжёлых агентов, если хватает rsyslog.
Настройка rsyslog для отправки логов деплоя на центральный сервер
Сначала читаем локальный файл модулем imfile, затем пересылаем его на сервер сбора:
module(load="imfile")
input(type="imfile" File="/var/log/deploy/deploy.log" Tag="deploy" Severity="info" PersistStateInterval="10")
*.* @@logserver:514
Один символ @ означает UDP, два символа @@ означают TCP. Для деплоя выбирайте TCP: при перегрузке сети UDP теряет пакеты молча, и в центральном хранилище останутся дыры именно в момент аварии. Из скрипта отдельное сообщение удобно отправлять через syslog: logger -t deploy "deployment failed at step 5, exit code 1". Для Loki ставят promtail с секцией scrape_configs на тот же файл, для Elasticsearch - filebeat с output.elasticsearch; оба варианта читают файл целиком и разбирают его на поля по регулярному выражению.
Использование journalctl для диагностики сбоев systemd-юнитов
Если деплой запускается юнитом, обращение к журналу systemd быстрее любого поиска по файлам:
- journalctl -u deploy.service --since "10 min ago" -p err покажет только сообщения уровня err и выше за последние десять минут.
- journalctl -u deploy.service -o json-pretty отдаёт записи со всеми метаданными: PID, UID, время, сообщение.
- journalctl -u deploy.service -b -n 200 ограничит вывод текущей загрузкой и последними двумястами строками.
- journalctl -u deploy.service -f ведёт поток в реальном времени, это удобно держать открытым во время деплоя.
Метаданные journald избавляют от ручного сопоставления: время, идентификатор процесса и код выхода уже записаны, остаётся отфильтровать нужный интервал. Приложения на Python пишут в те же стоки, а как избежать утечек секретов и переполнения диска в таких логах, разобрано в руководстве про настройку логирования с ротацией и защитой данных.
Диагностика типовых сбоев по логам: команды и приёмы
Когда лог структурирован и в нём есть код возврата, диагностика сводится к нескольким командам. Держите их под рукой, чтобы не изобретать фильтры в момент аварии.
Поиск ошибок по кодам возврата и ключевым словам
- grep -E 'ERROR|FATAL' /var/log/deploy/deploy.log | tail -20 - последние двадцать проблемных строк.
- grep 'Exit code: [^0]' /var/log/deploy/deploy.log - все строки с ненулевым кодом возврата.
- awk '/ERROR/ {print; getline; print}' /var/log/deploy/deploy.log - строка ошибки вместе со следующей, где обычно лежит текст от утилиты.
- sed -n '/2026-09-24T08:10/,/2026-09-24T08:15/p' /var/log/deploy/deploy.log - срез по временному диапазону, когда известен интервал аварии.
- journalctl -u deploy.service -p err -b - ошибки юнита в текущей загрузке.
Полезная привычка на будущее: логировать код возврата после каждого значимого вызова в формате command; rc=$?; log INFO "шаг 4, код ${rc}". Одна строка на шаг стоит копейки, а в момент разбора экономит часы, потому что не нужно восстанавливать порядок событий по косвенным признакам.
Корреляция логов скрипта и приложения
Причина часто лежит на стороне приложения, а не скрипта. Связывайте записи по времени и общему идентификатору: пишите в лог деплоя начало и конец каждого шага, а в приложении фиксируйте время старта и request ID. Дальше ищем пересечение: например, по журналу видно, что reload nginx завершился в 08:14:07, а первый ответ 502 пришёл в 08:14:09, значит дело в upstream, который не успел подняться, а не в конфиге.
В централизованных системах такую стыковку делают автоматически: в Kibana и Grafana Loki запросы по полям service, host и trace_id позволяют собрать картину одного инцидента из разных источников. Без централизации тот же результат достигается ручной сверкой timestamp в двух файлах с точностью до секунды.
Чек-лист: добавляем обработку ошибок и логирование в существующий скрипт
- Поставьте set -euo pipefail сразу после shebang, а переменную LOG_FILE определите до первого логирования.
- Заведите функции log, on_error и on_exit, повесьте trap на ERR и EXIT.
- Реализуйте rollback: копии конфигов и дампы БД делайте до изменения, а не после.
- Обновляйте переменную CURRENT_STEP перед каждым блоком работ, чтобы в логе было имя шага.
- Сделайте шаги идемпотентными: проверяйте состояние файла, сервиса, пакета и пользователя перед изменением.
- Добавьте проверки результата после старта сервиса, включая ожидание health-check с таймаутом через curl.
- Включите уровни логирования и уберите из сообщений пароли, токены и содержимое .env.
- Настройте ротацию: logrotate для файла деплоя, SystemMaxUse для journald.
- Отправьте логи в общую точку сбора через rsyslog с TCP, promtail или filebeat.
- Проверьте сценарии сбоя намеренно: задайте неверный путь, недоступный порт и прервите скрипт сигналом TERM, а затем убедитесь, что откат сработал, а лог читается.
Начните с одного шага: добавьте set -euo pipefail и trap с логированием в скрипт, который запускаете чаще всего. Дальше по мере правок закрывайте пункты чек-листа, а проверку сценариев сбоя вынесите в отдельный тестовый контур, чтобы откат не пришлось отлаживать сразу на проде.