Traps für mehrere Signale gleichzeitig registrieren und verwalten
AI generated
$_
#!/
Bash · Signal-Handling · Prozesskontrolle · Linux
Traps für mehrere Signale
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.

16 Min. Lesezeit trap · SIGINT · SIGTERM · SIGHUP Bash 4.x · 5.x · Daemon-Skripte

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.

11. FAQ: Traps für mehrere Signale in Bash: Das Wichtigste auf einen Blick

1Kann ein einziger trap-Aufruf mehrere Signale gleichzeitig abdecken?
Ja. trap 'befehl' INT TERM HUP registriert denselben Befehl für alle drei genannten Signale in einer einzigen Zeile, ohne dass separate trap-Aufrufe nötig sind.
2Wie erkennt mein Handler, welches Signal ihn ausgelöst hat?
Registriere denselben Handler mit getrennten trap-Aufrufen, die jeweils den Signalnamen als Argument übergeben, etwa trap 'cleanup INT' INT. Innerhalb der Funktion steht der Name dann als $1 zur Verfügung.
3Was passiert, wenn ich trap zweimal für dasselbe Signal aufrufe?
Der zweite Aufruf überschreibt den ersten vollständig. Bash stapelt keine mehreren Handler für ein Signal, nur der zuletzt registrierte Befehl wird ausgeführt.
4Warum läuft mein Cleanup-Code zweimal ab?
Wenn der Signal-Handler mit exit endet, löst das zusätzlich einen registrierten EXIT-Trap aus. Ein Guard-Flag, das die Cleanup-Logik nur beim ersten Aufruf ausführt, macht die Funktion idempotent.
5Warum SIGINT, SIGTERM und SIGHUP zusammen behandeln?
Sie decken die drei häufigsten Beendigungsanforderungen ab: interaktive Unterbrechung, reguläre Beendigungsanfrage durch ein Orchestrierungswerkzeug und Terminal-Schluss beziehungsweise Reload-Anforderung.
6Kann ich SIGKILL mit einem Trap abfangen?
Nein. SIGKILL lässt sich grundsätzlich nicht abfangen oder ignorieren, der Prozess wird sofort vom Kernel beendet. Cleanup-Logik muss vorher über SIGTERM erreicht werden, bevor eine Eskalation zu SIGKILL erfolgt.
7Gilt ein registrierter Trap auch in einer Subshell?
Nein, nicht automatisch. Eine mit runden Klammern gestartete Subshell sowie jede Komponente einer Pipeline haben ihre eigene Signaldisposition, die unabhängig vom Trap der Hauptshell ist.
8Wie soll ein Daemon auf SIGHUP reagieren?
Historisch bedeutete SIGHUP einen sofortigen Abbruch, heute interpretieren viele Daemons es stattdessen als Anforderung, die Konfiguration neu zu laden, ohne den Hauptprozess zu beenden.
9Muss ich in einem Hintergrundjob mit & einen eigenen Trap registrieren?
Ja, wenn der Hintergrundjob eigene Signale behandeln soll. Ein solcher Job erbt nicht automatisch die Traps der Hauptshell und startet mit der Standard-Signaldisposition.
10Wie viel Zeit sollte ein Daemon für ein geordnetes Beenden nach SIGTERM haben?
Genug, um laufende Operationen sauber abzuschließen und Daten zu sichern, aber nicht so viel, dass der Orchestrator zu einem harten SIGKILL eskaliert. Systemd nutzt hierfür typischerweise ein konfigurierbares Timeout von einigen Sekunden.