Linux System Administration

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 if oder elif ist
  • Teil der Bedingung nach while oder until ist
  • 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

  1. Die Funktion wird als Bedingung nach if aufgerufen. Damit steht sie in einem Kontext, in dem -e ausgesetzt ist.
  2. Laut Dokumentation gilt diese Aussetzung für den gesamten Funktionsrumpf: Schlägt tar fehl, bricht die Funktion nicht ab.
  3. pruefe läuft trotzdem — auf einer Datei, die es nicht oder nur halb gibt.
  4. if sieht am Ende nur den Status des letzten Befehls im Rumpf. Ist der 0, 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

  1. Ohne Anführungszeichen zerlegt die Shell den Wert am Leerzeichen.
  2. rm bekommt zwei Argumente: /var/ablage/alte und daten/* — nicht das eine gemeinte Verzeichnis.
  3. Existiert /var/ablage/alte zufällig, wird es gelöscht. Das eigentliche Ziel bleibt unberührt.
  4. 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" in
  • nginx|postgresql|app-api) ;;
  • *) echo "Unbekannter Dienst: $DIENST" >&2; exit 64 ;;
  • esac
  • systemctl 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.

Fehlannahme„Das Skript läuft nur intern, die Eingaben sind vertrauenswürdig.“ Der häufigere Fall ist nicht der Angriff, sondern der Tippfehler: ein Leerzeichen zu viel, eine leere Zeile in der Eingabedatei, ein Wert aus dem falschen Feld. Eine Prüfung fängt beides ab.

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.
163
Fachliche Fehler des Skripts. Selbst vergeben und im Kopf des Skripts dokumentiert.
64 aufwärts
Aufruffehler: falsches Argument, unbekannter Wert. Konvention aus <sysexits.h>, die auch Werkzeuge wie flock verwenden.
126 / 127
Befehl gefunden, aber nicht ausführbar / Befehl nicht gefunden. Diese beiden vergibt die Shell selbst.
128 plus Signalnummer
Der Befehl wurde durch ein Signal beendet — 137 steht 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.

BetriebsfolgeUnter Cron wird jede Ausgabe eines Jobs zur E-Mail. Ein Skript, das bei jedem Lauf plaudert, erzeugt täglich Post, die niemand liest. Unter systemd landen beide Kanäle im Journal und lassen sich über 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.

Grenzetrap 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.

BedingungDas gilt nur innerhalb desselben Dateisystems. Liegt die Zwischendatei unter /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.

IdempotentEin 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 app abfragen — nur bei Fehlen anlegen. Sonst scheitert der zweite Lauf.
mkdir /srv/app
mkdir -p /srv/app genü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.

Fehlannahme„Der Neustart des Hosts nach einem Fehler ersetzt die saubere Wiederholung.“ Er vergrößert nur die Fläche des Eingriffs und verwischt, welcher Schritt überhaupt geändert hatte. Wiederholbarkeit gehört in den einzelnen Schritt, nicht in das umgebende Verfahren.

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

Standard umdrehen: set -euo pipefail, und wissen, wo -e schweigtEingaben gegen eine Liste erlaubter Werte prüfen, nicht maskierenAufräumen an trap … EXIT hängen, nicht an die letzte ZeileDateien über Zwischendatei und Umbenennen im selben Dateisystem ersetzenExklusiven Zugriff mit flock sichern, nicht mit einer SperrdateiJeden Schritt so bauen, dass der zweite Lauf nichts mehr ändertErgebnis als Exitcode melden — Diagnose nach stderr

Keiner 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:
  1. 01man7.org/linux/man-pages/man1/bash.1.html
  2. 02man7.org/linux/man-pages/man1/flock.1.html
  3. 03man7.org/linux/man-pages/man2/flock.2.html
  4. 04man7.org/linux/man-pages/man2/rename.2.html
  5. 05man7.org/linux/man-pages/man1/mktemp.1.html
  6. 06shellcheck.net
  7. 07man7.org/linux/man-pages/man1/cat.1.html
  8. 08freedesktop.org/software/systemd/man/latest/systemd.service.html
  9. 09man7.org/linux/man-pages/man7/signal.7.html
  10. 10man7.org/linux/man-pages/man1/mv.1.html
  11. 11man7.org/linux/man-pages/man1/mkdir.1.html
  12. 12man7.org/linux/man-pages/man8/useradd.8.html