0%
Bash Scripting : Écrire des Scripts Robustes et Maintenables

Bash Scripting : Écrire des Scripts Robustes et Maintenables

Guide complet du scripting Bash : variables, quoting, set -euo pipefail, fonctions, getopts, trap, tableaux et shellcheck pour des scripts fiables.

I

InSkillCoach

· min

Bash Scripting : Écrire des Scripts Robustes et Maintenables

Tout le monde peut écrire un script Bash qui fonctionne une fois, sur sa machine, un jour de beau temps. Écrire un script qui survit aux espaces dans les noms de fichiers, aux commandes qui échouent silencieusement et aux interruptions en plein milieu d’un traitement, c’est une autre affaire. Dans ce guide, nous passons en revue les techniques qui séparent un script jetable d’un script de production : le mode strict, le quoting rigoureux, les fonctions, la gestion des arguments, le nettoyage avec trap et la validation avec shellcheck. Le tout se conclut par un script de sauvegarde complet et commenté.

1. Shebang et Exécution

La première ligne compte

Le shebang indique au système quel interpréteur utiliser. Deux formes coexistent :

#!/bin/bash
# Chemin direct : simple, fonctionne sur la quasi-totalité des distributions

#!/usr/bin/env bash
# Recherche bash dans le PATH : préférable pour la portabilité (macOS, Nix, etc.)

N’utilisez pas #!/bin/sh si votre script contient des constructions spécifiques à Bash ([[ ]], tableaux, local) : sur Debian et Ubuntu, /bin/sh pointe vers dash, qui ne les comprend pas.

Rendre le script exécutable

# Donner le droit d'exécution
chmod +x mon_script.sh

# Exécuter avec le chemin relatif
./mon_script.sh

# Exécuter sans droit d'exécution (en passant par l'interpréteur)
bash mon_script.sh

# Vérifier la syntaxe sans exécuter
bash -n mon_script.sh

# Exécuter en mode trace pour déboguer (affiche chaque commande)
bash -x mon_script.sh

2. Le Mode Strict : set -euo pipefail

C’est la première ligne à écrire après le shebang de tout script sérieux :

#!/usr/bin/env bash
set -euo pipefail

Détail de chaque option :

OptionEffetSans elle
set -eArrête le script à la première commande qui échoueLe script continue après une erreur et empire les dégâts
set -uErreur si une variable non définie est utiliséeUne faute de frappe donne une chaîne vide silencieuse
set -o pipefailUn pipeline échoue si l’une de ses commandes échouecommande_cassee | grep x réussit si grep réussit

Exemple concret du danger sans set -u :

# CATASTROPHE potentielle : si REPERTOIRE n'est pas défini,
# cette ligne devient "rm -rf /" avec certaines valeurs
rm -rf "${REPERTOIRE}/cache"

# Avec set -u, le script s'arrête immédiatement avec :
# ligne N : REPERTOIRE : variable sans liaison

Quand une commande a le droit d’échouer, dites-le explicitement :

# Autoriser l'échec d'une commande précise malgré set -e
grep "motif" fichier.log || true

# Ou capturer le code de retour
if ! systemctl is-active --quiet nginx; then
    echo "nginx est arrêté"
fi

3. Variables et Quoting

Déclarer et utiliser des variables

# Affectation : aucun espace autour du signe égal
nom="serveur-web-01"

# Toujours entourer les variables de guillemets doubles
echo "Machine : ${nom}"

# Substitution de commande : préférez $() aux backticks
date_jour=$(date +%Y-%m-%d)

# Valeur par défaut si la variable est vide ou absente
port="${PORT:-8080}"

# Erreur explicite si la variable est absente
fichier="${1:?Usage : $0 <fichier>}"

# Variables en lecture seule pour les constantes
readonly VERSION="2.1.0"

Le quoting : la source du bug numéro un

Une variable non quotée subit le découpage en mots et l’expansion des jokers. C’est la cause de la majorité des bugs Bash.

fichier="rapport final.pdf"

