2 points par GN⁺ 2024-03-03 | 1 commentaires | Partager sur WhatsApp
  • Quand un script Bash ne se comporte pas comme prévu, voir telles quelles les commandes exécutées suffit souvent à faciliter la recherche de la cause
  • set -x affiche chaque ligne après expansion des variables, ce qui permet de vérifier quelles commandes le script exécute réellement
  • Lancer bash -x script.sh en ligne de commande produit le même effet que mettre set -x en haut de script.sh
  • En combinant le trap DEBUG avec read, on peut s’arrêter avant l’exécution de chaque ligne pour vérifier le nom du fichier, le numéro de ligne et la prochaine commande
  • La fonction die() { echo $1 >&2; exit 1; }, ajoutée après une commande qui échoue, simplifie le flux consistant à afficher un message sur la sortie d’erreur standard puis quitter

Vérifier visuellement le flux d’exécution

  • set -x affiche les lignes exécutées par le script, avec les variables remplacées par leurs valeurs développées
  • On peut l’utiliser en ajoutant set -x en haut du script
  • Le même comportement peut aussi être activé depuis la ligne de commande
    • $ bash -x script.sh
    • C’est équivalent à mettre set -x tout en haut de script.sh

S’arrêter à chaque ligne pour vérifier

  • Le trap DEBUG s’exécute avant chaque ligne de code
  • En plaçant le code ci-dessous au début du script, l’exécution attend une pression sur Entrée avant de lancer la commande suivante
    • trap '(read -p "\[$BASH_SOURCE: $LINENO] $BASH_COMMAND")' DEBUG
    • read -p affiche un message et attend une pression sur Entrée
    • $BASH_SOURCE correspond au nom du fichier de script
    • $LINENO correspond au numéro de ligne
    • $BASH_COMMAND correspond à la commande qui sera exécutée ensuite

Laisser un message puis quitter en cas d’échec

  • La fonction die peut servir à afficher un message et terminer le programme lorsqu’une commande échoue
    • die() { echo $1 >&2; exit 1; }
    • Il suffit de l’ajouter après une commande susceptible d’échouer, par exemple some_command || die "oh no!"
  • Cette fonction envoie le message vers l’erreur standard et termine avec exit 1

1 commentaires

 
GN⁺ 2024-03-03
Avis sur Hacker News
  • Dans ZFSBootMenu, quelques fonctions maison assez pratiques sont utilisées pour aider au débogage
    On trouve une fonction de journalisation zdebug un peu partout dans le code : https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme...
    Avec la journalisation de débogage activée, appuyer sur Ctrl-T dans le menu principal affiche ce type d’écran : https://i.imgur.com/Ge75zkP.png
    Il y a aussi du profilage par flamegraph, activable via https://github.com/zbm-dev/zfsbootmenu/blob/master/zfsbootme... ; en réassemblant les données vidées via le port série, on peut produire un graphe comme https://raw.githubusercontent.com/zbm-dev/zfsbootmenu/master...
    Bash est étonnamment flexible
  • Quand on utilise set -x, PS4='+ ${BASH_SOURCE:-}:${FUNCNAME[0]:-}:L${LINENO:-}: ' est très utile
    Cela affiche le nom du fichier, le nom de la fonction et le numéro de ligne, ce qui aide pas mal pour déboguer de gros scripts Bash
  • Je recommande aussi shellcheck. Même s’il ne détecte pas directement le problème, il signale des problèmes potentiels
    Je recommande également de réécrire les scripts dans un autre langage. Dans mon entreprise, nous remplaçons des scripts Bash par du Rust ; le coût d’entrée est plus élevé, mais le code obtenu est bien plus facile à maintenir et plus fiable
    Bash reste très bien pour les scripts rapides, mais au-delà d’une centaine de lignes, il vaut la peine d’utiliser un langage offrant des garanties plus fortes
    • Je suis d’accord, mais il faudrait aussi tenir ce discours à l’ingénierie CI/CD et aux pipelines YAML
  • On peut améliorer davantage le débogage en utilisant les codes de sortie de cette façon
    die() est une fonction utilitaire qui affiche un message d’erreur sur la sortie d’erreur standard, puis quitte avec le code d’erreur indiqué
    On trouve davantage de codes de sortie de scripts shell et de fonctions utilitaires ici : https://github.com/SixArm/unix-shell-script-kit/blob/main/un...
    • Bonne liste, et j’imagine que tout utilisateur expérimenté a ses propres fonctions utilitaires
      Cela dit, la philosophie de ce die me laisse un peu sur ma faim. Une fonction die devrait, par défaut, transmettre le code de sortie de la commande qui a échoué, et ne pas masquer non plus la sortie d’erreur de cette commande
      Dans un gros script, si je veux donner moi-même un sens particulier à l’échec d’une commande, j’utiliserai un die plus spécialisé. Mon die ressemble en gros à __errex "$?" "${LINENO}" "$0", qui affiche l’erreur fatale, le numéro de ligne, le nom du script et le message, puis termine avec ce code de sortie
  • Si vous utilisez beaucoup de fonctions Bash, il est aussi possible de produire une sorte de trace de pile
    Un exemple d’implémentation se trouve ici : https://github.com/TritonDataCenter/sdc-headnode/blob/master...
    • Voici une autre implémentation de trace de pile : https://github.com/runag/runag/blob/main/lib/fail.sh
      En l’utilisant comme some-command || fail "message", si some-command renvoie un état de sortie non nul, cela produit une trace de pile et quitte le shell
      Si vous voulez produire une trace de pile depuis une fonction puis retourner, vous pouvez faire some-command || softfail "message" || return $?
  • Je me demande s’il y a d’autres raisons que l’inertie du legacy pour lesquelles Bash reste de fait le langage de scripting shell
    Il permet de faire ce dont on a besoin, mais il est rugueux et sa syntaxe est atroce. Quand un script atteint une certaine taille ou complexité, il vous force à passer à un vrai langage ; peut-être que c’est même une conception voulue
    • L’usage historique représente certainement une grande part de sa popularité
      Sur une distribution moderne, on a généralement une version assez récente de Bash, et tant qu’on ne cherche pas à utiliser des choses comme les tableaux, il n’est pas vraiment nécessaire de se soucier de la version
      L’attrait de Bash tient à la place qu’il occupe entre les autres langages et les outils. Il est idéal pour enchaîner d’autres outils, assez proche du système d’exploitation pour être pratique, et il n’exige pas d’installer des bibliothèques comme Python
      On entend souvent dire qu’il faut migrer les scripts plus complexes vers un langage comme Python, mais cela ajoute une couche de complexité qui peut ne pas aider sur le long terme. Un script Bash écrit il y a 20 ans tourne encore très bien, alors qu’un programme Python vieux de 20 ans risque d’avoir des problèmes de version
    • Le scripting shell Bourne est suffisamment correct pour être presque impossible à remplacer
      Le rc de Plan 9 est un peu plus propre, mais personne ne migre simplement pour quelque chose de « similaire, mais plus propre ». On peut dès maintenant installer quelque chose de similaire mais meilleur depuis https://pkgsrc.se/shells, et pourtant on ne l’utilise pas, et cela ne change pas non plus la façon dont les autres l’exécutent
      Pour remplacer une technologie bien établie, il faut être plusieurs fois meilleur sur des aspects essentiels. Plan 9 est lui aussi meilleur que les systèmes de la famille UNIX, mais pas suffisamment pour les remplacer
      Il est difficile de créer quelque chose d’assez bon pour remplacer la niche du scripting shell Bourne. Avant d’atteindre ce niveau, on bascule déjà dans la niche écologique ou le domaine de problèmes de vrais langages de script comme Perl, Python ou Ruby
      Dans un domaine étroit, un optimum local aspire tout l’air, ce qui rend difficile l’émergence d’un concurrent proche de l’optimum global théorique
    • Je pense vraiment que c’est à cause du legacy et de l’inertie

Les ajouts récents se sont bien greffés sur sh/Bash, mais au fond, le scripting shell reste un moyen d’arriver à ses fins et doit évoluer bien plus lentement qu’un langage de programmation généraliste
La caractéristique essentielle de Bash/sh, c’est son côté anti-entropique. Il y a très peu de développement ou d’évolution, donc peu de chances d’avoir des ennuis à cause de dépendances ou de nouvelles fonctionnalités, et ce qui fonctionnait il y a 20 ans reste dans les outils de base
Par conception, cela en fait un système réticent au changement, et quand on en atteint les limites, cela incite les gens à aller voir ailleurs

  • Pas sûr que ce soit vraiment Bash
    La plupart des scripts de FreeBSD sont écrits pour sh, et comme sh fait partie de la norme POSIX, j’ai l’impression qu’il est bien plus largement pris en charge. Bash me semble simplement assez populaire
  • Le fait qu’il soit partout compte beaucoup
    Mais Bash est tellement mauvais que j’ai créé tout un tas d’utilitaires avec des espaces de noms raccourcis pour pouvoir écrire des scripts Groovy. On peut développer avec un IDE, le système de bibliothèques est sûr, et Groovy gomme presque toutes les lourdeurs de Java, donc c’était bien mieux
  • Il existe aussi un vrai débogueur assez puissant, dans le style de gdb : https://bashdb.sourceforge.net/
  • Petite autopromo vaguement liée : j’avais créé à l’époque un débogueur de pipelines Bash qui conserve les sorties intermédiaires
    Il a quelques limites, mais peut généralement être utile : https://github.com/ketancmaheshwari/pd
  • La technique die() est bonne, mais Bash a une caractéristique agaçante. Si on tente de faire un exit dans un sous-shell, seul le sous-shell se termine et le reste du script continue à s’exécuter
    Par exemple, si on appelle die dans un pipeline comme cat myfile | while read line; do ... die "Found match" ... done, le echo "I don't want this line" qui suit sera quand même affiché
    On peut souvent éviter les sous-shells, et dans cet exemple shellcheck a raison de signaler l’UUOC ; le corriger règle aussi le problème de die dans le sous-shell
    Mais parfois, on ne peut pas éviter les sous-shells, ou les éviter rendrait le script trop complexe. Dans ce cas, on peut capturer le PID au début du script avec MYPID=$$, puis tuer le script avec quelque chose comme die() { echo "$1" >&2; kill -9 $MYPID; exit 1; }
    Bien sûr, cela reste un compromis. Cette manière de tuer le processus est assez brutale et, sans que je sache pourquoi, elle ne s’est pas non plus révélée parfaitement fiable
    • Il suffit d’ajouter set -e pour que le script se termine aussi quand le sous-shell sort avec un code d’erreur non nul
      J’ai du mal à imaginer une bonne raison de ne pas mettre set -e dans n’importe quel script shell
    • En tuant ce PID de cette façon, ne risque-t-on pas de créer des processus zombies ?
  • En tête de mes scripts Bash, je mets toujours set -euxo pipefail
    Cela rend les tests conditionnels un peu plus difficiles, mais pipefail à lui seul s’est déjà rentabilisé plusieurs fois
    • https://mywiki.wooledge.org/BashPitfalls#set_-euo_pipefail
    • On peut aussi l’activer sur plusieurs lignes puis le désactiver avec set +x
      Le laisser activé en permanence devient assez vite fastidieux
    • C’est un réglage qui sauve la vie
      En revanche, je garde -x pour les moments où j’ai vraiment besoin de voir toute la sortie de débogage bien verbeuse