shells

Was `set -e` wirklich tut (und warum Ihr Skript trotzdem undicht ist)

Bourne errexit ist die am meisten missverstandene Zeile in jeder Devops-Onboarding-Dokumentation. Hier ist, was sie tatsächlich tut, Schritt für Schritt, mit den Regeln in der POSIX-Spezifikation und den Bash-Erweiterungen, die ihnen widersprechen.

Diese Seite wurde maschinell aus dem English-Original übersetzt. What `set -e` actually does (and why your script still leaks) (EN).

Sie haben diese Zeile schon einmal gesehen. Wahrscheinlich haben Sie sie heute morgen geschrieben. Fast jedes “robuste Bash-Skript” im Internet beginnt mit einer Variante von:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

Es ist, Zeile für Zeile, das am häufigsten kopierte Snippet in der DevOps-Welt. Und fast jeder Ingenieur, mit dem ich gearbeitet habe, einschließlich (bis vor kurzem) mir, hat sich geirrt, was set -e tut.

In diesem Beitrag geht es um die Regeln. Nicht die Folklore.

Die Folklore

Die Kurzschrift, die Ingenieure im Kopf haben, ist in der Regel eine der folgenden:

> “Das Skript wird beendet, wenn ein Befehl fehlschlägt.”

> “Es wird beendet, wenn ein Befehl ungleich Null zurückkommt.”

> “Es ist wie try/catch, aber für Bash.”

Alle drei sind in einer Weise falsch, die wichtig ist. Der dritte ist in einer Weise falsch, die zu Ausfällen geführt hat.

Gehen wir mit ihnen spazieren.

Regel 1: errexit wird innerhalb von Bedingungen unterdrückt

Dies ist diejenige, die jeden erwischt. Das Exit-on-Error-Verhalten wird für jeden Befehl, dessen Exit-Status getestet wird, ausgesetzt. Das schließt ein:

  • Der Bedingungsteil von if, while, until. - Alles links von && oder ||. - Ein Befehl, der mit ! negiert wird. - Jeder Befehl in einer Pipeline außer dem letzten (es sei denn, pipefail ist gesetzt, dazu unten mehr).

Dieses Skript beendet sich also nicht, wenn es fehlschlägt grep:

#!/usr/bin/env bash
set -e

if grep "needle" haystack.txt; then
  echo "found"
else
  echo "not found"
fi

echo "still running"

Das ist gewollt und nützlich. Sonst könnte man keine bedingte Logik schreiben. Aber es führt zu einem weniger offensichtlichen Fehler:

#!/usr/bin/env bash
set -e

check_thing() {
  grep "needle" haystack.txt   # might fail
  echo "found needle"
}

if check_thing; then
  echo "ok"
fi

Die Leute erwarten von check_thing, dass das Skript abgebrochen wird, wenn grep fehlschlägt. Das tut es aber nicht. Da check_thing in einer Bedingung aufgerufen wird, wird errexit für den gesamten Funktionskörper ausgesetzt. Die Funktion gibt found needle auch dann aus, wenn grep einen Wert ungleich Null zurückgegeben hat, weil die nächste Zeile sich nicht um den Exit-Status der vorherigen Zeile kümmert.

Dies wird manchmal als das “vererbte Errexit-Problem” bezeichnet. Das Bash-Handbuch hat es in die Spezifikation in 4.4 mit diesem Satz aufgenommen:

> Wenn ein zusammengesetzter Befehl, bei dem es sich nicht um eine Subshell handelt, einen Status ungleich Null zurückgibt, weil ein Befehl fehlgeschlagen ist, während -e ignoriert wurde, wird die Shell nicht beendet.

Lesen Sie es langsam. Die Bedingung, unter der errexit ausgesetzt wird, überträgt sich in zusammengesetzte Befehle, die aus diesem Kontext heraus aufgerufen werden. Eine Funktion, die in der Mitte fehlschlägt und von dort aus weiterläuft, tut genau das, was die Spezifikation vorschreibt.

Regel 2: Pipelines prüfen nur den letzten Befehl

