Administration Linux
Scripts Bash : des scripts sûrs avec arguments, boucles, fonctions et traps
Intermédiaire10 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Un script shell est du code de production qui a simplement l'air d'une liste de commandes, et la plupart de ses bugs viennent de la fâcheuse habitude du shell de continuer après un échec. Ce guide montre la structure d'un script digne de confiance : un préambule strict, des arguments analysés, des conditions testées, des fonctions aux vrais codes de retour, un nettoyage confié à un trap et un linter dans la boucle. Il se termine par un script de sauvegarde complet et commenté.
Ce que vous allez apprendre
- Structurer un script avec un shebang, un préambule strict et des fonctions bien séparées
- Analyser les options et les arguments positionnels, et les valider avant d'effectuer le moindre travail
- Choisir la bonne construction de test et écrire des boucles qui résistent aux espaces et aux listes vides
- Garantir le nettoyage et des messages d'erreur utiles avec trap et les gestionnaires ERR et EXIT
- Linter les scripts avec ShellCheck et savoir reconnaître quand Bash n'est pas le bon outil
Ce qui sépare une poignée de commandes tapées une fois d'un script dont d'autres dépendent, c'est la gestion des erreurs. Le shell interactif est indulgent : une commande échoue, vous lisez le message et essayez autre chose. Un script, lui, n'a personne pour le surveiller, et par défaut le shell passe simplement à la ligne suivante. C'est ainsi qu'un script de sauvegarde supprime la sauvegarde précédente alors que la nouvelle n'a jamais été créée, et qu'un script de déploiement redémarre un service avec un fichier de configuration qui n'a jamais été écrit.
Ce guide explique comment écrire délibérément la seconde catégorie de script. Il suppose que vous maîtrisez déjà la ligne de commande — développements, quoting, redirections, code de retour — vus dans les bases du shell, et se concentre sur les constructions qui transforment des commandes en programme : mode strict, gestion des arguments, structures de contrôle, fonctions, tableaux, traps de nettoyage et linting.
La forme d'un script
Un script commence par un shebang, la séquence de deux caractères #! suivie de l'interpréteur que le noyau doit lancer. #!/usr/bin/env bash trouve Bash où qu'il soit installé au lieu de supposer /bin/bash, ce qui compte sur les systèmes où il se trouve sous /usr/local. N'écrivez pas #!/bin/sh si le script n'est pas strictement POSIX : sur Debian et Ubuntu, /bin/sh est Dash, qui n'a ni tableaux, ni [[, ni local -n, et dont le echo se comporte différemment.
#!/usr/bin/env bash
#
# deploy.sh — install a release into /opt and switch the current symlink.
# Usage: deploy.sh [-n] [-d DIR] VERSION
Rendez-le exécutable avec chmod +x deploy.sh et lancez-le avec ./deploy.sh. Lancer bash deploy.sh ignore complètement le shebang, et c'est exactement ainsi qu'un script Bash finit interprété par Dash dans un job de CI, où il échoue sur une construction qui fonctionne partout ailleurs.
Placez en tête un bloc de commentaires qui dit ce que fait le script et comment l'appeler. Six mois plus tard, ce bloc sera la documentation.
Le préambule strict
Trois options du shell, activées ensemble, font passer le comportement par défaut de « continuer quoi qu'il arrive » à « s'arrêter au premier signe de problème ».
set -euo pipefail
IFS=$'\n\t'
-equitte le script lorsqu'une commande renvoie un code non nul qui n'est pas testé.-utraite le développement d'une variable non définie comme une erreur, ce qui attrape les fautes de frappe et la configuration manquante au lieu de substituer en silence une chaîne vide. C'est ce qui empêcherm -rf "$PREFIX/data"de devenirrm -rf /data.-o pipefailfait renvoyer à un pipeline le code du dernier étage en échec, et non celui du seul dernier étage :curl … | tar -xne signale donc plus un succès quand le téléchargement a échoué.- Restreindre
IFSau retour à la ligne et à la tabulation retire l'espace des caractères de découpage en mots, ce qui limite les dégâts d'un développement sans guillemets. C'est facultatif et cela modifie le comportement de certaines tournures idiomatiques : adoptez-le en connaissance de cause.
Sachez ce que -e n'attrape pas. Une commande placée dans un if, un while, un && ou derrière ! est testée : son échec est attendu et ne provoque pas de sortie. local var=$(cmd) masque le code de retour de cmd derrière celui de local, qui réussit toujours ; séparez donc les deux :
local sha
sha=$(sha256sum "$file" | cut -d' ' -f1) # now a failure here stops the script
Quand une commande a réellement le droit d'échouer, dites-le explicitement avec || true pour que l'intention soit visible, et quand vous voulez le code de retour sans quitter, capturez-le :
grep -q "^enabled=1" "$conf" || echo "warning: feature disabled"
if ! systemctl is-active --quiet nginx; then
echo "nginx is not running" >&2
fi
Arguments et options
Les paramètres positionnels sont $1, $2, etc. ; $# en donne le nombre, "$@" les développe tous en autant de mots protégés, et $0 est le nom du script lui-même. "$@" avec ses guillemets est l'une des rares choses en Bash qui ne sont presque jamais fausses, alors que $*, qui fusionne tout en une seule chaîne, n'est presque jamais le bon choix.
Au-delà d'un ou deux arguments, analysez les options avec getopts. Il gère les options courtes, leur regroupement et les options avec valeur, et comme c'est une commande interne, il n'y a rien à installer.
usage() {
cat >&2 <<'USAGE'
Usage: deploy.sh [-n] [-d DIR] VERSION
-n dry run: print what would happen, change nothing
-d DIR installation root (default /opt)
USAGE
exit 64
}
dry_run=false
root=/opt
while getopts ':nd:h' opt; do
case "$opt" in
n) dry_run=true ;;
d) root=$OPTARG ;;
h) usage ;;
:) printf 'option -%s requires an argument\n' "$OPTARG" >&2; usage ;;
?) printf 'unknown option -%s\n' "$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[[ $# -eq 1 ]] || usage
version=$1
Le deux-points initial de ':nd:h' fait passer getopts en mode d'erreur silencieux pour que vos propres messages soient utilisés ; un deux-points après une lettre indique que l'option attend une valeur. shift $((OPTIND - 1)) retire les options analysées et laisse en place les arguments positionnels.
Validez tout avant de faire quoi que ce soit de destructeur. Un script qui vérifie ses entrées en tête puis s'exécute est bien plus facile à comprendre qu'un script qui découvre à mi-parcours qu'un répertoire manque.
[[ -d $root ]] || { printf 'no such directory: %s\n' "$root" >&2; exit 66; }
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { printf 'bad version: %s\n' "$version" >&2; exit 65; }
command -v rsync >/dev/null || { echo 'rsync is required' >&2; exit 69; }
Sortez avec 0 en cas de succès et avec un code non nul distinct par catégorie d'échec ; les valeurs de /usr/include/sysexits.h (64 pour l'usage, 65 pour les données, 66 pour une entrée absente, 69 pour un service indisponible) constituent une convention raisonnable dès qu'il vous faut plus que « ça a planté ». Quel que soit votre choix, envoyez les diagnostics sur la sortie d'erreur avec >&2, afin qu'un appelant qui branche votre sortie sur un pipe ne reçoive que la sortie.
Conditions
Bash propose trois constructions de test, et leurs différences comptent.
| Construction | À utiliser pour |
|---|---|
[[ … ]] | tout, en Bash : pas de découpage en mots à l'intérieur, prise en charge de =~, &&, ||, filtrage par motif avec == |
[ … ] | les scripts POSIX uniquement ; chaque développement doit être entre guillemets |
(( … )) | l'arithmétique entière : (( count > 3 )), (( i++ )) |
[[ -f $config ]] # exists and is a regular file
[[ -d $dir && -w $dir ]] # directory, writable
[[ -s $log ]] # exists and is non-empty
[[ -z ${API_TOKEN:-} ]] # unset or empty, safe under set -u
[[ $name == web-* ]] # glob match
[[ $line =~ ^([0-9]{3})\ (.*)$ ]] && code=${BASH_REMATCH[1]}
(( retries < max_retries ))
À l'intérieur de [[ ]], il n'y a pas de découpage en mots : [[ -f $config ]] est donc sûr même si le chemin contient des espaces — l'un des rares endroits où l'on peut omettre les guillemets. À droite de == et de =~, les guillemets ont l'effet inverse : entre guillemets, c'est littéral ; sans guillemets, c'est un motif ou une regex.
case est plus lisible qu'une chaîne de comparaisons pour confronter une valeur à plusieurs motifs :
case "$environment" in
prod|production) replicas=6 ;;
stag*) replicas=2 ;;
dev) replicas=1 ;;
*) printf 'unknown environment: %s\n' "$environment" >&2; exit 65 ;;
esac
Des boucles qui résistent aux espaces
Itérer sur un glob ou sur un tableau est sûr ; itérer sur la sortie de ls ne l'est pas.
for file in /var/log/app/*.log; do # safe; use shopt -s nullglob for empty matches
gzip -9 -- "$file"
done
for host in "${hosts[@]}"; do
ssh "$host" 'uptime'
done
for i in {1..5}; do
curl -fsS "https://api.example.com/health" && break
sleep $(( i * 2 )) # simple exponential backoff
done
Lire un fichier ligne par ligne n'a qu'une forme correcte, et chacun de ses éléments est indispensable :
while IFS= read -r line; do
printf 'got: %s\n' "$line"
done < "$input_file"
IFS= empêche la suppression des espaces en début et en fin de ligne, -r évite que les antislashs soient interprétés comme des échappements, et la redirection placée sur done maintient la boucle dans le shell courant. Ce dernier point est à l'origine de l'un des bugs Bash les plus fréquents : alimenter une boucle par un pipe (cat file | while read …) l'exécute dans un sous-shell, et toute variable qu'elle définit a disparu ensuite. Utilisez une redirection, ou une substitution de processus quand l'entrée est une commande :
count=0
while IFS= read -r unit; do
(( count++ ))
done < <(systemctl list-units --type=service --state=failed --no-legend | awk '{print $1}')
printf 'failed units: %d\n' "$count"
Pour des noms de fichiers susceptibles de contenir absolument n'importe quoi, associez find -print0 à read -d '' :
while IFS= read -r -d '' path; do
rm -v -- "$path"
done < <(find /tmp/build -type f -name '*.tmp' -print0)
Fonctions
Une fonction regroupe des étapes, leur donne un nom et permet de les tester isolément. Les arguments arrivent dans $1, $2, "$@", exactement comme dans le script lui-même ; return fixe un code de retour, et c'est ce qu'elle écrit sur stdout qui lui permet de renvoyer une valeur.
log() { printf '%s [%s] %s\n' "$(date -Is)" "$1" "${*:2}" >&2; }
require_service() {
local unit=$1
if ! systemctl is-active --quiet "$unit"; then
log ERROR "$unit is not running"
return 1
fi
}
current_release() {
readlink -f /opt/app | sed 's|.*/||'
}
release=$(current_release)
require_service postgresql || exit 69
Déclarez chaque variable interne avec local, sans quoi elle est globale et deux fonctions finiront par entrer en collision. Envoyez les lignes de log sur la sortie d'erreur, comme le fait log ci-dessus, pour qu'une fonction dont la sortie standard est capturée par une substitution de commande ne soit pas polluée par ses propres logs — un bug subtil qui produit des valeurs étranges chez l'appelant.
return n'accepte que des valeurs comprises entre 0 et 255 : une fonction ne peut donc pas renvoyer ainsi un nombre, une chaîne ou une liste. Affichez la valeur et capturez-la.
Tableaux
Les tableaux sont la raison d'exiger Bash plutôt que le sh POSIX. Ils conservent une liste de valeurs en préservant leurs frontières, ce dont les chaînes sont incapables.
rsync_opts=(--archive --delete --human-readable)
[[ $dry_run == true ]] && rsync_opts+=(--dry-run)
rsync "${rsync_opts[@]}" "$src/" "$dest/"
hosts=(web01 web02 db01)
printf 'host count: %d\n' "${#hosts[@]}"
printf '%s\n' "${hosts[@]}"
mapfile -t failed < <(systemctl list-units --state=failed --no-legend | awk '{print $1}')
"${array[@]}" avec ses guillemets se développe en un mot par élément et préserve les espaces ; ${array[*]} fusionne tout en une seule chaîne. mapfile -t (aussi appelé readarray) lit des lignes dans un tableau sans leurs retours à la ligne finaux, et il est bien plus sûr que arr=$(cmd) suivi d'un découpage en mots.
Les tableaux associatifs servent de petit magasin clé-valeur et doivent être déclarés :
declare -A port_of=( [http]=80 [https]=443 [postgres]=5432 )
for name in "${!port_of[@]}"; do
printf '%-10s %s\n' "$name" "${port_of[$name]}"
done
Here-documents et fichiers générés
Un here-document fournit un bloc de texte littéral sur l'entrée standard d'une commande. Mettre ou non le délimiteur entre guillemets décide si le corps est développé :
cat > /etc/myapp/config.ini <<EOF
[server]
host = ${bind_address}
port = ${port}
EOF
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'UNIT'
[Unit]
Description=My application
UNIT
<<'EOF' avec guillemets est littéral — rien n'est développé à l'intérieur, ce qui est exactement ce qu'il faut pour générer un script ou une configuration truffés de $. <<EOF sans guillemets développe les variables et les substitutions de commande. <<-EOF supprime en plus les tabulations en début de ligne (pas les espaces), ce qui permet d'indenter le corps au niveau du code environnant.
N'écrivez jamais un fichier de configuration sur place avec une redirection pendant qu'un service le lit. Écrivez dans un fichier temporaire du même répertoire, puis mettez-le en place avec mv : au sein d'un même système de fichiers, mv est atomique, si bien que les lecteurs voient soit l'ancien fichier, soit le nouveau, jamais un fichier à moitié écrit.
Le nettoyage avec les traps
Un trap exécute une commande quand le shell reçoit un signal ou s'apprête à se terminer. C'est ainsi qu'un script fait le ménage derrière lui, qu'il ait réussi, échoué ou été interrompu.
workdir=$(mktemp -d)
cleanup() {
local status=$?
rm -rf -- "$workdir"
(( status == 0 )) || log ERROR "failed with status $status at line $BASH_LINENO"
exit "$status"
}
trap cleanup EXIT
trap 'log WARN "interrupted"; exit 130' INT TERM
EXIT se déclenche sur tous les chemins de sortie, ce qui en fait l'endroit idéal pour supprimer des répertoires temporaires, libérer un verrou ou restaurer un fichier de configuration. Capturez $? dès la première instruction du gestionnaire, avant que quoi que ce soit ne l'écrase.
Un motif complémentaire empêche deux exemplaires d'un script périodique de tourner en même temps :
exec 9> /var/lock/backup.lock
flock -n 9 || { echo 'another run is in progress' >&2; exit 75; }
Le verrou est détenu par le descripteur de fichier et libéré automatiquement à la fin du processus, y compris s'il est tué : aucun fichier de verrou périmé à nettoyer. flock existe aussi sous forme de commande englobante, bien pratique dans une unité timer : flock -n /var/lock/backup.lock /usr/local/bin/backup.sh.
Un exemple complet
Toutes les pièces assemblées, dans un script que vous pourriez planifier depuis un timer systemd :
#!/usr/bin/env bash
#
# backup-db.sh — dump a PostgreSQL database, compress it, upload it and prune old copies.
set -euo pipefail
readonly DB=${DB:-orders}
readonly DEST=${DEST:-/srv/backups}
readonly KEEP_DAYS=${KEEP_DAYS:-14}
readonly STAMP=$(date +%Y%m%dT%H%M%S)
log() { printf '%s %s\n' "$(date -Is)" "$*" >&2; }
workdir=$(mktemp -d)
trap 'rc=$?; rm -rf -- "$workdir"; exit "$rc"' EXIT
exec 9> /var/lock/backup-db.lock
flock -n 9 || { log 'another backup is running'; exit 75; }
for tool in pg_dump zstd sha256sum; do
command -v "$tool" >/dev/null || { log "missing required tool: $tool"; exit 69; }
done
dump=$workdir/$DB-$STAMP.sql.zst
log "dumping $DB"
pg_dump --format=plain --no-owner "$DB" | zstd -T0 -19 -o "$dump" -q
# Guard against an empty or truncated dump before anything is pruned.
size=$(stat -c %s "$dump")
(( size > 1024 )) || { log "dump is only $size bytes; aborting"; exit 65; }
sha256sum "$dump" > "$dump.sha256"
install -d -m 0750 "$DEST"
mv -- "$dump" "$dump.sha256" "$DEST/"
log "stored $DEST/$(basename "$dump") ($size bytes)"
# Only prune once the new backup is safely in place.
deleted=0
while IFS= read -r -d '' old; do
rm -f -- "$old" "$old.sha256"
(( ++deleted ))
done < <(find "$DEST" -maxdepth 1 -name "$DB-*.sql.zst" -mtime "+$KEEP_DAYS" -print0)
log "pruned $deleted old backup(s)"
Observez l'ordre des opérations : vérifier les outils, prendre le verrou, produire le nouvel artefact, contrôler sa vraisemblance, le mettre en place de façon atomique, et seulement ensuite supprimer les anciens. Un script qui purge d'abord et exporte ensuite finira tôt ou tard par vous laisser sans aucune sauvegarde.
Linting, tests et formatage
ShellCheck est de loin l'outil le plus rentable du développement shell. Il analyse statiquement un script et signale les développements sans guillemets, les cat inutiles, les variables perdues dans un sous-shell, les mauvais usages de [ et des dizaines d'autres vrais bugs, chacun accompagné d'une page de wiki qui explique le raisonnement.
sudo apt install shellcheck # or: dnf install ShellCheck
shellcheck -x -S style deploy.sh # -x follows sourced files
shfmt -i 2 -ci -w deploy.sh # consistent formatting
bash -n deploy.sh # syntax check without executing
bash -x deploy.sh 1.2.3 # trace every command as it runs
Exécutez ShellCheck en CI avec vos autres vérifications : il s'intègre naturellement aux pipelines décrits dans livraison continue. Quand un avertissement est réellement injustifié, faites taire cette seule occurrence avec un commentaire # shellcheck disable=SC2086 accompagné d'une note qui explique pourquoi, plutôt que de désactiver la règle globalement.
Pour le comportement, bats fournit un petit framework de test, mais on obtient déjà beaucoup simplement en écrivant des fonctions qui prennent des arguments et affichent des résultats, puis en les appelant depuis un script de test. Ajoutez temporairement set -x, ou PS4='+ ${BASH_SOURCE}:${LINENO}: ' juste avant, pour obtenir des traces qui indiquent le fichier et la ligne.
Erreurs courantes
- Les développements sans guillemets.
cp $src $dstcasse dès le premier chemin contenant un espace. Mettez tout entre guillemets et laissez ShellCheck trouver les oublis. - Analyser la sortie de
ls. Elle est destinée aux humains. Utilisez un glob,find -print0oumapfile. - Alimenter une boucle
whilepar un pipe. La boucle s'exécute dans un sous-shell et ses variables disparaissent. Utilisez une redirection ou une substitution de processus. - Supposer que
cda réussi.cd "$dir" && rm -rf ./*est sûr ; la même chose sur deux lignes sansset -eest une catastrophe. local x=$(cmd)sousset -e. L'échec est masqué. Déclarez et affectez séparément.- Construire des chemins sans vérification.
rm -rf "$root/$name"avec un$namevide supprime toute l'arborescence.set -uassocié à une validation explicite l'empêche. - Écrire 800 lignes de Bash. À cette taille, le langage travaille contre vous ; passez à Python, à Go ou à un outil de gestion de configuration.
Points clés à retenir
- Commencez chaque script par
#!/usr/bin/env bash,set -euo pipefailet un commentaire qui dit ce qu'il fait — puis connaissez les cas que-ene peut pas attraper. - Analysez les options avec
getopts, validez toutes les entrées d'emblée, écrivez les diagnostics sur stderr et sortez avec des codes qui ont du sens. - Préférez
[[ … ]]pour les tests et(( … ))pour l'arithmétique ; utilisezcasepour confronter une valeur à plusieurs motifs. - Bouclez sur des globs et des tableaux, alimentez
while readpar une redirection ou une substitution de processus, et utilisez-print0/read -d ''pour les noms de fichiers arbitraires. - Gardez les variables de fonction en
local, loguez sur stderr, et renvoyez les valeurs en les affichant plutôt que parreturn. trap … EXITgarantit le nettoyage sur tous les chemins, etflocksur un descripteur de fichier empêche les exécutions concurrentes sans fichier de verrou périmé.- Placez les opérations destructrices en dernier : créer, vérifier, mettre en place de façon atomique, puis purger.
- Passez ShellCheck sur chaque script et traitez ses remarques comme des échecs de build, pas comme des suggestions.
Sources et lectures complémentaires
Questions fréquentes
- set -e suffit-il à rendre mon script sûr ?
- Il aide, mais ses angles morts sont documentés. Une commande qui échoue dans une condition, à gauche d'un && ou dans une fonction appelée depuis un if ne déclenche pas de sortie. Une substitution de commande dans une affectation masque le code de retour, sauf si vous déclarez et affectez sur deux lignes distinctes. Voyez le préambule strict comme un filet de sécurité qui vient compléter une gestion d'erreurs soignée, pas comme un substitut.
- Pourquoi ma boucle while perd-elle les variables qu'elle a définies ?
- Chaque étage d'un pipeline s'exécute dans un sous-shell : une boucle placée à droite d'un pipe modifie donc une copie de l'environnement, qui disparaît à la fin du pipeline. Alimentez plutôt la boucle par une redirection ou une substitution de processus — done < file, ou done < <(command) — et elle s'exécutera dans le shell courant, où les affectations survivent. Bash propose aussi shopt -s lastpipe pour les shells non interactifs.
- Quand faut-il abandonner Bash au profit de Python ?
- Bash excelle à lancer des programmes et à brancher leurs entrées et sorties entre elles. Il est faible pour les structures de données, l'arithmétique, la manipulation de chaînes, la gestion d'erreurs sur de nombreuses étapes et tout ce qui demande des tests. Dès qu'un script dépasse quelques centaines de lignes, manipule des données imbriquées, analyse sérieusement du JSON ou du XML, ou doit être testé unitairement, un vrai langage de programmation sera plus court et bien plus facile à maintenir.
- Comment gérer les noms de fichiers qui contiennent des espaces ou des retours à la ligne ?
- Mettez chaque développement entre guillemets, utilisez des tableaux plutôt que des chaînes séparées par des espaces, et n'analysez jamais la sortie de ls. Avec find, utilisez -print0 avec xargs -0, ou -exec cmd {} +, qui transmet les arguments directement sans passer par un shell. Pour lire une liste ligne par ligne, utilisez IFS= read -r -d '' avec une entrée séparée par des caractères nuls : même un retour à la ligne dans un nom est alors correctement traité.