Bash · Signale · trap · Linux · DevOps
Signal-Handling und
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.

14 Min. Lesezeit trap · SIGINT · SIGTERM · SIGPIPE · ERR · EXIT Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: Signal-Handling und Cleanup mit trap

1Unterschied trap EXIT vs. trap ERR?
EXIT: läuft bei jedem Skript-Ende. ERR: nur bei Nicht-Null-Exit-Code vor set -e-Abbruch. ERR für Logging, EXIT für Cleanup.
2Exit-Code am Anfang der Cleanup-Funktion sichern?
Cleanup-Operationen überschreiben $?. local exit_code=$? am Anfang sichern, dann exit "$exit_code" am Ende.
3Korrekter Exit-Code nach SIGINT?
128 + Signalnummer. SIGINT (2) = 130, SIGTERM (15) = 143. Erlaubt aufrufenden Prozessen Fehler von Signal zu unterscheiden.
4Warum erben Subshells trap-Handler nicht?
Subshells ($() oder ()) sind eigenständige Prozesse, die trap-Handler auf Standard zurücksetzen. Funktionen hingegen erben sie.
5Was passiert ohne trap '' PIPE mit pipefail?
head/grep beenden früh → SIGPIPE → pipefail sieht Nicht-Null-Exit → unerwarteter Skript-Abbruch. Lösung: trap '' PIPE.
6Library überschreibt trap des Hauptskripts?
Bestehenden Handler abfragen (trap -p EXIT), dann beide kombinieren. Oder: Libraries verwalten Cleanup-Listen, kein eigenes trap setzen.
7Warum werden Hintergrundprozesse zu Waisen?
Signale werden nicht automatisch an Hintergrundprozesse weitergeleitet. PIDs im Array speichern, im Handler iterieren und Signal weiterleiten.
8Wie teste ich Signal-Handling?
Skript starten, PID notieren, aus zweitem Terminal kill -SIGTERM PID senden. Mit BATS als Hintergrundprozess starten, Signal senden, Exit-Code und Cleanup-Zustand prüfen.
9trap - vs. trap '' SIGNAL?
trap - SIGNAL: Standard-Signalverhalten wiederherstellen. trap '' SIGNAL: Signal vollständig ignorieren — empfangen, aber keine Wirkung.
10Lockfile als letztes freigeben?
Solange Lockfile existiert, weiß neuer Instanzstart dass Cleanup läuft. Frühe Freigabe erlaubt neuen Start bevor alte Ressourcen vollständig aufgeräumt sind.