2 Punkte von GN⁺ 2024-03-03 | 1 Kommentare | Auf WhatsApp teilen
  • Wenn sich ein Bash-Skript anders als erwartet verhält, lässt sich die Ursache oft schon leichter finden, indem man sich einfach die tatsächlich ausgeführten Befehle anzeigen lässt
  • set -x gibt jede Zeile nach der Variablenexpansion aus und zeigt so, welche Befehle das Skript tatsächlich ausführt
  • Wenn man es auf der Kommandozeile mit bash -x script.sh ausführt, hat das denselben Effekt, als würde man am Anfang von script.sh set -x einfügen
  • Mit dem DEBUG-Trap zusammen mit read kann man vor der Ausführung jeder Zeile anhalten und Dateiname, Zeilennummer und den nächsten Befehl prüfen
  • Die Funktion die() { echo $1 >&2; exit 1; } vereinfacht den Ablauf, nach einem fehlgeschlagenen Befehl eine Standardfehlermeldung auszugeben und zu beenden

Den Ausführungsablauf mit eigenen Augen prüfen

  • set -x gibt die Zeilen aus, die das Skript ausführt, und zeigt Variablen mit ihren expandierten Werten
  • Man kann es verwenden, indem man set -x am Anfang des Skripts einfügt
  • Dasselbe Verhalten ist auch auf der Kommandozeile möglich
    • $ bash -x script.sh
    • Das ist gleichbedeutend damit, am Anfang von script.sh set -x einzufügen

Bei jeder Zeile anhalten und prüfen

  • Der DEBUG-Trap wird ausgeführt, bevor jede Codezeile ausgeführt wird
  • Wenn man den folgenden Code am Anfang des Skripts einfügt, wartet es vor der Ausführung des nächsten Befehls auf die Eingabe von Enter
    • trap '(read -p "\[$BASH_SOURCE: $LINENO] $BASH_COMMAND")' DEBUG
    • read -p gibt eine Meldung aus und wartet auf die Eingabe von Enter
    • $BASH_SOURCE ist der Name der Skriptdatei
    • $LINENO ist die Zeilennummer
    • $BASH_COMMAND ist der Befehl, der als Nächstes ausgeführt wird

Beim Fehlschlag eine Meldung ausgeben und beenden

  • Die Funktion die kann verwendet werden, um bei einem Befehlsfehler eine Meldung auszugeben und das Programm zu beenden
    • die() { echo $1 >&2; exit 1; }
    • Man hängt sie einfach an einen Befehl an, der fehlschlagen kann, etwa so: some_command || die "oh no!"
  • Diese Funktion sendet die Meldung an stderr und beendet sich mit exit 1