set -e
false | true
echo "still here"

Druckt still here. Der Nicht-Null-Exit von false wird verschluckt, da der Exit-Status einer Pipeline dem letzten Befehl in der Pipe entspricht, und true 0 zurückgegeben hat.

Dafür ist set -o pipefail gedacht. Wenn pipefail aktiviert ist, ist der Exit-Status der Pipeline der ganz rechte Exit, der nicht Null ist, oder Null, wenn jeder Befehl erfolgreich war. Kombiniert mit errexit:

set -eo pipefail
false | true
echo "still here"   # never prints

pipefail ist eine Bash-Erweiterung. POSIX sh hat sie nicht. Wenn Sie /bin/sh auf einem abgespeckten Container (BusyBox, Alpine’s ash) verwenden, erhalten Sie es nicht, und Sie müssen $? nach der Pipeline selbst überprüfen. In der Bash haben Sie auch PIPESTATUS:

some_command | grep -v noise
echo "${PIPESTATUS[0]}"   # exit code of some_command, not of grep

In POSIX sh ist die sauberste Abhilfe eine temporäre Datei:

tmp=$(mktemp) || exit 1
trap 'rm -f "$tmp"' EXIT
some_command > "$tmp"
status=$?
grep -v noise < "$tmp"

Wenn Sie es ohne eine temporäre Datei machen wollen, ist der wirklich portable Trick ein Datei-Deskriptor-Shuffle, der den Exit-Status des inneren Befehls auf fd 3 speichert, während der Rest der Pipeline läuft:

exec 4>&1
status=$(
  { some_command; echo "$?" >&3; } 3>&1 >&4 | grep -v noise >&4
)
exec 4>&-
echo "some_command exited with: $status"

Es gibt einen Grund, warum dash unpopulär ist. Es gibt auch einen Grund dafür, dass “wir behalten das einfach in POSIX sh, das wird schon gehen” den Kontakt mit der Realität nicht überlebt.

Regel 3: Befehlssubstitutionen sind ihre eigenen Subshells

Dieser hat mich im letzten Quartal gebissen:

#!/usr/bin/env bash
set -e

CONFIG_HASH=$(sha256sum missing-file.txt | cut -d' ' -f1)
echo "Hash is $CONFIG_HASH"

Sie könnten erwarten, dass das Skript bei sha256sum abbricht. Das ist aber nicht der Fall. CONFIG_HASH ist leer, echo "Hash is " wird ordnungsgemäß gedruckt und das Skript fährt mit dem nachgelagerten Schritt fort, der einen leeren Hash verwendet.

Hier stapeln sich zwei Mechanismen zur Umgehung von errexit. Erstens ist sha256sum nicht der letzte Befehl in seiner Pipeline; cut ist es. cut liest fröhlich seine leere stdin und beendet sich mit 0, so dass der Exit-Status der Pipeline 0 ist. Zweitens, selbst wenn Sie das behoben hätten, läuft die Befehlssubstitution $(...) in einer Subshell, und die Subshell erbt nicht standardmäßig errexit.

Die Lösung ist eine explizite Überprüfung:

if ! CONFIG_HASH=$(sha256sum missing-file.txt | cut -d' ' -f1); then
  echo "couldn't hash config" >&2
  exit 1
fi

Oder, in bash 4.4+, shopt -s inherit_errexit damit Befehlssubstitutionen tatsächlich -e berücksichtigen. Standardmäßig ist es ausgeschaltet. Die meisten “robusten Bash”-Vorlagen im Internet enthalten sie nicht.

Regel 4: || true tötet errexit geräuschlos

Ein Muster, das in jedem CI-Skript zu finden ist:

set -e
some_step || echo "step failed, continuing anyway"
later_step

Gut. Die Absicht ist sichtbar, der Betreiber weiß, was passiert ist.

Dieser hier ist nicht in Ordnung:

set -e
result=$(some_step) || true
process "$result"

