
Das Debuggen von Bash-Skripten ist wie die Suche nach einer Nadel im Heuhaufen, besonders wenn neue Ergänzungen in eine bestehende Codebasis eingebracht werden, ohne rechtzeitige Überprüfung von Struktur, Logging und Zuverlässigkeit. In solchen Situationen können sowohl eigene Fehler als auch das Management komplexer Skriptanhäufungen zu Problemen führen.
Der Befehl hat einen Artikel mit Empfehlungen übersetzt, die Ihnen helfen, Ihre Skripte besser zu schreiben, zu debuggen und zu warten. Ob Sie es glauben oder nicht, aber nichts kann mit der Zufriedenheit verglichen werden, sauberen, einsatzbereiten Bash-Code zu schreiben, der jedes Mal funktioniert.
Im Artikel teilt der Autor, was er in den letzten Jahren gelernt hat, sowie einige häufige Fehler, die ihn überrascht haben. Das ist wichtig, denn jeder Softwareentwickler arbeitet irgendwann in seiner Karriere mit Skripten zur Automatisierung wiederkehrender Aufgaben.
Fallenhandler
Die meisten Bash-Skripte, mit denen ich gearbeitet habe, haben nie einen effektiven Bereinigungsmechanismus verwendet, wenn während der Ausführung des Skripts etwas Unerwartetes passiert.
Unerwartete Ereignisse können von außen kommen, wie das Empfangen eines Signals vom Kernel. Die Handhabung solcher Fälle ist entscheidend, damit Skripte ausreichend zuverlässig für den Einsatz in Produktionssystemen sind. Ich verwende häufig Exit-Handler, um auf solche Szenarien zu reagieren:
function handle_exit() {
// Hier Bereinigungscode hinzufügen
// z.B. rm -f "/tmp/${lock_file}.lock"
// mit einem entsprechenden Statuscode beenden
}
// trap
trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM
trap ist ein eingebauter Shell-Befehl, der Ihnen hilft, eine Bereinigungsfunktion zu registrieren, die bei bestimmten Signalen aufgerufen wird. Dabei ist jedoch besondere Vorsicht bei solchen Handlern wie SIGINT, der das Skript unterbricht.
Darüber hinaus sollten in den meisten Fällen nur EXITabgefangen werden, aber die Idee ist, dass Sie das Verhalten des Skripts für jedes einzelne Signal tatsächlich anpassen können.
Eingebaute Funktionen set — schnelles Beenden bei Fehlern
Es ist äußerst wichtig, Fehler sofort zu beheben, wenn sie auftreten, und die Ausführung schnell zu stoppen. Nichts ist schlimmer, als einen Befehl wie diesen fortzusetzen:
rm -rf ${directory_name}/*
Bitte beachten Sie, dass die Variable directory_name nicht definiert ist.
Um mit solchen Szenarien umzugehen, ist es wichtig, die integrierten Funktionen zu verwenden: set, wie set -o errexit, set -o pipefail oder set -o nounset am Anfang des Skripts. Diese Funktionen stellen sicher, dass Ihr Skript beendet wird, sobald es einen beliebigen Fehlercode trifft, den Einsatz von undefinierten Variablen, falsche Befehle, die über eine Pipeline übergeben werden, usw.:
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
function print_var() {
echo "${var_value}"
}
print_var
$ ./sample.sh
./sample.sh: line 8: var_value: unbound variable
Hinweis: Integrierte Funktionen wie set -o errexit, beenden das Skript sofort, sobald ein "unerwarteter" Rückgabewert (außer null) auftritt. Daher ist es besser, eine benutzerdefinierte Fehlerbehandlung einzuführen, zum Beispiel:
#!/bin/bash
error_exit() {
line=$1
shift 1
echo "ERROR: non zero return code from line: $line -- $@"
exit 1
}
a=0
let a++ || error_exit "$LINENO" "let operation returned non 0 code"
echo "you will never see me"
# run it, now we have useful debugging output
$ bash foo.sh
ERROR: non zero return code from line: 9 -- let operation returned non 0 code
Solche Skriptschreibweisen zwingen dazu, das Verhalten aller Befehle im Skript sorgfältiger zu überwachen und die Möglichkeit von Fehlern vorauszusehen, bevor sie zu einer Überraschung werden.
Verwenden Sie ShellCheck zur Fehlererkennung während der Entwicklung.
Es lohnt sich, etwas wie in Ihre Entwicklungs- und Test-Pipelines, um Ihren Bash-Code auf die besten Praktiken zu überprüfen.
Ich verwende es in meinen lokalen Entwicklungsumgebungen, um Berichte über Syntax, Semantik und einige Fehler im Code zu erhalten, die ich während der Entwicklung möglicherweise übersehen habe. Es ist ein statisches Analysewerkzeug für Ihre Bash-Skripte, und ich kann es nur empfehlen.
Verwendung eigener Exit-Codes
Rückgabewerte in POSIX sind nicht nur null oder eins, sondern null oder ein nicht nullwertiges Ergebnis. Nutzen Sie diese Möglichkeiten, um benutzerdefinierte Fehlermeldungscodes (zwischen 201-254) für verschiedene Fehlerszenarien zurückzugeben.
Diese Informationen können dann von anderen Skripten verwendet werden, die Ihr Skript kapseln, um genau zu verstehen, welche Art von Fehler aufgetreten ist, und entsprechend zu reagieren:
#!/usr/bin/env bash
SUCCESS=0
FILE_NOT_FOUND=240
DOWNLOAD_FAILED=241
function read_file() {
if ${file_not_found}; then
return ${FILE_NOT_FOUND}
fi
}
Hinweis: Bitte seien Sie besonders vorsichtig mit den von Ihnen definierten Variablennamen, um ein versehentliches Überschreiben von Umgebungsvariablen zu vermeiden.
Logger-Funktionen
Eine ansprechende und strukturierte Protokollführung ist entscheidend, um die Ergebnisse des Skriptablaufs leicht zu verstehen. Wie in anderen Hochsprachen verwende ich in meinen Bash-Skripten stets eigene Logging-Funktionen, wie zum Beispiel __msg_info, __msg_error usw.
Das hilft dabei, eine standardisierte Struktur der Protokollführung zu gewährleisten, indem Änderungen an nur einer Stelle vorgenommen werden:
#!/usr/bin/env bash
function __msg_error() {
[[ "${ERROR}" == "1" ]] && echo -e "[ERROR]: $*"
}
function __msg_debug() {
[[ "${DEBUG}" == "1" ]] && echo -e "[DEBUG]: $*"
}
function __msg_info() {
[[ "${INFO}" == "1" ]] && echo -e "[INFO]: $*"
}
__msg_error "File could not be found. Cannot proceed"
__msg_debug "Starting script execution with 276MB of available RAM"
In der Regel versuche ich, in meinen Skripten einen Mechanismus zu haben __init, in dem solche Logger-Variablen und andere systemweite Variablen initialisiert oder auf Standardwerte gesetzt werden. Diese Variablen können auch über die Kommandozeilenparameter beim Aufruf des Skripts gesetzt werden.
Zum Beispiel so etwas wie:
$ ./run-script.sh --debug
Wenn ein solches Skript ausgeführt wird, ist sichergestellt, dass die allgemeinen Systemeinstellungen auf Standardwerte gesetzt werden, wenn dies erforderlich ist, oder zumindest mit passenden Werten initialisiert werden, wenn dies notwendig ist.
Ich treffe in der Regel eine Entscheidung darüber, was initialisiert werden soll und was nicht, basierend auf einem Kompromiss zwischen Benutzeroberfläche und den Konfigurationsdetails, in die der Benutzer einsteigen kann/soll.
Architektur für Wiederverwendbarkeit und einen sauberen Systemzustand
Modularer / wiederverwendbarer Code
├── framework
│ ├── common
│ │ ├── loggers.sh
│ │ ├── mail_reports.sh
│ │ └── slack_reports.sh
│ └── daily_database_operation.sh
Ich halte ein separates Repository, das zur Initialisierung eines neuen Projekts/ eines Bash-Skripts, das ich entwickeln möchte, verwendet werden kann. Alles, was wiederverwendet werden kann, sollte im Repository gespeichert werden und kann in anderen Projekten, die solche Funktionen nutzen möchten, abgerufen werden. Eine solche Struktur der Projekte reduziert erheblich die Größe anderer Skripte und stellt auch sicher, dass die Codebasis klein und leicht testbar ist.
Wie im oben genannten Beispiel enthalten sind alle Logging-Funktionen, wie __msg_info, __msg_error und andere, wie z.B. Slack-Berichte, separat in common/* und werden dynamisch in anderen Skripten, wie daily_database_operation.sh.
Hinterlassen Sie ein sauberes System
Wenn Sie während der Ausführung eines Skripts Ressourcen hochladen, empfiehlt es sich, alle Daten in einem allgemeinen Verzeichnis mit einem zufälligen Namen zu speichern, zum Beispiel /tmp/AlRhYbD97/*. Sie können Zufallstextgeneratoren verwenden, um den Verzeichnisnamen auszuwählen:
rand_dir_name="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)"
Nach Abschluss der Arbeit kann die Reinigung solcher Verzeichnisse in den oben besprochenen Trap-Handlern gewährleistet werden. Wenn Sie sich nicht um die temporären Verzeichnisse kümmern, sammeln sie sich an und führen irgendwann zu unerwarteten Problemen auf dem Host, wie zum Beispiel einem vollen Datenträger.
Verwendung von Lock-Dateien
Häufig muss sichergestellt werden, dass nur eine Instanz eines Skripts zu einem bestimmten Zeitpunkt auf dem Host ausgeführt wird. Dies kann mit Lock-Dateien erreicht werden.
Ich erstelle normalerweise Lock-Dateien in /tmp/project_name/*.lock und überprüfe deren Vorhandensein zu Beginn des Skripts. Dies hilft, das Skript ordnungsgemäß zu beenden und unerwartete Systemzustandsänderungen durch ein parallel laufendes anderes Skript zu vermeiden. Lock-Dateien sind nicht erforderlich, wenn das gleiche Skript parallel auf diesem Host ausgeführt werden soll.
Messen und verbessern
Wir müssen oft mit Szenarien arbeiten, die über einen längeren Zeitraum ausgeführt werden, wie zum Beispiel tägliche Datenbankoperationen. Solche Vorgänge umfassen in der Regel eine Abfolge von Schritten: Daten laden, Anomalien überprüfen, Daten importieren, Statusberichte senden usw.
In solchen Fällen versuche ich immer, das Skript in einzelne kleinere Skripte zu unterteilen und über ihren Status und die Ausführungszeit mithilfe von:
time source "${filepath}" "${args}" >> "${LOG_DIR}/RUN_LOG" 2>&1
Später kann ich die Ausführungszeit mit folgendem Befehl überprüfen:
tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"
Das hilft mir, problematische/langsame Bereiche in den Skripten zu identifizieren, die optimiert werden müssen.
Viel Erfolg!
Weitere Leseempfehlungen:
Quelle: habr.com