1 Kommentare

 
GN⁺ 2024-03-03
Meinungen auf Hacker News
  • Bei ZFSBootMenu werden ein paar recht gute eigene Funktionen eingesetzt, um beim Debugging zu helfen.
    Überall im Code steckt die Logging-Funktion zdebug: https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme...
    Wenn Debug-Logging aktiviert ist und man im Hauptmenü Ctrl-T drückt, erscheint so ein Bildschirm: https://i.imgur.com/Ge75zkP.png
    Außerdem gibt es Flamegraph-Profiling, das sich über https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme... aktivieren lässt; wenn man die über die serielle Schnittstelle ausgegebenen Daten wieder zusammensetzt, kann man Graphen wie https://raw.githubusercontent.com/zbm-dev/zfsbootmenu/master... erzeugen.
    Bash ist überraschend flexibel.
  • Bei der Verwendung von set -x ist PS4='+ ${BASH_SOURCE:-}:${FUNCNAME[0]:-}:L${LINENO:-}: ' sehr nützlich.
    Damit werden Dateiname, Funktionsname und Zeilennummer angezeigt, was beim Debuggen großer Bash-Skripte ziemlich hilft.
  • Auch shellcheck ist empfehlenswert. Selbst wenn es ein Problem nicht direkt findet, weist es auf potenzielle Probleme hin.
    Außerdem ist es empfehlenswert, Skripte in einer anderen Sprache neu zu schreiben. In unserer Firma stellen wir Bash-Skripte auf Rust um; die Einstiegshürde ist hoch, aber der resultierende Code ist viel leichter zu warten und zuverlässiger.
    Bash ist für schnelle Skripte weiterhin gut, aber ab etwa 100 Zeilen lohnt sich eine Sprache mit stärkeren Garantien.
    • Zustimmung, aber das sollte man auch dem Bereich CI/CD-Engineering und YAML-Pipelines sagen.
  • Mit Exit-Codes in dieser Art lässt sich das Debugging weiter verbessern.
    die() ist eine Hilfsfunktion, die eine Fehlermeldung auf die Standardfehlerausgabe schreibt und dann mit dem angegebenen Fehlercode beendet.
    Weitere Exit-Codes und Hilfsfunktionen für Shell-Skripte gibt es hier: https://github.com/SixArm/unix-shell-script-kit/blob/main/un...
    • Gute Liste, und erfahrene Nutzer haben vermutlich alle ihre eigenen Hilfsfunktionen.
      Allerdings habe ich an der Philosophie dieses die ein wenig auszusetzen. Eine die-Funktion sollte grundsätzlich den Exit-Code des fehlgeschlagenen Befehls weitergeben und auch dessen Fehlerausgabe nicht unterdrücken.
      Wenn ich einem Befehlsfehler in einem großen Skript selbst eine Bedeutung geben will, würde ich ein spezialisierteres die separat verwenden. Mein die sieht grob wie __errex "$?" "${LINENO}" "$0" aus: Es gibt einen fatalen Fehler, die Zeilennummer, den Skriptnamen und eine Nachricht aus und beendet mit diesem Exit-Code.
  • Wenn man viele Bash-Funktionen verwendet, kann man auch eine Art Stacktrace erzeugen.
    Ein Implementierungsbeispiel gibt es hier: https://github.com/TritonDataCenter/sdc-headnode/blob/master...
    • Es gibt noch eine weitere Stacktrace-Implementierung: https://github.com/runag/runag/blob/main/lib/fail.sh
      Wenn man sie wie some-command || fail "message" verwendet, erzeugt sie einen Stacktrace und beendet die Shell, sobald some-command einen Exit-Status ungleich 0 zurückgibt.
      Wenn man in einer Funktion einen Stacktrace erzeugen und zurückkehren möchte, kann man etwas wie some-command || softfail "message" || return $? verwenden.
  • Ich frage mich, ob es außer Legacy-Trägheit noch andere Gründe gibt, warum Bash weiterhin die De-facto-Sprache für Shell-Scripting ist.
    Man kann damit erledigen, was nötig ist, aber sie ist klobig und die Syntax ist schrecklich. Sobald ein Skript eine gewisse Größe oder Komplexität erreicht, zwingt sie einen praktisch dazu, auf eine richtige Sprache umzusteigen; vielleicht war das ja sogar beabsichtigtes Design.
    • Es stimmt, dass Legacy-Nutzung einen großen Teil der Popularität ausmacht.
      Auf modernen Distributionen gibt es in der Regel eine recht aktuelle Bash-Version, und wenn man nicht gerade Dinge wie Arrays verwenden will, muss man sich auch nicht groß um die Version kümmern.
      Der Reiz von Bash liegt in der Position, die sie zwischen anderen Sprachen und Tools einnimmt. Sie ist ideal, um andere Tools zu verbinden, nah genug am Betriebssystem, um bequem zu sein, und verlangt nicht wie Python die Installation von Bibliotheken.
      Bei komplexerem Scripting hört man oft, man solle auf eine Sprache wie Python wechseln, aber dadurch kommt eine zusätzliche Komplexitätsschicht hinzu, die langfristig nicht unbedingt hilft. Ein Bash-Skript von vor 20 Jahren läuft heute immer noch problemlos, während ein Python-Programm von vor 20 Jahren wahrscheinlich Versionsprobleme bekommt.
    • Bourne-Shell-Scripting ist gut genug, um kaum ersetzbar zu sein.
      Plan 9s rc ist etwas sauberer, aber wegen „ähnlich, nur sauberer“ wechselt niemand. Man kann schon jetzt unter https://pkgsrc.se/shells etwas Ähnliches, aber Besseres installieren, tut es aber nicht, und für andere ändert sich dadurch auch nicht die Ausführungsweise.
      Um eine etablierte Technologie zu ersetzen, muss man in zentralen Aspekten um ein Vielfaches besser sein. Auch Plan 9 war besser als UNIX-artige Systeme, aber nicht gut genug, um sie zu ersetzen.
      Es ist schwer, etwas zu schaffen, das gut genug ist, um die Nische des Bourne-Shell-Scriptings zu ersetzen. Bevor es so gut wird, landet es bereits im ökologischen Platz oder Problembereich echter Skriptsprachen wie Perl, Python oder Ruby.
      In engen Problembereichen saugt ein lokales Optimum die ganze Luft ab und macht es schwer, dass Konkurrenten entstehen, die einem theoretischen globalen Optimum nahekommen.
    • Ich denke, es liegt wirklich an Legacy und Trägheit.

