◀ Retour au blog
Linux

Shell scripting pour le DevOps

Publié le 01 May 2024· 8 min de lecture
#Linux#Bash#Automatisation

L'art du shell scripting

Le shell scripting reste un outil fondamental pour tout ingénieur DevOps. Malgré l'essor d'Ansible, de Terraform ou des pipelines CI déclaratifs, il y a toujours un moment où l'on écrit quelques lignes de Bash : un hook de déploiement, une sauvegarde nocturne, un point d'entrée de conteneur, une étape de pipeline. Ces scripts sont souvent écrits vite et relus rarement, ce qui en fait une source classique d'incidents. Voici les patterns et bonnes pratiques essentiels pour écrire des scripts fiables, lisibles et faciles à maintenir.

Quand choisir Bash (et quand l'éviter)

Bash excelle pour orchestrer des commandes existantes : enchaîner git, rsync, docker, systemctl, vérifier un code de retour, écrire un log. Il est présent sur pratiquement tous les serveurs Linux et ne demande aucune dépendance.

En revanche, dès qu'un script manipule des structures de données (JSON, tableaux associatifs imbriqués), fait des calculs non triviaux ou dépasse quelques centaines de lignes, un langage comme Python ou PHP devient plus sûr et plus lisible. En production, je recommande une règle simple : si vous commencez à parser du JSON avec grep et sed, il est temps de changer d'outil (ou au minimum d'utiliser jq).

Structure d'un script robuste

Un bon script suit toujours le même squelette : un shebang explicite, un mode strict, des constantes en tête, des fonctions, un nettoyage garanti et une fonction main appelée à la fin.

#!/bin/bash
set -euo pipefail
IFS=$'\n\t'

# Variables
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly LOG_FILE="/var/log/deploy.log"
readonly TEMP_DIR="$(mktemp -d)"

# Fonctions
log() {
    echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

cleanup() {
    log "Nettoyage en cours..."
    rm -rf "$TEMP_DIR"
}
trap cleanup EXIT

# Script principal
main() {
    log "Démarrage du déploiement"
    # ...
    log "Déploiement terminé"
}

main "$@"

Détaillons chaque élément :

  • set -e : le script s'arrête dès qu'une commande échoue, au lieu de continuer dans un état incohérent.
  • set -u : toute variable non définie provoque une erreur. Une faute de frappe dans $TEMP_DIR ne se transforme plus en rm -rf /.
  • set -o pipefail : un pipeline échoue si n'importe laquelle de ses commandes échoue, pas seulement la dernière. Sans lui, commande_qui_echoue | tee fichier est considéré comme un succès.
  • IFS=$'\n\t' : le découpage des mots ne se fait plus sur les espaces, ce qui évite les mauvaises surprises avec des noms de fichiers contenant des espaces.
  • readonly : les constantes ne peuvent pas être écrasées par erreur plus loin dans le script.
  • mktemp -d : crée un répertoire temporaire unique, au lieu d'un chemin fixe comme /tmp/deploy qui peut entrer en collision avec une autre exécution.
  • trap cleanup EXIT : la fonction cleanup est exécutée quelle que soit la façon dont le script se termine (succès, erreur, exit explicite). Les fichiers temporaires ne traînent jamais.
  • main "$@" : tout le code s'exécute dans une fonction, ce qui rend le flux lisible et garantit que Bash a lu le fichier en entier avant de commencer.

Les limites de set -e

Le mode strict n'est pas magique. set -e est ignoré dans certaines situations : dans la condition d'un if, à gauche d'un && ou d'un ||, et dans une fonction appelée depuis l'un de ces contextes. De même, local var=$(commande) masque le code de retour de la commande, car c'est celui de local qui compte. Déclarez la variable puis affectez-la sur deux lignes :

# Mauvais : l'échec de curl est masqué par "local"
local body=$(curl -fsS "$url")

# Bon : l'échec de curl arrête le script
local body
body=$(curl -fsS "$url")

Autre piège classique : ((compteur++)) renvoie un code d'erreur quand la valeur avant incrémentation vaut 0. Avec set -e, le script s'arrête sans message. Préférez compteur=$((compteur + 1)) quand le compteur peut partir de zéro.

Patterns utiles

Certaines fonctions reviennent dans presque tous les scripts d'exploitation. Les deux suivantes sont de bons exemples : une vérification d'état de service et une relance avec temporisation progressive.

# Vérifier si un service est actif
check_service() {
    if systemctl is-active --quiet "$1"; then
        echo "$1 est actif"
    else
        echo "$1 est inactif" && return 1
    fi
}

# Retry avec backoff
retry() {
    local max_attempts=$1; shift
    local attempt=1
    while [ $attempt -le $max_attempts ]; do
        if "$@"; then return 0; fi
        echo "Tentative $attempt/$max_attempts échouée"
        sleep $((attempt * 2))
        ((attempt++))
    done
    return 1
}

La fonction retry prend le nombre de tentatives en premier argument, puis la commande à exécuter. shift retire le premier argument, et "$@" exécute le reste tel quel, en préservant les espaces et les guillemets. L'attente augmente à chaque échec (2, 4, 6 secondes…), ce qui laisse le temps à une base de données ou une API de redémarrer. Ici attempt commence à 1, donc ((attempt++)) ne pose pas de problème avec set -e. Exemple d'utilisation :

retry 5 curl -fsS https://example.com/health
check_service nginx || systemctl restart nginx

Valider les arguments et les prérequis

Un script lancé avec de mauvais arguments doit échouer immédiatement, avec un message clair, avant d'avoir modifié quoi que ce soit. De même, vérifiez que les outils nécessaires sont installés :

usage() {
    echo "Usage: $(basename "$0") <environnement> [version]" >&2
    exit 1
}

require() {
    local cmd
    for cmd in "$@"; do
        command -v "$cmd" >/dev/null 2>&1 || {
            echo "Commande requise introuvable : $cmd" >&2
            exit 1
        }
    done
}

[ $# -ge 1 ] || usage
readonly ENVIRONMENT="$1"
readonly VERSION="${2:-latest}"

case "$ENVIRONMENT" in
    staging|production) ;;
    *) echo "Environnement invalide : $ENVIRONMENT" >&2; exit 1 ;;
esac

require git rsync curl

Notez l'usage de ${2:-latest} pour une valeur par défaut, compatible avec set -u, et la redirection des messages d'erreur vers la sortie d'erreur (>&2), pour qu'ils ne se mélangent pas à une sortie éventuellement exploitée par un autre programme.

Empêcher les exécutions simultanées

Un script lancé par cron toutes les cinq minutes peut se chevaucher avec l'exécution précédente si celle-ci prend du retard. Deux sauvegardes ou deux déploiements en parallèle, c'est la garantie d'un état incohérent. La commande flock (paquet util-linux) règle le problème proprement :

exec 9>/var/lock/deploy.lock
if ! flock -n 9; then
    echo "Une autre exécution est déjà en cours" >&2
    exit 1
fi

Le verrou est libéré automatiquement par le noyau quand le script se termine, même en cas de plantage : pas de fichier de verrou orphelin à supprimer à la main.

Guillemets et variables

La majorité des bugs Bash viennent des variables non protégées. Écrivez toujours "$variable" entre guillemets doubles, sauf si vous voulez explicitement le découpage en mots. Préférez $(commande) aux backticks, qui s'imbriquent mal, et [[ ... ]] à [ ... ] dans les scripts Bash : il gère mieux les chaînes vides et supporte les expressions régulières avec =~.

Bonnes pratiques

  • Toujours utiliser set -euo pipefail
  • Documenter les variables et fonctions
  • Utiliser des fonctions pour la lisibilité
  • Gérer les erreurs avec trap
  • Tester avec shellcheck
  • Mettre les variables entre guillemets doubles, systématiquement
  • Écrire les erreurs sur stderr et renvoyer un code de sortie non nul
  • Rendre les scripts idempotents : une deuxième exécution ne doit rien casser

ShellCheck mérite une mention particulière : cet analyseur statique détecte les variables non protégées, les comparaisons incorrectes, les pièges de local et des dizaines d'autres erreurs. Il s'intègre dans la plupart des éditeurs et se lance facilement en CI :

shellcheck scripts/*.sh
bash -n scripts/deploy.sh   # vérification de syntaxe seule

Quand ne pas écrire de script

Avant d'écrire un nouveau script, vérifiez qu'un outil dédié n'existe pas déjà. Pour planifier une tâche, un timer systemd offre journalisation et gestion des échecs. Pour configurer un parc de serveurs de façon reproductible, Ansible est plus adapté qu'une boucle SSH. Le bon script shell est court, fait une seule chose, échoue bruyamment et se relit en deux minutes.