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
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"
Что важно
trapставим сразу после создания tmpdir. Не позже. Иначе если падение междуmktempиtrap— cleanup не сработает.mktemp -dсоздаёт уникальную директорию (с XXXXXX суффиксом). Не используй фиксированное имя/tmp/etl-tmp— race condition если два инстанса.-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.
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 решает 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"
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. Будь внимателен.
Cross-links
- Урок 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) — все эти паттерны в одном скрипте.
Попробуй сам
- Создай скрипт с 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. Проверь — директория удалилась.
- Lock через flock — открой два терминала, запусти один и тот же скрипт:
#!/bin/bash
exec 9>/tmp/test.lock
flock -n 9 || { echo "Locked"; exit 1; }
echo "Got lock, sleeping..."
sleep 20
- 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"
- ERR trap для логирования:
#!/bin/bash
set -euo pipefail
trap 'echo "Failed on line $LINENO: $BASH_COMMAND"' ERR
echo "step 1"
false # упадёт
echo "step 2"