Expert GuideShellskripte: Schritt-für-Schritt-Anleitung zur Fehlerbehandlung und -vermeidung

Claus Küthe — 20. Februar 2025
Lesezeit: 2:01 Minuten

Shellskripte: Schritt-für-Schritt-Anleitung zur Fehlerbehandlung und -vermeidung

Shellskripte: Hier finden Sie eine Schritt-für-Schritt-Anleitung zur Fehlerbehandlung und -vermeidung

Wie Sie Fehlerbehandlung in Shellskripten nutzen können

Im folgenden Text soll es um ein in der Praxis oft stiefmütterlich behandeltes Thema gehen: Fehlerbehandlung und -vermeidung in Shellskripten.

Shellskripte werden in der Linux-Administration gerne dazu verwendet, um kleinere und größere, wiederkehrende Aufgaben zu automatisieren. Passiert Administratoren ein Fehler in der Programmierung, hat Bash den Nachteil, dass ein Skript nach Fehlern gnadenlos weiterläuft.

Dies kann im schlimmsten Fall dazu führen, dass ein Skript großen Schaden anrichtet, wenn es beispielsweise Daten erfassen und neu schreiben soll, bei der Erfassung aber scheitert. Infolgedessen überschreibt es beispielsweise bestehende Dateien mit leeren Dateien.

Schritt 1: Zielverzeichnis fehlt

Als Beispiel sei folgende Aufgabe gegeben: Ein Skript soll den Inhalt von /etc regelmäßig nach  /virtual/etc-backup/ schreiben und dabei einen Zeitstempel verwenden. Dies wird häufig, wie folgt, realisiert:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup"
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE"

Damit erhalten wir fortlaufende Verzeichnisse nach Art 2025-01-08 15:37:30 im Verzeichnis /virtual/etc-backup .

Folgende unberücksichtigte Fehler können Grund dafür sein:

  • Das Backup-Zielverzeichnis existiert nicht oder ist kein Verzeichnis.
  • Auf das Backup-Zielverzeichnis kann nicht geschrieben werden.
  • Die Backup-Quelle existiert nicht oder darin vorhandene Dateien sind nicht lesbar
  • Oder weitere Gründe.

Wir prüfen daher also zunächst auf das Vorhandensein des Backup-Verzeichnisses:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup"
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup path $BACKUP_DIR does not exist or is not a directory."
exit 1
fi
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE"

Damit wird der mögliche Fehler eines nicht bestehenden Zielverzeichnisses abgefangen.

Dies ist umso wichtiger, wenn BACKUP_DIR aus einer Benutzereingabe befüllt wird.

Daher wird dieser Fehler auch privilegiert behandelt. exit 1 beendet nicht nur das Skript, sondern gibt einen Rückgabewert ungleich 0 zurück. Dadurch kann nach Aufruf des Skripts überprüft werden, ob es fehlerfrei durchgelaufen ist.

Schritt 2: Server startet während des Kopiervorgangs neu oder stürzt ab

Wenden wir uns dem nächsten Fehler zu, den wir nicht immer über Fehlerbehandlung erfassen können: Während des Kopiervorgangs startet der Server neu oder stürzt ab. In dem Fall habe ich ein halbes Backup herumliegen.

Man kann argumentieren, dass ein halbes Backup besser ist als gar kein Backup, aber ich will zumindest darüber Bescheid wissen, dass es ein halbes Backup ist. 

Daher pflege ich in diesen Fällen mit temporären Dateien zu arbeiten:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup/"
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup path $BACKUP_DIR does not exist or is not a directory.";
exit 1;
fi
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE.tmp"
if [ $? -ne 0 ]; then
echo "An error occurred while copying /etc to $BACKUP_DIR/$BACKUP_DATE.tmp.";
exit 1;
fi
mv "$BACKUP_DIR/$BACKUP_DATE.tmp" "$BACKUP_DIR/$BACKUP_DATE"

Dadurch wird das Backup zunächst mit der Endung .tmp angelegt. Scheitert der Kopiervorgang auf halbem Wege, beendet das Skript, behält aber das halbe Backup als solches ersichtlich bei.

Die Fehlerbehandlung können wir testen, indem wir z. B. /etc durch ein nichtexistierendes Verzeichnis ersetzen.

Schritt 3: Verzeichnisse komprimieren

Im nächsten Schritt wollen wir Platz sparen und verwenden daher tar und gzip, um die Verzeichnisse zu komprimieren und überprüfen dabei jeden Einzelschritt:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup/"
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup path $BACKUP_DIR does not exist or is not a directory.";
exit 1;
fi
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE.tmp"
if [ $? -ne 0 ]; then
echo "An error occurred while copying /etc to $BACKUP_DIR/$BACKUP_DATE.tmp.";
exit 1;
fi
tar -cf "$BACKUP_DIR/$BACKUP_DATE.tmp.tar" "$BACKUP_DIR/$BACKUP_DATE.tmp"
if [ $? -ne 0 ]; then
echo "An error occurred while creating the tar archive $BACKUP_DIR/$BACKUP_DATE.tmp.tar.";
exit 1;
fi
gzip "$BACKUP_DIR/$BACKUP_DATE.tmp.tar"
if [ $? -ne 0 ]; then
echo "An error occurred while compressing the tar archive $BACKUP_DIR/$BACKUP_DATE.tmp.tar.";
exit 1;
fi
mv "$BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz" "$BACKUP_DIR/$BACKUP_DATE.tar.gz"
if [ $? -ne 0 ]; then
echo "An error occurred while renaming the tar archive $BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz to $BACKUP_DIR/$BACKUP_DATE.tar.gz.";
exit 1;
fi
# Cleanup of temporary files
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp"
if [ $? -ne 0 ]; then
echo "An error occurred while removing the temporary directory $BACKUP_DIR/$BACKUP_DATE.tmp.";
exit 1;
fi

