Wenn Sie Backups einrichten oder Ordner synchronisieren wollen, kommen Sie kaum an der Shell vorbei. Dann ist es an der Zeit, sich über kluges Skripting das Admin-Leben etwas zu erleichtern.
Als Linux-Anwender landen Sie zwangsläufig irgendwann auf der Kommandozeile. Viele administrative Aufgaben lassen sich hier einfach deutlich eleganter lösen als über mehr oder weniger vollständige GUI-Tools. Zahlreiche Aufgaben, die ein gewisses Maß an Komplexität mitbringen, schreien förmlich nach Skripting und Automatisierung via Cron-Jobs.
Meine ersten Skripte mögen noch Aneinanderreihungen von Kommandos gewesen sein, ohne Fallunterscheidungen oder andere Kontrollstrukturen – was für banale Aufgaben durchaus genügt. Häufig bedarf es aber genau solcher Fallunterscheidungen oder Ausnahmebehandlungen, beispielsweise sobald ein Dateisystem vollläuft oder ein Netzwerk-Share nicht eingehängt ist.
Auf die Kontrollstrukturen der Bash gehe ich hier nicht explizit ein, dazu gibt es im Internet eine Fülle an Material. In dieser Folge der Kolumne geht es stattdessen um verschiedene Möglichkeiten, persönliche Skripte mit einem Grundstock an Ausnahmebehandlung zu versehen: Willkommen beim defensiven Bash-Skripting. Was es mit “defensiv” auf sich hat, verrät Ihnen der Kasten “Defensive Programmierung”.
Stellen Sie sich vor, Sie haben einen Ordner, in den Ihre Backups laufen. Er liegt auf einem Samba-, NFS- oder WebDAV-Share. Dummerweise bekommen Sie nicht mit, dass das Share – aus welchem Grund auch immer – gerade nicht verfügbar ist. Jetzt sichert Ihr Skript eben nicht mehr auf ein ausreichend großes Backup-Laufwerk, sondern benutzt einen vermutlich recht begrenzt dimensionierten, lokalen Ordner. Das Ende vom Lied: Im einfachsten Fall erreicht das Laufwerk seine Kapazitätsgrenze, und das Backup bricht schlicht ab. Im ungünstigsten Fall können Sie sich nicht mehr an Ihrer Maschine anmelden, weil Ihr Root-FS vollgelaufen ist. So, da haben wir dann den Salat.
Defensive Programmierung
Defensive Programmierung kommt ursprünglich aus der Security-Ecke und zielt darauf ab, mögliche Fehleingaben und Ausnahmen von vorneherein abzufangen, sodass sie sich gar nicht erst zum ernsthaften Problem auswachsen können. Das Programm oder Skript wehrt sich sozusagen selbst gegen versehentlich oder böswillig Eingetipptes. Je sauberer das Skript oder Programm die Eingaben filtert, desto zuverlässiger wird die Anwendung funktionieren – quasi das Gegenstück zum allgemein bekannten Prinzip “Mist rein, Mist raus”.
Eingaben clever verarbeiten
Simple Skripte, die nur einen einzigen Zweck erfüllen, kommen wahrscheinlich ohne Parameter aus. Hier ist es legitim, Variablen hart im Quellcode zu hinterlegen. Viel flexibler sind Sie allerdings, wenn Sie Ihr Skript bequem über Parameter auf der Kommandozeile steuern können.
Getopt kommt eigentlich aus der C-Entwicklung, konkret von der gleichnamigen C-Library, und dient dort zum Parsen von Kommandozeilenparametern. Das Tool ist standardmäßig Teil der Basisinstallation der meisten Distributionen und versetzt Sie in die Lage, Parameter in ihren eigenen Skripten auszuwerten. Im Gegensatz zu Getopt ist Getopts kein externes Tool, sondern ein Bash-Builtin, also Teil der Shell. Getopts beherrscht nur Short-Parameter. Wer die Langversionen benötigt, greift besser auf Getopt aus dem Paket util-linux zurück.
Die Syntax eines Aufrufs von »getopts« ist recht simpel. In Zeile 19 von Listing 1 definiert der Optstring »:ha:b:r:y:«, dass das Skript die Optionen »h«, »a«, »b«, »r« und »y« akzeptiert. Auf Buchstaben folgende Doppelpunkte bedeuten, dass der jeweilige Parameter ein Argument erwartet. Der führende Doppelpunkt weist Getopts an, Fehler still zu verarbeiten und nicht direkt auszugeben.
Für die Ausgabe der Hilfe ist es ratsam, eine Funktion einzusetzen, im konkreten Fall »usage« genannt. So verwenden Sie ein und denselben Code an vielen Stellen im Skript wieder. Die mehrzeilige Ausgabe erfolgt hier über ein Heredoc, das ich mit »<<- EOF« einleite. »EOF« fungiert dabei als Stop-Token. »<<-« bedeutet, dass das Skript den gesamten Text bis »EOF« ausgibt und dabei führenden Whitespace (anders als bei »<<«) ignoriert. Dadurch gewinnt das Ganze innerhalb der eingerückten Zeilen einer Funktion an Lesbarkeit.
Die eigentliche Parser-Arbeit findet nun innerhalb der While-Schleife statt. Getopts setzt mehrere Variablen, zum Beispiel »OPTIND« für den Index der nächsten Option, und ermöglicht so, über die Parameter zu iterieren. Den aktuellen Parameter packt Getopts in die Variable »OPTARG«.
Ein Case-Statement übernimmt die Fallunterscheidung und die Validierung der Parameter. So prüfen Sie mit Konstrukten wie dem aus Zeile 23 von Listing 1 elegant, ob der angegebene Wert von »r« gültige Werte für die Abspielgeschwindigkeit der Platte enthält. Zeile 25 verdeutlicht, wie Sie per regulärem Ausdruck als Wertebereich eine Jahreszahl erzwingen. Die Eingabe nicht definierter Parameter und »h« (wie Hilfe) bewirken in Zeile 27 schließlich die Ausgabe der Hilfe.
Listing 1
Getopts
#!/usr/bin/env bash
ERR_CMD_OPTIONS=1
usage() {
# Hilfe per HEREDOC ausgeben
cat <<- EOF
Usage: $0 OPTIONS
OPTIONS:
-h Diese Hilfe ausgeben
-a Der Interpret (Artist)
-b Das Album
-y Das Erscheinungsjahr
-r Abspielgeschwindigkeit
EOF
exit $ERR_CMD_OPTIONS
}
while getopts ":ha:b:r:y:" o; do
case "${o}" in
a) a=${OPTARG}
;;
b) b=${OPTARG}
;;
r) r=${OPTARG}
((r == 33 || r == 45)) || usage
;;
y) [[ "${OPTARG}" =~ ^[0-9]{4}$ ]] && y=${OPTARG} || usage
;;
h|*)
usage
;;
esac
done
shift $((OPTIND-1))
if [ -z "${a}" ] || [ -z "${b}" ] || [ -z "${r}" ] || [ -z "${y}" ]; then
usage
fi
echo "Interpret: ${a}"
echo "Album: ${b}"
echo "Erscheinungsjahr: ${y}"
echo "Abspielgeschwindigkeit: ${r} min^-1"
echo
echo "Positionsparameter:"
echo "Pos 1: $1"
echo "Pos 2: $2"
Zeile 32 hat einen besonderen Hintergrund: Nach Abschluss der Case-Fallunterscheidung sollten alle benannten Parameter verarbeitet sein. Die meisten Tools wie Ls, Mv, Rm und so weiter akzeptieren außer benannten Parametern zusätzlich Positionsparameter. Beispielsweise fungieren beim Umbenennen einer Datei mit »mv alt.txt Neu.txt« die Strings »alt.txt« und »Neu.txt« als Positionsparameter: Ihre Reihenfolge definiert, was wohin verschoben werden soll.
Im Skript veranlasst das Kommando »shift« in Zeile 34 das Verschieben der Parameter um »OPTIND-1« nach links. Haben Sie beispielsweise 8 Parameter verarbeitet und »OPTIND« steht auf 9, dann rutscht über »shift $((OPTIND-1))« jeder Parameter um 8 Stellen nach links. An der ersten Position steht dann der Parameter, der zuvor noch Position 9 innehatte.
Im ersten Moment mag das etwas verwirrend klingen, und Sie fragen sich vermutlich, was das alles bringen soll. Ganz einfach: Nach dem Auswerten der benannten Parameter per Getopts und dem Aufruf von »shift« lassen sich alle nachfolgenden Positionsparameter komfortabel über die wohlbekannten Variablen »$1«, »$2« …»$#« verarbeiten.
Am Schluss prüft der Code in Zeile 33, ob auch wirklich alle erforderlichen Variablen über Kommandozeilenparameter gesetzt werden konnten, und gibt gegebenenfalls eine Fehlermeldung aus. Ab Zeile 36 können Sie sich jetzt um die eigentliche Programmlogik kümmern.
Mit Getopts liefert die Bash eine wirklich hilfreiche und einfache Methode, die eigenen Skripte von außen über Parameter zu steuern. Das ermöglicht sehr viel Flexibilität.
Bash-Schalter für mehr Sicherheit
Wer arbeitet, macht Fehler, und wer programmiert, programmiert mitunter Mist. Die Bash hat hier einige hochinteressante Features parat, die deutlich mehr Wohlbefinden im Arbeitsalltag versprechen. Der Klassiker schlechthin, den viele vermutlich recht häufig unreflektiert von Stack Overflow oder anderen gängigen Quellen einfach in die eigenen Skripte übernehmen, sieht so aus: »set -euxo pipefail«. Das Kommando ist die verkürzte Form der Befehle aus Listing 2.
Listing 2
set -euxo pipefail
set -e set -u set -o pipefail set -x
Der Schalter »set -e« sorgt dafür, dass jeglicher Non-Zero-Return-Code, also jeder Fehler eines Kommandos innerhalb des Skripts, zum sofortigen Abbruch führt. Das ist sehr sinnvoll, denn in den seltensten Fällen werden nachfolgende Befehle nicht auf den Erfolg des Vorgängers angewiesen sein. Der Schalter steht üblicherweise in Bash auf inaktiv – wer möchte schon wegen eines Tippfehlers ausgeloggt werden? Innerhalb eines Skripts dagegen ist der Abbruch sehr wünschenswert: Was ist, wenn das letzte Kommando tatsächlich »0« zurückliefert und damit Gesamterfolg vermeldet? Das Ergebnis sieht dann zwar gut aus, ist es aber nicht.
Der Schalter »set -x« aktiviert die Debugging-Ausgabe. Alle ausgeführten Kommandos werden daraufhin im Terminal ausgegeben, was die Fehlersuche massiv erleichtert. »set -u« betrifft Variablen. Mit dieser Option bedingt jede Referenz auf eine noch nicht initialisierte Variable einen Fehler und führt damit unweigerlich zur Beendigung des Programms. Das entspricht genau dem Standardverhalten vieler Programmiersprachen wie C oder Python. An dieser Stelle bringt uns erneut ein Beispiel weiter (Listing 3).
Listing 3
set -u
#!/usr/bin/env bash
fileDir=/usr/local/share/
fileName=wallpaper001.jpg
filePath="${filedir}${fileName}"
Der Fehler ist so simpel wie subtil: Die letzte Zeile nimmt Bezug auf die Variable »filedir«, die allerdings gar nicht existiert. Dahinter steckt eine kleine Nachlässigkeit in Sachen Groß- und Kleinschreibung. Dank »set -u« fällt der Lapsus sofort auf und lässt sich leicht beheben. Ohne den Schalter wundern Sie sich vielleicht, warum die Datei nicht am erwarteten Zielort liegt, sondern stattdessen im aktuellen Ordner.
Mit »set -o pipefail« vermeiden Sie, dass Fehler innerhalb von Pipes verdeckt werden und somit verloren gehen. Falls es zu einem Return Code »!= 0« kommt, scheitert nicht nur das x-te Kommando, sondern die vollständige Pipeline (Listing 4, Zeile 7ff.).
Ohne »pipefail« liefert »grep« den Return Code »2« und schreibt diesen nach Stderr. In Stdout liefert Grep jedoch einen leeren String, der damit auch Sort erreicht. »Sort« ist sich nicht zu fein, einen leeren String zu sortieren, und liefert selbst wiederum den Rückgabewert »0« (Listing 4, Zeile 2 bis 5). Das ist auf der Shell in Ordnung, denn Sie sehen ja unmittelbar die leere Ausgabe. In einem Shell-Skript kann es fatal sein.
Listing 4
set -o pipefail
### Ohne set -o pipefail $ grep some-string /non/existent/file | sort grep: /non/existent/file: No such file or directory $ echo $? 0 ### Mit set -o pipefail $ set -o pipefail $ grep some-string /non/existent/file | sort grep: /non/existent/file: No such file or directory $ echo $? 2
Ausnahmebehandlung mit Bash Traps
Sollten Sie Erfahrungen mit Programmiersprachen wie Java oder Python haben, kennen Sie sicherlich Try-Catch beziehungsweise Try-Except. Diese Sprachkonstrukte versetzen Sie in die Lage, auf Ausnahmen zu reagieren, die sich nicht über klassische Fallunterscheidungen (If-Else) abfangen lassen. Das kann etwa vorkommen, wenn während des Schreibens auf ein Remote-Laufwerk die Netzwerkverbindung abbricht.
Auf dieses Feature müssen Sie auch in Bash nicht verzichten. Mit dem Schlüsselwort »trap« beauftragen Sie Bash, das mitgegebene Kommando bei Auftreten eines bestimmten Signals auszuführen. Das Kommando »bash trap« verarbeitet also Signale. Das bekannteste darunter dürfte »SIGKILL« sein. Nicht mehr reagierende Prozesse tötet ein »kill -9 PID« oder »kill -SIGKILL PID«. Eine schöne Übersicht liefert die Manpage zu »signal«.
Zu den gebräuchlichsten Signalen gehören »SIGHUP«, »SIGINT«, »SIGKILL«, »SIGQUIT« und »SIGTERM« (Listing 5). Die Syntax für den Aufruf von »trap« sieht folgendermaßen aus.
trap AKTION SIGNAL1 SIGNAL2 ... SIGNALn
Listing 5
Manpage von Signal (Ausschnitt)
SIGHUP P1990 Term Verbindung am steuernden Terminal beendet (aufgehängt) oder der steuernde Prozess wurden beendet SIGINT P1990 Term Unterbrechung von der Tastatur SIGKILL P1990 Term Kill-Signal SIGQUIT P1990 Core Abbruch von der Tastatur SIGTERM P1990 Term Beendigungssignal (termination signal)
Ein ausführliches Beispiel dazu finden Sie in Listing 6. Zeile 5 legt mit »mktemp« ein Einwegverzeichnis an, dessen Name in der Variablen »mytmpdir« gespeichert wird. Der Code in den Zeilen 8 und 9 entfernt vom Pfad des Tarballs sowohl die Verzeichnisse als auch die Dateinamenserweiterung. Diese sogenannten Brace Expansions tun hier nichts anderes als die bekannten Werkzeuge Basename und Dirname. Sie können also auf die externen Kommandos verzichten und erhalten somit einen Performance-Gewinn.
Listing 6
Bash-Traps in der Praxis
#!/usr/bin/env bash
set -e
set -u
set -o pipefail
mytmpdir=$(mktemp -d)
echo "mytmpdir=${mytmpdir}"
mytarball=/home/treuss/Downloads/biglargetarball.tar.gz
mytargetdir="${mytarball##*/}"
mytargetdir="${mytargetdir%%.*}"
function cleanup() {
echo "Bereinige temporäre Dateien und Ordner."
rm -rf ${mytmpdir}
echo "Temporäre Dateien und Ordner bereinigt."
}
function handle_error() {
echo "Fehler in Zeile $1"
cleanup
exit 1
}
trap 'cleanup' SIGKILL SIGHUP SIGINT SIGABRT;
trap 'handle_error $LINENO' ERR
tar -C "${mytmpdir}" -xzf "${mytarball}"
[ -d ${mytmpdir} ] && mv -v "${mytmpdir}" "${mytargetdir}"
exit 0
Spannend wird es in der Zeile 20. Hier bekommt die Bash den Auftrag, beim Empfang eines der vier aufgelisteten Signale die Funktion »cleanup()« auszuführen. Wenn Sie während des späteren Entpackens [Strg]+[C] drücken und damit »SIGINT« an das Skript senden, löscht »cleanup()« also den temporären Ordner. Zeile 21 definiert anschließend eine weitere Trap: Tritt im Skript ein Fehler auf (Signal »ERR«), ruft Bash die Funktion »handle_error()« mit der vorbelegten Zeilennummer »$LINENO« auf. Letztere gibt »handle_error()« dann aus, bereinigt unter Mithilfe von »cleanup()« den temporären Ordner und beendet das Skript mit dem Return Code »1«.
Wird nun Tar (etwa via »pkill tar«) unterbrochen, während es das Archiv in Zeile 22 in den temporären Ordner entpackt, schnappt die Bash Trap zu und sorgt dafür, dass keine Dateileichen zurückbleiben. Danach prüft der Code aus Zeile 23, ob es »mytmpdir« noch gibt, also ob alles korrekt abgelaufen ist, und benennt das Einwegverzeichnis wunschgemäß um.
Abschließend gibt das Skript noch den Return Code »0« zurück, um die in diesem Fall fehlerfreie Ausführung zu bestätigen. Nicht vergessen: Ein Fehler würde via Trap die Funktion »handle_error()« auslösen und damit das Skript mit einem Rückgabewert von »1« beenden.
Fazit: Lessons learned?
Bei nicht wenigen meiner alten Skripte schaudert es mich heute, wie wenig ich von den überaus hilfreichen Features der Bash Gebrauch gemacht habe. Angefangen von fürchterlichen Debugging-Stunden mit massiver Ausgabe mittels Echo bis hin zu kopierten Skripts, bei denen sich eigentlich nur eine Variable unterschied, die ein Paradebeispiel für Parameter gewesen wäre, habe ich so manchen Facepalm-Moment erlebt.
Wer sich die Zeit nimmt, nicht einfach nur kopflos Codeschnipsel von Stack Overflow einzusetzen, sondern sich ernsthaft mit den oben genannten Hilfsmitteln auseinandersetzt, wird seine wahre Freude daran haben und die Möglichkeiten defensiven Skriptens nicht mehr missen wollen. (csi)





