Wann welcher Trap in Bash wirklich greift, und wo beide Lücken haben
trap ERR und trap EXIT klingen wie zwei Varianten desselben Mechanismus, verhalten sich aber grundverschieden. EXIT feuert garantiert, ERR hat dieselben blinden Flecken wie set -e. Wer beide kombiniert, versteht wann eigene Stacktraces mit BASH_LINENO nötig werden, und wie man Fehler zuverlässig loggt statt sie stillschweigend zu verschlucken.
Inhaltsverzeichnis
- 1. trap in Bash: Signale, Pseudo-Signale und der grundlegende Mechanismus
- 2. EXIT: der Trap, der garantiert bei jedem Skriptende feuert
- 3. ERR: der Trap, der bei jedem fehlgeschlagenen Kommando feuert, außer wenn nicht
- 4. Die Lücken von ERR: dieselben Kontexte, die auch set -e ignoriert
- 5. set -o errtrace: ERR in Funktionen und Subshells vererben
- 6. Eigene Stacktraces bauen mit BASH_LINENO und FUNCNAME
- 7. ERR und EXIT kombinieren: einmal loggen, garantiert aufräumen
- 8. Praxisbeispiel: ein Deployment-Skript mit vollständiger Fehlerbehandlung
- 9. ERR und EXIT im Vergleich: wann welcher Trap die richtige Wahl ist
- 10. Zusammenfassung
- 11. FAQ
1. trap in Bash: Signale, Pseudo-Signale und der grundlegende Mechanismus
Das Kommando trap registriert einen Befehl oder eine Funktion, die Bash aufruft, sobald ein bestimmtes Ereignis eintritt. Neben echten Betriebssystem-Signalen wie SIGTERM oder SIGINT kennt Bash zusätzlich zwei Pseudo-Signale, die kein Kernel jemals verschickt, sondern die Bash selbst intern auslöst: EXIT und ERR. Beide lassen sich mit derselben Syntax registrieren, trap 'befehl' EXIT und trap 'befehl' ERR, verhalten sich beim Auslösen aber fundamental unterschiedlich, was in der Praxis regelmäßig zu falschen Annahmen führt.
Wer trap zum ersten Mal einsetzt, geht oft davon aus, dass ERR und EXIT einfach zwei Varianten desselben Cleanup-Mechanismus sind, einmal für den Fehlerfall und einmal für das normale Ende. Tatsächlich ist EXIT der zuverlässige Basismechanismus, der praktisch immer feuert, während ERR ein Werkzeug mit klar definierten, aber leicht zu übersehenden Ausnahmen ist. Diese Unterscheidung zu verstehen ist die Grundlage für jede robuste Fehlerbehandlung in produktiven Bash-Skripten.
2. EXIT: der Trap, der garantiert bei jedem Skriptende feuert
Ein trap ... EXIT wird ausgeführt, sobald das Skript beendet wird, unabhängig davon, ob es normal durchläuft, mit exit abbricht, ein unbehandeltes Signal empfängt oder wegen set -e vorzeitig terminiert. Genau diese Zuverlässigkeit macht EXIT zum Standardwerkzeug für Cleanup-Aufgaben: temporäre Dateien löschen, Locks freigeben, Hintergrundprozesse beenden. Ein einziger EXIT-Trap am Skriptanfang deckt praktisch jeden Ausstiegspfad ab, ohne dass an jeder Stelle im Code manuell aufgeräumt werden muss.
Wichtig ist, dass ein EXIT-Trap innerhalb einer Funktion standardmäßig nicht funktioniert wie erwartet: Traps sind grundsätzlich skriptweit gültig, nicht funktionslokal, und ein in einer Funktion registrierter EXIT-Trap überschreibt schlicht den zuvor gesetzten. Für Cleanup-Logik, die an eine bestimmte Funktion gebunden sein soll, braucht es entweder einen zentralen EXIT-Trap, der eine Liste von Cleanup-Funktionen abarbeitet, oder das Zurücksetzen des Traps am Ende der Funktion.
#!/usr/bin/env bash
set -euo pipefail
TMP_DIR="$(mktemp -d)"
cleanup() {
local exit_code=$?
rm -rf "$TMP_DIR"
echo "Cleanup done, script exited with code $exit_code" >&2
}
# Fires on: normal exit, explicit exit, set -e abort, most signals
trap cleanup EXIT
echo "Working in $TMP_DIR"
false # would abort here because of set -e; cleanup still runs
3. ERR: der Trap, der bei jedem fehlgeschlagenen Kommando feuert, außer wenn nicht
Ein trap ... ERR soll auslösen, sobald ein einfaches Kommando mit einem Rückgabewert ungleich null endet, ähnlich wie das Kriterium, das auch set -e für einen Skriptabbruch verwendet. In der Praxis heißt das: ERR ist das richtige Werkzeug, um beim ersten Fehler eine Diagnosezeile zu loggen, bevor das Skript beendet wird, statt am Ende nur einen nackten Exit-Code ohne Kontext zu haben.
Der entscheidende Haken ist, dass die Definition von einfaches Kommando in Bash enger ist, als die meisten erwarten. Ein fehlgeschlagener Befehl innerhalb einer if-Bedingung, nach && oder ||, oder als Teil einer Kommandoliste, deren Ergebnis explizit geprüft wird, löst den ERR-Trap in vielen Fällen nicht aus. Das ist keine Inkonsistenz, sondern exakt dasselbe Regelwerk, das auch bestimmt, wann set -e einen Abbruch auslöst und wann nicht.
4. Die Lücken von ERR: dieselben Kontexte, die auch set -e ignoriert
Weil ERR an dasselbe Kriterium wie set -e gekoppelt ist, gelten auch dieselben Ausnahmen. Ein Kommando in der Bedingung eines if, while oder until löst den Trap nicht aus, weil sein Rückgabewert bewusst als Steuerfluss abgefragt wird, nicht als Fehler. Ebenso feuert ERR nicht bei einem Kommando, das links von && oder rechts von || steht, weil auch dort der Exit-Code bewusst ausgewertet wird, statt einen unerwarteten Fehler zu signalisieren.
Eine weitere Lücke betrifft Pipelines: Ohne set -o pipefail zählt nur der Exit-Code des letzten Kommandos einer Pipe-Kette, sodass ein fehlschlagendes Kommando in der Mitte der Pipeline unbemerkt bleibt und den ERR-Trap nicht auslöst. Wer diese drei Lücken (Bedingungen, &&/||, Pipelines ohne pipefail) nicht kennt, verlässt sich fälschlich darauf, dass ERR jeden Fehler abfängt, und wundert sich später über Skripte, die trotz eines echten Fehlers klaglos durchlaufen.
5. set -o errtrace: ERR in Funktionen und Subshells vererben
Standardmäßig wird ein ERR-Trap, der im Hauptskript gesetzt ist, in Funktionen, Command-Substitutionen und Subshells nicht automatisch aktiv. Ein Fehler, der tief in einer aufgerufenen Funktion passiert, bleibt so unsichtbar für den äußeren Trap, obwohl genau dort die interessantesten Fehler auftreten. Die Option set -o errtrace (kurz set -E) schaltet dieses Verhalten um: Der ERR-Trap wird dann konsequent an Funktionsaufrufe und Subshells vererbt.
In der Praxis gehört set -o errtrace deshalb neben set -euo pipefail in praktisch jedes produktive Bash-Skript, das mit einem globalen ERR-Trap arbeitet, sonst greift die Fehlerbehandlung nur an der obersten Ebene und lässt genau die Fehler durchrutschen, die in tiefer verschachtelten Funktionen auftreten. Ohne diese Option wirkt ein ERR-Trap zuverlässiger, als er tatsächlich ist, was in größeren Skripten mit vielen Funktionen zu trügerischer Sicherheit führt.
#!/usr/bin/env bash
set -euo pipefail
set -o errtrace # equivalent to set -E: propagate ERR into functions
report_error() {
echo "ERROR in ${FUNCNAME[1]:-main} at line ${BASH_LINENO[0]}" >&2
}
trap report_error ERR
deploy_step() {
# Without errtrace, a failure here would NOT trigger the outer trap
rsync -a --delete ./build/ /var/www/app/
}
deploy_step
6. Eigene Stacktraces bauen mit BASH_LINENO und FUNCNAME
Bash pflegt zwei parallele Arrays, die zusammen einen vollständigen Aufrufstapel ergeben: FUNCNAME enthält die Namen aller aktuell aktiven Funktionen, von der innersten bis zur äußersten, und BASH_LINENO enthält für jeden dieser Aufrufe die Zeilennummer, an der die jeweils nächsttiefere Funktion aufgerufen wurde. Aus beiden Arrays lässt sich innerhalb eines ERR-Traps ein vollständiger, menschenlesbarer Stacktrace zusammensetzen, der genau zeigt, über welche Funktionsaufrufe ein Fehler entstanden ist.
Für produktive Deployment- oder Wartungsskripte lohnt sich eine kleine, wiederverwendbare Funktion, die diesen Stacktrace bei jedem ERR-Ereignis ausgibt und wahlweise an ein Log-System weiterreicht. Dabei ist wichtig, den Trap-Body als Funktionsaufruf zu registrieren statt als komplexen Inline-String, damit die Arrays im richtigen Kontext ausgewertet werden und keine Quoting-Fehler entstehen, die gerade bei verschachtelten Anführungszeichen in Trap-Strings ein häufiges Problem sind.
#!/usr/bin/env bash
set -euo pipefail
set -o errtrace
print_stacktrace() {
local i
echo "Stacktrace (most recent call first):" >&2
for ((i = 0; i < ${#FUNCNAME[@]} - 1; i++)); do
echo " at ${FUNCNAME[$i]}() called from line ${BASH_LINENO[$i]}" >&2
done
}
trap print_stacktrace ERR
level_two() { grep "pattern" /nonexistent-file; }
level_one() { level_two; }
level_one
7. ERR und EXIT kombinieren: einmal loggen, garantiert aufräumen
In robusten Skripten übernehmen ERR und EXIT unterschiedliche, sich ergänzende Aufgaben: ERR ist für die Diagnose zuständig, es protokolliert an genau der Stelle, an der ein Fehler auftritt, mit Zeilennummer und Funktionsname, während EXIT unabhängig vom Erfolg oder Misserfolg garantiert aufräumt. Beide Traps gleichzeitig zu setzen ist kein Widerspruch, sondern das übliche Muster: ERR liefert den Kontext, EXIT liefert die Garantie.
Ein häufiger Fehler ist, im ERR-Trap direkt exit aufzurufen und dabei zu vergessen, dass dieser Aufruf selbst wieder den EXIT-Trap auslöst. Das ist meistens erwünscht, kann aber zu doppelter Ausgabe führen, wenn der EXIT-Trap denselben Fehlerstatus erneut loggt. Ein sauberes Muster speichert den Exit-Code einmal in einer Variable und reicht ihn zwischen den Traps weiter, statt ihn mehrfach neu zu berechnen oder zu erraten.
8. Praxisbeispiel: ein Deployment-Skript mit vollständiger Fehlerbehandlung
In einem echten Deployment-Skript zeigt sich der Wert dieser Kombination am deutlichsten: Schlägt ein Schritt wie rsync, eine Datenbankmigration oder ein Health-Check fehl, soll das Team sofort eine aussagekräftige Meldung erhalten, inklusive der Funktion und Zeile, in der es passiert ist, und gleichzeitig soll das Skript garantiert Sperrdateien freigeben und temporäre Verzeichnisse entfernen, egal wie es endet.
Das folgende Muster kombiniert einen ERR-Trap mit Stacktrace-Ausgabe, einen EXIT-Trap für Cleanup und set -o errtrace, damit auch Fehler in verschachtelten Funktionen zuverlässig erfasst werden. Dieses Grundgerüst lässt sich für die meisten produktiven Bash-Skripte praktisch unverändert übernehmen und ist deutlich robuster als ein einzelner globaler trap ... EXIT ohne gezielte Fehlerdiagnose.
#!/usr/bin/env bash
set -euo pipefail
set -o errtrace
LOCK_FILE="/var/lock/deploy.lock"
on_error() {
local exit_code=$?
echo "DEPLOY FAILED (exit $exit_code) in ${FUNCNAME[1]:-main} at line ${BASH_LINENO[0]}" >&2
}
trap on_error ERR
on_exit() {
rm -f "$LOCK_FILE"
}
trap on_exit EXIT
acquire_lock() { : > "$LOCK_FILE"; }
run_migrations() { bin/magento setup:upgrade; }
health_check() { curl -fsS https://shop.example.com/health >/dev/null; }
acquire_lock
run_migrations
health_check
echo "Deploy successful
9. ERR und EXIT im Vergleich: wann welcher Trap die richtige Wahl ist
Wer beide Traps kennt, wählt sie gezielt nach Aufgabe statt aus Gewohnheit: EXIT für alles, was unabhängig vom Ausgang garantiert passieren muss, ERR für die gezielte Diagnose an der Fehlerstelle selbst. Ein Skript, das nur EXIT nutzt, räumt zwar zuverlässig auf, verliert aber den genauen Ort des Fehlers. Ein Skript, das nur ERR nutzt, protokolliert zwar präzise, riskiert aber verwaiste Ressourcen, wenn ein Fehlerpfad den Trap aus einem der bekannten Gründe nicht auslöst.
Die folgende Tabelle fasst die wichtigsten Unterschiede zusammen und hilft bei der Entscheidung, welcher Trap für welchen Zweck in einem Skript zum Einsatz kommen sollte, inklusive der bekannten Lücken, die ERR mit set -e teilt.
| Merkmal | trap ... EXIT | trap ... ERR | Praxis-Empfehlung |
|---|---|---|---|
| Auslöser | Jedes Skriptende, normal oder abnormal | Fehlgeschlagenes einfaches Kommando | Beide gemeinsam registrieren |
| Bedingungen (if, while) | Nicht relevant, feuert immer | Löst nicht aus | Fehler dort manuell prüfen |
| &&, || | Nicht relevant | Löst nicht aus | Bewusst als Steuerfluss behandeln |
| Funktionen/Subshells | Immer aktiv | Nur mit set -o errtrace | errtrace immer setzen |
| Typischer Einsatz | Cleanup, Locks freigeben | Diagnose, Stacktrace loggen | ERR für Kontext, EXIT für Garantie |
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Shell-Skripte, die in der Produktion zuverlässig laufen?
Wir analysieren bestehende Bash-Skripte, erkennen fragile Muster und ersetzen sie durch robuste Bash-Patterns: mit vollständiger Fehlerbehandlung, Logging und sicherer Parallelisierung für euren Deployment-Stack.
Code-Review
ShellCheck-Analyse und manuelle Prüfung auf kritische Bash-Pattern-Verstöße.
Refactoring
Fehlerbehandlung, Logging und sichere Dateioperationen nachrüsten.
CI-Integration
ShellCheck und BATS in Pipelines integrieren und Regressionstests aufbauen.
10. Zusammenfassung
trap ERR vs. trap EXIT: Das Wichtigste auf einen Blick
EXIT
Feuert garantiert bei jedem Skriptende, normal oder abnormal. Das richtige Werkzeug für Cleanup-Aufgaben wie Locks und temporäre Dateien.
ERR
Feuert bei fehlgeschlagenen einfachen Kommandos, aber nicht in if-Bedingungen, nach && oder ||, oder in Pipelines ohne pipefail.
errtrace
set -o errtrace (set -E) vererbt ERR in Funktionen und Subshells. Ohne diese Option bleiben Fehler in tieferen Funktionen unsichtbar.
Stacktrace
FUNCNAME und BASH_LINENO liefern zusammen Funktionsname und Zeilennummer für jede Ebene des Aufrufstapels innerhalb eines ERR-Traps.