Die jüngst hinzugekommenen Funktionen fügen sich zwar gut auf sh/Bash ein, aber letztlich ist Shell-Scripting ein Mittel zum Zweck und sollte sich deutlich langsamer weiterentwickeln als allgemeine Programmiersprachen
Das zentrale Merkmal von Bash/sh ist, dass es anti-entropisch ist. Es gibt kaum Entwicklung oder Evolution, sodass die Wahrscheinlichkeit gering ist, wegen Abhängigkeiten oder neuer Funktionen Probleme zu bekommen, und Dinge, die vor 20 Jahren funktionierten, bleiben weiterhin Standardwerkzeuge
Durch dieses Design wird es zu einem System, das Veränderungen ablehnt, und wenn man an Grenzen stößt, entsteht ein Anreiz, außerhalb davon weiterzumachen

  • Ich bin mir nicht sicher, ob das wirklich Bash ist
    Die meisten Skripte in FreeBSD sind für sh geschrieben, und sh ist Teil des POSIX-Standards, daher wirkt es deutlich breiter unterstützt. Bash halte ich einfach für ziemlich populär
  • Ein großer Punkt ist, dass es überall vorhanden ist
    Aber Bash war so schlecht, dass ich eine Menge Utilities mit reduziertem Namensraum gebaut habe, um Groovy-Skripte zu verwenden. Man konnte in einer IDE entwickeln, das Bibliothekssystem war sicher, und Groovy hat fast alle Unbequemlichkeiten von Java geglättet, was deutlich besser war
  • Es gibt auch einen ziemlich leistungsfähigen echten Debugger im gdb-Stil: https://bashdb.sourceforge.net/
  • Als kleine thematisch verwandte Eigenwerbung: Ich habe früher einen Bash-Pipeline-Debugger gebaut, der Zwischenausgaben erhält
    Es gibt ein paar Einschränkungen, aber im Allgemeinen kann er nützlich sein: https://github.com/ketancmaheshwari/pd
  • Die die()-Technik ist gut, aber Bash hat eine nervige Eigenschaft. Wenn man innerhalb einer Subshell exit ausführen will, wird nur die Subshell beendet, und der Rest des Skripts läuft weiter
    Wenn man zum Beispiel in einer Pipeline wie cat myfile | while read line; do ... die "Found match" ... done die aufruft, wird das nachfolgende echo "I don't want this line" trotzdem ausgegeben
    Oft kann man Subshells vermeiden, und in diesem Beispiel weist shellcheck zu Recht auf UUOC hin; behebt man das, ist auch das die-Problem in der Subshell gelöst
    Manchmal lassen sich Subshells aber nicht vermeiden, oder das Skript würde dadurch zu kompliziert. In solchen Fällen kann man am Anfang des Skripts mit MYPID=$$ die PID festhalten und es dann etwa mit die() { echo "$1" >&2; kill -9 $MYPID; exit 1; } beenden
    Natürlich ist auch das ein Kompromiss. Diese Art des Beendens ist ziemlich grob, und aus unbekannten Gründen war sie auch nicht völlig zuverlässig
    • Schon das Hinzufügen von set -e sorgt dafür, dass das Skript mit beendet wird, wenn eine Subshell mit einem Fehlercode ungleich 0 endet
      Mir fällt kaum ein Grund ein, warum man in irgendeinem Shell-Skript auf set -e verzichten sollte
    • Wenn man auf diese Weise die PID killt, könnte das nicht Zombie-Prozesse erzeugen?
  • An den Anfang von Bash-Skripten setze ich immer set -euxo pipefail
    Bedingungstests werden dadurch etwas schwieriger, aber insbesondere pipefail allein hat sich schon mehrfach bezahlt gemacht
    • https://mywiki.wooledge.org/BashPitfalls#set_-euo_pipefail
    • Man kann es auch für mehrere Zeilen einschalten und dann mit set +x wieder ausschalten
      Wenn es dauerhaft eingeschaltet bleibt, wird es ziemlich ermüdend
    • Diese Einstellung ist ein echter Lebensretter
      Allerdings spare ich mir -x auf, bis ich wirklich die ganze unordentliche Debug-Ausgabe sehen muss