set -euo pipefail, Traps und Exit-Codes richtig einsetzen
Viele Shell-Skripte scheitern still — ein Befehl gibt Fehlercode 1 zurück, die Pipeline läuft weiter, das Monitoring sieht nichts. set -euo pipefail, ERR-Traps mit LINENO und saubere Exit-Codes sind die Grundlage jedes Bash-Skripts, das im Produktionsbetrieb zuverlässig arbeitet und bei Fehlern sofort und nachvollziehbar reagiert.
Inhaltsverzeichnis
- 1. Warum stille Fehler das größte Problem in Bash-Skripten sind
- 2. set -e: Sofortiger Abbruch bei Nicht-Null-Exit-Code
- 3. set -u: Ungesetzte Variablen als harte Fehler behandeln
- 4. set -o pipefail: Fehler in Pipes nicht verlieren
- 5. ERR-Trap und LINENO: Fehlerstellen exakt lokalisieren
- 6. EXIT-Trap: Cleanup für alle Beendigungsszenarien
- 7. Exit-Codes: Bedeutung, Konventionen und eigene Codes
- 8. Fallstricke: Wann set -euo pipefail nicht greift
- 9. Fehlerbehandlungs-Muster im Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum stille Fehler das größte Problem in Bash-Skripten sind
Der gefährlichste Fehler in einem Bash-Skript ist nicht der, der das Skript abstürzen lässt — es ist der Fehler, der unbemerkt bleibt. Ohne explizite Fehlerbehandlung läuft ein Shell-Skript standardmäßig weiter, selbst wenn ein Befehl mit Exit-Code 1 oder höher endet. Das bedeutet in der Praxis: Ein Backup-Skript komprimiert nichts, gibt aber am Ende "Backup erfolgreich" aus. Ein Deployment-Skript überspringt einen kritischen Schritt, weil ein Vorbereitungsbefehl fehlschlug. Das Monitoring sieht in beiden Fällen Exit-Code 0 — alles grün, obwohl die Operation komplett fehlgeschlagen ist.
Robuste Bash-Skripte schreiben bedeutet in erster Linie: die Shell so konfigurieren, dass sie bei Fehlern laut und sofort reagiert, statt still weiterzumachen. Die Kombination aus set -euo pipefail, einem ERR-Trap der die Fehlerstelle mit LINENO protokolliert und sauberen Exit-Codes für alle Ausgangszustände ist das Fundament, ohne das kein Skript im Produktionsbetrieb verlässlich arbeiten kann. Dieser Artikel analysiert jeden dieser Bausteine im Detail — einschließlich der oft unbekannten Grenzen und Fallstricke.
Die gute Nachricht: Diese Fehlerbehandlung kostet keine nennenswerte Performance und erhöht den Schreibaufwand minimal. Das schlechte Skript-Muster — keine Fehlerbehandlung, Fehler werden im Rauschen übersehen — kostet dagegen Stunden bei der Fehlersuche, wenn etwas in der Produktion falsch läuft. Robuste Bash-Skripte schreiben ist keine Kür, sondern die Pflicht für jeden Shell-Code der automatisiert und unbeaufsichtigt läuft.
2. set -e: Sofortiger Abbruch bei Nicht-Null-Exit-Code
Das Flag set -e (Langform: set -o errexit) weist die Shell an, das Skript sofort zu beenden, wenn ein einfacher Befehl einen Exit-Code ungleich Null zurückgibt. Ohne set -e ignoriert Bash fehlgeschlagene Befehle standardmäßig — ein Verhalten, das für interaktive Shells sinnvoll ist, in automatisierten Skripten aber zu schwer nachvollziehbaren Fehlerzuständen führt. Wer robuste Bash-Skripte schreiben will, beginnt jede Datei mit dieser Option.
Wichtig: set -e greift nur bei einfachen Befehlen in bestimmten Kontexten. In einer if-Bedingung, nach && und nach || ist ein Nicht-Null-Exit-Code kein Fehler, sondern Bedingungsergebnis — set -e löst dort bewusst nicht aus. Das ist Teil der POSIX-Spezifikation und keine Bash-Schwäche. Wer diesen Unterschied nicht kennt, schreibt Skripte, die sich falsch verhalten, obwohl set -e gesetzt ist: if failing_command; then terminiert das Skript nicht, weil der Exit-Code der Bedingung auswertet.
Eine häufige Quelle von Verwirrung: Funktionsaufrufe in Bedingungen. if my_function; then deaktiviert set -e auch innerhalb der Funktion für den Dauer des Aufrufs. Dieses Verhalten ist in Bash 4.0 geändert worden, aber ältere Versionen verhalten sich anders. Für maximale Vorhersagbarkeit beim robuste Bash-Skripte schreiben gilt: Bedingungen explizit mit Rückgabewert-Prüfung schreiben, nicht darauf verlassen, dass set -e alle Fehlerfälle abdeckt.
3. set -u: Ungesetzte Variablen als harte Fehler behandeln
Das Flag set -u (Langform: set -o nounset) behandelt jede Expansion einer nicht gesetzten Variable als Fehler. Ohne diese Option expandiert Bash ungesetzte Variablen stillschweigend zu einem leeren String — mit oft katastrophalen Folgen. Das klassische Beispiel: rm -rf "$TARGET_DIR/" löscht bei leerem TARGET_DIR effektiv rm -rf /. Mit set -u bricht das Skript mit einer klaren Fehlermeldung ab, bevor der Befehl ausgeführt wird.
Beim robuste Bash-Skripte schreiben mit set -u muss man aber auf Standardwerte für optionale Parameter achten. ${variable:-Standardwert} und ${variable:?Fehlermeldung} funktionieren auch mit set -u korrekt: Die Default-Expansion gibt den Standardwert zurück, ohne dass ein Fehler ausgelöst wird. Die Error-Expansion :? hingegen gibt explizit die eigene Fehlermeldung aus und beendet das Skript — was für Pflichtparameter ideal ist. Arrays haben eine Sonderregel: ${array[@]} auf einem leeren Array löst mit set -u einen Fehler aus; die Variante ${array[@]+"${array[@]}"} ist die sichere Alternative.
#!/usr/bin/env bash
# robust-foundation.sh — Complete error handling foundation
set -euo pipefail
IFS=$'\n\t'
# --- Mandatory environment variables with set -u ---
# :? aborts with custom message if variable is missing or empty
readonly DEPLOY_ENV="${DEPLOY_ENV:?Variable DEPLOY_ENV must be set (e.g. staging, production)}"
readonly DEPLOY_TARGET="${DEPLOY_TARGET:?Variable DEPLOY_TARGET (hostname or path) must be set}"
# Optional variables with safe defaults — compatible with set -u
readonly LOG_LEVEL="${LOG_LEVEL:-INFO}"
readonly DRY_RUN="${DRY_RUN:-0}"
readonly MAX_RETRIES="${MAX_RETRIES:-3}"
# Safe empty-array expansion — avoids set -u error on empty array
declare -a extra_flags=()
all_args=("--env" "$DEPLOY_ENV" "${extra_flags[@]+"${extra_flags[@]}"}")
echo "Deploying to $DEPLOY_TARGET in $DEPLOY_ENV mode"
echo "Args: ${all_args[*]}"
4. set -o pipefail: Fehler in Pipes nicht verlieren
Ohne set -o pipefail bestimmt der Exit-Code des letzten Befehls in einer Pipe den Gesamtstatus — unabhängig davon, ob frühere Befehle fehlgeschlagen sind. Das ist eines der tückischsten Probleme beim robuste Bash-Skripte schreiben. Das Beispiel generate_data | process | store: Wenn generate_data mit Exit-Code 1 scheitert, gibt store möglicherweise Exit-Code 0 zurück — und set -e ohne pipefail sieht keinen Fehler. Mit set -o pipefail ist der Pipe-Exit-Code das Maximum aller Exit-Codes in der Kette.
Die Variable PIPESTATUS enthält nach einer Pipeline die einzelnen Exit-Codes jedes Befehls als Array und ist auch ohne pipefail nützlich für genaue Fehlerdiagnose. ${PIPESTATUS[0]} ist der Exit-Code des ersten Befehls, ${PIPESTATUS[1]} der zweite usw. In Kombination mit pipefail und einem ERR-Trap können beim robuste Bash-Skripte schreiben Fehler in Pipelines exakt lokalisiert werden. Achtung: PIPESTATUS wird bei jedem neuen Befehl überschrieben — direkt nach der Pipeline in eine Variable sichern.
5. ERR-Trap und LINENO: Fehlerstellen exakt lokalisieren
Der ERR-Trap wird ausgeführt, bevor set -e das Skript beendet — er ist damit der ideale Ort für detaillierte Fehlerprotokolle. In Kombination mit den Bash-Variablen LINENO (aktuelle Zeilennummer), BASH_LINENO (Array der Zeilennummern im Call-Stack) und FUNCNAME (Array der Funktionsnamen) lässt sich beim robuste Bash-Skripte schreiben ein vollständiger Stack-Trace für jeden Fehler ausgeben. Das macht die Fehlersuche in langen Skripten erheblich schneller.
Der ERR-Trap bekommt den Exit-Code des fehlgeschlagenen Befehls nicht automatisch übergeben — er muss über $? ausgelesen werden. Das ist ein wichtiges Detail: $? im Trap-Handler enthält noch den Exit-Code des fehlgeschlagenen Befehls, bevor der Trap ausgeführt wird. Dieser Wert sollte sofort in einer lokalen Variablen gespeichert werden, weil er durch weitere Befehle im Trap-Handler überschrieben wird. Die Kombination aus Zeilennummer, Funktionsname und Exit-Code ergibt vollständige Fehlerkontext-Informationen für jeden Fehler im Skript.
#!/usr/bin/env bash
# err-trap-demo.sh — ERR trap with full stack trace and LINENO
set -euo pipefail
IFS=$'\n\t'
# --- Logging helpers ---
readonly TS_FORMAT="%Y-%m-%dT%H:%M:%S"
log_info() { printf "[%s] [INFO] %s\n" "$(date +"$TS_FORMAT")" "$*"; }
log_error() { printf "[%s] [ERROR] %s\n" "$(date +"$TS_FORMAT")" "$*" >&2; }
# --- ERR trap: captures exit code, line, function stack ---
on_error() {
local exit_code=$?
local line_number=${1:-$LINENO}
log_error "--- Script failed ---"
log_error "Exit code : $exit_code"
log_error "Line : $line_number"
# Build call stack from BASH_LINENO / FUNCNAME arrays
local i
for ((i = 1; i < ${#FUNCNAME[@]}; i++)); do
log_error " at ${FUNCNAME[$i]}() line ${BASH_LINENO[$((i - 1))]}"
done
}
# Pass current line number into the trap handler at registration time
trap 'on_error $LINENO' ERR
# --- EXIT trap: always runs — cleanup resources ---
cleanup() {
local exit_code=$?
[[ -n "${TMP_FILE:-}" ]] && rm -f "$TMP_FILE"
log_info "Script finished with exit code $exit_code"
}
trap cleanup EXIT
# --- Example: create temp file safely ---
TMP_FILE="$(mktemp /tmp/deploy.XXXXXX)"
log_info "Working with temp file: $TMP_FILE"
# This line will trigger ERR trap and show exact line number
false # intentionally failing command — line 42
6. EXIT-Trap: Cleanup für alle Beendigungsszenarien
Der EXIT-Trap läuft bei jedem Beenden des Skripts — egal ob durch normalen Ablauf, durch set -e, durch explizites exit N oder durch ein empfangenes Signal. Er ist damit die zuverlässigste Möglichkeit, Ressourcen freizugeben und Aufräumarbeiten durchzuführen. Beim robuste Bash-Skripte schreiben gehört ein EXIT-Trap zu den ersten Dingen, die nach set -euo pipefail implementiert werden. Lockfiles, temporäre Dateien, offene Dateideskriptoren und gemountete Verzeichnisse können so für alle Ausgangszustände zuverlässig bereinigt werden.
Ein wichtiges Detail: Mehrere trap-Befehle für dasselbe Signal überschreiben sich gegenseitig — der letzte gewinnt. Wer modular aufgebaute Skripte mit Library-Funktionen schreibt, muss Traps koordinieren. Das übliche Muster ist eine zentrale Cleanup-Funktion, die alle Bereinigungsschritte enthält und einmalig für EXIT registriert wird. Alternativ kann man bestehende Traps mit trap -p EXIT abfragen und die neue Aktion prependen. Der EXIT-Trap erhält den tatsächlichen Exit-Code des Skripts in $? — so kann die Cleanup-Funktion zwischen normalem Ende und Fehler unterscheiden und unterschiedliche Benachrichtigungen senden.
Signals wie SIGINT und SIGTERM brauchen eigene Traps, weil der EXIT-Trap zwar danach noch ausgeführt wird, aber die ursprüngliche Signal-Semantik verloren geht. Das korrekte Muster beim robuste Bash-Skripte schreiben: SIGINT und SIGTERM auf eine Handler-Funktion mappen, die einen sauberen Exit mit dem richtigen Exit-Code (128 + Signalnummer) durchführt, und dem EXIT-Trap die eigentliche Bereinigung überlassen.
7. Exit-Codes: Bedeutung, Konventionen und eigene Codes
Exit-Codes sind die einzige Kommunikationsschnittstelle zwischen einem Shell-Skript und seinem Aufrufer — ob das eine CI-Pipeline, ein Cron-Job, ein Monitoring-System oder ein übergeordnetes Skript ist. Exit-Code 0 bedeutet Erfolg. Exit-Code 1 ist der allgemeine Fehler. Exit-Codes 2–125 sind konventionell für anwendungsspezifische Fehler reserviert. Exit-Codes 126 und 127 sind von der Shell für "nicht ausführbar" und "nicht gefunden" reserviert. Exit-Codes 128+N bedeuten, dass das Skript durch Signal N beendet wurde. Beim robuste Bash-Skripte schreiben ist die saubere Nutzung dieser Konventionen wichtig für die Integration in Monitoring-Systeme.
Eigene Exit-Code-Konstanten machen Skripte selbstdokumentierend und erleichtern die Fehlerdiagnose erheblich. readonly E_CONFIG_MISSING=10; readonly E_NETWORK_ERROR=11; readonly E_PERMISSION_DENIED=12 — wenn das Monitoring Exit-Code 11 empfängt, weiß es sofort, dass ein Netzwerkproblem vorliegt, ohne den Log zu lesen. Diese Konstanten sollten am Anfang des Skripts definiert und in der Cleanup-Funktion mit sinnvollen Meldungen verbunden werden. Skripte mit dokumentierten Exit-Codes sind deutlich leichter in Automatisierungsframeworks und Alerting-Systeme zu integrieren.
#!/usr/bin/env bash
# exit-codes.sh — Documented exit codes for monitoring integration
set -euo pipefail
# --- Exit code constants (2–125 are application-specific) ---
readonly E_OK=0
readonly E_GENERAL=1
readonly E_CONFIG_MISSING=10
readonly E_NETWORK_TIMEOUT=11
readonly E_PERMISSION_DENIED=12
readonly E_LOCK_ACQUIRED=13
readonly E_VALIDATION_FAILED=14
# --- Cleanup with exit code context ---
cleanup() {
local code=$?
case $code in
$E_OK) echo "[INFO] Deployment completed successfully" ;;
$E_CONFIG_MISSING) echo "[ALERT] Deployment aborted: configuration missing" >&2 ;;
$E_NETWORK_TIMEOUT) echo "[ALERT] Deployment aborted: network timeout" >&2 ;;
$E_LOCK_ACQUIRED) echo "[WARN] Another deployment is already running" >&2 ;;
*) echo "[ERROR] Deployment failed with unexpected code $code" >&2 ;;
esac
}
trap cleanup EXIT
# --- Acquire exclusive lock ---
exec 9>/var/lock/deploy.lock
flock -n 9 || exit $E_LOCK_ACQUIRED
# --- Config validation ---
[[ -f "/etc/deploy/config.env" ]] || exit $E_CONFIG_MISSING
# shellcheck source=/dev/null
source /etc/deploy/config.env
# --- Network reachability check ---
curl --silent --max-time 5 --head "$DEPLOY_HOST" > /dev/null \
|| exit $E_NETWORK_TIMEOUT
echo "All checks passed — starting deployment"
exit $E_OK
8. Fallstricke: Wann set -euo pipefail nicht greift
Das größte Missverständnis beim robuste Bash-Skripte schreiben mit set -euo pipefail: Viele Entwickler glauben, dass die Fehlerbehandlung damit vollständig ist. Sie ist es nicht. Es gibt eine Reihe von Kontexten, in denen Nicht-Null-Exit-Codes diese Flags bewusst nicht auslösen. Neben Bedingungskontexten (if, while, until) und nach logischen Operatoren (&&, ||) gilt das auch für Befehle in Klammerausdrücken (( )) und [[ ]] — dort ist ein Nicht-Null-Ergebnis semantisch kein Fehler.
Ein besonders tückischer Fall: local result=$(failing_command). Das local-Builtin gibt immer Exit-Code 0 zurück, egal was die Subshell macht — weil die Deklaration selbst erfolgreich war. Dadurch wird der Fehler von failing_command vollständig verschluckt und set -e greift nicht. Das korrekte Muster beim robuste Bash-Skripte schreiben: Deklaration und Zuweisung in zwei separate Zeilen trennen. local result; result="$(failing_command)" — so greift set -e auf die Zuweisung, nicht auf die Deklaration. ShellCheck (SC2155) warnt explizit vor dem kombinierten Pattern.
Ein weiterer Fallstrick: set -e wird in Subshells nicht automatisch vererbt, wenn die Subshell über ( ) gestartet wird. In einer $(command)-Substitution hingegen wird das Flag vererbt. Für Subshell-Blöcke muss set -e explizit wiederholt werden. Wer Skripte per source einbindet, bekommt die Flags des aufrufenden Skripts — aber Library-Dateien, die direkt ausgeführt werden sollen, müssen die Flags selbst setzen. Diese Nuancen machen das robuste Bash-Skripte schreiben anspruchsvoll, aber das Verstehen dieser Grenzen ist Voraussetzung für korrekte Fehlerbehandlung.
9. Fehlerbehandlungs-Muster im Vergleich
Die verschiedenen Ansätze zur Fehlerbehandlung in Bash unterscheiden sich erheblich in Robustheit, Lesbarkeit und Wartungsaufwand. Beim robuste Bash-Skripte schreiben ist die Wahl des richtigen Musters für jeden Kontext entscheidend.
| Kontext | Unsicheres Muster | Robustes Muster | Warum |
|---|---|---|---|
| Variablen-Expansion | local x=$(cmd) |
local x; x="$(cmd)" |
local gibt immer 0 zurück (SC2155) |
| Pflicht-Umgebungsvariable | if [ -z "$VAR" ]; then exit 1; fi |
${VAR:?Fehlermeldung} |
Kürzer, klare Meldung, set -u-kompatibel |
| Cleanup-Logik | rm -f $tmp; exit 0 |
trap 'rm -f "$tmp"' EXIT |
Läuft auch bei Fehler und Signal |
| Pipe-Fehler | cmd1 | cmd2; echo $? |
set -o pipefail; cmd1 | cmd2 |
cmd1-Fehler nicht durch cmd2 verdeckt |
| Fehlerstelle protokollieren | echo "Error" >&2; exit 1 |
trap 'on_error $LINENO' ERR |
Stack-Trace mit Zeilennummer automatisch |
Die Muster in der Tabelle ergänzen sich gegenseitig — sie sind kein Entweder-oder. Ein vollständig robustes Skript kombiniert alle fünf: set -euo pipefail als Basis, :? für Pflichtparameter, local x; x="$(cmd)" für Variablendeklaration, ERR-Trap für Fehlerlokalisierung und EXIT-Trap für Cleanup. Wer robuste Bash-Skripte schreiben lernt, internalisiert diese Muster als Standard, nicht als Ausnahme.
Mironsoft
Shell-Automatisierung, DevOps-Tooling und Deployment-Infrastruktur
Bash-Skripte, die bei Fehlern sofort reagieren?
Wir analysieren bestehende Shell-Skripte, identifizieren stille Fehlerstellen und implementieren vollständige Fehlerbehandlung mit set -euo pipefail, ERR-Traps, LINENO-Protokollierung und dokumentierten Exit-Codes.
Fehleranalyse
Stille Fehler in bestehenden Skripten identifizieren und priorisieren
Trap-Framework
ERR- und EXIT-Traps mit Stack-Trace und Monitoring-Integration aufbauen
Exit-Code-Dokumentation
Eigene Exit-Codes für alle Fehlerzustände definieren und integrieren
10. Zusammenfassung
Robuste Bash-Skripte schreiben beginnt mit dem Verständnis, dass Bash von Haus aus fehlertolerante Defaults hat — hilfreich für die interaktive Shell, gefährlich in automatisierten Skripten. set -euo pipefail ändert diese Defaults fundamental: -e beendet bei Nicht-Null-Exit-Code, -u behandelt ungesetzte Variablen als Fehler, -o pipefail propagiert Fehler durch Pipes. Der ERR-Trap mit LINENO und FUNCNAME liefert vollständige Stack-Traces für jede Fehlerstelle. Der EXIT-Trap bereinigt Ressourcen für alle Beendigungsszenarien. Dokumentierte Exit-Codes machen Skripte in Monitoring-Systeme integrierbar.
Die Grenzen kennen ist genauso wichtig wie die Werkzeuge: set -e greift nicht in Bedingungskontexten, nicht nach || und &&, nicht bei local x=$(cmd). Wer diese Fallstricke ignoriert, schreibt Skripte, die sich mit Fehlerbehandlung robuster anfühlen, als sie es sind. Die Kombination aus den beschriebenen Mustern und ShellCheck als statischem Analyzer in der CI-Pipeline ist der vollständige Ansatz für robuste Bash-Skripte schreiben, der in der Produktion hält, was er verspricht.
Robuste Bash-Skripte — Das Wichtigste auf einen Blick
set -euo pipefail
Das Fundament: -e bricht bei Fehler ab, -u macht ungesetzte Variablen zum Fehler, -o pipefail propagiert Fehler durch Pipes. Pflicht in jedem Produktionsskript.
ERR-Trap + LINENO
trap 'on_error $LINENO' ERR liefert Stack-Traces mit Zeilennummer und Funktionskette für jede Fehlerstelle — unverzichtbar für die Fehlersuche in der Produktion.
EXIT-Trap
trap cleanup EXIT läuft bei normalem Ende, Fehler und Signal. Lockfiles, Tmpfiles und Verbindungen zuverlässig bereinigen — ohne Duplizierung in jedem exit-Pfad.
Exit-Code-Konventionen
Eigene Exit-Codes (2–125) für Fehlerzustände dokumentieren. Monitoring-Systeme können ohne Log-Analyse sofort den Fehlertyp identifizieren.