Achtung: Hinweis zu rm in Skripten

Man sollte sich immer die Frage stellen, wie das Skript reagiert, wenn alle Variablen eines rm -Befehls, hier $BACKUP_DIRund $BACKUP_DATE leer wären - im vorliegenden Beispiel wird dann versucht, /.tmp zu löschen.

Ohne den festen Anteil würde hier, abhängig von den Rechten des ausführenden Benutzers, das Rootverzeichnis gelöscht und das System zerstört werden. Daher empfiehlt es sich, Pfade nicht nur aus Variablen zusammenzusetzen.

Man mag an der Stelle einwenden, dass moderne Distributionen rm --preserve-root als Defaultwert gesetzt haben und ein reines rm es mithin nicht mehr erlaubt, das Rootverzeichnis zu löschen. Allerdings bleibt die Empfehlung weiterhin sinnvoll, da man ja auch an anderer Stelle mit zu weit gefassten Löschbefehlen viel Schaden anrichten kann.

Vorteile

Wir sehen den Vorteil der Fehlerbehandlung: falls nur ein Befehl in der Kette fehlschlägt, scheitert das Skript vorzeitig, belässt aber den bis dahin erreichten Stand im Verzeichnis.

Nun wird der Code aber durch die ganze Fehlerbehandlung recht unübersichtlich und die Frage stellt sich, ob eine derart detaillierte Behandlung nötig ist. Dies auch in Hinblick darauf, dass die Befehle selbst schon Fehlermeldungen absetzen.

Hier gibt es aber die Möglichkeit eines ‘catchall’:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup/"
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup path $BACKUP_DIR does not exist or is not a directory.";
exit 1;
fi
trap 'echo "An error occurred, check output. Exiting..."; exit 1;' ERR
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE.tmp"
tar -cf "$BACKUP_DIR/$BACKUP_DATE.tmp.tar" "$BACKUP_DIR/$BACKUP_DATE.tmp"
gzip "$BACKUP_DIR/$BACKUP_DATE.tmp.tar"
mv "$BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz" "$BACKUP_DIR/$BACKUP_DATE.tar.gz"
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp"

Der Befehl trap fängt den ersten Fehler ( ERR ) auf und beendet das Skript.

Dies erspart einerseits eine unnötig aufwändige Behandlung einzelner Rückgabewerte und bricht das Skript andererseits vorzeitig ab, so dass beispielsweise das temporäre Verzeichnis nicht gelöscht wird, wenn tar fehlschlägt.

Dies kann mit einer Funktion weiter verschönert werden, was es uns auch erleichtert, SIGINT ( STRG+C ) abzufangen.

In letzterem Fall räumen wir als Beispiel zusätzlich auf:

#!/bin/bash
BACKUP_DIR="/virtual/etc-backup/"
# Check if backup directory exists and is a directory.
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup path $BACKUP_DIR does not exist or is not a directory.";
exit 1;
fi
# Function to handle errors
handle_error() {
echo "An error occurred on line $1, check output. Exiting...";
exit 1;
}

# Function to handle SIGINT
handle_sigint() {
echo "SIGINT received, cleaning up and exiting...";
if [ -d "$BACKUP_DIR/$BACKUP_DATE.tmp" ]; then
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp"
fi
if [ -f "$BACKUP_DIR/$BACKUP_DATE.tmp.tar" ]; then
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp.tar"
fi
if [ -f "$BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz" ]; then
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz"
fi
exit 1;
}

# Trap errors and SIGINT
trap 'handle_error $LINENO' ERR
trap 'handle_sigint' SIGINT

# Backup
BACKUP_DATE=$(date +"%Y-%m-%d %H:%M:%S")
cp -a /etc "$BACKUP_DIR/$BACKUP_DATE.tmp"
tar -cf "$BACKUP_DIR/$BACKUP_DATE.tmp.tar" "$BACKUP_DIR/$BACKUP_DATE.tmp"
# Simulate a long running process
echo "Sleeping for 10 seconds, press CTRL+C if you are impatient..."
sleep 10
echo "Waking up."
gzip "$BACKUP_DIR/$BACKUP_DATE.tmp.tar"
mv "$BACKUP_DIR/$BACKUP_DATE.tmp.tar.gz" "$BACKUP_DIR/$BACKUP_DATE.tar.gz"
rm -rf "$BACKUP_DIR/$BACKUP_DATE.tmp"

Fazit

Am vorliegenden Beispiel haben wir gezeigt, wie ein Skript durch Fehlerbehandlung Stück für Stück robuster gestaltet werden kann und wie mit trap sowohl Fehler als auch POSIX-Standardsignale (man signal für mehr) abgefangen werden können, so dass die Fehlerbehandlung übersichtlich bleibt. Auch wurde ein Beispiel für ein Design vorgestellt, das mögliche externe Faktoren wie z. B. ein Neustart des Systems mit berücksichtigt.

André Wild

Sie haben Fragen zu Shellskripten? Nehmen Sie gern Kontakt zu uns auf.

André Wild, Consultant
Telefon +49172 541 142 29

Wenn Sie sich hierfür interessieren, dann interessieren Sie sich vielleicht auch für...