Debugging и тестирование bash-скриптов
Bash-скрипты — это код. Код имеет баги. Код требует тестов. Самая частая ошибка Junior — относиться к bash как к одноразовой записке: «работает на моей машине — деплоим». А на production у Airflow worker нет TTY, нет твоих env-vars, тот же скрипт ведёт себя иначе и падает каждые два часа.
В этом уроке: инструменты для профессиональной разработки bash — set -x для трассировки исполнения, shellcheck (ловит 90% багов автоматически), bats для unit-тестов, и интеграция в CI через GitHub Actions, чтобы каждый коммит в репо был валидирован.
После этого урока твой bash-код будет качественно отличаться от любительского. И большинство «странностей», на которые тратятся часы, будут найдены за секунды линтером.
set -x: пошаговая трассировка
set -x (или set -o xtrace) включает режим, в котором bash печатает каждую команду перед выполнением, с раскрытыми переменными.
#!/bin/bash
set -x
NAME="Alice"
COUNT=3
for i in $(seq 1 $COUNT); do
echo "Hello $NAME #$i"
done
Вывод:
+ NAME=Alice
+ COUNT=3
++ seq 1 3
+ for i in $(seq 1 $COUNT)
+ echo 'Hello Alice #1'
Hello Alice #1
+ for i in $(seq 1 $COUNT)
+ echo 'Hello Alice #2'
Hello Alice #2
...
Каждая строка + — это команда, которую bash сейчас выполнит. Двойной плюс ++ — вложенный subshell (внутри $(...)).
Когда включать/выключать
#!/bin/bash
set -euo pipefail
# Обычный код, без трассировки:
echo "Starting..."
prepare_files
# Сложный блок, нужна трассировка:
set -x
critical_logic_that_breaks
set +x
# Обратно нормальный режим:
echo "Done"
set -x включает, set +x выключает. Удобно ограничить трассировкой только проблемный участок, иначе stdout захлёбывается.
Глобально через bash -x
$ bash -x ./myscript.sh
# Запустит script с -x с самого начала, без модификации файла
Полезно для дебага чужого скрипта или для одноразовой проверки.
PS4: кастомизация трассировки
PS4 — prompt-string, который bash префиксит к каждой trace-строке. Дефолт — + . Можно сделать гораздо информативнее:
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
echo "test"
# Вывод: + ./script.sh:5: echo test
Полезные переменные:
$LINENO— номер строки в файле.$BASH_SOURCE— имя файла (важно при include через.).$FUNCNAME— имя функции (или пусто).$SECONDS— время с начала скрипта.
Production-grade PS4 для дебага:
PS4='+ $(date +%H:%M:%S.%N) ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
Каждая trace-строка будет:
+ 14:32:15.456789012 myscript.sh:42:process_users: curl https://api.example.com/users
shellcheck: статический линтер
shellcheck — самый важный инструмент в bash-разработке. Это линтер, который анализирует скрипт и предупреждает о потенциальных багах без запуска.
Установка
# Debian/Ubuntu:
sudo apt install shellcheck
# macOS:
brew install shellcheck
# Docker (для CI):
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable myscript.sh
Базовое использование
$ shellcheck myscript.sh
In myscript.sh line 5:
for f in $(ls *.csv); do
^-- SC2045: Iterating over ls output is fragile. Use globs.
In myscript.sh line 12:
echo $name
^-- SC2086: Double quote to prevent globbing and word splitting.
shellcheck выдаёт код проблемы (SC2045, SC2086) и краткое объяснение. Полная база — на shellcheck.net (онлайн-проверка тоже там).
Топ-warnings и что они значат
Подавление false positives
# Подавить для конкретной строки:
# shellcheck disable=SC2086
echo $UNQUOTED_BUT_INTENTIONAL
# Глобально в файле (после shebang):
# shellcheck disable=SC1091
source /etc/dynamic-config.sh
Используй осознанно — каждый disable должен быть оправдан. Если линтер прав в 95% случаев, и ты подавляешь его как мусор — обычно у тебя действительно баг.
Интеграция с редактором
VS Code, vim, Sublime, emacs — все имеют shellcheck-плагины. Подсвечивают проблемы прямо в коде, как ESLint для JS. Настрой однажды — экономишь часы в год.
В CI/CD shellcheck должен быть обязательным gate. PR с warnings — не мержится. Это стоит 10 секунд в pipeline и спасает от 90% классов bash-багов.
bats: тесты для bash
bats (Bash Automated Testing System) — фреймворк для unit-тестов на bash. Простой, не требует зависимостей кроме bash.
Установка
# Debian/Ubuntu:
sudo apt install bats
# macOS:
brew install bats-core
# Из исходников:
git clone https://github.com/bats-core/bats-core.git
sudo ./bats-core/install.sh /usr/local
Простой тест
# test_math.bats:
@test "addition works" {
result=$((2 + 2))
[ "$result" -eq 4 ]
}
@test "string equality" {
name="alice"
[ "$name" = "alice" ]
}
@test "command output matches" {
run echo "hello"
[ "$status" -eq 0 ]
[ "$output" = "hello" ]
}
Запуск:
$ bats test_math.bats
test_math.bats
[x] addition works
[x] string equality
[x] command output matches
3 tests, 0 failures
Тестирование production скрипта
Допустим, есть lib.sh с функциями:
# lib.sh
parse_log_line() {
local line="$1"
# Extract HTTP status (9-й столбец)
awk '{print $9}' <<< "$line"
}
is_valid_url() {
local url="$1"
[[ "$url" =~ ^https?:// ]]
}
Тесты к ним:
# test_lib.bats
setup() {
# source библиотеку перед каждым тестом
source ./lib.sh
}
@test "parse_log_line extracts status code" {
line='10.0.0.1 - - [01/Jan/2026:12:00:00] "GET /api HTTP/1.1" 200 1234'
result=$(parse_log_line "$line")
[ "$result" = "200" ]
}
@test "is_valid_url accepts http" {
run is_valid_url "http://example.com"
[ "$status" -eq 0 ]
}
@test "is_valid_url accepts https" {
run is_valid_url "https://api.example.com/users"
[ "$status" -eq 0 ]
}
@test "is_valid_url rejects ftp" {
run is_valid_url "ftp://example.com"
[ "$status" -ne 0 ]
}
@test "is_valid_url rejects empty string" {
run is_valid_url ""
[ "$status" -ne 0 ]
}
run cmd запускает команду, захватывая $status (exit code), $output (stdout), ${lines[@]} (массив строк).
setup и teardown
setup() {
# Перед каждым тестом
TEST_DIR=$(mktemp -d)
}
teardown() {
# После каждого теста (даже при падении)
rm -rf "$TEST_DIR"
}
@test "creates output file" {
./myscript.sh -o "$TEST_DIR/out.json"
[ -f "$TEST_DIR/out.json" ]
}
setup/teardown — стандарт для изоляции тестов друг от друга.
CI: shellcheck + bats в GitHub Actions
GitHub Actions CI — полный workflow для data-инженераМинимальный workflow для bash-репозитория:
# .github/workflows/test.yml
name: test
on:
push:
branches: [main]
pull_request:
jobs:
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run shellcheck
uses: ludeeus/action-shellcheck@master
with:
severity: warning
bats:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install bats
run: sudo apt-get install -y bats
- name: Run tests
run: bats tests/
Что происходит на каждый PR:
- shellcheck проходит по всем
.shфайлам, репортит warnings и errors. - bats запускает unit-тесты.
- Если что-то падает — статус PR красный, мержить нельзя (если включена branch protection).
Pre-commit hook: shellcheck перед каждым коммитом
pre-commit framework — управление хуками для любого языкаpre-commit framework запускает hooks локально перед каждым git commit. Defaults к зрелому developer-workflow.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
args: [--severity=warning]
Установка:
pip install pre-commit
pre-commit install
Теперь при git commit:
$ git commit -m "fix"
shellcheck...............................................................Failed
- exit code: 1
- files were modified by this hook
myscript.sh:5:13: warning: Iterating over ls output is fragile. Use globs. [SC2045]
Коммит блокируется до фикса. Это рекомендуемая практика — лучше поймать на локальной машине за 5 секунд, чем в CI через 10 минут.
Debugging: PS4 + LINENO в production
В production скриптах часто полезно всегда включать verbose-режим в лог-файл:
#!/bin/bash
set -euo pipefail
# Сохранять trace в файл, но НЕ в stdout (чтобы оркестратор не путался):
exec 19> /var/log/myscript.trace
BASH_XTRACEFD=19
PS4='+ $(date +%H:%M:%S) ${BASH_SOURCE}:${LINENO}: '
set -x
# Скрипт работает; вся трассировка пишется в /var/log/myscript.trace
echo "Visible in stdout"
BASH_XTRACEFD=19 — bash будет писать trace-output в FD 19. exec 19> file открывает FD 19 на запись в файл. Stdout остаётся чистым для оркестратора, но при дебаге есть полный лог исполнения.
Подводные камни
1. shellcheck не ловит семантические ошибки
shellcheck — статический анализатор. Он не знает, что переменная $URL должна содержать https-URL. Если ты опечатался в имени переменной — он среагирует (SC2154 referenced but not assigned). Если логика обработки неправильная — нет.
Поэтому shellcheck + bats тесты + ручное smoke-test = надёжный pipeline.
2. set -x не работает в subshells без явной передачи
set -x
( command ) # не trace
Subshell имеет свой set state. Если нужен trace внутри — (set -x; command).
3. bats тесты медленные
Bash тесты гораздо медленнее pytest. Сложный testsuite на 50+ тестов может занимать 30-60 секунд. Это нормально для bash, но если у тебя 200+ tests — задумайся, может быть, основной код стоит на Python.
4. PS4 с newlines ломает форматирование
Сложные PS4 могут испортить вывод. Тестируй PS4 на простом скрипте перед production.
Bash 5.3: ${ cmd; } substitution без fork
Bash 5.3 (июль 2025) добавил ${ command; } — substitution без fork subshell. Раньше $(cmd) всегда форкал процесс (затратно для коротких команд).
# Bash 5.3+:
size=${ wc -l < file.txt; } # без fork, быстрее
Влияние на трассировку: команда внутри ${ ; } выполняется в основном процессе, и set -x показывает её на том же уровне (не двойной плюс ++).
Cross-links
- Урок 01 (preamble) —
set -e+ shellcheck отлавливают 95% Junior-багов. - Урок 03 (trap) — shellcheck предупреждает о неправильных trap.
- Capstone (модуль 20) — shellcheck и bats обязательны в lab-проекте.
Попробуй сам
- Создай заведомо плохой скрипт
bad.sh:
#!/bin/bash
for f in $(ls *.txt); do
cat $f
echo $f processed
done
Запусти shellcheck bad.sh — увидишь 3-4 warnings. Исправь каждый.
- Запусти script с
bash -x:
bash -x bad.sh
Сравни с обычным запуском.
- Создай
lib.shс функцией:
#!/bin/bash
to_upper() {
echo "$1" | tr '[:lower:]' '[:upper:]'
}
И тест test_lib.bats:
setup() { source ./lib.sh; }
@test "to_upper converts" {
[ "$(to_upper hello)" = "HELLO" ]
}
@test "to_upper handles empty" {
[ "$(to_upper '')" = "" ]
}
Запусти bats test_lib.bats.
- Установи pre-commit для своего проекта:
pip install pre-commit
# создай .pre-commit-config.yaml с shellcheck hook
pre-commit install
Сделай тест-коммит со скриптом, в котором есть echo $unquoted. Увидишь блокировку.
- Добавь GitHub Actions workflow (если есть GitHub-репо) с shellcheck и убедись, что PR с warnings становится красным.