Wenn some_step fehlschlägt, ist result leer und process wird ohne Eingabe ausgeführt. || true verwandelt den Fehler in einen stillen Erfolg. Keine Protokollzeile, kein Exit-Code, nichts, wonach ein Operator suchen könnte. Der Fehler taucht drei Ebenen tiefer in process mit einer generischen Meldung wie expected non-empty argument auf, und jetzt debuggen Sie eine andere Funktion.

Dies ist das Shell-Äquivalent zu try { x() } catch { /* swallow */ }. Auf diese Weise entwickeln Produktionsskripte zwei Jahre alte stille Eckfälle.

Überprüfungsregel 1

Speichern Sie das Folgende unter errexit-test.sh:

#!/usr/bin/env bash
set -e

step() {
  echo "  step: about to fail"
  false
  echo "  step: still alive after failure"
}

echo "calling step directly:"
step
echo "after direct call (should never print)"

Führen Sie es aus:

$ bash errexit-test.sh
calling step directly:
  step: about to fail
$ echo $?
1

Gut. Fügen Sie nun den Aufruf in eine if ein:

echo "calling step in if:"
if step; then
  echo "  step returned ok"
else
  echo "  step returned non-zero"
fi
echo "after if (will print)"
$ bash errexit-test.sh
calling step in if:
  step: about to fail
  step: still alive after failure
  step returned ok
after if (will print)

step gab Null zurück. Denn die echo nach false war erfolgreich, und das war die letzte Anweisung der Funktion. Die false wurde von der vererbten errexit-Suspension verschluckt; die Funktion lügt stillschweigend über ihren eigenen Erfolg.

Dies ist genau die Art von Fehler, die ein Deployment-Skript in einen Ausfall verwandelt.

Referenzkarte

| Construct | set -e triggers? | |—|—| cmd standalone | ja | | cmd \|\| true | nein (RHS schluckt es) | | cmd && other | nein für cmd (es ist eine Bedingung) | | if cmd; then ... | nein für cmd | | ! cmd | nein | | cmd1 \| cmd2 (kein pipefail) | nur wenn cmd2 fehlschlägt | | cmd1 \| cmd2 (pipefail) | ja | | var=$(failing_cmd) | ja (der Ausgang der Subshell wird weitergegeben) | | var=$(failing_cmd \| other) (kein Pipefail) | nein | | var=$(failing_cmd; succeeding_cmd) (kein inherit_errexit) | nein | | var=$(failing_cmd; succeeding_cmd) (inherit_errexit) | ja | | func aufgerufen wie if func | nein, für den gesamten Funktionskörper | | (cmd) subshell | yes (subshells inherit -e) |

Pinnen Sie es irgendwo an, wo Sie es sehen können.

Was sollten Sie also tun?

Eine kurze Liste, nach Prioritäten geordnet:

  1. Betrachten Sie set -e als eine Absicherung, nicht als Strategie. Überprüfen Sie die Exit-Codes explizit dort, wo es wichtig ist. set -e fängt die Fälle ab, die Sie vergessen haben; es sollte niemals Ihre erste Verteidigungslinie sein. 2. Kombinieren Sie errexit immer mit pipefail, nounset (-u), und inherit_errexit. Diese schließen die offensichtlichen Lecks. 3. Trap ERR mit -E, damit die Ausbreitung innerhalb von Funktionen und Subshells funktioniert. LINENO und BASH_COMMAND sind beide im Trap-Body nützlich. 4. Schreiben Sie ab fünfzig Zeilen in einer richtigen Sprache. Bash ist ab einer bestimmten Komplexitätsschwelle das falsche Werkzeug. Die Schwelle ist niedriger, als es Ihr Geschmack vermuten lässt.

Die Shell ist eine wunderbare Sache: eine fünfzig Jahre alte Sprache, mit der noch immer die Deployments der Welt laufen. Ihr Fehlermodell wurde für eine interaktive REPL entwickelt, in der der Mensch Fehler fängt. Wenn Sie diese REPL in einen unbeaufsichtigten Cron-Job verwandeln, müssen Sie die Fehler selbst beheben.


Weiterführende Literatur: