Learning Platform
Глоссарий Troubleshooting
Урок 19.03 · 25 мин
Средний
BashtrapSignalsCleanupProduction patternsLock files

trap: cleanup, сигналы, lock-файлы

В production скрипт обязан убирать за собой. Создал tempdir на 50 GB? Удали. Открыл lock-file? Освободи. Загрузил частично данные в S3? Откати или пометь incomplete. Без cleanup на одной из тысячи запусков скрипт упадёт, оставит мусор, и через месяц диск переполнится.

Базовый bash-механизм — trap. Это обработчики событий: «когда придёт сигнал X, выполни команду Y». В сочетании с set -e они дают нам гарантированный cleanup даже при падении в произвольной точке.

В этом уроке: пsевдо-сигнал EXIT (всегда срабатывает), real-signals (INT/TERM/HUP), паттерны temp-directories, lock-файлов, partial-write protection, propagation в child processes.


Что такое trap

trap 'COMMAND' SIGNAL [SIGNAL...]

Зарегистрировать COMMAND как обработчик для одного или нескольких сигналов. Когда сигнал придёт — bash прервёт текущее выполнение и запустит COMMAND.

trap 'echo "Cleanup!"' EXIT

# В любой точке скрипта при выходе (нормальный или error) — выведется "Cleanup!"

Сигналы и pseudo-signals

Главные сигналы и trap-события
EXITPseudo-signal. Срабатывает на ЛЮБОМ выходе из скрипта — success, error, signal-induced. Самый важный для cleanup
ERRPseudo-signal. Срабатывает на любой ненулевой exit code (как set -e, но позволяет custom-обработку). Удобно для логирования
SIGINTCtrl-C от пользователя. Сигнал 2. По умолчанию завершает программу. Trap позволяет 'gracefully' завершиться
SIGTERMTerminate request. Сигнал 15. systemd, kill, docker stop посылают именно SIGTERM. Дай скрипту шанс корректно завершиться
SIGHUPHang up. Сигнал 1. Когда закрывается терминал. nohup защищает от него
SIGKILLСигнал 9. НЕ ПЕРЕХВАТЫВАЕТСЯ. Если процесс не реагирует на TERM — kill -9. Скрипт умирает без cleanup
WARNING

SIGKILL (9) и SIGSTOP (19) не перехватываются. Это by design Linux — должен быть способ убить процесс. Поэтому 100%-cleanup невозможен. Но если процесс получает SIGKILL — это уже аварийная ситуация (внешний kill -9, OOM-killer), и мусор после неё — наименьшая из проблем.


Базовый паттерн: cleanup временной директории

Классическая ошибка Junior — создать tmpdir и забыть удалить:

#!/bin/bash
set -euo pipefail

# Создать tmpdir:
tmpdir=$(mktemp -d -t etl.XXXXXX)
# где-то на /tmp/etl.AbC123/

# Долгий процесс...
curl ... > "$tmpdir/data.json"
process_data "$tmpdir"
upload_to_s3 "$tmpdir"

# Очистка В КОНЦЕ:
rm -rf "$tmpdir"

Если в середине что-то упало — set -e убьёт скрипт, и rm -rf не выполнится. Tmpdir останется. После 1000 запусков /tmp забит.

Правильно — через trap EXIT:

#!/bin/bash
set -euo pipefail

tmpdir=$(mktemp -d -t etl.XXXXXX)
trap 'rm -rf "$tmpdir"' EXIT

# Теперь — что угодно. Падение, exit, Ctrl-C — всё равно tmpdir будет удалён.
curl ... > "$tmpdir/data.json"
process_data "$tmpdir"
upload_to_s3 "$tmpdir"

Что важно

  1. trap ставим сразу после создания tmpdir. Не позже. Иначе если падение между mktemp и trap — cleanup не сработает.
  2. mktemp -d создаёт уникальную директорию (с XXXXXX суффиксом). Не используй фиксированное имя /tmp/etl-tmp — race condition если два инстанса.
  3. -t prefix.XXXXXX создаёт /tmp/prefix.AbC123 или подобное. Полезно для grep по имени в ls /tmp.

Trap-функция: cleanup сложнее одной команды

Если cleanup многоступенчатый — лучше функция:

#!/bin/bash
set -euo pipefail

tmpdir=$(mktemp -d -t etl.XXXXXX)
lockfile="/var/run/etl.lock"

cleanup() {
    local exit_code=$?
    
    # Удалить tmpdir:
    [ -n "${tmpdir:-}" ] && rm -rf "$tmpdir"
    
    # Освободить lock:
    [ -f "$lockfile" ] && rm -f "$lockfile"
    
    # Лог финального статуса:
    if [ $exit_code -eq 0 ]; then
        echo "$(date -Iseconds) SUCCESS"
    else
        echo "$(date -Iseconds) FAILED with code $exit_code" >&2
    fi
    
    exit $exit_code
}

trap cleanup EXIT

Несколько важных деталей:

  • local exit_code=$?первая строка функции, иначе $? перетрётся следующими командами.
  • ${tmpdir:-} — безопасное обращение с set -u (если переменная не определена — пусто).
  • exit $exit_code в конце — сохраняем оригинальный exit code (иначе функция вернёт код последней команды).

Обработка сигналов: Ctrl-C, SIGTERM от systemd

EXIT срабатывает после любого сигнала. Но иногда нужно различать: например, при Ctrl-C — записать частичный прогресс перед выходом, при SIGTERM от systemd — отменить какие-то операции.

#!/bin/bash
set -euo pipefail

handle_interrupt() {
    echo "Received interrupt signal, saving progress..." >&2
    save_partial_progress
    # Дальше EXIT trap всё равно сработает, делаем общий cleanup
    exit 130   # 128 + signal num (2 для INT)
}

handle_term() {
    echo "Received SIGTERM, graceful shutdown..." >&2
    notify_slack "Job terminated by orchestrator"
    exit 143   # 128 + 15
}

cleanup() {
    rm -rf "$tmpdir"
    [ -f "$lockfile" ] && rm -f "$lockfile"
}

trap cleanup EXIT
trap handle_interrupt INT
trap handle_term TERM

# Основной код...

Конвенция exit code при signal: 128 + signal_number. Так bash сообщает оркестратору причину выхода. systemd, docker, k8s читают эти коды для корректного reporting.

Signal-induced exit codes
Ctrl-CSIGINT (2). Exit code: 128 + 2 = 130. Стандарт для cancel-by-user
kill PIDSIGTERM (15) по умолчанию. Exit code: 128 + 15 = 143. Используется systemd, docker stop, k8s graceful
kill -9SIGKILL (9). Процесс не успевает обработать. Exit code 137. Cleanup не выполняется
Graceful shutdown в Kubernetes — SIGTERM и PreStop хуки

Lock-файлы: запретить параллельные запуски

В DE часто нужно гарантировать, что скрипт не запустится дважды одновременно. Cron triggered every 5 min, но job иногда длится 7 минут — две копии будут конфликтовать за tmpdir или одновременно вписать в БД.

Наивный lock через [ -f ]

LOCKFILE=/var/run/myjob.lock

if [ -f "$LOCKFILE" ]; then
    echo "Another instance is running, exiting"
    exit 0
fi

touch "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT

# Делаем работу...

Это сломано: между [ -f ] и touch есть race condition. Два инстанса могут увидеть отсутствие файла, оба touch, оба продолжить.

Правильный lock через flock

flock(1) — атомарная блокировка через flock(2) syscall. Гарантировано безопасна.

#!/bin/bash
set -euo pipefail

LOCKFILE=/var/run/myjob.lock

# Открыть файл на FD 9, попытаться lock-exclusive non-blocking:
exec 9>"$LOCKFILE"
if ! flock -n 9; then
    echo "Another instance is running, exiting"
    exit 0
fi

