Bash · Shell-Scripting · Fehlerbehandlung · DevOps
Robuste Bash-Skripte schreiben
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.

12 Min. Lesezeit set -euo pipefail · ERR-Trap · LINENO · Exit-Codes · pipefail Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: Robuste Bash-Skripte schreiben

1Was bewirkt set -euo pipefail genau?
-e bricht bei Nicht-Null ab, -u macht ungesetzte Variablen zum Fehler, -o pipefail propagiert Fehler durch Pipes. Zusammen verhindern sie stilles Scheitern in allen drei häufigsten Fehlerfällen.
2Warum greift set -e nicht in if-Bedingungen?
In Bedingungskontexten ist Nicht-Null kein Fehler, sondern Bedingungsergebnis. POSIX-Semantik. Nach || und && gilt dasselbe. Explizit mit || { exit 1; } absichern.
3Was ist der ERR-Trap?
Läuft vor dem set-e-Abbruch. Ideal für Fehlerprotokolle mit LINENO und FUNCNAME. $? enthält noch den Fehlercode des fehlgeschlagenen Befehls — sofort sichern.
4Warum local x=$(cmd) gefährlich ist?
local gibt immer 0 zurück. Fehler von cmd wird verschluckt, set -e greift nicht. Korrekt: local x; x="$(cmd)" — zwei getrennte Zeilen (ShellCheck SC2155).
5Exit-Code im EXIT-Trap ermitteln?
$? im EXIT-Trap enthält den finalen Exit-Code des Skripts. Damit kann zwischen Erfolg (0) und verschiedenen Fehlertypen unterschieden und unterschiedlich reagiert werden.
6Welche Exit-Codes sind für eigene Codes reserviert?
2–125 für anwendungsspezifische Fehler. 0 = Erfolg, 1 = allgemein, 126 = nicht ausführbar, 127 = nicht gefunden, 128+N = durch Signal N beendet.
7Werden Flags an Subshells vererbt?
$()-Subshells erben Flags. ( )-Blöcke erben, können sie aber überschreiben. Library-Dateien, die direkt ausgeführt werden, müssen Flags selbst setzen.
8Was ist PIPESTATUS?
Array mit Exit-Codes jedes Pipeline-Befehls. Wird beim nächsten Befehl überschrieben — sofort sichern. Nützlich für genaue Fehlerdiagnose auch ohne pipefail.
9Mehrere trap-Definitionen koordinieren?
Ein zweiter trap überschreibt den ersten. Beste Praxis: Eine zentrale cleanup()-Funktion, einmalig mit trap cleanup EXIT registriert. Mit trap -p EXIT aktuellen Trap abfragen.
10Wie teste ich Fehlerbehandlung systematisch?
false einfügen und prüfen ob Abbruch erfolgt. false | true prüft pipefail. ShellCheck und BATS-Tests sind der systematische Ansatz für vollständige Testabdeckung.