gleichzeitig registrieren, unterscheiden und sauber verwalten
Ein Bash-Skript, das als Daemon oder langlaufender Prozess läuft, muss auf SIGINT, SIGTERM und SIGHUP reagieren können, ohne verwaiste Sperrdateien oder halb geschriebene Ausgaben zu hinterlassen. Ein einziger trap-Aufruf kann denselben Handler für mehrere Signale registrieren, doch wer im Handler wissen will, welches Signal konkret ausgelöst hat, und Doppelläufe beim Zusammenspiel mit einem EXIT-Trap vermeiden will, braucht ein paar zusätzliche Kniffe.
Inhaltsverzeichnis
- 1. Warum Signal-Handling in produktiven Bash-Skripten wichtig ist
- 2. Trap-Grundlagen: trap 'befehl' SIGNAL und mehrere Signale in einem Aufruf
- 3. Einen gemeinsamen Handler für INT, TERM und HUP registrieren
- 4. Das auslösende Signal im Handler unterscheiden
- 5. Mehrere trap-Aufrufe für dasselbe Signal: Reihenfolge und Überschreiben
- 6. EXIT-Trap zusammen mit Signal-Traps: Interaktion und doppelte Ausführung vermeiden
- 7. Praxisbeispiel: sauberes Shutdown-Skript für einen Hintergrund-Daemon
- 8. Fallstricke: Traps in Subshells und Vererbung an Funktionen
- 9. Signal-Verhalten im Vergleich: welches Signal wofür
- 10. Zusammenfassung
- 11. FAQ
1. Warum Signal-Handling in produktiven Bash-Skripten wichtig ist
Ein Skript, das als Hintergrunddienst, in einem Cron-Job oder als Teil einer Deployment-Pipeline über längere Zeit läuft, bekommt früher oder später ein Signal von außen: ein Nutzer drückt Strg+C und schickt SIGINT, ein Orchestrator wie systemd oder Docker schickt beim Beenden SIGTERM, und ein Terminal, das geschlossen wird, schickt seinen Kindprozessen SIGHUP. Ohne eigene Behandlung beendet Bash den Prozess mit dem Standardverhalten des jeweiligen Signals, meist ein sofortiger Abbruch ohne jede Aufräumlogik.
Ein sauberes Signal-Handling sorgt dafür, dass Sperrdateien entfernt, offene Datei-Deskriptoren geschlossen, gepufferte Daten geschrieben und der Vorgang mit einer nachvollziehbaren Log-Zeile beendet wird, statt einfach zu verschwinden. Gerade bei Deployment-Skripten, die Ressourcen wie temporäre Verzeichnisse oder Datenbank-Sperren belegen, entscheidet gutes Signal-Handling darüber, ob ein abgebrochener Lauf den nächsten Lauf blockiert oder nicht.
2. Trap-Grundlagen: trap 'befehl' SIGNAL und mehrere Signale in einem Aufruf
Die Grundsyntax lautet trap 'befehl' SIGNAL, wobei befehl beim Empfang von SIGNAL ausgeführt wird. Praktischerweise akzeptiert trap mehrere Signalnamen in einem einzigen Aufruf: trap 'cleanup' INT TERM HUP registriert denselben Befehl für alle drei genannten Signale, ohne dass drei separate trap-Zeilen nötig wären.
Signalnamen ohne SIG-Präfix wie INT statt SIGINT funktionieren in Bash gleichwertig und sind die übliche Schreibweise in Skripten. Numerische Signalcodes wie 2 statt INT funktionieren zwar ebenfalls, sind aber weniger portabel, weil sich die Nummerierung mancher Signale zwischen Systemen unterscheidet, während die Namen stabil bleiben.
#!/usr/bin/env bash
set -euo pipefail
cleanup() {
echo "Cleaning up before exit..."
rm -f /tmp/mironsoft-deploy.lock
}
# One trap call registers the same handler for three signals
trap cleanup INT TERM HUP
echo "Working... press Ctrl+C to test"
sleep 30
3. Einen gemeinsamen Handler für INT, TERM und HUP registrieren
Die Kombination aus INT, TERM und HUP deckt die drei häufigsten Beendigungsanforderungen ab: INT steht für eine interaktive Unterbrechung durch den Nutzer, TERM für eine reguläre Beendigungsanfrage durch ein Orchestrierungswerkzeug, und HUP ursprünglich für ein geschlossenes Terminal, wird heute aber von vielen Daemons zusätzlich als Signal für einen Konfigurations-Reload interpretiert.
Für einfache Skripte reicht ein gemeinsamer cleanup-Handler für alle drei Signale völlig aus, solange die Reaktion in jedem Fall dieselbe ist: den aktuellen Vorgang sauber beenden. Sobald aber HUP statt eines Abbruchs einen Neustart der Konfiguration auslösen soll, während INT und TERM den Prozess tatsächlich beenden sollen, muss der Handler das auslösende Signal kennen.
#!/usr/bin/env bash
set -euo pipefail
readonly LOCK_FILE="/tmp/mironsoft-deploy.lock"
touch "$LOCK_FILE"
cleanup() {
echo "Received termination signal, shutting down gracefully"
rm -f "$LOCK_FILE"
exit 0
}
trap cleanup INT TERM HUP
for i in {1..10}; do
echo "Working step $i"
sleep 2
done
4. Das auslösende Signal im Handler unterscheiden
Ein einzelner trap cleanup INT TERM HUP-Aufruf teilt dem Handler selbst nicht mit, welches der drei Signale konkret eingetroffen ist. Um das nachzurüsten, registriert man denselben Handler stattdessen mit drei getrennten trap-Aufrufen, die jeweils den Signalnamen als Argument mitgeben: trap 'cleanup INT' INT, trap 'cleanup TERM' TERM und trap 'cleanup HUP' HUP.
Innerhalb der Funktion steht der übergebene Signalname dann als $1 zur Verfügung und kann für unterschiedliches Verhalten ausgewertet werden, etwa einen anderen Exit-Code je nach Signal oder eine unterschiedliche Log-Meldung. Diese Technik ist der praktikabelste Weg, weil Bash selbst keine eingebaute Variable bereitstellt, die das zuletzt empfangene Signal automatisch enthält.
#!/usr/bin/env bash
set -euo pipefail
handle_signal() {
local sig="$1"
case "$sig" in
HUP)
echo "Received HUP: reloading configuration, staying alive"
;;
INT|TERM)
echo "Received $sig: shutting down"
exit 0
;;
esac
}
trap 'handle_signal HUP' HUP
trap 'handle_signal INT' INT
trap 'handle_signal TERM' TERM
while true; do
sleep 5
done
5. Mehrere trap-Aufrufe für dasselbe Signal: Reihenfolge und Überschreiben
Bash stapelt keine mehreren Handler für ein und dasselbe Signal. Ruft ein Skript trap first_handler INT und später trap second_handler INT auf, ersetzt der zweite Aufruf den ersten vollständig, ohne dass first_handler jemals wieder ausgeführt wird. Dieses Verhalten unterscheidet sich deutlich von Event-Systemen anderer Sprachen, die typischerweise mehrere registrierte Listener nacheinander abarbeiten.
Wer mehrere unabhängige Aufräum-Schritte bei einem Signal ausführen möchte, etwa ein Bibliotheksmodul, das eigene Cleanup-Logik registrieren will, ohne den Haupttrap des Skripts zu überschreiben, baut sich eine eigene Dispatcher-Funktion, die eine Liste registrierter Cleanup-Funktionen intern nacheinander aufruft, statt sich auf mehrere trap-Aufrufe zu verlassen.
#!/usr/bin/env bash
set -euo pipefail
declare -a cleanup_steps=()
register_cleanup() {
cleanup_steps+=("$1")
}
run_all_cleanups() {
local step
for step in "${cleanup_steps[@]}"; do
"$step"
done
exit 0
}
trap run_all_cleanups INT TERM HUP
register_cleanup 'rm -f /tmp/mironsoft-deploy.lock'
register_cleanup 'echo "Closed database connection"'
6. EXIT-Trap zusammen mit Signal-Traps: Interaktion und doppelte Ausführung vermeiden
Ein trap cleanup EXIT feuert bei jeder Art der Skriptbeendigung, egal ob normal, per exit-Aufruf oder durch ein Signal, sofern der Signal-Handler selbst mit exit endet. Ruft der Signal-Handler exit auf, löst das anschließend zusätzlich den registrierten EXIT-Trap aus, wodurch die Aufräumlogik ohne Schutzmaßnahme zweimal läuft.
Ein einfacher Schutz dagegen ist ein Guard-Flag: Die Cleanup-Funktion prüft zu Beginn eine Variable wie cleanup_done, führt die eigentliche Logik nur beim ersten Aufruf aus und setzt die Variable danach. So bleibt die Funktion idempotent, egal ob sie über den Signal-Trap, den EXIT-Trap oder beide nacheinander erreicht wird.
#!/usr/bin/env bash
set -euo pipefail
cleanup_done=0
cleanup() {
if [[ "$cleanup_done" -eq 1 ]]; then
return
fi
cleanup_done=1
echo "Running cleanup exactly once"
rm -f /tmp/mironsoft-deploy.lock
}
trap cleanup EXIT
trap 'cleanup; exit 130' INT
trap 'cleanup; exit 143' TERM
7. Praxisbeispiel: sauberes Shutdown-Skript für einen Hintergrund-Daemon
Ein realistisches Daemon-Skript kombiniert alle bisherigen Techniken: INT und TERM beenden die Hauptschleife geordnet und schreiben noch ausstehende Daten weg, während HUP lediglich die Konfiguration neu lädt und die Schleife weiterlaufen lässt. Das entspricht dem Verhalten, das Werkzeuge wie systemd erwarten, wenn sie einen Dienst per SIGTERM stoppen oder per SIGHUP zum Neuladen auffordern.
Wichtig ist außerdem, dem Orchestrator genügend Zeit für ein geordnetes Beenden zu geben, bevor ein hartes SIGKILL folgt, das sich grundsätzlich nicht abfangen lässt. Ein Daemon, der auf SIGTERM zügig reagiert, vermeidet den harten Kill und damit den Verlust noch nicht geschriebener Daten.
#!/usr/bin/env bash
set -euo pipefail
running=1
config_file="/etc/mironsoft/deploy-daemon.conf"
reload_config() {
echo "Reloading configuration from $config_file"
# shellcheck source=/dev/null
source "$config_file"
}
shutdown_daemon() {
echo "Shutting down after current iteration"
running=0
}
trap reload_config HUP
trap shutdown_daemon INT TERM
while [[ "$running" -eq 1 ]]; do
echo "Daemon tick $(date +%H:%M:%S)"
sleep 5
done
echo "Daemon stopped cleanly"
8. Fallstricke: Traps in Subshells und Vererbung an Funktionen
Ein im Hauptskript registrierter Trap gilt nicht automatisch in einer Subshell, die mit runden Klammern ( ... ) gestartet wird. Auch jede Komponente einer Pipeline läuft in einer eigenen Subshell, weshalb ein Signal, das während einer laufenden Pipe eintrifft, den Haupt-Trap unter Umständen erst nach Abschluss der Pipeline erreicht, statt sofort in der Pipeline-Komponente zu greifen.
Innerhalb von Funktionen und Kommando-Substitutionen derselben Shell bleibt ein registrierter Trap dagegen gültig, weil diese Konstrukte anders als runde Klammern keine eigene Subshell öffnen. Ein im Hintergrund mit & gestarteter Job bekommt dagegen wieder die Standard-Signaldisposition und muss, falls nötig, seinen eigenen Trap explizit registrieren.
9. Signal-Verhalten im Vergleich: welches Signal wofür
Jedes der gängigen Signale hat eine eigene, historisch gewachsene Bedeutung und ein eigenes Standardverhalten, wenn kein Trap registriert ist. Ein Skript, das produktionsreif sein soll, sollte diese Bedeutungen kennen und sich bei der eigenen Reaktion so weit wie möglich an die etablierten Konventionen halten, statt eigene, überraschende Semantiken einzuführen.
| Signal | Standardverhalten | Typischer Auslöser | Empfohlene Reaktion |
|---|---|---|---|
SIGINT |
Prozess wird beendet | Strg+C im Terminal | Sofortiges, geordnetes Beenden |
SIGTERM |
Prozess wird beendet | systemd, Docker, kill | Geordnetes Beenden mit Zeitpuffer |
SIGHUP |
Prozess wird beendet | Terminal geschlossen, Reload-Anforderung | Konfiguration neu laden oder beenden |
SIGKILL |
Prozess wird sofort beendet | kill -9, letzte Eskalationsstufe | Nicht abfangbar, Cleanup vorher sicherstellen |
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
Traps für mehrere Signale in Bash: Das Wichtigste auf einen Blick
Mehrere Signale, ein Aufruf
trap cleanup INT TERM HUP registriert denselben Handler für drei Signale in einer einzigen Zeile.
Signal unterscheiden
Getrennte trap-Aufrufe mit dem Signalnamen als Argument, z. B. trap 'cleanup INT' INT, lassen den Handler das auslösende Signal erkennen.
Keine Handler-Stapel
Ein neuer trap-Aufruf für dasselbe Signal überschreibt den vorherigen vollständig, für mehrere Schritte braucht es eine eigene Dispatcher-Liste.
EXIT-Trap absichern
Ein Guard-Flag verhindert doppelte Ausführung, wenn ein Signal-Handler mit exit endet und dadurch zusätzlich den EXIT-Trap auslöst.