# FAUX : Bash voit deux arguments, "rapport" et "final.pdf"
cp $fichier /backup/

# CORRECT : un seul argument, quel que soit le contenu
cp "$fichier" /backup/

# FAUX : si le répertoire contient *.txt, le joker est développé
message="Fichiers *.txt trouvés"
echo $message

# CORRECT
echo "$message"

Règle simple : quotez toujours vos variables ("$var"), sauf si vous savez exactement pourquoi vous ne le faites pas.

4. Tests avec [[ ]]

La double paire de crochets [[ ]] est propre à Bash et plus sûre que [ ] : pas de découpage en mots à l’intérieur, opérateurs logiques lisibles et correspondance de motifs intégrée.

# Tests sur les chaînes
if [[ -z "$variable" ]]; then echo "vide"; fi
if [[ -n "$variable" ]]; then echo "non vide"; fi
if [[ "$reponse" == "oui" ]]; then echo "confirmé"; fi

# Correspondance de motif (glob)
if [[ "$fichier" == *.log ]]; then echo "fichier de log"; fi

# Expression régulière
if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
    echo "ressemble à une adresse IPv4"
fi

# Tests sur les fichiers
if [[ -f "/etc/nginx/nginx.conf" ]]; then echo "fichier existe"; fi
if [[ -d "/var/www" ]]; then echo "répertoire existe"; fi
if [[ -r "$fichier" && -w "$fichier" ]]; then echo "lecture et écriture"; fi

# Comparaisons numériques (ou utilisez (( )) pour l'arithmétique)
if [[ "$nombre" -gt 100 ]]; then echo "supérieur à 100"; fi
if (( nombre > 100 )); then echo "idem, syntaxe arithmétique"; fi

5. Boucles

# Boucle sur une liste explicite
for service in nginx postgresql redis; do
    systemctl status "$service" --no-pager
done

