Shell-Skripte: unbeaufsichtigt laufen lassen
Ein Skript, das im Terminal funktioniert, ist noch kein Job. Sobald es nachts um drei aus einem Timer startet, liest niemand mehr die Fehlermeldung — es muss selbst entscheiden, was bei einem Abbruch in der Mitte passiert, was beim zweiten Lauf, und was, wenn zwei Läufe sich überholen. Dieses Modul zeigt dir die Handvoll Mittel, mit denen man das in Bash löst: die Schalter im Kopf jedes Skripts und ihre dokumentierten Lücken, Anführungszeichen als Sicherheitsmaßnahme, Aufräumen nach dem Abbruch, das atomare Ersetzen einer Datei und eine Sperre, die auch nach einem harten Abschuss wieder freigibt.
Lehrtext · 12 Abschnitte · zuletzt geprüft: 2026-09-03
Der Unterschied zwischen einem Befehl und einem Job
Ein Befehl, den du in der Shell eintippst, hat einen Zuschauer: dich. Geht etwas schief, steht die Meldung auf dem Bildschirm, du liest sie und entscheidest, was als Nächstes passiert. Diese Rückkopplung ist so selbstverständlich, dass man sie beim Schreiben eines Skriptes stillschweigend voraussetzt.
Sobald dasselbe Skript nachts um drei aus einem Timer startet, ist der Zuschauer weg. Was es tut, tut es allein. Was es meldet, liest frühestens am nächsten Morgen jemand — oder nie. Damit ändert sich die Frage, die man an das Skript stellt. Sie lautet nicht mehr „läuft es?“, sondern:
- Was tut es, wenn ein Schritt in der Mitte fehlschlägt?
- Was tut es beim zweiten Lauf, weil der erste hängen blieb und jemand nachgezogen hat?
- Was tut es, wenn zwei Läufe sich überschneiden?
- Woran erkennt die aufrufende Stelle hinterher, ob es geklappt hat?
Alle vier Fragen haben Antworten, die in jeder Bash-Installation bereitliegen. Man muss sie nur einmal bewusst treffen, statt sie dem Zufall zu überlassen. Genau das ist der Inhalt dieses Moduls.
Der Prolog: drei Schalter, die den Standard umdrehen
Die Voreinstellung der Shell stammt aus dem interaktiven Gebrauch: Ein fehlgeschlagener Befehl beendet die Sitzung nicht, sonst würde dich jeder Tippfehler abmelden. Für ein unbeaufsichtigtes Skript ist genau das die falsche Voreinstellung — es läuft weiter, als wäre nichts gewesen, und baut die folgenden Schritte auf einem Zustand auf, den es gar nicht gibt.
Deshalb beginnt fast jedes ernst gemeinte Skript mit derselben Zeile: set -euo pipefail. Sie besteht aus drei unabhängigen Schaltern.
-e- Beendet das Skript, sobald ein Befehl einen Fehler meldet — statt weiterzulaufen.
-u- Beendet das Skript, wenn eine nicht gesetzte Variable gelesen wird.
-o pipefail- Lässt eine Pipeline als fehlgeschlagen gelten, wenn irgendein Teil fehlschlägt — nicht nur der letzte.
Der zweite Schalter verhindert eine ganze Klasse von Unfällen. Ohne ihn wird eine falsch geschriebene Variable zur leeren Zeichenkette: Aus rm -rf "$BASISVERZ/" wird bei einem Tippfehler im Namen rm -rf /. Mit -u bricht das Skript an dieser Stelle ab, statt sie auszuführen. Laut Dokumentation sind von dieser Prüfung nur die Sonderparameter @ und * ausgenommen — ein Skript ohne Argumente stolpert also nicht über sein eigenes "$@".
Der dritte Schalter korrigiert eine Eigenheit von Pipelines: Ihr Rückgabewert ist normalerweise der des letzten Befehls. cat fehlt.txt | wc -l gibt eine Fehlermeldung aus, liefert 0 als Zeilenzahl — und meldet Erfolg, weil wc erfolgreich war. Ist pipefail gesetzt, gilt laut Dokumentation der Wert des am weitesten rechts stehenden Befehls, der mit einem Fehler endete. Die Option ist standardmäßig aus.
Was `set -e` alles nicht abfängt
Der erste Schalter wird oft für ein Sicherheitsnetz gehalten, das eigene Fehlerbehandlung überflüssig macht. Das ist er nicht. Die Dokumentation nennt eine ausdrückliche Ausnahmeliste, und die Ausnahmen sind ausgerechnet die Konstruktionen, die man täglich schreibt. Kein Abbruch erfolgt, wenn der fehlgeschlagene Befehl:
- Teil der Bedingung nach
ifoderelifist - Teil der Bedingung nach
whileoderuntilist - links von
&&oder||steht — nur der letzte Befehl der Kette zählt - in einer Pipeline steht und nicht der letzte ist (hier greift
pipefail) - mit
!negiert wird
Die Funktion, die in der Bedingung ihre Fehlerbehandlung verliert
Szenario
Ein Sicherungsskript ruft eine Funktion auf und will danach melden, ob es geklappt hat. Es beginnt mit set -euo pipefail.
Anforderungen
sichern() { tar -cf "$1" /var/lib/app; pruefe "$1"; }- Aufruf:
if sichern /mnt/ziel/app.tar; then echo fertig; fi
Schritte
- Die Funktion wird als Bedingung nach
ifaufgerufen. Damit steht sie in einem Kontext, in dem-eausgesetzt ist. - Laut Dokumentation gilt diese Aussetzung für den gesamten Funktionsrumpf: Schlägt
tarfehl, bricht die Funktion nicht ab. pruefeläuft trotzdem — auf einer Datei, die es nicht oder nur halb gibt.ifsieht am Ende nur den Status des letzten Befehls im Rumpf. Ist der0, meldet das Skript „fertig“.
Merksatz: Wo das Ergebnis eines Schrittes zählt, muss es geprüft werden. -e ist die Auffanglinie für die Fälle, an die man nicht gedacht hat — nicht der Ersatz für die, an die man denken muss.
Der Schalter hat auch die umgekehrte Kante: Er schlägt zu, wo man es nicht will. treffer=$(grep -c FEHLER protokoll.log) beendet unter -e das gesamte Skript, wenn nichts gefunden wurde — denn grep meldet bei null Treffern einen Fehlercode, und „keine Fehler im Protokoll“ ist der gute Fall. An solchen Stellen gehört ein ausdrückliches || true oder eine eigene Abfrage hin.
Anführungszeichen sind keine Stilfrage
Setzt du eine Variable ohne Anführungszeichen ein, zerlegt die Shell ihren Inhalt anschließend erneut an Leerzeichen und wertet Sternchen und Fragezeichen als Dateimuster aus. Bei einem Pfad ohne Leerzeichen fällt das nie auf. Beim ersten Verzeichnis mit Leerzeichen im Namen wird aus einem Argument plötzlich zwei — und der Befehl macht etwas anderes als gedacht.
Ein Leerzeichen im Pfad verändert, was gelöscht wird
Szenario
Ein Aufräumskript soll den Inhalt eines Ablageverzeichnisses leeren. Der Pfad kommt aus einer Konfigurationsdatei.
Anforderungen
VERZ="/var/ablage/alte daten"- Geschrieben wurde:
rm -rf $VERZ/*
Schritte
- Ohne Anführungszeichen zerlegt die Shell den Wert am Leerzeichen.
rmbekommt zwei Argumente:/var/ablage/alteunddaten/*— nicht das eine gemeinte Verzeichnis.- Existiert
/var/ablage/altezufällig, wird es gelöscht. Das eigentliche Ziel bleibt unberührt. - Richtig ist
rm -rf "$VERZ"/*: Der Wert bleibt ein Argument, das Sternchen wirkt weiterhin als Muster.
Merksatz: Die Regel ist einfach genug, um sie ausnahmslos anzuwenden: Jede Variableneinsetzung kommt in Anführungszeichen, außer du willst die Zerlegung ausdrücklich.
Bei den Argumenten des Skripts selbst gibt es drei Schreibweisen, die unterschiedlich viel kaputt machen können:
"$@"- Jedes Argument bleibt ein eigenes Argument — laut Dokumentation gleichbedeutend mit
"$1" "$2" …. Das ist fast immer die richtige Form. $@- Wird erneut an Leerzeichen zerlegt. Ein Dateiname mit Leerzeichen wird zu zwei Argumenten.
"$*"- Verbindet alle Argumente zu einer Zeichenkette. Nützlich für eine Ausgabezeile, falsch für die Weitergabe an einen Befehl.
Eingaben prüfen statt sie zu maskieren
Werte, die von außen kommen — ein Dienstname aus einem Ticket, ein Hostname aus einer Liste, ein Dateiname aus einem Formular — sind Daten. Gefährlich werden sie erst an der Stelle, an der die Shell sie ein zweites Mal als Code liest. eval tut genau das, und zwar mit Absicht.
Steht in eval "systemctl restart $DIENST" im Wert von DIENST die Zeichenfolge nginx; rm -rf /var/lib/app, führt die Shell beides aus. Sie kann nicht anders: Das Semikolon ist für sie ein Trennzeichen, kein Buchstabe. Zusätzliche Anführungszeichen an dieser Stelle verschieben das Problem nur, sie lösen es nicht.
Der Ausweg ist keine bessere Maskierung, sondern eine Liste erlaubter Werte, die auch ablehnen darf:
case "$DIENST" innginx|postgresql|app-api) ;;*) echo "Unbekannter Dienst: $DIENST" >&2; exit 64 ;;esacsystemctl restart "$DIENST"
Der Preis dieser Trennung ist Pflegeaufwand: Jeder neue Dienst muss in die Liste eingetragen werden, und ein vergessener Eintrag führt zu einer Ablehnung im Betrieb. Der Gegenwert ist eine harte Obergrenze dafür, wie weit eine manipulierte oder schlicht falsche Eingabe reichen kann.
Der Exitcode ist die einzige Nachricht, die ankommt
Ein Timer, ein Cron-Eintrag, eine Pipeline in einer Deployment-Kette — sie alle lesen von deinem Skript genau eine Zahl. 0 bedeutet „hat geklappt“, jeder andere Wert bedeutet „hat nicht geklappt“. Was auf dem Bildschirm stand, interessiert die aufrufende Stelle nicht.
Daraus folgt eine Entscheidung, die viele Skripte nie bewusst treffen: Ist „es war nichts zu tun“ ein Erfolg oder ein Fehler? In fast allen Fällen ist es ein Erfolg — das Skript hat den Zielzustand geprüft und ihn bereits vorgefunden. Meldet es dafür einen Fehlercode, schlägt die Überwachung an jedem ruhigen Tag an. Nach zwei Wochen schaut niemand mehr hin, und der eine echte Ausfall geht im Rauschen unter.
0- Zielzustand erreicht — auch dann, wenn dafür nichts zu tun war.
1–63- Fachliche Fehler des Skripts. Selbst vergeben und im Kopf des Skripts dokumentiert.
64aufwärts- Aufruffehler: falsches Argument, unbekannter Wert. Konvention aus
<sysexits.h>, die auch Werkzeuge wieflockverwenden. 126/127- Befehl gefunden, aber nicht ausführbar / Befehl nicht gefunden. Diese beiden vergibt die Shell selbst.
128plus Signalnummer- Der Befehl wurde durch ein Signal beendet —
137steht für ein hart abgeschossenes Programm.
Ebenso wichtig ist die Trennung der beiden Ausgabekanäle. Auf stdout gehört das Ergebnis: die Daten, die jemand weiterverarbeiten könnte. Auf stderr gehört die Diagnose: was das Skript gerade tut und was schiefging. Wer Fortschrittsmeldungen nach stdout schreibt, macht die Ausgabe unbrauchbar, sobald sie in eine Pipeline gerät. Für Meldungen genügt echo "Starte Sicherung" >&2.
StandardOutput= und StandardError= getrennt lenken.Aufräumen, das auch nach dem Abbruch stattfindet
Ein Skript, das eine Arbeitsdatei anlegt und sie in der letzten Zeile wieder löscht, lässt sie liegen, sobald es vorher abbricht. Mit set -e bricht es häufiger ab als vorher — die beiden Maßnahmen arbeiten also gegeneinander, solange das Aufräumen an der letzten Zeile hängt.
Die Lösung ist ein Handler, der beim Verlassen der Shell ausgeführt wird, unabhängig vom Grund:
ARBEIT="$(mktemp -d)"trap 'rm -rf "$ARBEIT"' EXIT
EXIT ist laut Dokumentation der Signalname für „beim Verlassen der Shell“. Er greift beim regulären Ende, beim Abbruch durch set -e und bei einem exit mitten im Skript. Die Zeile steht direkt hinter dem Anlegen — nicht am Ende, sonst schützt sie genau den Bereich nicht, in dem etwas schiefgehen kann.
mktemp -d erzeugt das Verzeichnis mit einem nicht vorhersagbaren Namen und nur für das aufrufende Konto zugänglich. Ein fest verdrahteter Pfad wie /tmp/mein-job ist demgegenüber ein bekannter Name in einem Verzeichnis, in das jeder schreiben darf — ein anderer Prozess kann ihn vorher anlegen und damit bestimmen, worauf dein Skript arbeitet.
trap greift nicht bei einem harten Abschuss: SIGKILL ist nicht abfangbar. Aufräumen nach diesem Fall braucht deshalb zusätzlich einen Weg, der beim nächsten Lauf liegengebliebenes Material erkennt und beseitigt.Eine Datei ersetzen, ohne einen halben Zustand zu hinterlassen
Wer eine Konfigurationsdatei direkt beschreibt, öffnet ein Zeitfenster, in dem sie halb geschrieben ist. Bricht das Skript genau darin ab, oder liest ein Dienst in diesem Moment, ist die Datei kaputt — und zwar auf eine Weise, die erst beim nächsten Neustart auffällt.
Der Umweg über eine Zwischendatei schließt dieses Fenster:
neu="$(mktemp /etc/app/config.json.XXXXXX)"erzeuge_config > "$neu"mv "$neu" /etc/app/config.json
Das Umbenennen ersetzt das Ziel laut Dokumentation atomar: Es gibt keinen Zeitpunkt, an dem ein anderer Prozess die Datei nicht vorfindet. Er sieht entweder vollständig die alte Fassung oder vollständig die neue. Fehlgeschlagen ist das Erzeugen dann folgenlos — die alte Datei steht unverändert.
/tmp und das Ziel unter /etc, und sind das getrennte Dateisysteme, scheitert das Umbenennen mit EXDEV; mv fällt dann auf Kopieren und Löschen zurück — und das Zeitfenster ist zurück. Deshalb wird die Zwischendatei neben dem Ziel angelegt, nicht in einem Ablageverzeichnis.Nur einer gleichzeitig
Ein Timer weiß nicht, ob der vorige Lauf fertig ist. Dauert ein Job, der alle fünf Minuten startet, an einem schlechten Tag acht Minuten, laufen zwei Läufe gleichzeitig — auf derselben Datei, derselben Tabelle, demselben Verzeichnis. Zeitplanung und exklusiver Zugriff sind zwei verschiedene Zusagen, und der Zeitplan gibt die zweite nicht.
Die naheliegende Lösung ist eine Sperrdatei, in der die Prozessnummer steht. Sie hat einen Fehler, der erst im Ernstfall auffällt: Stirbt der Prozess hart, bleibt die Datei liegen. Jeder weitere Lauf hält sich für blockiert und tut nichts — bis nachts um drei ein Mensch aufräumt.
flock verlagert die Sperre in den Kernel: flock -n /var/lock/mein-job.lock /usr/local/bin/mein-job. Sie hängt nicht am Inhalt der Datei, sondern an einem geöffneten Dateideskriptor. Laut Dokumentation wird sie freigegeben, sobald alle darauf verweisenden Deskriptoren geschlossen sind — und das erledigt der Kernel, wenn der Prozess endet, auch bei einem harten Abschuss. Eine liegengebliebene Datei blockiert deshalb nichts.
-n- Bricht sofort ab, wenn die Sperre belegt ist. Richtig für Jobs, die ohnehin gleich wieder starten.
-w 30- Wartet höchstens 30 Sekunden auf die Sperre. Richtig für Läufe, die sich nur kurz überschneiden.
-E 75- Legt den Rückgabewert für „belegt“ fest; ohne diese Angabe ist er
1. So unterscheidet die Überwachung „konnte nicht starten“ von „ist gelaufen und fehlgeschlagen“.
Kurzcheck
Ein Job wird alle fünf Minuten gestartet und mit flock -n gegen Überschneidung gesichert. Der laufende Prozess wird hart abgeschossen. Was passiert beim nächsten Start?
- Er startet normal — der Kernel hat die Sperre beim Prozessende freigegeben
- Er bricht sofort ab, weil die Sperrdatei noch existiert
- Er wartet, bis ein Zeitlimit abgelaufen ist, und startet dann
Treffer. Richtig. Die Sperre hängt am Dateideskriptor, und den schließt der Kernel beim Beenden des Prozesses. Die Sperrdatei selbst bleibt liegen, spielt aber keine Rolle.
Der zweite Lauf muss langweilig sein
Ein Job wird wiederholt. Nach einem Fehler, nach einem Zeitüberschritt, weil jemand ihn im Zwischenfall von Hand nachzieht. Wiederholbar heißt: Der zweite Lauf führt zum selben Zustand wie der erste und richtet dabei nichts an.
Idempotent — Ein Schritt heißt idempotent, wenn seine mehrfache Ausführung dasselbe Ergebnis hat wie eine einmalige. Nicht: „er läuft ohne Fehler durch“ — sondern: „er verändert beim zweiten Mal nichts mehr“.
Das erreicht man nicht durch Vorsicht, sondern durch eine feste Reihenfolge: erst den Ist-Zustand lesen, dann nur die Abweichung ändern. Die meisten nicht wiederholbaren Schritte lassen sich mit wenig Aufwand umbauen:
echo eintrag >> /etc/hosts- Erst prüfen, ob die Zeile existiert — nur bei Fehlen anhängen. Sonst wächst die Datei mit jedem Lauf.
useradd app- Erst
id appabfragen — nur bei Fehlen anlegen. Sonst scheitert der zweite Lauf. mkdir /srv/appmkdir -p /srv/appgenügt: Der Schalter macht den Schritt bereits wiederholbar.
Der Preis ist Länge. Jede Zustandsabfrage ist eine zusätzliche Verzweigung, und das Skript wird schwerer zu lesen als die drei Zeilen, die es ersetzt. Der Gegenwert ist ein Job, den man ohne Nachdenken noch einmal starten darf — und genau das tut man im Zwischenfall, wenn zum Nachdenken keine Zeit ist.
Der Rückweg gehört vor die Änderung
Ein Skript, das eine Konfiguration schreibt und danach einen Dienst neu lädt, ist eine Änderung an einem laufenden System — mit denselben Anforderungen wie eine Änderung von Hand. Die entscheidende Frage lautet nicht, wie das Skript neu startet, sondern ob es zum vorherigen Zustand zurückkommt, wenn die Prüfung nach der Änderung fehlschlägt.
Der Rückweg muss vor der Änderung existieren und erreichbar sein. Eine Kopie der alten Datei, die im selben Lauf angelegt wurde und im selben Verzeichnis liegt, erfüllt das. Ein Verweis auf „die Konfiguration steht ja in der Versionsverwaltung“ erfüllt es nicht, solange niemand geprüft hat, ob sie sich nachts um drei ohne fremde Hilfe zurückholen lässt.
Daraus ergibt sich die Reihenfolge, die ein änderndes Skript einhält: alte Fassung sichern, neue schreiben, prüfen, und erst dann freigeben. Viele Dienste bringen für den mittleren Schritt ein eigenes Werkzeug mit, das die Konfiguration liest, ohne sie zu übernehmen. Wer diesen Schritt auslässt, erfährt vom Fehler erst durch den Ausfall.
Der Vertrag eines betreibbaren Jobs
set -euo pipefail, und wissen, wo -e schweigt→Eingaben gegen eine Liste erlaubter Werte prüfen, nicht maskieren→Aufräumen an trap … EXIT hängen, nicht an die letzte Zeile→Dateien über Zwischendatei und Umbenennen im selben Dateisystem ersetzen→Exklusiven Zugriff mit flock sichern, nicht mit einer Sperrdatei→Jeden Schritt so bauen, dass der zweite Lauf nichts mehr ändert→Ergebnis als Exitcode melden — Diagnose nach stderrKeiner dieser Punkte ist aufwendig, und jeder einzelne beantwortet eine der vier Fragen vom Anfang. Zusammen unterscheiden sie ein Skript, das im Terminal funktioniert, von einem, das man einem Timer überlassen kann.
Jetzt anwenden
Diesen Stoff gibt es als Modul mit bewerteten Entscheidungs-Checks — dieselbe Einführung, danach die Übungen.
Zum Modul →Quellen & Aktualität12 Primärquellen · zuletzt geprüft:
- 01man7.org/linux/man-pages/man1/bash.1.html
- 02man7.org/linux/man-pages/man1/flock.1.html
- 03man7.org/linux/man-pages/man2/flock.2.html
- 04man7.org/linux/man-pages/man2/rename.2.html
- 05man7.org/linux/man-pages/man1/mktemp.1.html
- 06shellcheck.net
- 07man7.org/linux/man-pages/man1/cat.1.html
- 08freedesktop.org/software/systemd/man/latest/systemd.service.html
- 09man7.org/linux/man-pages/man7/signal.7.html
- 10man7.org/linux/man-pages/man1/mv.1.html
- 11man7.org/linux/man-pages/man1/mkdir.1.html
- 12man7.org/linux/man-pages/man8/useradd.8.html