# Удалить lockfile при выходе (не обязательно, но красиво):
trap 'rm -f "$LOCKFILE"' EXIT

# Работа...
flock — atomic lock
exec 9>$fileОткрыть FD 9 на запись в файл. FD остаётся в течение скрипта. Закроется при exit, что освободит lock
flock -n 9Non-blocking exclusive lock на FD 9. Если кто-то уже держит — exit code 1 моментально. Без -n будет ждать
workДелаем работу, держа эксклюзивный лок
exitПри выходе скрипта FD 9 закрывается, lock автоматически освобождается ядром. Не нужно явно flock -u

flock решает race condition потому что сам syscall flock() атомарен. Два процесса могут одновременно открыть файл, но только один получит lock — это гарантия ядра.

Альтернатива: flock как wrapper

Можно завернуть весь скрипт во flock без явных FD:

# В /etc/cron.d/myjob:
*/5 * * * * deploy_user flock -n /var/run/myjob.lock /opt/etl/myjob.sh

flock сам откроет файл, попытается lock, и запустит команду только если получит. Не получил — exit 1, cron не запускает. Это самый простой и надёжный паттерн для cron jobs.


ERR trap: централизованное логирование ошибок

ERR срабатывает на ненулевом exit code (как set -e, но даёт hook’у выполниться перед смертью):

#!/bin/bash
set -euo pipefail

on_error() {
    local exit_code=$?
    local line_no=$1
    local cmd="$BASH_COMMAND"
    
    echo "ERROR on line $line_no: command '$cmd' failed with code $exit_code" >&2
    curl -X POST -d "{\"text\":\"Job failed on line $line_no: $cmd\"}" "$SLACK_WEBHOOK" || true
    
    # set -e всё равно остановит скрипт после возврата из trap
}

trap 'on_error $LINENO' ERR

# Если любая команда упадёт — сначала on_error логирует, потом set -e убивает скрипт.

Полезные специальные переменные в ERR trap:

  • $LINENO — строка скрипта, где случилась ошибка.
  • $BASH_COMMAND — текст команды, которая упала.
  • $BASH_SOURCE — имя файла (при include через .).
  • ${FUNCNAME[@]} — стек функций (для вложенных вызовов).

Partial-write protection (atomic file writes)

Классическая проблема: скрипт пишет файл, в середине упал. Файл остался полу-записанным. Downstream читает мусор.

Решение — write-then-rename:

output=/data/processed/users.json
tmp_output="${output}.tmp.$$"   # .tmp.<PID> уникален для процесса

trap 'rm -f "$tmp_output"' EXIT

# Писать в tmp:
generate_data > "$tmp_output"

# В случае успеха — атомарный rename:
mv "$tmp_output" "$output"
TIP

mv внутри одной файловой системы — атомарный syscall rename(). Файл output либо старая версия, либо новая, никогда не “наполовину”. Это гарантия POSIX. Trick из 70-х годов, до сих пор валиден.

Если скрипт упал в generate_data — trap удалит tmp, файл output остаётся со старой версией. Downstream consumers всегда видят consistent data.


Multiple trap handlers: разные сигналы — разная логика

Можно ставить разные trap на разные сигналы:

trap 'cleanup_normal' EXIT    # всегда
trap 'handle_int' INT          # Ctrl-C
trap 'handle_term' TERM        # systemd stop
trap 'on_error $LINENO' ERR    # любая ошибка

Порядок выполнения при ошибке: ERR -> (set -e срабатывает -> exit) -> EXIT. То есть в одну ошибку могут отработать оба trap.

Снять trap

trap - EXIT     # удалить EXIT handler
trap - INT      # удалить INT handler
trap '' INT     # игнорировать сигнал (но НЕ обработчик; пустая строка vs дефис)

trap - SIG восстанавливает дефолт. trap '' SIG означает игнорировать сигнал полностью (no-op). Будь осторожен — trap '' INT сделает Ctrl-C неработающим, придётся через kill из другого терминала.


Realistic DE-example: полный production-grade pattern

#!/bin/bash
# nightly_etl.sh — ETL job, runs from cron, full error handling
set -euo pipefail
IFS=$'\n\t'

# === Constants ===
JOB_NAME="nightly_users_etl"
LOCKFILE="/var/run/${JOB_NAME}.lock"
LOG_FILE="/var/log/${JOB_NAME}.log"
SLACK_WEBHOOK="${SLACK_WEBHOOK:?required}"

# === State (will be set later) ===
tmpdir=""
start_time=$(date -Iseconds)

# === Logging helper ===
log() {
    echo "$(date -Iseconds) [$JOB_NAME] $*" | tee -a "$LOG_FILE"
}

# === Slack notify (best-effort, never fail) ===
slack_notify() {
    local msg="$1"
    curl -X POST -H 'Content-Type: application/json' \
         --data "{\"text\":\"$msg\"}" \
         --max-time 5 \
         "$SLACK_WEBHOOK" >/dev/null 2>&1 || true
}

# === Cleanup на любой выход ===
cleanup() {
    local exit_code=$?
    
    # tmpdir
    [ -n "$tmpdir" ] && rm -rf "$tmpdir"
    
    # lockfile
    rm -f "$LOCKFILE"
    
    # Notify
    if [ $exit_code -eq 0 ]; then
        slack_notify ":white_check_mark: $JOB_NAME succeeded (took $SECONDS s)"
        log "SUCCESS in ${SECONDS}s"
    else
        slack_notify ":x: $JOB_NAME FAILED with exit $exit_code (took ${SECONDS}s)"
        log "FAILED with exit $exit_code in ${SECONDS}s"
    fi
}
trap cleanup EXIT

# === Error trap для деталей ===
on_error() {
    log "ERROR on line $1: $BASH_COMMAND (exit $?)"
}
trap 'on_error $LINENO' ERR

# === Граф shutdown на signal ===
trap 'log "Received SIGTERM"; exit 143' TERM
trap 'log "Received SIGINT"; exit 130' INT

# === Lock acquire ===
exec 9>"$LOCKFILE"
if ! flock -n 9; then
    log "Another instance running, skipping this run"
    exit 0
fi

# === Setup tmpdir ===
tmpdir=$(mktemp -d -t "${JOB_NAME}.XXXXXX")
log "Started, tmpdir=$tmpdir"

# === Work ===
log "Fetching users..."
curl --fail --max-time 60 https://api.example.com/users > "$tmpdir/users.json"

log "Validating..."
jq -e 'length > 0' "$tmpdir/users.json" >/dev/null

log "Uploading to S3..."
aws s3 cp "$tmpdir/users.json" "s3://my-bucket/raw/$(date +%Y-%m-%d).json"

log "Done"
# cleanup() triggered by EXIT

Это production-grade. Любой Junior должен уметь читать и писать такой код к концу курса.


Подводные камни

1. Trap в subshell не наследуется

trap 'echo OUTER' EXIT

(
    # subshell
    echo "in subshell"
    # OUTER trap не сработает здесь — другой shell context
)

# OUTER сработает только после exit main script

В каждом subshell нужен свой trap (если он там нужен). Это рассинхронизация — часто проще писать flat код без subshells.

2. trap ERR не вызывается в условиях

Как и set -e, trap ERR не срабатывает в if cmd; then, while cmd; do, &&/||. Это by design — exit code там часть логики.

3. trap EXIT в функциях — на уровне всего скрипта

function bad_idea() {
    trap 'echo "func cleanup"' EXIT   # это НЕ cleanup функции — это глобальный trap!
}

trap — глобальный. Если вызовешь функцию дважды, перезапишешь handler. Нет нативного «trap в scope функции». Для локального cleanup используй явную unwind-логику или set -E для inheritance.

4. trap для DEBUG и RETURN

Bash поддерживает DEBUG (выполняется перед каждой командой) и RETURN (на выходе из функции). Редко нужны на практике, но полезны для tracing/profiling.


