В предыдущей статье я рассказал, как писал скрипт мониторинга системы с нуля от идеи до рабочего инструмента с меню, цветным выводом и уведомлениями в Telegram. Там же выяснилось, что большинство проблем при написании bash-скриптов возникает не из-за сложной логики, а из-за незаметных ошибок: забытых токенов-заглушек, тихих падений через pipe, команд которые молча возвращают не то. Именно тогда я понял, что echo для отладкиэто каменный век.
Скрипт мониторинга написан и работает. Теперь поговорим о том, что делать, когда он перестаёт работать или ведёт себя странно. Я собрал инструменты и приёмы, которые реально меняют то, как работать с bash. Не абстрактный список из документации, а вещи которые я использую сам и которые несколько раз спасали в ситуации «скрипт упал на проде, надо найти за пять минут».
Для тех, кто спешит: быстрая шпаргалка
set -euo pipefail # упасть при ошибке, неинициализированной переменной, ошибке в pipe set -x # печатать каждую команду перед выполнением bash -n script.sh # проверить синтаксис без запуска shellcheck script.sh # статический анализатор bashdb script.sh # полноценный дебаггер с breakpoints
Если интересны подробности читайте дальше.
Проблема, с которой всё началось
Помните, как в прошлой статье скрипт мониторинга молча стучался в Telegram с фейковыми токенами и получал отказ? curl не показывал ошибку он честно отправлял запрос, просто ответ нас не интересовал. Это классическая «тихая» проблема bash. Следующий случай из той же серии, но сложнее. Несколько месяцев назад у меня был скрипт деплоя. Простой, проверенный, работал полгода. В один прекрасный день он начал иногда падать примерно в одном случае из десяти. Причём молча: exit code 0, никаких ошибок в stdout.
Оказалось, проблема была в конструкции вроде:
result=$(some_command | grep "pattern") echo "Готово: $result"
Когда grep ничего не находил, он возвращал exit code 1. Но этот код терялся, потому что result=$(...) всегда возвращает 0. Скрипт продолжал работу, считая, что всё хорошо.
Поймать это через echo было нереально нужны были нормальные инструменты.
set -euo pipefail минимальный стандарт
Начну с базы, потому что без неё всё остальное теряет смысл.
Большинство bash-скриптов начинаются с #!/bin/bash и сразу попадают в режим, где ошибки игнорируются по умолчанию. Это огромная проблема.
#!/bin/bash set -euo pipefail
Что здесь происходит:
set -e скрипт завершается при первой ненулевой команде. Без этого bash продолжает работу даже после ошибки.
set -u ошибка при обращении к неинициализированной переменной. Опечатка в $DATABASEE_HOST вместо $DATABASE_HOST станет заметна сразу, а не через полчаса дебага.
set -o pipefail весь pipeline возвращает ненулевой код, если хотя бы одна команда в нём упала. Это и был корень проблемы из моего примера выше.
Небольшое предупреждение: set -e иногда ведёт себя неожиданно. Например, такая конструкция завершит скрипт, хотя выглядит безобидно:
set -e count=$(grep -c "pattern" file.txt) # если pattern не найден grep возвращает 1
Решение явно обрабатывать такие случаи:
count=$(grep -c "pattern" file.txt || true) # или count=$(grep -c "pattern" file.txt) || count=0
set -x с настроенным PS4 трассировка с контекстом
set -x все знают. Но мало кто настраивает PS4 переменную, которая определяет префикс перед каждой напечатанной командой.
По умолчанию это просто +. Скучно и неинформативно, особенно когда скрипт длинный.
Вот что я использую:
export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] ' set -x
Теперь вывод выглядит так:
+ [deploy.sh:47] rsync -av ./build/ user@server:/var/www/ + [deploy.sh:48] ssh user@server "systemctl restart app"
Сразу видно, в каком файле и на какой строке выполняется команда. При вложенных вызовах через source особенно полезно.
Можно добавить таймстамп, чтобы понять, где скрипт тормозит:
export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] $(date "+%H:%M:%S.%3N") '
А чтобы не засорять stdout, трассировку можно отправить в отдельный файл:
exec 5>/tmp/bash_trace.log export BASH_XTRACEFD=5 export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] ' set -x
Основной вывод остаётся чистым, трассировка пишется в /tmp/bash_trace.log. Очень удобно в CI, где stdout читают люди.
EPOCHREALTIME профилирование без внешних инструментов
Bash 5.0+ добавил переменную EPOCHREALTIME текущее время в секундах с долями, как float. Это позволяет делать простое профилирование прямо внутри скрипта.
start=$EPOCHREALTIME # что-то долгое some_heavy_function
elapsed=$(echo "$EPOCHREALTIME - $start" | bc) echo "Выполнялось: ${elapsed} сек"
Для более удобного использования обёртка:
time_it() { local label="$1" shift local start=$EPOCHREALTIME "$@" local status=$? local elapsed elapsed=$(printf "%.3f" "$(echo "$EPOCHREALTIME - $start" | bc)") echo "[TIMING] $label: ${elapsed}s" >&2 return $status } time_it "rsync" rsync -av ./build/ user@server:/var/www/ time_it "restart" ssh user@server "systemctl restart app"
Вывод идёт в stderr (чтобы не ломать pipe), и вы видите, какой шаг занимает сколько времени. Нашёл таким образом, что ssh с проверкой ключей у нас занимал 8 секунд из 10 на деплой оказалось, проблема в DNS-резолвинге.
ShellCheck линтер, который читает ваши мысли
ShellCheck это статический анализатор для shell-скриптов. Если вы его не используете, вы наверняка периодически тратите час на баг, который он нашёл бы за секунду.
Установка:
# Ubuntu/Debian apt install shellcheck # macOS brew install shellcheck
Пример того, что он ловит:
#!/bin/bash files=$(ls *.txt) for f in $files; do process "$f" done
ShellCheck скажет: не используйте ls для перебора файлов это ломается при пробелах в именах. И покажет правильный вариант:
for f in *.txt; do process "$f" done
Особенно полезная фича объяснения. Каждый код ошибки (SC2086, SC2045 и т.д.) имеет страницу на wiki с примерами, почему это плохо и как исправить. Это само по себе учебник по bash.
Интеграция в CI (GitHub Actions):
- name: ShellCheck uses: ludeeus/action-shellcheck@master
Добавьте один раз и новые скрипты в PR будут проверяться автоматически. Я добавил это полгода назад и с тех пор не видел в команде ни одного бага типа «забыл кавычки».
bash -n и bash --norc синтаксическая проверка и чистое окружение
Два флага, о которых часто забывают:
bash -n script.sh проверяет синтаксис без выполнения. Быстро, можно добавить в pre-commit hook:
#!/bin/bash # .git/hooks/pre-commit for script in $(git diff --cached --name-only | grep '\.sh$'); do if ! bash -n "$script"; then echo "Синтаксическая ошибка в $script" exit 1 fi done
bash --norc --noprofile script.sh запускает скрипт без загрузки ~/.bashrc и ~/.bash_profile. Полезно, когда скрипт работает у вас, но падает в CI: скорее всего дело в переменных или алиасах из вашего профиля. Чистый запуск воспроизведёт CI-окружение.
bashdb настоящий дебаггер с breakpoints
Вот это уже серьёзный инструмент, который знают единицы. bashdb — полноценный дебаггер для bash в стиле gdb: breakpoints, step, next, inspect переменных, стек вызовов. Важная оговорка: пакет удалён из репозиториев Debian/Ubuntu ещё в 2017 году (bug #870992) и с тех пор не восстановлен. Устанавливать придётся из исходников — и это нетривиально.
Установка:
# Debian/Ubuntu: пакет удалён из репозиториев в 2017 году (bug #870992) # apt install bashdb — НЕ РАБОТАЕТ. Сборка из исходников: wget https://sourceforge.net/projects/bashdb/files/bashdb/5.0-1.1.2/bashdb-5.0-1.1.2.tar.bz2 tar xjf bashdb-5.0-1.1.2.tar.bz2 && cd bashdb-5.0-1.1.2 ./configure && sudo make install # macOS — через Homebrew (работает): brew install bashdb
Запуск:
bashdb script.sh
Основные команды внутри дебаггера:
(bashdb) l # показать текущий код (bashdb) b 42 # поставить breakpoint на строку 42 (bashdb) b my_function # breakpoint на функцию (bashdb) r # запустить/продолжить (bashdb) n # следующая строка (не заходить в функции) (bashdb) s # шаг внутрь функции (bashdb) p $MY_VAR # вывести значение переменной (bashdb) x # показать все локальные переменные (bashdb) bt # backtrace — стек вызовов (bashdb) q # выйти
Пример сессии. Есть скрипт:
#!/bin/bash process_file() { local file="$1" local lines lines=$(wc -l < "$file") echo "Строк: $lines" } for f in /tmp/data/*.csv; do process_file "$f" done
$ bashdb script.sh (bashdb) b process_file Breakpoint 1 set in file script.sh, line 2 (bashdb) r Stopped at /home/user/script.sh line 2 (bashdb) p $file /tmp/data/january.csv (bashdb) p $lines (bashdb) n (bashdb) p $lines 1547
Видно, что после local lines переменная пуста, и только после wc -l получает значение. Звучит очевидно, но когда в реальном скрипте сотня строк это золото.
bashdb особенно незаменим для сложных скриптов с вложенными функциями, где set -x даёт слишком много шума.
strace когда проблема не в bash, а в системных вызовах
Иногда скрипт делает что-то странное на уровне ОС: открывает не тот файл, зависает на сетевом вызове, не может создать сокет. strace позволяет увидеть все системные вызовы процесса.
strace -e trace=file bash script.sh 2>&1 | grep -E "(open|access|stat)"
Флаг -e trace=file фильтрует только файловые операции иначе вывод будет огромным.
Реальный кейс: скрипт читал конфигурационный файл, но настройки почему-то не применялись. strace показал, что open("/etc/myapp/config", O_RDONLY) = -1 ENOENT файл открывался из /etc/myapp/, а не из /usr/local/etc/myapp/, где он реально лежал. Нашли за минуту вместо часа.
Для подпроцессов флаг -f:
strace -f -e trace=network bash script.sh 2>&1 | grep connect
Покажет все сетевые соединения, включая те, что делают вызываемые программы.
Ловушка ERR красивые сообщения об ошибках
Когда скрипт падает с set -e, сообщение обычно выглядит так:
./deploy.sh: line 47: some_command: command not found
Полезно, но можно лучше. trap ERR позволяет перехватить любую ошибку и выдать красивый backtrace:
#!/bin/bash set -euo pipefail err_report() { local exit_code=$? echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 echo "Ошибка в ${BASH_SOURCE[1]}:${BASH_LINENO[0]}" >&2 echo "Команда: ${BASH_COMMAND}" >&2 echo "Exit code: ${exit_code}" >&2 echo "Стек вызовов:" >&2 local i for ((i=1; i<${#FUNCNAME[@]}; i++)); do echo " ${FUNCNAME[$i]} (${BASH_SOURCE[$i+1]:-main}:${BASH_LINENO[$i-1]})" >&2 done echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 } trap err_report ERR
Теперь при ошибке вы видите:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Ошибка в deploy.sh:47 Команда: rsync -av ./build/ user@server:/var/www/ Exit code: 23 Стек вызовов: deploy_files (deploy.sh:31) main (deploy.sh:89) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Точно знаешь, что упало, откуда вызвалось и с каким кодом. Это особенно ценно в скриптах, которые запускаются автоматически ночью утром смотришь в логи и сразу понимаешь, что произошло.
Собираем всё вместе: шаблон production-ready скрипта
#!/bin/bash set -euo pipefail # настройка трассировки в отдельный файл exec 5>/tmp/"$(basename "$0")".trace export BASH_XTRACEFD=5 export PS4='+ [${BASH_SOURCE[0]##*/}:${LINENO}] $(date "+%H:%M:%S.%3N") ' set -x # красивые сообщения об ошибках err_report() { local exit_code=$? echo "Ошибка в ${BASH_SOURCE[1]:-main}:${BASH_LINENO[0]}: ${BASH_COMMAND} (exit ${exit_code})" >&2 } trap err_report ERR # логирование с уровнями log() { echo "[$(date '+%H:%M:%S')] INFO $*" >&2; } warn() { echo "[$(date '+%H:%M:%S')] WARN $*" >&2; } die() { echo "[$(date '+%H:%M:%S')] ERROR $*" >&2; exit 1; } # ваш код здесь log "Скрипт запущен"
Этот шаблон я кладу в начало любого скрипта, который будет работать в production. Занимает меньше минуты, а сколько времени экономит не счесть.
Итог
Инструмент | Когда использовать |
| Всегда, в каждом скрипте |
| Трассировка выполнения с контекстом |
| Профилирование где скрипт тормозит |
| CI, pre-commit, ревью |
| Быстрая проверка синтаксиса |
| Сложная логика, вложенные функции |
| Проблемы на уровне ОС и системных вызовов |
| Диагностируемые падения в production |
Bash часто недооценивают как «просто скрипты» и потом удивляются, почему их сложно поддерживать. Нормальные инструменты отладки решают эту проблему.
Если есть свои любимые приёмы или инструменты, которые я не упомянул пишите в комментарии, интересно узнать.
Попробуйте добавить шаблон из последнего раздела в скрипт мониторинга из прошлой статьи и вы сразу увидите разницу, когда что-то пойдёт не так. Если статья была полезнапоставьте плюс, мне приятно знать, что это читают живые люди, а не только мои коллеги.