
Il debug degli script bash è come cercare un ago in un pagliaio, soprattutto quando nuove aggiunte compaiono in una base di codice esistente senza un'adeguata revisione della struttura, del logging e dell'affidabilità. In tali situazioni, è facile incorrere sia in errori personali che nella gestione di complessi grovigli di script.
Team Ho tradotto un articolo con raccomandazioni che ti aiuteranno a scrivere, debuggare e mantenere meglio i tuoi script. Vuoi crederci o no, ma nulla può eguagliare la soddisfazione di scrivere codice bash pulito, pronto all'uso, che funziona ogni volta.
Nell'articolo, l'autore condivide ciò che ha appreso negli ultimi anni, oltre ad alcuni errori comuni che lo hanno colto di sorpresa. Questo è importante, perché ogni sviluppatore di software nella propria carriera si trova, prima o poi, a lavorare con script per automatizzare compiti lavorativi di routine.
Gestori di segnali
La maggior parte degli script bash con cui ho avuto a che fare non ha mai utilizzato un meccanismo di pulizia efficace quando durante l'esecuzione dello script accade qualcosa di imprevisto.
Le sorprese possono provenire dall'esterno, ad esempio ricevendo un segnale dal kernel. Gestire tali situazioni è estremamente importante per garantire che gli script siano sufficientemente affidabili da essere eseguiti in sistemi di produzione. Spesso utilizzo gestori di uscita per rispondere a tali scenari:
function handle_exit() {
// Aggiungi qui il codice di pulizia
// per esempio. rm -f "/tmp/${lock_file}.lock"
// esci con un codice di stato appropriato
}
// trap
trap handle_exit 0 SIGHUP SIGINT SIGQUIT SIGABRT SIGTERM
trap è un comando incorporato nella shell che ti aiuta a registrare una funzione di pulizia che viene chiamata in caso di segnali. Tuttavia, è necessario prestare particolare attenzione a gestori come SIGINT, che provoca l'interruzione dello script.
Inoltre, nella maggior parte dei casi è opportuno catturare solo EXIT, ma l'idea è che puoi effettivamente personalizzare il comportamento dello script per ogni singolo segnale.
Funzioni incorporate set - terminazione rapida in caso di errore
È molto importante reagire agli errori non appena si presentano e fermare rapidamente l'esecuzione. Niente è peggio che continuare l'esecuzione di un comando come questo:
rm -rf ${directory_name}/*
Nota che la variabile directory_name non è definita.
Per gestire tali scenari è importante utilizzare funzioni integrate set, come set -o errexit, set -o pipefail o set -o nounset all'inizio dello script. Queste funzioni garantiscono che il tuo script si interrompa non appena incontra un codice di uscita diverso da zero, l'uso di variabili non definite, comandi errati passati attraverso una pipe e così via:
#!/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
Nota: funzioni integrate, come set -o errexit, usciranno dallo script non appena si presenta un codice di ritorno "non gestito" (diverso da zero). È quindi meglio implementare una gestione degli errori personalizzata, ad esempio:
#!/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
Scrivere in questo modo costringe a prestare maggiore attenzione al comportamento di tutti i comandi nello script e a prevedere la possibilità di errori prima che si verifichino inattesi.
ShellCheck per individuare errori durante lo sviluppo
È consigliabile integrare qualcosa come nei tuoi pipeline di sviluppo e test per controllare il tuo codice bash rispetto alle migliori pratiche.
Lo utilizzo nei miei ambienti di sviluppo locali per ricevere rapporti su sintassi, semantica e alcuni errori nel codice che potrei aver trascurato durante lo sviluppo. È uno strumento di analisi statica per i tuoi script bash e lo consiglio vivamente.
Utilizzo dei propri codici di uscita
I codici di ritorno in POSIX non sono semplicemente zero o uno, ma zero o un valore diverso da zero. Usa queste opportunità per restituire codici di errore personalizzati (tra 201-254) per vari casi di errore.
Queste informazioni possono poi essere utilizzate da altri script che avvolgono il tuo per comprendere esattamente che tipo di errore si è verificato e reagire di conseguenza:
#!/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
}
Nota: Per favore, fai particolare attenzione ai nomi delle variabili che definisci per evitare di sovrascrivere accidentalmente le variabili d'ambiente.
Funzioni di logging
Una registrazione log ben strutturata è importante per comprendere facilmente i risultati dell'esecuzione dello script. Come in altri linguaggi di programmazione di alto livello, utilizzo sempre le mie funzioni di registrazione in script bash, come __msg_info, __msg_error e così via.
Questo aiuta a garantire una struttura standardizzata per la registrazione, apportando modifiche in un solo luogo:
#!/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"
Di solito cerco di avere qualche meccanismo nei miei script __init, dove tali variabili di registrazione e altre variabili di sistema vengono inizializzate o impostate ai valori predefiniti. Queste variabili possono anche essere impostate dai parametri della riga di comando durante la chiamata dello script.
Ad esempio, qualcosa come:
$ ./run-script.sh --debug
Quando uno script del genere viene eseguito, è garantito che le impostazioni di sistema siano impostate sui valori predefiniti, se obbligatorie, o almeno inizializzate con qualcosa di appropriato, se necessario.
Di solito baso la mia scelta su cosa inizializzare e cosa no, su un compromesso tra l'interfaccia utente e i dettagli delle configurazioni in cui l'utente può/deve immergersi.
Architettura per il riutilizzo e uno stato pulito del sistema
Codice modulare / riutilizzabile
├── framework
│ ├── common
│ │ ├── loggers.sh
│ │ ├── mail_reports.sh
│ │ └── slack_reports.sh
│ └── daily_database_operation.sh
Tengo un repository separato che può essere utilizzato per inizializzare un nuovo progetto/script bash che desidero sviluppare. Tutto ciò che può essere riutilizzato può essere salvato nel repository e ottenuto in altri progetti che vogliono utilizzare tali funzionalità. Questa organizzazione dei progetti riduce significativamente le dimensioni di altri script, garantendo anche che la base di codice sia piccola e facilmente testabile.
Come nell'esempio sopra, tutte le funzioni di registrazione, come __msg_info, __msg_error e altre, come i report di Slack, sono contenute separatamente in common/* e sono collegate dinamicamente in altri scenari, come daily_database_operation.sh.
Lascia un sistema pulito
Se carichi alcune risorse durante l'esecuzione dello script, è consigliabile memorizzare tutti questi dati in una cartella comune con un nome casuale, ad esempio /tmp/AlRhYbD97/*. Puoi utilizzare generatori di testo casuale per scegliere un nome di directory:
rand_dir_name="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)"
Dopo il completamento delle operazioni, la pulizia di tali directory può essere garantita nei gestori di trap, come discusso sopra. Se non si presta attenzione alla rimozione delle directory temporanee, si accumuleranno, e a un certo punto causeranno problemi imprevisti sull'host, come un disco pieno.
Utilizzo di file di lock
Spesso è necessario garantire l'esecuzione di un solo esemplare di uno script sull'host in qualsiasi momento. Questo può essere fatto usando file di lock.
Di solito creo file di lock in /tmp/project_name/*.lock e verifico la loro presenza all'inizio dello script. Questo aiuta a concludere correttamente il lavoro dello script ed evitare cambiamenti imprevisti nello stato del sistema da parte di un altro script che gira in parallelo. I file di lock non sono necessari se desideri che lo stesso script venga eseguito in parallelo su questo host.
Misurare e migliorare
Spesso dobbiamo lavorare con script che vengono eseguiti per lunghi periodi di tempo, ad esempio operazioni giornaliere con i database. Queste operazioni di solito includono una sequenza di passaggi: caricamento dei dati, verifica di anomalie, importazione dei dati, invio di report di stato e così via.
In questi casi cerco sempre di suddividere lo script in piccoli script separati e comunicare il loro stato e il loro tempo di esecuzione tramite:
time source "${filepath}" "${args}" >> "${LOG_DIR}/RUN_LOG" 2>&1
Successivamente posso controllare il tempo di esecuzione con:
tac "${LOG_DIR}/RUN_LOG.txt" | grep -m1 "real"
Questo mi aiuta a identificare le aree problematiche/lente negli script che necessitano di ottimizzazione.
Buona fortuna!
Cosa leggere ancora:
Fonte: habr.com