# Boucle sur des fichiers : le glob gère correctement les espaces
for fichier in /var/log/*.log; do
    echo "Traitement de $fichier"
done

# Boucle numérique C-style
for (( i = 1; i <= 5; i++ )); do
    echo "Tentative $i"
done

# Boucle while avec compteur
tentatives=0
while (( tentatives < 3 )); do
    (( tentatives++ )) || true
    echo "Essai numéro $tentatives"
done

# Boucle until : attendre qu'un service réponde
until curl -sf http://localhost:8080/health > /dev/null; do
    echo "En attente du service..."
    sleep 2
done

N’itérez jamais sur la sortie de ls : utilisez les globs ou find avec -print0.

6. Fonctions

# Définition : variables locales obligatoires pour éviter les effets de bord
log() {
    local niveau="$1"
    local message="$2"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${niveau}] ${message}" >&2
}

# Fonction avec valeur de retour via stdout
espace_libre_mo() {
    local point_montage="$1"
    df --output=avail -m "$point_montage" | tail -1 | tr -d ' '
}

# Utilisation
log "INFO" "Démarrage du traitement"
libre=$(espace_libre_mo /backup)

if (( libre < 1024 )); then
    log "ERREUR" "Moins de 1 Go disponible sur /backup"
    exit 1
fi

Deux règles d’or : déclarez toutes les variables de fonction avec local, et écrivez les messages de diagnostic sur stderr (>&2) pour ne pas polluer les valeurs retournées sur stdout.

7. Arguments et getopts

Les variables spéciales

echo "Nom du script : $0"
echo "Premier argument : $1"
echo "Nombre d'arguments : $#"
echo "Tous les arguments : $*"

# "$@" préserve les arguments individuels (toujours préférer cette forme)
for arg in "$@"; do
    echo "Argument : $arg"
done

Analyser les options avec getopts

#!/usr/bin/env bash
set -euo pipefail

usage() {
    echo "Usage : $0 [-v] [-o fichier_sortie] -s source" >&2
    exit 1
}

verbeux=false
sortie="/tmp/resultat.txt"
source_dir=""

while getopts ":vo:s:" option; do
    case "$option" in
        v) verbeux=true ;;
        o) sortie="$OPTARG" ;;
        s) source_dir="$OPTARG" ;;
        :) echo "L'option -$OPTARG requiert un argument" >&2; usage ;;
        \?) echo "Option inconnue : -$OPTARG" >&2; usage ;;
    esac
done
shift $((OPTIND - 1))

# Valider les arguments obligatoires
[[ -n "$source_dir" ]] || usage

"$verbeux" && echo "Source : $source_dir, sortie : $sortie"

8. trap et Nettoyage

Un script peut être interrompu à tout moment (Ctrl+C, kill, erreur avec set -e). trap garantit que le nettoyage a lieu quoi qu’il arrive.

#!/usr/bin/env bash
set -euo pipefail

# Créer un répertoire de travail temporaire
travail=$(mktemp -d)

# La fonction de nettoyage s'exécute à la sortie du script,
# qu'elle soit normale, due à une erreur ou à une interruption
nettoyage() {
    local code=$?
    rm -rf "$travail"
    echo "Nettoyage effectué (code de sortie : $code)" >&2
}
trap nettoyage EXIT

# Afficher la ligne fautive en cas d'erreur : précieux pour le débogage
trap 'echo "Erreur à la ligne $LINENO" >&2' ERR

# Le reste du script peut utiliser $travail sereinement
cp /etc/hosts "$travail/"

Le trap sur EXIT couvre presque tous les cas : sortie normale, exit explicite, erreur avec set -e et signaux SIGINT/SIGTERM (Bash exécute le trap EXIT après réception du signal).

9. Tableaux

# Tableau indexé
serveurs=("web-01" "web-02" "db-01")

# Ajouter un élément
serveurs+=("cache-01")

# Nombre d'éléments
echo "Nombre de serveurs : ${#serveurs[@]}"

# Itérer : toujours "${tableau[@]}" avec guillemets
for serveur in "${serveurs[@]}"; do
    echo "Vérification de $serveur"
done

# Tableau associatif (Bash 4 et plus)
declare -A ports
ports[http]=80
ports[https]=443
ports[ssh]=22

for service in "${!ports[@]}"; do
    echo "$service écoute sur le port ${ports[$service]}"
done

10. Lecture de Fichiers

La méthode canonique pour lire un fichier ligne par ligne :

# IFS= préserve les espaces en début/fin de ligne
# -r empêche l'interprétation des antislashs
while IFS= read -r ligne; do
    echo "Lu : $ligne"
done < /etc/hostname

# Lire un fichier CSV en découpant les champs
while IFS=, read -r nom ip role; do
    echo "Serveur $nom ($ip) : rôle $role"
done < serveurs.csv

# Charger un fichier entier dans un tableau
mapfile -t lignes < /etc/hosts
echo "Le fichier contient ${#lignes[@]} lignes"

Évitez le piège classique cat fichier | while read : le pipe crée un sous-shell et les variables modifiées dans la boucle sont perdues à la sortie.

11. shellcheck : votre relecteur automatique

shellcheck analyse vos scripts et détecte les erreurs classiques : variables non quotées, comparaisons douteuses, constructions non portables.

# Installation
sudo apt install shellcheck        # Debian/Ubuntu
sudo dnf install ShellCheck        # Fedora/Rocky

# Analyser un script
shellcheck mon_script.sh

# Ignorer une règle précise sur une ligne (avec justification en commentaire)
# shellcheck disable=SC2086  # découpage volontaire des options
commande $options

Intégrez shellcheck à votre CI : un script qui ne passe pas shellcheck ne devrait pas être fusionné.

12. Exemple Complet : Script de Sauvegarde

Ce script assemble toutes les techniques vues précédemment.

#!/usr/bin/env bash
#
# backup.sh — Sauvegarde compressée d'un répertoire avec rotation.
# Usage : backup.sh [-k nombre_de_copies] -s /repertoire/source -d /repertoire/destination
#
set -euo pipefail

readonly SCRIPT_NAME="${0##*/}"

