Fehlerbehandlung ist nötig…

Dieser Code verursacht Übelkeit und kein Entwickler von Welt würde so etwas tun:

try {
  some_stuff();
} catch (Exception ex) {
  // do nothing
}
Java-Code ohne Fehlerbehandlung.

Fehler„behandlung“ durch „Ignorieren und Weitermachen“. Exception oder nicht? Egal. The show goes on. Igitt!

… auch in Shellskripten.

Dieser Code in einem Shellskript verursacht bei mir genau dieselbe Übelkeit:

PARAMETER=`some_stuff`
export PARAMETER
Shellskript ohne Fehlerbehandlung.

Aus demselben Grund. Ob es das Kommando some_stuff überhaupt gibt oder ob das Ding fehlschlägt: The show goes on.

set -e FTW

Dagegen hilft, ganz oben in sein Shellskript einzubauen:

set -e
Fehlerbehandlung anschalten

Damit schlägt das ganze Skript fehl, sobald das erste einzelne Kommando fehlschlägt. (Egal ob in `` oder einfach so.)

So weit, so gut.

Fehler und Pipes: Die versteckte Falle

Heute finde ich in einem Shellskript etwas in dieser Art:

set -e
DB_HOST=`aws ssm get-parameter blah blah | sed 's:^.\(.*\).$:\1:'`
export DB_HOST
Pipeline ohne Fehlerbehandlung.

Das ist leider auch daneben. Warum?

Eine nicht sehr bekannte Regel der Pipes+Filters-Architektur der Shell ist:

Ob die Pipeline fehlschlägt, entscheidet allein der letzte Filter.

Der Entscheider ist also sed. Der ist aber völlig zufrieden damit, keinen Input zu bekommen und nichts zu tun.

Ob also aws ssm get-parameter funktioniert oder nicht? Egal. The show goes on.

(Zum Ausprobieren kann man /bin/false nehmen statt aws ....)

set -o pipefail FTW

Dagegen kann man sich wehren, indem man oben in seinem Skript schreibt:

set -e
set -o pipefail
Fehlerbehandlung von Pipes

Das set -o pipefail schaltet in Pipelines das Konsensprinzip ein: Eine Pipeline scheitert, sobald irgendwo in der Pipeline etwas schief geht.

Das ist das, was man will (fast immer[1]).

Wähle Deine shell weise!

Leider beherrscht nicht jede Discounter-sh dieses set -o pipefail, wohl aber die bash.

Wenn man oben in der ersten Zeile des Shellskripts nicht #!/bin/sh, sondern #!/bin/bash sagt und das funktioniert, hat man gewonnen.

Das funktioniert natürlich nicht, wenn es eine /bin/bash nicht gibt. Manchmal muss man mit einer Discounter-sh Vorlieb nehmen.

Wenn man dann erst set -e und danach set -o pipefail gemacht hat und set -o pipefail wird nicht unterstützt, wirkt zumindest das set -e und das ganze Skript scheitert an dieser Stelle.

Und dann?

Dann könnte man als Notausgang versuchen, die | gar nicht zu nutzen.

set -e
aws ssm get-parameter blah blah > /tmp/db_host.$$
DB_HOST=`sed 's:^.\(.*\).$:\1:' < /tmp/db_host.$$`
export DB_HOST
Notausgang temporäre Dateien

Das funktioniert insofern, dass Fehler von aws ... zum Skriptabbruch führen. Trotzdem möchte man das nicht.

Aus offensichtlichen und weniger offensichtlichen Gründen. Ein weniger offensichtlicher: Das Öffnen von temporären Dateien an vorhersagbaren Stellen mit allgemeinem Schreibrecht öffnet Sicherheitslücken. Wenn auf dem System auch jemand Unangenehmes einen sh-Prompt hat, wird der Notausgang so möglicherweise zum Noteingang…

Und Fehlermeldungen?

Wenn Fehler zu einem sauberen Abbruch führen, ist das schon mal gut.

Die Situation bleibt trotzdem unangenehm: Denn man weiß nicht, was eigentlich das Problem ist.

Als Notnagel kann hier set -x dienen, womit alles geloggt wird, was die Shell tut.

Aber wer treibt schon den Aufwand, nicht nur eine ordentliche Fehlerbehandlung, sondern auch eine ordentliche Fehlermeldung vorzusehen? Wenn man das wirklich tut, werden aus einer Zeile schnell fünf:

if ! DB_HOST=`Kommando heikel und kompliziert`
then
  echo >&2 'ERROR: DB_HOST konnte nicht festgestellt werden.'
  exit 47
fi
Fehlerbehandlung mit Fehlermeldung

Noch besser: Mach’s ganz ohne Shell.

Geht das nicht einfacher?

Ja! …wenn man denn bereit ist, die Shell zu verlassen.

Meine Meinung: Ein Shellskript soll eine Handvoll Zeilen benötigen. Mehr nicht. Wird ein Skript länger, lasse ich die Shell Shell sein. Dann greife zu einer anderen Skriptsprache, gerne Python oder Ruby.

Das Beispiel sieht mit sauberer Fehlerbehandlung in Python 3.6 so aus:

db_host = run(['Kommando', 'heikel', 'und', 'kompliziert'],
              stdout=PIPE, check=True, encoding='UTF-8').stdout
Fehlerbehandlung in Python

Das ist nach bloßem from subprocess import run, PIPE. Mit eigener run Funktion wird der Aufruf noch kürzer und knackiger: Eine Zeile reicht!

Aber diesmal mit sauberer Fehlerbehandlung. Und eine informative Fehlermeldung fällt gleich mit aus der Tüte:

Traceback (most recent call last):
  File "./b", line 6, in <module>
    stdout=subprocess.PIPE, check=True, encoding='UTF-8').stdout
  File "/usr/local/lib/python3.6/subprocess.py", line 403, in run
    with Popen(*popenargs, **kwargs) as process:
  File "/usr/local/lib/python3.6/subprocess.py", line 709, in __init__
    restore_signals, start_new_session)
  File "/usr/local/lib/python3.6/subprocess.py", line 1344, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)
FileNotFoundError: [Errno 2] No such file or directory: 'Kommando': 'Kommando'
Output

Nebenbei bringt dieser Ansatz weitere Annehmlichkeiten. RegExp, JSON/YAML, HTTP-Client, unvorhersagbare Temp-Dateien, Datenbankzugriff, eigene wiederverwendbare Module: Luxus, an dem man sich gerne und schnell gewöhnt.

Und Pipes+Filters?

Was eine bash kann, kann jede Skriptsprache schon lange! Die bewährte Pipes+Filters-Architektur steht weiter zur Verfügung.

Aber Details dazu würden hier den Rahmen sprengen.

Wen sie interessieren: Bei einem innoQ-intern Workshop habe ich solche Details vorgeführt. Die damaligen Workshopunterlagen (mit Code auf Ruby-Basis) stehen öffentlich zur Verfügung.

  1. Ausnahmen sind z.B. Pipelines mit head als Abschluss oder einem ähnlichen Filter, der sich um NVC nicht kümmert und nicht abwartet, bis die Vorderfilter ausgeredet haben. Was diese häufig für einen Fehlerfall halten.  ↩

TAGS