Cleanup mit trap
Wenn ein Shell-Skript durch Ctrl+C, kill oder einen Fehler beendet wird, bleiben ohne Signal-Handling Lockfiles, temporäre Dateien und halbfertige Datenbankoperationen zurück. Das Bash-Builtin trap registriert Handler für SIGINT, SIGTERM, SIGPIPE, ERR und EXIT — und macht Cleanup zu einem zuverlässigen, geordneten Prozess.
Inhaltsverzeichnis
- 1. Signale in Linux und die Rolle von trap
- 2. trap EXIT und trap ERR — das Fundament
- 3. SIGINT und SIGTERM: Benutzerabbruch und kill
- 4. SIGPIPE: stille Kills in Pipes
- 5. Cleanup-Reihenfolge richtig strukturieren
- 6. Subshells und Hintergrundprozesse: Signale weiterleiten
- 7. trap in Funktionen und Libraries
- 8. Praktisches Beispiel: robustes Deployment-Skript
- 9. Signal-Vergleich: Verhalten und Empfehlung
- 10. Zusammenfassung
- 11. FAQ
1. Signale in Linux und die Rolle von trap
Signale sind der Unix-Mechanismus, mit dem das Betriebssystem oder andere Prozesse einem laufenden Prozess Ereignisse mitteilen. Das bekannteste Signal ist SIGINT (Signal 2), das durch Ctrl+C erzeugt wird und standardmäßig den Prozess beendet. SIGTERM (Signal 15) ist das "freundliche" Beendigungs-Signal, das kill ohne explizite Angabe sendet und dem Prozess Gelegenheit gibt, sauber zu beenden. SIGKILL (Signal 9) kann nicht abgefangen oder ignoriert werden — es beendet den Prozess sofort. Das Bash-Builtin trap erlaubt es, für die meisten Signale eigene Handler zu registrieren, die vor dem Beenden ausgeführt werden.
Neben den klassischen Prozess-Signalen kennt Bash zwei besondere Pseudo-Signale: EXIT und ERR. Ein trap auf EXIT wird bei jedem Beenden des Skripts ausgeführt — normaler Ablauf, exit-Aufruf, set -e-getriggerter Abbruch oder abgefangenes Signal. Ein trap auf ERR wird ausgeführt, wenn ein Befehl einen Nicht-Null-Exit-Code zurückgibt (unter Berücksichtigung der set -e-Regeln). Diese beiden Pseudo-Signale sind das wichtigste Werkzeug für robustes Cleanup und Fehlerlogging in Shell-Skripten.
Die Syntax von trap ist trap 'Befehl' SIGNAL [SIGNAL ...] oder trap Funktionsname SIGNAL. Mehrere Signale können einem einzelnen Handler zugewiesen werden. trap '' SIGNAL ignoriert das Signal vollständig. trap - SIGNAL setzt den Handler auf den Standard zurück. Diese drei Varianten decken alle praktischen Anwendungsfälle ab, von der einfachen Cleanup-Funktion bis zum differenzierten Signal-Handling mit verschiedenen Exit-Codes.
2. trap EXIT und trap ERR — das Fundament
Der trap cleanup EXIT-Handler ist das wichtigste Werkzeug für robuste Shell-Skripte. Er wird ausgeführt, egal wie das Skript endet — und das ist seine wichtigste Eigenschaft. Ohne diesen Handler muss jeder mögliche Beendigungspfad explizit behandelt werden: normaler Ablauf, exit-Aufrufe an verschiedenen Stellen, Fehler durch set -e und jedes abgefangene Signal. Das ist fehleranfällig und erzeugt duplizierte Cleanup-Logik. Der trap auf EXIT zentralisiert diese Logik an einem Ort. Wichtig: In der Cleanup-Funktion den aktuellen Exit-Code mit local exit_code=$? am Anfang sichern, weil die Cleanup-Operationen selbst den Exit-Code überschreiben können.
Der trap 'on_error' ERR-Handler wird bei jedem Fehler ausgeführt, bevor das Skript durch set -e abgebrochen wird. Er eignet sich ideal für Fehlerlogging mit Kontext: Welcher Befehl ist fehlgeschlagen, in welcher Zeile (${BASH_LINENO[0]}), in welcher Funktion (${FUNCNAME[0]}), mit welchem Exit-Code ($?). Diese Information ist in Produktion unverzichtbar — statt eines stummen Abbruchs enthält das Log genau den Kontext, den der Operator braucht, um den Fehler zu diagnostizieren. Der ERR-Handler wird nicht ausgelöst, wenn ein fehlgeschlagener Befehl in einer if-Bedingung, nach || oder nach && steht — das ist dasselbe Verhalten wie set -e.
#!/usr/bin/env bash
# trap-foundation.sh — EXIT and ERR traps as script foundation
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
declare -a CLEANUP_FILES=()
declare -a CLEANUP_DIRS=()
# ERR trap: log detailed context before set -e kills the script
on_error() {
local exit_code=$?
local line="${BASH_LINENO[0]}"
local func="${FUNCNAME[1]:-main}"
local cmd="${BASH_COMMAND}"
printf '[%s] ERROR in %s:%s (%s) — command: %s — exit: %d\n' \
"$(date '+%Y-%m-%dT%H:%M:%S')" "$SCRIPT_NAME" "$line" "$func" "$cmd" "$exit_code" \
| tee -a "$LOG_FILE" >&2
}
# EXIT trap: ordered cleanup regardless of how script ends
cleanup() {
local exit_code=$?
# Disable ERR trap during cleanup to avoid noise
trap - ERR
for f in "${CLEANUP_FILES[@]:-}"; do
[[ -f "$f" ]] && rm -f -- "$f"
done
for d in "${CLEANUP_DIRS[@]:-}"; do
[[ -d "$d" ]] && rm -rf -- "$d"
done
exit "$exit_code"
}
trap 'on_error' ERR
trap 'cleanup' EXIT
# Register temp resources for cleanup
tmpfile="$(mktemp)"
CLEANUP_FILES+=("$tmpfile")
workdir="$(mktemp -d)"
CLEANUP_DIRS+=("$workdir")
echo "Working in: $workdir"
3. SIGINT und SIGTERM: Benutzerabbruch und kill
SIGINT (Ctrl+C) und SIGTERM (kill PID) sind die häufigsten Signale, auf die ein laufendes Skript reagieren muss. Das Standardverhalten von Bash bei SIGINT: Das Signal wird an den aktuell laufenden Vordergrundprozess weitergeleitet, und nach dessen Beenden prüft Bash, ob es selbst auch beendet werden soll. Bei SIGTERM: Bash beendet sich nach dem aktuellen Befehl. Mit einem trap-Handler kann dieses Verhalten angepasst werden. Das typische Muster: SIGINT und SIGTERM mit einem Handler abfangen, der eine geordnete Beendigung einleitet — Laufende Operationen abbrechen, Cleanup ausführen, dann mit dem korrekten Exit-Code enden.
Der korrekte Exit-Code nach einem Signal ist wichtig für den aufrufenden Prozess. Die Unix-Konvention: Ein Prozess, der durch Signal N beendet wird, sollte Exit-Code 128 + N zurückgeben. Für SIGINT (2) ist das 130, für SIGTERM (15) ist das 143. Das ermöglicht dem aufrufenden Prozess oder der CI-Pipeline zu unterscheiden, ob das Skript durch einen Fehler (Exit-Code 1) oder durch ein Signal (130 oder 143) beendet wurde. In der trap-Handler-Funktion: exit $((128 + signal_number)) aufrufen. Für SIGTERM empfiehlt sich zusätzlich, das Signal an alle Kind-Prozesse weiterzuleiten, damit auch diese sauber beendet werden.
4. SIGPIPE: stille Kills in Pipes
SIGPIPE (Signal 13) ist das Signal, das an einen Prozess gesendet wird, wenn er in eine Pipe schreibt, deren Lese-Ende bereits geschlossen ist. Das klassische Beispiel: long_running_command | head -20. head liest 20 Zeilen und schließt seinen stdin. Beim nächsten Schreibversuch von long_running_command empfängt der Prozess SIGPIPE und wird beendet. Normalerweise ist das erwünschtes Verhalten — aber mit set -e und pipefail führt der Nicht-Null-Exit-Code aus SIGPIPE dazu, dass das gesamte Skript abbricht, obwohl keine fehlerhafte Situation vorliegt.
Die Lösung für SIGPIPE in Bash ist das explizite Ignorieren mit trap: trap '' PIPE. Das verhindert, dass SIGPIPE den Prozess beendet, und der Schreibbefehl gibt stattdessen Exit-Code 1 zurück — was mit explizitem || true behandelt werden kann. Alternativ gibt es den Ansatz, SIGPIPE-Fehler in Pipes durch geschickte Struktur zu vermeiden: Wenn möglich, head durch Argumente ersetzen (command | awk 'NR<=20' mit frühem Exit) oder pipefail für bestimmte Pipe-Ausdrücke temporär deaktivieren. Das Verstehen von SIGPIPE ist entscheidend für alle Skripte, die set -o pipefail und Pipes mit begrenzend konsumierenden Tools kombinieren.
#!/usr/bin/env bash
# signal-handling.sh — complete signal handling with proper exit codes
set -euo pipefail
IFS=$'\n\t'
# Ignore SIGPIPE — handle write-to-closed-pipe gracefully
trap '' PIPE
declare -i _CAUGHT_SIGNAL=0
handle_signal() {
local signum="$1"
local signame="$2"
_CAUGHT_SIGNAL="$signum"
printf '\n[SIGNAL] Received %s — initiating graceful shutdown...\n' "$signame" >&2
# Forward signal to all child processes in our process group
kill -"$signum" -- -$$ 2>/dev/null || true
}
trap 'handle_signal 2 SIGINT' INT
trap 'handle_signal 15 SIGTERM' TERM
trap 'handle_signal 1 SIGHUP' HUP
cleanup() {
local exit_code=$?
trap - INT TERM HUP # Prevent re-entry during cleanup
# Determine final exit code: signal takes precedence
if (( _CAUGHT_SIGNAL > 0 )); then
exit_code=$(( 128 + _CAUGHT_SIGNAL ))
fi
rm -f -- /tmp/myskript.lock 2>/dev/null || true
printf '[CLEANUP] Exiting with code %d\n' "$exit_code" >&2
exit "$exit_code"
}
trap 'cleanup' EXIT
# Example: SIGPIPE-safe pipeline with head
generate_lines() {
local i=0
while (( i < 10000 )); do
printf 'Line %d\n' "$i"
(( i++ ))
done
}
# Without 'trap "" PIPE', this would trigger pipefail on SIGPIPE
generate_lines | head -5 || true
5. Cleanup-Reihenfolge richtig strukturieren
Die Reihenfolge der Cleanup-Operationen in einem trap-EXIT-Handler ist entscheidend für Korrektheit. Die allgemeine Regel: In umgekehrter Reihenfolge der Ressourcen-Allokation aufräumen — das zuletzt Erstellte zuerst freigeben. Praktisch bedeutet das: Zuerst Hintergrundprozesse stoppen, dann Datenbankverbindungen schließen, dann Dateisystemoperationen rückgängig machen (Mounts unmounten), dann temporäre Verzeichnisse löschen, dann temporäre Dateien löschen, zuletzt Lockfiles freigeben. Das Lockfile als letztes freizugeben ist wichtig: Solange das Lockfile existiert, weiß ein neuer Instanzstart, dass noch Cleanup läuft.
In der Cleanup-Funktion sollten Fehler nicht mit set -e abbrechen — eine Cleanup-Operation, die fehlschlägt, sollte nicht die gesamte restliche Cleanup-Sequenz verhindern. Daher empfiehlt sich am Anfang der Cleanup-Funktion: trap - ERR EXIT (alle Traps deregistrieren) und dann jede Cleanup-Operation mit || true oder einer expliziten Fehlerbehandlung versehen. Alternativ kann der gesamte Cleanup-Block in einer Subshell mit (set +e; cleanup_ops) ausgeführt werden. Das Ziel: der Cleanup läuft immer vollständig durch, auch wenn einzelne Schritte fehlschlagen — der Exit-Code des ursprünglichen Fehlers bleibt erhalten.
6. Subshells und Hintergrundprozesse: Signale weiterleiten
Ein häufiges Missverständnis beim Bash-trap: Subshells und Hintergrundprozesse erben nicht automatisch die trap-Handler des Parent-Prozesses. Subshells (via $() oder ()) setzen alle trap-Handler auf die Standardwerte zurück. Funktionen hingegen erben die trap-Handler. Das bedeutet: Ein trap auf EXIT, der in der Hauptshell registriert ist, wird in einer Subshell nicht ausgeführt. Für Hintergrundprozesse gilt: Sie erhalten keine Signale, die an den Parent gesendet werden, außer der Parent leitet sie explizit weiter.
Das korrekte Muster für Signal-Weiterleitung an Hintergrundprozesse: Die PIDs aller gestarteten Hintergrundprozesse in einem Array speichern, und im Signal-Handler über das Array iterieren und jedem Prozess das Signal senden. Mit wait danach auf das Ende aller Kind-Prozesse warten, bevor die Cleanup-Funktion die Exit-Sequenz abschließt. Ohne dieses Pattern werden Hintergrundprozesse zu Waisen — Prozesse ohne aktiven Parent —, die weiterlaufen, nachdem das Hauptskript bereits beendet hat. Das ist besonders problematisch bei Resource-intensiven Hintergrundprozessen oder bei Prozessen, die Lockfiles halten.
#!/usr/bin/env bash
# subshell-signals.sh — forward signals to background children
set -euo pipefail
IFS=$'\n\t'
declare -a CHILD_PIDS=()
# Signal forwarding to all registered children
forward_signal() {
local sig="$1"
local pid
for pid in "${CHILD_PIDS[@]:-}"; do
kill -"$sig" "$pid" 2>/dev/null || true
done
}
cleanup() {
local exit_code=$?
trap - INT TERM EXIT
forward_signal TERM
# Wait for all children to finish cleanup
local pid
for pid in "${CHILD_PIDS[@]:-}"; do
wait "$pid" 2>/dev/null || true
done
exit "$exit_code"
}
trap 'forward_signal INT; exit 130' INT
trap 'forward_signal TERM; exit 143' TERM
trap 'cleanup' EXIT
# Start background workers and track their PIDs
worker() {
local id="$1"
trap 'echo "[WORKER $id] Caught signal, exiting cleanly" >&2; exit' INT TERM
while true; do
sleep 1
echo "[WORKER $id] tick"
done
}
worker 1 & CHILD_PIDS+=($!)
worker 2 & CHILD_PIDS+=($!)
echo "Main: workers started. PIDs: ${CHILD_PIDS[*]}"
wait # Wait for all children (or until signal)
7. trap in Funktionen und Libraries
Das Verhalten von trap in Funktionen hat eine wichtige Besonderheit: Ein in einer Funktion gesetzter trap gilt für die gesamte Shell-Session, nicht nur für die Funktion. Wenn eine Library-Funktion einen trap setzt, überschreibt sie damit den trap des aufrufenden Skripts. Das ist ein häufiger Bug in Shell-Libraries: Die Library setzt intern einen trap auf EXIT für ihre eigene Ressourcenfreigabe und überschreibt dabei den Exit-trap des aufrufenden Skripts.
Die korrekte Lösung für Libraries: Bestehende trap-Handler vor dem Setzen eines neuen Handlers abfragen (trap -p EXIT), den neuen Handler mit dem bestehenden kombinieren und beide registrieren. Das ist komplexer als ein einfaches trap 'fn' EXIT, aber der einzige Weg, der trap-Komposition in Libraries ermöglicht. Alternativ können Libraries eigene Cleanup-Listen verwalten, in die das aufrufende Skript registriert, und nur einen einzigen zentralen trap-Handler im Hauptskript verwenden, der über diese Listen iteriert. Das macht die trap-Verwaltung explizit und verhindert Konflikte zwischen Library-Code und Anwendungs-Code.
8. Praktisches Beispiel: robustes Deployment-Skript
Ein Deployment-Skript ist der typische Anwendungsfall, bei dem trap und Signal-Handling den Unterschied zwischen einem sicheren und einem gefährlichen Skript ausmachen. Wird ein Deployment-Skript ohne Signal-Handling abgebrochen, kann es Datenbanken in einem Migrationszwischenzustand zurücklassen, Maintenance-Mode aktiviert lassen, symbolische Links auf unfertige Releases zeigen lassen oder Lockfiles auf dem Produktionssystem hinterlassen. Mit korrekt strukturiertem Signal-Handling und einer geordneten Cleanup-Funktion werden alle diese Zustände auch bei Ctrl+C oder kill sauber aufgeräumt.
Das Deployment-Skript demonstriert die vollständige Integration: ERR-Logging mit Kontext, EXIT-Cleanup mit Ressourcen-Liste, SIGINT/SIGTERM-Handler mit Signal-Weiterleitung, SIGPIPE-Ignorierung und die korrekte Exit-Code-Konvention. In der Praxis würde ein solches Skript zusätzlich Rollback-Logik enthalten — wenn bestimmte Deployment-Schritte fehlschlagen, werden vorherige Schritte rückgängig gemacht. Diese Rollback-Logik wird natürlich ebenfalls im trap-EXIT-Handler oder in einer dedizierten Rollback-Funktion verankert, die vom ERR-Handler aufgerufen wird.
9. Signal-Vergleich: Verhalten und Empfehlung
Die verschiedenen Signale und Pseudo-Signale, die Bash trap abfangen kann, haben unterschiedliche Semantiken und erfordern unterschiedliche Handling-Strategien. Eine Übersicht der wichtigsten:
| Signal / Pseudo-Signal | Ursache | Empfohlenes trap-Handling | Exit-Code |
|---|---|---|---|
| EXIT | Jedes Skript-Ende | Cleanup-Funktion mit Ressourcen-Liste | Originalen Exit-Code bewahren |
| ERR | Nicht-Null-Exit-Code | Fehlerlogging mit BASH_LINENO, FUNCNAME | $? sichern vor Cleanup |
| INT (SIGINT) | Ctrl+C vom Terminal | Signal an Kinder weiterleiten, exit 130 | 128 + 2 = 130 |
| TERM (SIGTERM) | kill PID / systemd stop | Graceful shutdown, exit 143 | 128 + 15 = 143 |
| PIPE (SIGPIPE) | Schreiben in geschlossene Pipe | trap '' PIPE — ignorieren | Write-Syscall gibt EPIPE zurück |
Die wichtigste Erkenntnis aus dieser Tabelle: EXIT ist das universelle Cleanup-Signal — es wird immer ausgeführt, egal welches andere Ereignis das Skript beendet. ERR ist das präzise Logging-Signal — es wird vor set -e-Abbrüchen ausgeführt und hat Zugriff auf den vollen Fehlerkontext. SIGINT und SIGTERM erfordern aktive Signal-Weiterleitung an Kind-Prozesse. SIGPIPE sollte in den meisten Produktions-Skripten ignoriert werden, um unerwartete Pipeline-Abbrüche durch head, grep oder andere begrenzende Konsumenten zu vermeiden.
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Deployment-Skripte mit robustem Signal-Handling?
Wir überprüfen bestehende Deployment-Skripte auf fehlende Signal-Handler und Cleanup-Lücken, implementieren vollständiges trap-Handling für alle relevanten Signale und stellen sicher, dass Skripte auch bei Ctrl+C oder kill sauber enden.
Signal-Audit
Analyse bestehender Skripte auf fehlende trap-Handler und Cleanup-Lücken
Implementierung
EXIT, ERR, SIGINT, SIGTERM und SIGPIPE-Handler für Deployment-Skripte
Testing
BATS-Tests für Cleanup-Verhalten bei verschiedenen Signal-Szenarien
10. Zusammenfassung
Robustes Signal-Handling mit trap in Bash besteht aus vier Schichten: trap 'on_error' ERR für detailliertes Fehlerlogging mit Zeilennummer und Funktionsname, trap 'cleanup' EXIT als universeller Cleanup-Handler für alle Beendigungsszenarien, explizite Handler für SIGINT und SIGTERM mit Signal-Weiterleitung an Kind-Prozesse und Exit-Code 128 + N, sowie trap '' PIPE um unerwartete Pipeline-Abbrüche durch begrenzend konsumierende Tools zu verhindern. Diese vier Schichten decken alle relevanten Beendigungsszenarien ab und stellen sicher, dass Ressourcen auch unter unerwarteten Bedingungen freigegeben werden.
Die Cleanup-Reihenfolge folgt dem Prinzip "umgekehrte Allokationsreihenfolge": zuerst Prozesse, dann Verbindungen, dann Mounts, dann Dateien, zuletzt Locks. In der Cleanup-Funktion werden alle trap-Handler deregistriert, um Re-Entrantz zu verhindern, und jede Cleanup-Operation wird mit Fehlerbehandlung versehen, damit der Cleanup auch bei partiellen Fehlern vollständig durchläuft. Das Lockfile wird als letztes freigegeben — solange es existiert, signalisiert es, dass der Cleanup noch nicht abgeschlossen ist.
Signal-Handling mit trap — Das Wichtigste auf einen Blick
trap EXIT
Universeller Cleanup-Handler — wird bei jedem Skript-Ende ausgeführt. Exit-Code am Anfang sichern, alle Traps deregistrieren, Ressourcen in umgekehrter Reihenfolge freigeben.
trap ERR
Fehlerlogging mit BASH_LINENO[0], FUNCNAME[0] und BASH_COMMAND vor set -e-Abbruch. Macht stumme Fehler diagnostizierbar.
SIGINT/SIGTERM
Signal an alle Kind-Prozesse weiterleiten, dann exit $((128 + N)). Ohne Weiterleitung werden Hintergrundprozesse zu Waisen.
SIGPIPE
trap '' PIPE — ignorieren. Verhindert unerwartete Skript-Abbrüche durch head, grep und andere begrenzend konsumierende Pipeline-Tools.