Bash 5.3: новые возможности

Bash 5.3 (июль 2025) добавил ${ cmd; } substitution — выполнить команду в текущем процессе без fork. Раньше $(cmd) всегда форкал subshell. Теперь:

# Bash 5.3+:
output=${ ls /tmp; }   # без fork, быстрее

Это влияет на trap context: команда внутри ${ ; } выполняется в основном процессе, и любые установленные ею trap-handlers применяются к main. Будь внимателен.


  • Урок 01 (preamble) — trap дополняет set -e, обеспечивая cleanup при падении.
  • Урок 05 (debugging) — shellcheck предупреждает о неправильных trap.
  • Модуль 14 (systemd) — systemd посылает SIGTERM на stop, trap TERM критичен для graceful shutdown.
  • Модуль 15 (cron) — flock в cron predicate защищает от overlapping runs.
  • Capstone (модуль 20) — все эти паттерны в одном скрипте.

Попробуй сам

  1. Создай скрипт с tmpdir + trap, и убей его Ctrl-C на середине. Проверь, что tmpdir удалён:
#!/bin/bash
set -euo pipefail

tmpdir=$(mktemp -d -t demo.XXXXXX)
trap 'echo "Cleaning $tmpdir"; rm -rf "$tmpdir"' EXIT
echo "tmpdir=$tmpdir"
sleep 30

Запусти, посмотри в /tmp/demo.*. Жми Ctrl-C. Проверь — директория удалилась.

  1. Lock через flock — открой два терминала, запусти один и тот же скрипт:
#!/bin/bash
exec 9>/tmp/test.lock
flock -n 9 || { echo "Locked"; exit 1; }
echo "Got lock, sleeping..."
sleep 20
  1. Atomic write через trap:
#!/bin/bash
out=/tmp/output.txt
tmp="$out.tmp.$$"
trap 'rm -f "$tmp"' EXIT

echo "line 1" > "$tmp"
sleep 5
# Прерви Ctrl-C — увидишь что output.txt не изменён (старый)
echo "line 2" >> "$tmp"
mv "$tmp" "$out"
  1. ERR trap для логирования:
#!/bin/bash
set -euo pipefail
trap 'echo "Failed on line $LINENO: $BASH_COMMAND"' ERR

echo "step 1"
false   # упадёт
echo "step 2"

Проверка знанийKnowledge check
Скрипт каждые 5 минут запускается по cron, делает ETL и пишет в /data/output.json. На production: 1) иногда два инстанса работают параллельно и пишут друг поверх друга, 2) при падении остаётся tmpdir /tmp/etl.XXX, 3) downstream читает наполовину записанный output.json. Какой набор техник решает все три проблемы?
ОтветAnswer
1) **Параллельные запуски** — flock с FD: exec 9>/var/run/etl.lock; flock -n 9 || exit 0. Или wrap в cron: flock -n /var/run/etl.lock /opt/etl.sh. Гарантирует, что одновременно работает только один инстанс благодаря атомарности flock() syscall на уровне ядра. 2) **Tmpdir leak** — tmpdir=\$(mktemp -d); trap 'rm -rf \"\$tmpdir\"' EXIT. EXIT — pseudo-signal, срабатывает при любом выходе (success, error, signal). Trap ставится сразу после mktemp. 3) **Partial writes** — write-then-rename: generate > \"\$out.tmp.\$\$\"; mv \"\$out.tmp.\$\$\" \"\$out\". mv внутри одной FS — атомарный syscall rename(), файл либо старая версия, либо новая. Tmp-файл тоже в trap для cleanup при падении. Все три паттерна вместе — production-grade ETL skeleton, который мы соберём в capstone (модуль 20). Дополнительно: trap '...' INT TERM для graceful shutdown на cancel, и trap 'on_error \$LINENO' ERR для централизованного логирования.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Чем pseudo-signal EXIT отличается от обычных сигналов вроде INT/TERM?

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

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

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

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