# --- Fonctions -------------------------------------------------------------

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}

usage() {
    echo "Usage : $SCRIPT_NAME [-k copies] -s source -d destination" >&2
    exit 1
}

nettoyage() {
    # Supprimer l'archive partielle si le script est interrompu
    if [[ -n "${archive_temp:-}" && -f "$archive_temp" ]]; then
        rm -f "$archive_temp"
        log "Archive partielle supprimée : $archive_temp"
    fi
}
trap nettoyage EXIT

# --- Analyse des arguments -------------------------------------------------

copies=7
source_dir=""
dest_dir=""

while getopts ":k:s:d:" option; do
    case "$option" in
        k) copies="$OPTARG" ;;
        s) source_dir="$OPTARG" ;;
        d) dest_dir="$OPTARG" ;;
        *) usage ;;
    esac
done

[[ -n "$source_dir" && -n "$dest_dir" ]] || usage
[[ -d "$source_dir" ]] || { log "ERREUR : source introuvable : $source_dir"; exit 1; }
mkdir -p "$dest_dir"

# --- Sauvegarde ------------------------------------------------------------

horodatage=$(date +%Y%m%d-%H%M%S)
archive="${dest_dir}/backup-${horodatage}.tar.gz"
archive_temp="${archive}.part"

log "Sauvegarde de $source_dir vers $archive"

# Écrire dans un fichier temporaire puis renommer : l'archive finale
# n'existe que si elle est complète
tar -czf "$archive_temp" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
mv "$archive_temp" "$archive"
archive_temp=""

log "Archive créée : $(du -h "$archive" | cut -f1)"

# --- Rotation : conserver les N archives les plus récentes ------------------

mapfile -t anciennes < <(ls -1t "${dest_dir}"/backup-*.tar.gz 2>/dev/null | tail -n +"$((copies + 1))")

for ancienne in "${anciennes[@]}"; do
    rm -f "$ancienne"
    log "Ancienne archive supprimée : $ancienne"
done

log "Terminé : $(ls -1 "${dest_dir}"/backup-*.tar.gz | wc -l) archive(s) conservée(s)"

Bonnes pratiques

  • Commencez chaque script par set -euo pipefail et justifiez chaque exception avec || true.
  • Quotez systématiquement vos variables : "$var", "${tableau[@]}", "$@".
  • Déclarez les variables de fonction avec local et les constantes avec readonly.
  • Écrivez les logs sur stderr pour garder stdout disponible pour les données.
  • Utilisez mktemp pour les fichiers temporaires et un trap EXIT pour les nettoyer.
  • Écrivez dans un fichier temporaire puis renommez avec mv : une opération atomique évite les fichiers à moitié écrits.
  • Passez chaque script dans shellcheck avant de le déployer.
  • Si un script dépasse quelques centaines de lignes ou manipule des structures de données complexes, envisagez Python : Bash excelle dans l’orchestration de commandes, pas dans la logique métier.

Conclusion

La robustesse d’un script Bash ne vient pas de sa longueur mais de sa discipline : le mode strict qui transforme les erreurs silencieuses en arrêts francs, le quoting qui neutralise les noms de fichiers pièges, trap qui garantit le nettoyage, et shellcheck qui relit chaque ligne à votre place. Ces habitudes coûtent quelques secondes à l’écriture et économisent des heures de débogage en production.

Reprenez le script de sauvegarde de la section 12 comme squelette pour vos propres outils : la structure logging, arguments, trap et opérations atomiques s’adapte à la quasi-totalité des scripts d’administration.

InSkillCoach

À propos de InSkillCoach

Expert en formation et technologies

Coach spécialisé dans les technologies avancées et l'IA, porté par GNeurone Inc.

Certifications:

  • AWS Certified Solutions Architect – Professional
  • Certifications Google Cloud
  • Microsoft Certified: DevOps Engineer Expert
  • Certified Kubernetes Administrator (CKA)
  • CompTIA Security+
1.6k
81

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !