Bash strict mode : des scripts fiables sans pièges cachés
Adoptez le strict mode Bash avec set -euo pipefail : erreurs détectées tôt, exceptions maîtrisées et scripts plus sûrs en production.
Pourquoi un script Bash peut échouer sans vous prévenir
Bash est un excellent langage pour relier des commandes système, automatiser une sauvegarde, déployer un fichier de configuration ou traiter des journaux. Mais son comportement par défaut peut surprendre : une commande qui échoue n’arrête pas nécessairement le script, une variable mal orthographiée peut être remplacée par une chaîne vide et l’échec d’une commande située au début d’un pipeline peut passer inaperçu.
Ce ne sont pas des défauts : Bash est conçu pour un usage interactif autant que pour l’écriture de scripts. Lorsqu’on travaille au terminal, il est souvent utile de pouvoir lancer une commande suivante après un échec. Dans un script d’automatisation exécuté par cron, par un timer systemd ou dans une chaîne de déploiement, cette souplesse devient en revanche un risque.
Prenons un script de sauvegarde très simple :
#!/usr/bin/env bash
rsync -a /srv/application/ /backup/application/
echo "Sauvegarde terminée"
Si rsync échoue parce que le volume de destination n’est plus monté, Bash exécute tout de même le echo final. Un outil de supervision qui surveille uniquement le code de retour du script peut alors considérer l’opération comme réussie. Le message affiché est lui aussi trompeur.
Un deuxième piège fréquent concerne les variables :
#!/usr/bin/env bash
destination="/backup/application"
rm -rf "$destinaton"
La variable utilisée contient une faute de frappe : destinaton au lieu de destination. Sans option particulière, Bash développe une variable non définie en chaîne vide. Dans cet exemple précis, la commande devient rm -rf "" ; son effet dépend ensuite du programme et de son contexte. Le résultat n’est peut-être pas destructeur, mais l’erreur de programmation n’est pas détectée là où elle apparaît.
Les pipelines ont leur propre subtilité. Considérez :
grep "ERREUR" /var/log/application.log | wc -l
Si le fichier n’existe pas, grep échoue. Pourtant, wc -l reçoit simplement une entrée vide, produit 0 et se termine correctement. Par défaut, le statut de sortie du pipeline est celui de sa dernière commande : ici, celui de wc. Le script peut donc conclure à tort qu’aucune erreur n’a été trouvée.
Le « strict mode » répond à ces cas usuels. Il ne transforme pas Bash en langage typé et ne remplace ni les tests ni la relecture. Il établit toutefois une base plus exigeante : un échec imprévu doit être visible, une variable manquante doit être signalée et un pipeline doit échouer si l’une de ses étapes échoue.
Le strict mode Bash : une convention, pas un interrupteur magique
L’expression strict mode désigne généralement cette ligne placée au début d’un script :
set -euo pipefail
Il ne s’agit pas d’un mode unique fourni par Bash sous ce nom. C’est une combinaison de trois options, très répandue dans les scripts d’administration. Vous pouvez aussi l’écrire sous une forme plus explicite :
set -e
set -u
set -o pipefail
Les deux écritures activent les mêmes options. La forme compacte est pratique quand l’équipe connaît la convention ; la forme développée est parfois plus facile à découvrir pour une personne qui maintient le script plusieurs mois plus tard.
Ce réglage doit être activé dès le début, après le shebang. Si le script utilise Bash, indiquez-le clairement :
#!/usr/bin/env bash
set -euo pipefail
Ne lancez pas ce fichier avec sh mon-script.sh. Selon le système, sh peut pointer vers un interpréteur différent de Bash, tel que dash. L’option pipefail, notamment, n’est pas imposée par POSIX. Rendez le script exécutable, puis utilisez ./mon-script.sh, ou invoquez explicitement bash mon-script.sh.
Le strict mode s’intègre bien avec les règles de base présentées dans notre guide sur ShellCheck pour fiabiliser les scripts Bash. ShellCheck peut notamment signaler des expansions non protégées, des tests fragiles et plusieurs erreurs fréquentes que les options de Bash ne détectent pas.
Comprendre set -e : arrêter le script sur un échec inattendu
L’option -e, également accessible avec set -o errexit, demande à Bash de quitter lorsqu’une commande retourne un statut différent de zéro dans certains contextes. Par convention Unix, un statut nul indique le succès ; une valeur non nulle indique une condition d’échec ou un résultat négatif.
Dans un script linéaire, le bénéfice est immédiat :
set -e
mkdir -p "$destination"
rsync -a --delete "$source/" "$destination/"
printf '%s\n' "Synchronisation terminée"
Si rsync retourne un statut d’erreur, la dernière ligne n’est pas exécutée. Le script renvoie également un statut non nul à son appelant, à condition qu’aucune autre logique ne le masque.
Mais -e possède des exceptions importantes. Bash ne quitte pas automatiquement lorsque le statut non nul est utilisé dans une construction où l’échec est attendu et doit être interprété : condition d’un if, partie gauche de && ou ||, commandes précédentes d’un pipeline, ou négation avec !. C’est généralement utile :
if grep -q "maintenance=true" "$config"; then
printf '%s\n' "Mode maintenance actif"
fi
Ici, l’absence de la chaîne recherchée n’est pas nécessairement une anomalie : c’est une réponse possible à une question. Bash ne doit donc pas interrompre le script pour ce seul motif.
La conséquence pratique est simple : n’utilisez pas set -e comme substitut à la logique métier. Toute commande dont le statut a une signification attendue doit figurer dans un test explicite. Toute commande qui doit impérativement réussir peut rester seule sur sa ligne, afin que son échec stoppe le traitement.
Les règles exactes de errexit sont plus nuancées dans les fonctions, les substitutions de commandes et les constructions composées. Pour les détails de référence, consultez la documentation officielle de Bash. En pratique, conserver des fonctions courtes et des conditions explicites évite la plupart des comportements difficiles à relire.
Comprendre set -u : refuser les variables oubliées
L’option -u, ou nounset, provoque une erreur lorsqu’une expansion référence une variable non définie. Elle attrape très tôt les fautes de frappe, les paramètres requis absents et les valeurs d’environnement supposées présentes sans l’être.
Voici un cas classique :
set -u
archive_dir="${ARCHIVE_DIR}"
tar -czf "$archive_dir/site.tar.gz" /srv/site
Si ARCHIVE_DIR n’a pas été exportée ou définie avant l’exécution, le script s’arrête au lieu de créer un chemin imprévu. C’est particulièrement utile avec les scripts appelés par cron ou systemd : leur environnement est souvent plus réduit que celui de votre session interactive.
Pour un paramètre obligatoire, utilisez une expansion accompagnée d’un message :
source_dir="${1:?Indiquez le répertoire source en premier argument}"
destination_dir="${2:?Indiquez le répertoire de destination en second argument}"
Si l’argument est absent ou vide, Bash affiche le texte fourni et interrompt l’exécution. Le contrat du script apparaît directement dans le code.
À l’inverse, une variable peut être optionnelle. Il faut alors exprimer sa valeur par défaut au lieu de désactiver -u :
retention_days="${RETENTION_DAYS:-30}"
verbose="${VERBOSE:-0}"
La syntaxe :- utilise la valeur indiquée si la variable est absente ou vide. Si vous souhaitez appliquer la valeur par défaut uniquement lorsque la variable est absente, mais conserver une valeur vide fournie volontairement, utilisez ${VARIABLE-valeur} sans les deux-points.
Les tableaux demandent aussi de l’attention sous nounset. Avant d’accéder à un élément qui peut ne pas exister, testez-le ou initialisez clairement le tableau. La même discipline vaut pour les paramètres positionnels : $1 n’est pas automatiquement disponible parce que le script « devrait » recevoir un argument.
Comprendre pipefail : ne plus perdre l’échec dans un pipeline
Avec set -o pipefail, le statut du pipeline devient non nul si au moins une commande échoue. Sans cette option, seul le statut de la dernière commande est conservé.
Reprenons l’exemple de journal :
set -o pipefail
grep "ERREUR" /var/log/application.log | wc -l
Si grep ne peut pas lire le fichier, le pipeline échoue, même si wc se termine correctement. Combiné à set -e, le script s’arrête alors au bon endroit.
Cette option est précieuse dans les chaînes de traitement, par exemple lors d’un téléchargement contrôlé :
curl --fail --silent --show-error "https://example.org/export.json" | jq -e '.items[] | select(.enabled == true)'
curl --fail retourne un échec pour les réponses HTTP d’erreur ; jq -e peut également renvoyer un statut non nul selon le résultat du filtre. Avec pipefail, un problème de téléchargement n’est pas confondu avec un traitement JSON valide mais vide. Pour mieux maîtriser les codes de retour et les options de cet outil, consultez notre article sur curl en ligne de commande.
Attention toutefois : un pipeline peut contenir une commande qui s’arrête volontairement avant la fin de son entrée. C’est le cas de certains usages avec head. La commande située en amont peut alors recevoir un signal lorsqu’elle continue d’écrire, ce qui peut rendre le pipeline non nul avec pipefail. Ce n’est pas une raison pour retirer l’option globalement ; c’est un signal pour écrire ce cas particulier de façon plus explicite, par exemple en évitant le pipeline si une autre construction est plus lisible.
Lorsqu’il faut diagnostiquer un pipeline, Bash expose les statuts individuels dans le tableau PIPESTATUS, juste après son exécution. C’est utile pour un débogage ponctuel, mais ne devrait pas servir à cacher indistinctement les erreurs de toutes les étapes.
Gérer les exceptions légitimes sans désactiver la sécurité
Un script robuste ne considère pas tous les statuts non nuls comme des incidents fatals. grep retourne 1 lorsqu’il ne trouve aucune correspondance, diff retourne 1 lorsque les fichiers diffèrent, et une ressource absente peut être une situation attendue. Le bon réflexe n’est pas d’écrire || true par défaut : cette forme masque aussi les vrais problèmes, comme une erreur de permission ou un fichier illisible.
Préférez un test qui documente l’intention :
if grep -q "feature_x=enabled" "$config_file"; then
enable_feature_x
else
printf '%s\n' "Feature X désactivée"
fi
Pour vérifier l’existence d’un fichier avant de le supprimer :
if [ -f "$temporary_file" ]; then
rm -- "$temporary_file"
fi
Le séparateur -- indique à de nombreux programmes que les arguments suivants ne sont plus des options. Il est utile lorsque les noms de fichiers peuvent commencer par un tiret. Les guillemets autour des variables restent indispensables : ils empêchent le découpage inattendu sur les espaces et l’expansion de motifs. Ces fondamentaux complètent les mécanismes expliqués dans notre guide sur stdin, stdout, stderr et les redirections Bash.
Quand un échec est acceptable mais mérite d’être signalé, gérez-le localement :
if ! rm -- "$cache_file"; then
printf '%s\n' "Impossible de supprimer le cache : $cache_file" >&2
fi
Cette écriture ne prétend pas que tout va bien. Elle rend la décision visible : l’échec de suppression est toléré, mais il est remonté sur la sortie d’erreur. Si l’opération est critique, terminez plutôt avec exit 1 ou laissez la commande échouer hors d’une condition.
Évitez aussi de désactiver puis réactiver set -e au milieu du script avec set +e et set -e. Cette pratique rend le comportement dépendant de l’ordre d’exécution et facilite les oublis. Un if, un case ou une fonction qui retourne explicitement un statut est plus facile à auditer.
Un squelette Bash réutilisable pour les tâches de production
Le strict mode est plus efficace lorsqu’il s’accompagne d’un petit cadre stable : vérification des arguments, création des répertoires nécessaires, messages envoyés au bon flux et nettoyage des fichiers temporaires. Voici un squelette volontairement sobre :
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf '%s\n' "Usage : $0 SOURCE DESTINATION" >&2
}
if [ "$#" -ne 2 ]; then
usage
exit 2
fi
source_dir="$1"
destination_dir="$2"
if [ ! -d "$source_dir" ]; then
printf '%s\n' "Répertoire source introuvable : $source_dir" >&2
exit 1
fi
mkdir -p -- "$destination_dir"
rsync -a --delete -- "$source_dir/" "$destination_dir/"
printf '%s\n' "Synchronisation terminée"
Ce modèle ne fait pas de promesse irréaliste : il ne garantit pas qu’une synchronisation soit adaptée à votre politique de sauvegarde, ni que la destination soit le bon volume. En revanche, il refuse une invocation incomplète, vérifie la source, laisse remonter un échec de rsync et ne confirme la réussite qu’après la commande principale.
Si votre script crée un fichier temporaire, utilisez mktemp plutôt qu’un nom fixe, puis prévoyez un nettoyage avec trap :
temporary_file="$(mktemp)"
trap 'rm -f -- "$temporary_file"' EXIT
Le piège EXIT exécute la commande de nettoyage à la fin du script, y compris lors d’une sortie provoquée par set -e. Gardez la commande du trap simple et sûre : un nettoyage ne doit pas masquer le problème initial ni supprimer un chemin construit de manière ambiguë.
Pour les scripts plus importants, ajoutez également une fonction de journalisation, des codes de sortie documentés et un contrôle préalable des dépendances. Par exemple, command -v rsync permet de vérifier que la commande est disponible avant de lancer la tâche. Testez ensuite le script dans un répertoire temporaire, avec un chemin contenant des espaces, une source absente et une destination non accessible.
Les limites réelles du strict mode
set -euo pipefail réduit une classe d’erreurs fréquentes ; il ne remplace ni la conception ni les tests. Une commande peut réussir tout en produisant un résultat métier incorrect. Par exemple, rsync peut s’exécuter correctement vers un répertoire local qui n’est pas le point de montage attendu. Le statut de sortie est alors nul, mais la sauvegarde n’est pas stockée où vous le pensiez.
De même, -u ne valide pas le contenu d’une variable. Une variable définie mais vide, un chemin incorrect ou une URL mal formée nécessitent des contrôles adaptés. pipefail ne sait pas non plus si les données affichées par une commande correspondent à votre besoin ; il ne surveille que les statuts des programmes.
Le strict mode peut aussi révéler des scripts qui s’appuyaient involontairement sur des comportements permissifs. C’est une bonne occasion de séparer les résultats attendus des erreurs réelles, mais il faut adopter le réglage progressivement sur les scripts existants : lisez leurs codes de retour, identifiez les pipelines et testez leurs scénarios normaux comme leurs scénarios de panne.
Enfin, ne confondez pas un script « strict » avec un script opaque. Une ligne de configuration globale est utile, mais elle doit s’accompagner de conditions lisibles, de noms de variables explicites et de messages d’erreur utiles. Un script court que l’équipe comprend reste plus sûr qu’un script rempli d’astuces difficiles à maintenir.
Conclusion : faire de l’échec un cas visible et maîtrisé
Le strict mode Bash constitue une excellente base pour les scripts d’automatisation : set -e évite de continuer après de nombreux échecs inattendus, set -u expose les variables oubliées et pipefail sécurise les pipelines. Sa valeur vient surtout de la discipline qu’il encourage : tester explicitement ce qui peut échouer normalement, ne pas masquer les erreurs avec || true et vérifier les préconditions importantes.
Commencez par appliquer ce cadre à un script non critique, passez-le dans ShellCheck et testez ses chemins d’erreur avant de le déployer. Cette méthode simple rend progressivement vos automatisations Linux plus prévisibles, sans ajouter de folklore inutile au terminal.