Learning Platform
Глоссарий Troubleshooting
Урок 19.05 · 25 мин
Средний
BashDebuggingset -xshellcheckbatsTestingCI

Debugging и тестирование bash-скриптов

Bash-скрипты — это код. Код имеет баги. Код требует тестов. Самая частая ошибка Junior — относиться к bash как к одноразовой записке: «работает на моей машине — деплоим». А на production у Airflow worker нет TTY, нет твоих env-vars, тот же скрипт ведёт себя иначе и падает каждые два часа.

В этом уроке: инструменты для профессиональной разработки bashset -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
PS4 уровни детализации
default '+'Минимум. Только маркер. Для очень коротких скриптов
line numbersPS4='+ \`${LINENO}`: ' — добавляет номера строк. Помогает локализовать
file:linePS4='+ \`${BASH_SOURCE}`:\`${LINENO}`: ' — file + line. Стандартный production-default
full contexttime + file + line + function. Для сложного debug серии скриптов

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 и что они значат

shellcheck — частые warnings
SC2086echo $var — без кавычек, подвержен word splitting и glob expansion. Решение: echo "$var"
SC2045Парсинг вывода ls для итерации. Сломается на пробелы/newlines в filenames. Решение: globs (for f in *.csv) или find -print0
SC2155local var=$(cmd) — local маскирует exit code $(). Решение: разделить на две строки: local var; var=$(cmd)
SC2034Переменная определена но не используется. Может быть опечатка или мёртвый код
SC2164cd без проверки. Решение: cd /path || exit. Если cd упадёт, скрипт продолжит в неправильной директории
SC1091source файла, который shellcheck не может найти. Часто false-positive в больших проектах. Workaround: # shellcheck source=./lib.sh

Подавление 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. Настрой однажды — экономишь часы в год.

TIP

В 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:

  1. shellcheck проходит по всем .sh файлам, репортит warnings и errors.
  2. bats запускает unit-тесты.
  3. Если что-то падает — статус PR красный, мержить нельзя (если включена branch protection).
CI pipeline для bash
git pushJunior пушит фичу в свою ветку и открывает PR
shellcheckЛинтер пробегает по всем .sh. Если warnings — статус failure. ~10 секунд
batsUnit-тесты на функции из lib.sh. ~30 секунд
mergeЕсли всё зелёное — PR мержится в main. Иначе junior фиксит и пушит снова

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 показывает её на том же уровне (не двойной плюс ++).


  • Урок 01 (preamble)set -e + shellcheck отлавливают 95% Junior-багов.
  • Урок 03 (trap) — shellcheck предупреждает о неправильных trap.
  • Capstone (модуль 20) — shellcheck и bats обязательны в lab-проекте.

Попробуй сам

  1. Создай заведомо плохой скрипт bad.sh:
#!/bin/bash
for f in $(ls *.txt); do
    cat $f
    echo $f processed
done

Запусти shellcheck bad.sh — увидишь 3-4 warnings. Исправь каждый.

  1. Запусти script с bash -x:
bash -x bad.sh

Сравни с обычным запуском.

  1. Создай 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.

  1. Установи pre-commit для своего проекта:
pip install pre-commit
# создай .pre-commit-config.yaml с shellcheck hook
pre-commit install

Сделай тест-коммит со скриптом, в котором есть echo $unquoted. Увидишь блокировку.

  1. Добавь GitHub Actions workflow (если есть GitHub-репо) с shellcheck и убедись, что PR с warnings становится красным.

Проверка знанийKnowledge check
Junior пишет скрипт, тестирует его на своей машине — работает. Деплоит на production — иногда падает с 'unbound variable', иногда обрабатывает не те файлы, иногда зависает. Какой минимальный набор инструментов и практик гарантированно поднимает качество кода до production-grade?
ОтветAnswer
Полный pipeline: 1) **set -euo pipefail + IFS=$'\n\t'** в preamble каждого скрипта — ловит silent failures и word-splitting (урок 01). 2) **shellcheck** локально и в CI — 90% багов ловится статически. Команды: shellcheck script.sh + pre-commit hook + GitHub Actions step. Подавлять warnings только с явным обоснованием. 3) **bats unit tests** на ключевые функции из lib.sh: проверка edge cases (empty input, special chars, long strings). Setup/teardown для изоляции. 4) **set -x с кастомным PS4** для дебага сложных мест: PS4='+ \${BASH_SOURCE}:\${LINENO}: '; set -x. Для production — направить trace в файл через BASH_XTRACEFD. 5) **trap EXIT для cleanup** — гарантирует, что мусор не остаётся даже при крашах (урок 03). 6) **--dry-run flag** — позволяет смотреть план без выполнения. 7) **CI**: GitHub Actions с shellcheck + bats обязательно на каждый PR. Без зелёного статуса — не мержить. Когда всё это есть — bash становится production language, а не "одноразовая записка". Подход тот же, что для Python/Go: lint + unit tests + integration tests + CI gate.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что делает команда `set -x` в bash-скрипте?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5