Aller au contenu
Processus, parallélisme et délais

Processus, parallélisme et délais

300 Expert ⏱ 1 h 40 bashlinuxubuntudebiansshsystemd

À la fin, vous saurez

  • Dire, pour chaque construction de Bash, si elle crée un sous-shell ou un processus, et en prévoir les conséquences sur les variables
  • Mesurer le coût des processus créés dans une boucle et le réduire par des fonctionnalités internes ou un seul awk
  • Lancer des tâches en arrière-plan, les attendre avec wait, wait -n et wait -p, et recueillir le code de sortie de chacune
  • Écrire une réserve de tâches qui borne le nombre de traitements simultanés, et choisir entre elle, xargs -P et GNU parallel
  • Borner la durée d'une commande avec timeout ou un délai natif, et interpréter les codes 124, 125 et 137
  • Éviter les pièges des boucles SSH, des sorties entremêlées et de set -e face à wait
  • Paralléliser ce qui ne modifie rien et sérialiser ce qui touche au service, sur l'exemple d'un déploiement

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) coreutils 9.4 (Ubuntu), 9.7 (Debian) findutils 4.9.0 (Ubuntu), 4.10.0 (Debian) openssh 9.6p1 (Ubuntu), 10.0p1 (Debian) systemd 255 (Ubuntu), 257 (Debian) , vérifié le 8 octobre 2026

Pourquoi

Lundi matin, l'API de sig-app-1 se fige : tous ses workers sont bloqués sur une requête à la base, le port 8000 accepte encore les connexions mais plus rien ne répond. Le script verifier-sante de Camille, lancé toutes les cinq minutes par un minuteur systemd, interroge les machines l'une après l'autre avec un simple curl http://172.16.8.11:8000/sante. Sans délai explicite, curl attend la réponse indéfiniment. Le contrôle de sig-app-2 ne vient jamais, et comme le service de la sonde est toujours actif, le minuteur ne la relance pas. Pendant une heure, la supervision n'a rien dit : ni de sig-app-1, qui allait très mal, ni de sig-app-2, qui allait très bien.

Le même jour, vous déployez la version 1.4.0 avec deployer. Le script lit la liste des machines dans un fichier et lance ssh pour chacune. Il annonce un succès, mais sig-app-2 tourne toujours en 1.3.2 : la boucle ne s'est exécutée qu'une fois, sans aucun message d'erreur. Et la nuit précédente, rapport-journaux a mis quarante minutes à compter les codes HTTP d'une journée de journaux, une tâche qu'un awk règle en une seconde.

Ces trois défauts ont la même racine : le script ignore quels processus il crée, combien de temps ils peuvent durer et ce qu'ils partagent (l'entrée standard, la sortie, le terminal). Cette leçon rend ces processus visibles. On mesure ce que coûte un fork, on apprend à lancer plusieurs traitements en même temps sans perdre le code de sortie d'aucun, à borner leur nombre et leur durée, et à distinguer ce qu'on peut paralléliser sans risque de ce qui doit rester en série.

Les concepts

Chaque commande externe est un processus

La leçon 10 de Premiers pas a décrit le mécanisme : pour lancer curl, Bash appelle fork(), qui duplique le shell, puis l'enfant appelle execve(), qui remplace son programme par curl, et le parent attend sa fin avec wait(). Deux conséquences guident toute cette leçon :

  • un processus coûte : duplication de l'espace mémoire (paresseuse, mais réelle), chargement d'un exécutable, de ses bibliothèques, puis ramassage. Quelques millisecondes, négligeables une fois, considérables cent mille fois ;
  • un processus est étanche : l'enfant reçoit une copie de l'environnement du parent, et rien de ce qu'il modifie ne remonte. C'est vrai d'un programme externe, et c'est vrai aussi des copies de Bash lui-même.

Les commandes internes (builtins : echo, printf, read, test, [[, cd) et les fonctions s'exécutent dans le shell courant, sans processus. C'est pour cela que cd est forcément interne : un cd externe changerait le répertoire de son propre processus, puis disparaîtrait.

Ce qui crée un sous-shell

Un sous-shell (subshell) est une copie du processus Bash, créée par fork() sans execve() : il connaît les variables, les fonctions et les options du parent au moment de la copie, mais tout ce qu'il change reste chez lui. Le manuel de Bash énumère les constructions qui en créent un :

ConstructionSous-shell ?Remarque
( commandes )ouic'est son rôle : isoler cd, umask, variables, options
{ commandes; }nonsimple regroupement, dans le shell courant
$( commande )ouila sortie revient, les variables non
commande &ouitâche d'arrière-plan
chaque élément d'un tube a | bouisauf le dernier avec shopt -s lastpipe (leçon 5)
<( commande ), >( commande )ouisubstitution de processus
coproc commandeouicoprocessus, voir plus loin

Le manuel ajoute un détail qui compte : dans un sous-shell, les pièges (trap) que le shell intercepte sont remis à leur valeur d'origine. Le piège EXIT de votre script ne s'exécute donc pas à la fin d'un $( ) ou d'un ( ), ce que la leçon 10 exploite.

$$ ne change pas, $BASHPID si

$$ vaut le PID du script, y compris dans ses sous-shells : le manuel de Bash le précise, conformément à la définition de POSIX. Pour connaître le PID du processus qui exécute réellement la ligne, Bash fournit $BASHPID (depuis la version 4.0). La variable BASH_SUBSHELL, elle, compte la profondeur d'imbrication : 0 dans le script, 1 dans un ( ), 2 dans un ( ( ) ).

Premier plan, arrière-plan

Une commande suivie de & est lancée en arrière-plan (on dit aussi asynchrone) : Bash crée le processus et passe immédiatement à la ligne suivante, sans attendre. Le PID de la dernière tâche lancée ainsi est dans $!. Le builtin wait attend ensuite sa fin et rend son code de sortie.

Dans un script, deux règles distinguent ces tâches de celles d'un terminal interactif :

  • pas de contrôle des tâches (job control) : pas de fg, pas de Ctrl-Z, pas de groupe de processus distinct par tâche. Toutes les tâches restent dans le groupe de processus du script, ce qui a des conséquences sur les signaux (voir « Sous le capot ») ;
  • entrée standard vide : le manuel de Bash précise que, sans contrôle des tâches, l'entrée standard par défaut d'une commande lancée avec & est /dev/null. Une tâche d'arrière-plan ne vole donc pas les lignes que lit votre boucle, sauf si vous lui redirigez explicitement l'entrée.

Le shell mémorise le code de sortie des tâches terminées que personne n'a encore attendues (dans la limite de CHILD_MAX, au plus 8 192 d'après le manuel) : on peut faire wait "$pid" longtemps après la fin du processus et obtenir encore son code. POSIX demande la même chose, et précise qu'un PID inconnu donne le code 127.

Parallélisme, concurrence et partage

Lancer deux tâches en même temps, c'est accepter qu'elles se disputent ce qu'elles partagent :

  • la sortie : deux processus qui écrivent sur le même terminal ou dans le même fichier mélangent leurs lignes, parfois au milieu d'une ligne ;
  • l'entrée standard : celui qui lit le premier prend les données ;
  • les fichiers : deux écritures dans le même fichier sans coordination produisent une condition de concurrence (race condition), un résultat qui dépend de l'ordre, imprévisible, dans lequel les processus ont été servis ;
  • les ressources des machines visées : lancer cent connexions SSH simultanées vers un parc, c'est cent démons sshd qui démarrent à la fois, et peut-être cent redémarrages de service au même instant.

D'où la règle de cette leçon : on parallélise avec une borne (pas plus de N tâches à la fois), en gardant le code de chaque tâche, et l'on ne parallélise que ce qui supporte de l'être.

Toute attente doit avoir une fin

Un script lancé par un minuteur n'a personne pour appuyer sur Ctrl-C. Toute commande qui dépend du réseau ou d'un autre service doit donc avoir une durée maximale. Deux moyens, que la page BashFAQ/068 de Greg's Wiki classe dans cet ordre de préférence :

  1. le délai natif de l'outil, quand il existe : curl --connect-timeout et --max-time, ssh -o ConnectTimeout=, psql avec connect_timeout et statement_timeout. L'outil sait s'arrêter proprement et produit un message clair ;
  2. timeout, de GNU coreutils, qui lance la commande et lui envoie un signal si elle dépasse la durée donnée, pour les outils qui n'ont pas de délai propre ou pour borner une durée totale.

En pratique

Les sorties ci-dessous ont été produites sur une machine de test sous Ubuntu 24.04 (Bash 5.2.21, coreutils 9.4), avec LC_ALL=C.UTF-8. Les PID et les durées varient d'une machine à l'autre ; ce sont les rapports entre durées qui comptent. Comme depuis la leçon 6, les messages des scripts sont montrés avec HORODATER=0, sans horodatage.

Voir les sous-shells

Le script voir-processus.sh :

#!/usr/bin/env bash
echo "parent : \$\$=$$ BASHPID=$BASHPID"
( echo "sous-shell : \$\$=$$ BASHPID=$BASHPID" )
{ echo "groupe : \$\$=$$ BASHPID=$BASHPID"; }
echo "substitution : $(echo "\$\$=$$ BASHPID=$BASHPID")"
echo x | { read -r v; echo "tube : BASHPID=$BASHPID"; }
n=1; ( n=2 ); echo "après ( ) : n=$n"
{ n=3; }; echo "après { } : n=$n"
$ bash voir-processus.sh
parent : $$=949997 BASHPID=949997
sous-shell : $$=949997 BASHPID=949998
groupe : $$=949997 BASHPID=949997
substitution : $$=949997 BASHPID=949999
tube : BASHPID=950001
après ( ) : n=1
après { } : n=3

$$ ne bouge jamais ; $BASHPID révèle un nouveau processus pour ( ), pour $( ) et pour le second élément du tube, mais pas pour { }. L'affectation n=2 faite dans le sous-shell est perdue, celle du groupe { } reste. Notez la syntaxe stricte du groupe : une espace après {, et un ; ou un saut de ligne avant }, parce que ce sont des mots réservés et non des opérateurs.

Le choix entre les deux est donc un choix d'isolation. ( cd /srv/donnees/exports && tar czf - . ) change de répertoire sans affecter la suite du script : c'est voulu. { commande1; commande2; } > fichier redirige deux commandes ensemble sans payer de processus : on n'a besoin que du regroupement.

Mesurer le coût d'un fork

Deux boucles de deux mille tours qui calculent l'heure courante en secondes, l'une avec la commande externe date, l'autre avec le format %(...)T de printf, interne à Bash (leçon 3) :

$ time (for i in {1..2000}; do d=$(date +%s); done)

real	0m3.178s
user	0m0.911s
sys	0m2.428s
$ time (for i in {1..2000}; do printf -v d '%(%s)T' -1; done)

real	0m0.030s
user	0m0.025s
sys	0m0.005s

Un facteur cent. Chaque tour de la première boucle crée un sous-shell pour $( ), puis un execve de /usr/bin/date : environ 1,5 ms par tour sur cette machine, surtout passées dans le noyau (colonne sys). Avec une commande interne dans la substitution, on économise l'execve mais pas le fork :

$ time (for i in {1..2000}; do x=$(echo a); done)

real	0m1.410s
$ time (for i in {1..2000}; do x=${HOSTNAME%%.*}; done)

real	0m0.011s

Ces millisecondes deviennent des minutes dans rapport-journaux. La version de Camille compte les codes HTTP ligne à ligne en extrayant le champ avec cut :

declare -A nb
while IFS= read -r ligne; do
  code=$(echo "$ligne" | cut -d' ' -f12)    # deux processus par ligne
  nb[$code]=$(( ${nb[$code]:-0} + 1 ))
done < "$journal"

Sur un fichier d'essai de 20 000 lignes au format des journaux reçus :

2026-10-07T08:41:07+02:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 08:41:07] "GET /signalements HTTP/1.1" 200 -

Le code HTTP est le douzième champ séparé par des espaces.

VersionProcessus créésDurée mesurée
$(echo "$ligne" | cut ...) à chaque ligneenviron 60 000 (sous-shell, echo dans le tube, cut)84,5 s
read -r _ _ _ _ _ _ _ _ _ _ _ code _ (découpage par read, leçon 5)aucun0,15 s
awk '{n[$12]++} END {for (c in n) print c, n[c]}'un seul0,01 s

Les trois donnent le même résultat. Un journal d'une journée en compte plusieurs centaines de milliers de lignes : les quarante minutes de la nuit s'expliquent sans autre mystère. La règle à retenir n'est pas « jamais de commande externe dans une boucle », mais : dans une boucle qui tourne sur des données, chaque processus se multiplie par le nombre de lignes. Pour un traitement ligne à ligne de gros volumes, un seul awk sur tout le fichier l'emporte de loin, et la boucle Bash se garde pour les traitements où chaque élément déclenche de toute façon une action coûteuse (une connexion SSH, une requête HTTP).

Lancer en arrière-plan et attendre

( sleep 0.3; exit 3 ) & p1=$!
( sleep 0.1; exit 0 ) & p2=$!
( sleep 0.2; exit 7 ) & p3=$!
wait "$p1"; echo "wait p1 -> $?"
wait "$p2"; echo "wait p2 (terminé depuis longtemps) -> $?"
wait; echo "wait sans argument -> $?"
wait p1 -> 3
wait p2 (terminé depuis longtemps) -> 0
wait sans argument -> 0

Trois enseignements :

  • $! se lit immédiatement après le & : la tâche suivante l'écrase. On le range dans une variable, ou mieux dans un tableau (exemple plus bas) ;
  • wait "$pid" rend le code de cette tâche, même si elle est terminée depuis longtemps : Bash l'a gardé ;
  • wait sans argument attend toutes les tâches, mais rend toujours 0, quel que soit leur sort. Le manuel le dit en toutes lettres. Un script qui lance dix copies en arrière-plan puis fait wait et affiche « terminé » n'a vérifié aucune d'elles. C'est le piège principal de cette leçon.

Warning

Sous set -e (leçon 9), wait "$pid" sur une tâche qui a échoué fait sortir le script à cet endroit, avec le code de la tâche, avant que vous ayez pu attendre les autres ou afficher quoi que ce soit. Récupérez le code sans déclencher errexit : code=0; wait "$pid" || code=$?.

$ bash -c 'set -e; (exit 3) & p=$!; wait "$p"; echo "jamais affiché"'; echo "code=$?"
code=3
$ bash -c 'set -e; (exit 3) & p=$!; code=0; wait "$p" || code=$?; echo "récupéré : $code"'
récupéré : 3

wait -n et wait -p

wait -n (Bash 4.3) attend la prochaine tâche qui se termine, quelle qu'elle soit, et rend son code. Seul, il ne dit pas laquelle : c'est ce que corrige wait -p variable (Bash 5.1), qui range son PID dans la variable. Les deux sont propres à Bash ; POSIX ne connaît que wait [pid...].

( sleep 0.2; exit 5 ) &
( sleep 0.1; exit 6 ) &
wait -n -p qui; echo "1 : $? ($qui)"
wait -n -p qui; echo "2 : $? ($qui)"
wait -n -p qui; echo "3 : $? (qui=${qui-non défini})"
1 : 6 (950807)
2 : 5 (950806)
3 : 127 (qui=non défini)

La tâche la plus courte arrive en premier, avec son PID. Quand il ne reste plus rien à attendre, wait -n rend 127 et la variable de -p est désaffectée (le manuel : the variable will be unset initially) ; sous set -u, la lire ensuite provoquerait une erreur.

Cette démonstration est trompeuse par sa simplicité. wait -n a une limite que le manuel ne dit pas : il ne voit que les tâches encore présentes dans la table des tâches du shell. Or Bash retire de cette table les tâches dont il considère avoir « notifié » la fin, et il le fait, a expliqué son mainteneur Chet Ramey sur la liste bug-bash le 28 janvier 2024, avant de lire la commande suivante, y compris dans un shell non interactif. Le code d'une tâche ainsi retirée n'est pas perdu : Bash le garde dans une seconde table, celle des tâches terminées, que POSIX lui impose de conserver pour wait. Mais, écrit-il, wait -n « ne regarde pas dans la table des codes sauvegardés » : son rôle est d'attendre de nouvelles fins de tâches.

Le cas le plus courant en pratique est celui d'une tâche tuée par un signal (un kill, le SIGTERM d'un arrêt, le noyau à court de mémoire). Le script tache-tuee.sh :

#!/usr/bin/env bash
sleep 30 & p=$!
kill "$p"                      # la tâche meurt par SIGTERM
sleep 0.2
wait -n -p qui; echo "wait -n : code $?, qui=${qui-non défini}"
wait "$p";      echo "wait \$p : code $?"
$ bash tache-tuee.sh
wait -n : code 127, qui=non défini
wait $p : code 143

wait -n répond qu'il n'y a plus rien à attendre (127) et ne désigne aucune tâche, alors que wait "$p" retrouve bien le code 143 (128 + 15, SIGTERM). Une réserve de tâches qui compte sur wait -n -p pour recueillir les codes ne saura jamais que cette tâche a existé ; selon la façon dont elle est écrite, elle oublie un échec, boucle indéfiniment ou s'arrête sur une variable non définie sous set -u.

Chet Ramey présente ce comportement comme un choix de conception plutôt que comme un bogue, en se demandant s'il faut le changer. Bash 5.3, publié le 5 juillet 2025, l'a changé : son annonce indique que wait -n peut désormais rendre des tâches dont l'utilisateur a déjà été notifié. Ubuntu 24.04 et Debian 13 livrent Bash 5.2 : il faut composer avec l'ancien comportement. D'où la règle de cette leçon : wait -n sert à attendre qu'une place se libère, jamais à recueillir un code de sortie. Les codes se recueillent par wait "$pid", tâche par tâche.

Une réserve de tâches bornée

Lancer une tâche par machine sans limite convient à deux machines, pas à quarante. La forme d'une réserve de tâches (job pool) sûre en Bash 5.2 : un tableau associatif qui relie chaque hôte au PID de sa tâche (leçon 7), une boucle qui attend qu'une place se libère avant chaque lancement, puis un wait par PID pour recueillir les codes.

max=4                          # tâches simultanées au plus
declare -A pid_de=()           # hôte -> PID de sa tâche
declare -A code_de=()          # hôte -> code de sortie

for hote in "${hotes[@]}"; do
  while (( $(jobs -rp | wc -l) >= max )); do
    wait -n || true            # une tâche s'est terminée, ou il n'y en a plus
  done
  traiter "$hote" > "$journaux/$hote.log" 2>&1 &
  pid_de[$hote]=$!
done

for hote in "${hotes[@]}"; do
  code=0
  wait "${pid_de[$hote]}" || code=$?
  code_de[$hote]=$code
done

Lisons les points délicats :

  • jobs -rp liste les PID des tâches en cours d'exécution (-r, running) ; wc -l les compte. La substitution coûte un processus, mais une fois par lancement de tâche, pas par ligne de données ;
  • wait -n || true bloque jusqu'à la fin d'une tâche quelconque. Son code ne nous intéresse pas, et || true évite que set -e arrête le script si cette tâche a échoué, ou si wait -n rend 127 parce que la tâche terminée a déjà quitté la table. Dans ce dernier cas, la boucle recompte simplement les tâches en cours : au pire, une place reste inoccupée un instant, aucune tâche n'est perdue ;
  • la seconde boucle recueille chaque code par wait "$pid", qui consulte la table des tâches terminées : fiable, même pour une tâche finie depuis longtemps ;
  • code=0; wait ... || code=$? récupère l'échec sans réveiller set -e ;
  • les codes sont lus dans l'ordre de la liste des hôtes : le rapport final est stable d'une exécution à l'autre, quel que soit l'ordre d'arrivée.

Vérifiée sur une machine de test avec dix tâches de 0,3 seconde et max=3, la réserve n'a jamais eu plus de trois tâches simultanées et a terminé en 1,2 seconde (quatre vagues), au lieu de 3 secondes en série. Répétée vingt fois, à la racine du script, dans un sous-shell ou dans un tube, elle a rendu les dix codes à chaque fois. Cette mécanique est réutilisée par verifier-sante et deployer plus bas.

xargs -P, quand chaque tâche est une commande

Quand chaque tâche est une commande externe et que seul compte le succès global, xargs fait la même chose en une ligne. Compresser en parallèle les journaux d'hier reçus sur sig-outils :

$ find /srv/donnees/journaux -name 'syslog.log.1' -print0 \
    | xargs -0 -r -n 1 -P 4 gzip --
  • -print0 et -0 : noms séparés par l'octet nul, le seul caractère qu'un chemin ne peut pas contenir (leçon 5) ;
  • -r (--no-run-if-empty, extension GNU) : ne rien lancer si la liste est vide ;
  • -n 1 : un fichier par commande. La page xargs(1) le rappelle : avec -P, il faut -n ou -L, sinon xargs risque de tout passer à une seule commande, et il n'y aura rien à paralléliser ;
  • -P 4 : quatre gzip au plus en même temps. -P 0 lance autant de processus que possible, ce qui n'est presque jamais ce que l'on veut ;
  • -- : fin des options pour gzip, au cas où un nom commencerait par un tiret.

Le code de sortie de xargs résume celui des commandes, d'après sa page de manuel :

CodeSens
0toutes les commandes ont réussi
123au moins une commande a rendu un code entre 1 et 125
124une commande a rendu 255 : xargs s'arrête aussitôt
125une commande a été tuée par un signal
126, 127la commande ne peut pas être lancée, ou est introuvable
$ printf '%s\0' a b c | xargs -0 -P 2 -n 1 sh -c 'echo "traite $1"; [ "$1" != b ]' _
traite a
traite b
traite c
$ echo $?
123

On sait qu'une commande a échoué, pas laquelle. Pour un rapport par élément, ou pour appeler une fonction du script (xargs ne lance que des programmes ; il faudrait export -f et bash -c, dont la leçon 6 a montré les risques), la réserve de tâches en Bash reste la bonne réponse.

Note

GNU parallel pousse la même idée beaucoup plus loin : --jobs pour la borne, --tag pour préfixer chaque ligne de sortie par son argument, --keep-order pour restituer les sorties dans l'ordre des arguments, --halt soon,fail=1 pour ne plus lancer de tâche après un échec, --joblog pour un journal par tâche avec durée et code de sortie, qui permet aussi de reprendre un traitement interrompu. Il s'installe par le paquet parallel. Attention à un homonyme : le paquet moreutils fournit lui aussi un /usr/bin/parallel, plus simple et à la syntaxe différente. Vérifiez lequel est installé (parallel --version) avant de copier un exemple.

Borner la durée avec timeout

timeout DURÉE commande lance la commande et, si elle dure plus de DURÉE (suffixes s, m, h, d), lui envoie SIGTERM. Les codes de sortie, d'après timeout(1), et vérifiés :

$ timeout 1 sleep 5; echo "code=$?"
code=124
$ timeout 5 bash -c 'exit 3'; echo "code=$?"
code=3
$ timeout --preserve-status 1 sleep 5; echo "code=$?"
code=143
$ timeout -s TERM -k 1 1 bash -c 'trap "" TERM; sleep 5'; echo "code=$?"
Killed
code=137
$ timeout 1 nonexistant; echo "code=$?"
timeout: failed to run command ‘nonexistant’: No such file or directory
code=127
CodeSens
124délai dépassé, la commande a été arrêtée par le signal
125timeout lui-même a échoué (option invalide)
126, 127commande non exécutable, ou introuvable
137SIGKILL envoyé après le délai de -k
autrecode de la commande, qui a fini à temps
  • -k 10 (--kill-after) : si la commande ignore SIGTERM (ici, trap "" TERM le fait ignorer), envoyer SIGKILL dix secondes plus tard. Sans -k, une commande qui ignore SIGTERM n'est jamais arrêtée ;
  • --preserve-status : rendre le code de la commande même en cas de délai dépassé (143, soit 128 + 15 pour SIGTERM). On perd alors la distinction claire du 124 ; à éviter dans un script qui doit dire « trop long » ;
  • -s : choisir le signal. SIGTERM, la valeur par défaut, laisse la commande se terminer proprement ; BashFAQ/068 déconseille les outils qui envoient d'emblée SIGKILL, qui ne laisse aucune chance de nettoyer.

Un délai natif reste préférable quand il existe, parce que l'outil sait ce qu'il interrompt. Pour la sonde de santé :

curl --fail --silent --show-error --connect-timeout 3 --max-time 5 \
  "http://172.16.8.11:8000/sante"

--connect-timeout borne l'établissement de la connexion, --max-time la durée totale ; en cas de dépassement, curl rend le code 28 (Operation timeout dans la liste des codes de curl(1)) avec un message qui dit à quelle étape il a renoncé, plus parlant qu'un 124. Sans ces options, curl n'a aucune limite sur la durée totale : c'est ce qui a figé la sonde de Camille. timeout sert alors de filet autour d'une étape qui enchaîne plusieurs commandes, ou d'un outil sans délai propre.

Caution

Arrêter un client SSH n'arrête pas forcément la commande lancée sur la machine distante. Si timeout 60 ssh sig-app-1 'longue-commande' expire, ssh meurt localement ; de l'autre côté, la commande peut continuer jusqu'à ce qu'elle essaie d'écrire dans la connexion fermée. Pour borner le travail distant, placez aussi le délai de l'autre côté : ssh sig-app-1 'timeout 50 longue-commande'.

SSH dans une boucle : l'entrée standard avalée

Voici, simplifiée, la boucle de Camille qui n'a déployé que sur la première machine :

while IFS= read -r hote; do
  ssh "deploiement@$hote" "sudo /opt/signalements/bin/activer-version $nom"
done < hotes.txt

ssh transmet son entrée standard à la commande distante. Ici, son entrée standard est celle de la boucle : le fichier hotes.txt. Le premier ssh lit donc tout le reste du fichier pour l'envoyer à activer-version, qui n'en fait rien ; au tour suivant, read trouve la fin du fichier et la boucle s'arrête. Aucune erreur, aucun message. On le reproduit sans réseau avec une fonction qui, comme ssh, consomme son entrée :

$ printf 'sig-app-1\nsig-app-2\n' > hotes.txt
$ faux_ssh() { cat > /dev/null; echo "commande sur $1"; }
$ while IFS= read -r h; do faux_ssh "$h"; done < hotes.txt
commande sur sig-app-1
$ while IFS= read -r h; do faux_ssh "$h" < /dev/null; done < hotes.txt
commande sur sig-app-1
commande sur sig-app-2

Trois corrections, à combiner selon le cas :

  • ssh -n : la page ssh(1) dit que cette option redirige l'entrée depuis /dev/null, « en fait, empêche de lire l'entrée standard ». C'est la correction la plus lisible ;
  • < /dev/null sur la commande, qui vaut pour tous les outils qui lisent l'entrée (ffmpeg, mysql, ssh, un script qui fait read) ;
  • lire la liste sur un autre descripteur : while IFS= read -r -u 3 hote; do ...; done 3< hotes.txt. L'entrée standard reste celle du script, la liste passe par le descripteur 3, et plus aucune commande de la boucle ne peut la voler.

Et parfois, l'entrée standard doit aller à ssh : c'est ainsi qu'on envoie une archive à décompresser de l'autre côté sans passer par scp, ssh hote 'tar -xzf - -C ...' < archive.tgz. Dans ce cas, on ne lit pas la liste des machines sur l'entrée standard.

Le manuel de Bash donne un dernier élément : une commande lancée avec & dans un script reçoit /dev/null comme entrée par défaut. Un ssh ... & dans la même boucle n'aurait pas avalé le fichier. Ne comptez pas sur ce hasard : écrivez -n.

Sorties entremêlées

Deux tâches parallèles qui écrivent sur la même sortie produisent un texte dont l'ordre dépend de l'ordonnanceur. Pire, un programme dont la sortie n'est pas un terminal écrit souvent par blocs de plusieurs kilo-octets, pas ligne par ligne : une ligne peut être coupée en deux par la sortie d'une autre tâche. La page xargs(1) le dit pour ses propres tâches : la sortie sera produite dans un ordre indéterminé, « et très probablement mélangée ».

Deux façons de rester lisible :

  • un fichier par tâche, affiché ensuite en bloc : c'est ce que fait la réserve de tâches ci-dessus avec "$journaux/$hote.log". On lit le rapport d'une machine d'un seul tenant, une fois qu'elle a terminé ;
  • un préfixe par ligne, si l'on veut suivre en direct : traiter "$hote" 2>&1 | sed -u "s/^/[$hote] /" &. L'option -u de GNU sed vide la sortie à chaque ligne : chaque ligne part en une seule écriture, assez courte pour ne pas être coupée par une autre (sur un tube, le noyau garantit qu'une écriture de moins de PIPE_BUF, 4 096 octets sous Linux, n'est pas entrelacée).

Avec un tube en arrière-plan, $! désigne le dernier élément (sed). Avec set -o pipefail, wait "$!" rend tout de même le code du tube entier, donc l'échec de traiter :

$ bash -c 'set -o pipefail; { echo a; exit 3; } | sed "s/^/[x] /" & wait $!; echo "code=$?"'
[x] a
code=3
$ bash -c '{ echo a; exit 3; } | sed "s/^/[x] /" & wait $!; echo "code=$?"'
[x] a
code=0

Sans pipefail, le préfixe masque l'échec. Encore une raison de l'activer partout.

verifier-sante en parallèle

On assemble : une sonde par machine, en parallèle, avec des délais natifs, une sortie par machine, un code par machine, et un code global qui dit si toutes vont bien. Les fonctions journaliser, avertir et mourir viennent de lib/commun.sh (leçon 6), le nettoyage par trap de la leçon 10.

#!/usr/bin/env bash
# verifier-sante : interroge GET /sante sur chaque machine applicative, en parallèle.
# Code de sortie : 0 si toutes répondent, 1 sinon.
set -euo pipefail
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"

declare -A ADRESSES=([sig-app-1]=172.16.8.11 [sig-app-2]=172.16.8.12)
DELAI=${DELAI:-5}             # secondes, durée totale d'une sonde

sonder() {
  local hote=$1 corps
  if ! corps=$(curl --fail --silent --show-error \
                    --connect-timeout 3 --max-time "$DELAI" \
                    "http://${ADRESSES[$hote]}:8000/sante" 2>&1); then
    printf '%s : KO (%s)\n' "$hote" "$corps"
    return 1
  fi
  printf '%s : OK %s\n' "$hote" "$corps"
}

main() {
  local hote code echecs=0
  local -A pid_de=()
  dossier=$(mktemp -d)                  # globale : le piège EXIT s'exécute après main
  trap 'rm -rf -- "$dossier"' EXIT

  for hote in "${!ADRESSES[@]}"; do     # toutes les sondes en même temps
    sonder "$hote" > "$dossier/$hote" 2>&1 &
    pid_de[$hote]=$!
  done

  for hote in "${!pid_de[@]}"; do       # puis un code par machine
    code=0
    wait "${pid_de[$hote]}" || code=$?
    cat -- "$dossier/$hote"
    (( code == 0 )) || echecs=$((echecs + 1))
  done

  (( echecs == 0 )) || mourir "$echecs machine(s) en échec"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Avec deux machines, la durée totale n'excède plus DELAI plus une fraction de seconde, que sig-app-1 réponde ou non : la sonde se termine toujours, et le minuteur la relance cinq minutes plus tard. Et l'on écrit echecs=$((echecs + 1)) plutôt que (( echecs++ )) : une expression arithmétique qui vaut 0 rend le code 1, et (( echecs++ )) vaut 0 au premier échec (post-incrément) ; sous set -e, le script s'arrêterait sur sa propre comptabilité.

Le chargement de lib/commun.sh et la garde finale sont ceux de la leçon 6. Essai sur une copie de travail, avec une doublure de curl placée en tête du PATH (la leçon 12 en fait une méthode) qui simule une API figée sur 172.16.8.11 et reproduit le message de curl dans ce cas :

$ bin/verifier-sante; echo "code=$?"
sig-app-1 : KO (curl: (28) Operation timed out after 5002 milliseconds with 0 bytes received)
sig-app-2 : OK {"etat": "ok", "version": "1.3.2"}
verifier-sante : erreur : 1 machine(s) en échec
code=1

Le tout arrive au bout de DELAI secondes au plus. Pas de borne ici : avec deux machines, on lance tout. Les lignes ne se mélangent pas puisque chacune est affichée d'un bloc par le script principal ; leur ordre est celui, arbitraire mais sans importance, des clés du tableau associatif.

deployer : envoyer en parallèle, activer en série

Le déploiement n'est pas une sonde : il modifie les machines. Le paralléliser entièrement redémarrerait le service partout au même instant, et une version défectueuse mettrait tout Signalements hors service d'un coup. La bonne découpe :

  1. envoyer l'archive dans /opt/signalements/versions/ : rien n'est encore en service, c'est la partie longue (un transfert réseau par machine), on peut la faire en parallèle sur toutes les machines, avec une borne ;
  2. activer la version (la commande distante activer-version décompresse l'archive, bascule le lien /opt/signalements/courant et redémarre le service), puis vérifier /sante : une machine à la fois, et l'on s'arrête au premier échec, en revenant en arrière sur celle-ci. C'est le principe de la mise à jour progressive.

On reprend la construction de la leçon 7 : le tableau options_ssh, le compte distant deploiement, la clé et le fichier known_hosts dédiés de /etc/signalements/deploiement/, et surtout une commande distante courte et fixe, sudo /opt/signalements/bin/activer-version NOM, la seule que la règle sudoers du compte autorise. Le script reçoit, comme en leçon 7, le chemin de l'archive (dist/signalements-1.4.0.tgz), dont il tire le nom de la version, signalements-1.4.0.

#!/usr/bin/env bash
# deployer ARCHIVE : déploie dist/signalements-X.Y.Z.tgz sur les machines applicatives.
# Envoi en parallèle, activation machine par machine avec vérification de /sante.
set -euo pipefail
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"

declare -A ADRESSES=([sig-app-1]=172.16.8.11 [sig-app-2]=172.16.8.12)
HOTES=(sig-app-1 sig-app-2)                       # ordre d'activation
COMPTE_DISTANT=deploiement
options_ssh=(
  -o BatchMode=yes
  -o ConnectTimeout=5
  -o ServerAliveInterval=10 -o ServerAliveCountMax=3
  -o StrictHostKeyChecking=yes
  -o UserKnownHostsFile=/etc/signalements/deploiement/known_hosts
  -i /etc/signalements/deploiement/cle
)
PARALLELE=${PARALLELE:-4}
DELAI_ENVOI=300                                   # secondes, par machine

distant() {                    # distant HÔTE COMMANDE [ARGUMENTS...] : sans entrée standard
  local hote=$1
  shift
  ssh -n "${options_ssh[@]}" "$COMPTE_DISTANT@$hote" "${*@Q}"
}

envoyer() {                    # envoyer HÔTE : copie l'archive, rien n'est encore en service
  timeout -k 10 "$DELAI_ENVOI" \
    scp -q "${options_ssh[@]}" -- "$archive" "$COMPTE_DISTANT@$1:/opt/signalements/versions/"
}

activer() {                    # activer HÔTE NOM : la seule commande que sudoers autorise
  distant "$1" sudo /opt/signalements/bin/activer-version "$2"
}

attendre_sante() {
  local essai
  for essai in {1..10}; do
    curl --fail --silent --connect-timeout 2 --max-time 3 -o /dev/null \
      "http://${ADRESSES[$1]}:8000/sante" && return 0
    sleep 3
  done
  return 1
}

main() {
  archive=${1:?usage : deployer ARCHIVE}
  [[ -r $archive ]] || mourir "archive illisible : $archive"
  nom=${archive##*/}
  nom=${nom%.tgz}
  [[ $nom =~ ^signalements-[0-9]+\.[0-9]+\.[0-9]+$ ]] \
    || mourir "nom d'archive inattendu : $archive"

  local hote precedente code pretes=1
  local -A pid_de=()
  journaux=$(mktemp -d)                 # globale, pour le piège EXIT
  trap 'rm -rf -- "$journaux"' EXIT

  # 1. Envoyer l'archive en parallèle, PARALLELE machines au plus
  for hote in "${HOTES[@]}"; do
    while (( $(jobs -rp | wc -l) >= PARALLELE )); do wait -n || true; done
    envoyer "$hote" > "$journaux/$hote" 2>&1 &
    pid_de[$hote]=$!
  done

  for hote in "${HOTES[@]}"; do
    code=0
    wait "${pid_de[$hote]}" || code=$?
    if (( code != 0 )); then
      avertir "$hote : envoi en échec (code $code)"
      sed "s/^/[$hote] /" -- "$journaux/$hote" >&2
      pretes=0
    fi
  done
  (( pretes )) || mourir "aucune activation : l'archive doit d'abord être présente partout"

  # 2. Activer en série, arrêt au premier échec
  for hote in "${HOTES[@]}"; do
    precedente=$(distant "$hote" readlink /opt/signalements/courant) \
      || mourir "$hote : version en service illisible"
    precedente=${precedente##*/}
    journaliser "$hote : $precedente -> $nom"
    if activer "$hote" "$nom" && attendre_sante "$hote"; then
      journaliser "$hote : $nom en service"
    else
      avertir "$hote : échec, retour à $precedente"
      activer "$hote" "$precedente" && attendre_sante "$hote" \
        || avertir "$hote : retour arrière en échec, intervention requise"
      mourir "déploiement interrompu sur $hote ; les machines suivantes n'ont pas été touchées"
    fi
  done
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Ce qui relève de cette leçon :

  • envoyer tourne en arrière-plan, une tâche par machine, dans la réserve bornée par PARALLELE ; son entrée standard est /dev/null, celle des tâches & d'un script, et scp n'en a pas besoin. Sa sortie part dans un fichier par machine, affiché seulement en cas d'échec, préfixé par le nom de la machine ;
  • distant utilise ssh -n pour toutes les commandes distantes, et transmet ses arguments protégés par ${*@Q} (une forme de la leçon 3, comme ${t[*]@Q} en leçon 7) : le shell distant reçoit 'readlink' '/opt/signalements/courant', et non une chaîne à redécouper ;
  • timeout -k 10 300 borne un envoi qui resterait bloqué (un lien saturé, une connexion qui ne meurt pas) ; ServerAliveInterval=10 et ServerAliveCountMax=3 font en plus abandonner ssh et scp après trente secondes sans nouvelles du serveur, et BatchMode=yes leur interdit de demander un mot de passe que personne ne saisira ;
  • aucune activation tant qu'un envoi a échoué : on ne commence pas une mise à jour progressive dont on sait qu'elle ne pourra pas aller au bout ;
  • l'activation s'arrête au premier échec : si sig-app-1 ne répond pas après l'activation, on y rétablit la version précédente (lue par readlink avant de commencer) et sig-app-2 n'est jamais touchée. Le service reste rendu par au moins une machine. Si l'échec survient sur sig-app-2, sig-app-1 reste en 1.4.0 : le parc est dans un état mixte, que le message signale et qu'il faut trancher, en relançant le déploiement après correction ou en redéployant la version précédente ;
  • la bascule atomique se fait de l'autre côté : activer-version prépare le nouveau lien à côté de l'ancien et le renomme par-dessus, la technique de la leçon 10 (rename(2) remplace en une seule opération). Le script local n'envoie qu'un nom de version validé, jamais de code ;
  • activer et attendre_sante sont appelées dans un if : set -e y est suspendu (leçon 9), et c'est voulu, puisque ces fonctions rendent explicitement leur statut ;
  • journaux, archive et nom sont globales : le piège EXIT s'exécute après la fin de main, quand ses variables locales ont disparu, et envoyer, lancée en arrière-plan, lit archive dans la copie du shell qu'elle reçoit.

Essais sur une copie de travail, avec des doublures de scp, ssh et curl qui simulent les machines (la version en service y est 1.3.2). Tout va bien :

$ bin/deployer dist/signalements-1.4.0.tgz; echo "code=$?"
deployer : sig-app-1 : signalements-1.3.2 -> signalements-1.4.0
deployer : sig-app-1 : signalements-1.4.0 en service
deployer : sig-app-2 : signalements-1.3.2 -> signalements-1.4.0
deployer : sig-app-2 : signalements-1.4.0 en service
code=0

activer-version échoue sur sig-app-1 (son message passe par la sortie d'erreur de ssh) :

$ bin/deployer dist/signalements-1.4.0.tgz; echo "code=$?"
deployer : sig-app-1 : signalements-1.3.2 -> signalements-1.4.0
activer-version : échec
deployer : attention : sig-app-1 : échec, retour à signalements-1.3.2
deployer : erreur : déploiement interrompu sur sig-app-1 ; les machines suivantes n'ont pas été touchées
code=1

L'envoi échoue vers sig-app-2, injoignable (la doublure reproduit les messages d'OpenSSH dans ce cas, et le code 255 que scp rend quand la connexion échoue) :

$ bin/deployer dist/signalements-1.4.0.tgz; echo "code=$?"
deployer : attention : sig-app-2 : envoi en échec (code 255)
[sig-app-2] ssh: connect to host sig-app-2 port 22: Connection timed out
[sig-app-2] scp: Connection closed
deployer : erreur : aucune activation : l'archive doit d'abord être présente partout
code=1

Pour deux machines, PARALLELE=4 ne change rien ; il prend son sens le jour où la mise à l'échelle ajoute des instances. La borne protège aussi le poste qui déploie : quarante transferts d'archive simultanés saturent un lien montant ordinaire.

Coprocessus, en bref

Un coprocessus (coproc) est une tâche d'arrière-plan reliée au shell par deux tubes, un pour lui écrire, un pour le lire. Bash range leurs descripteurs dans un tableau, et le PID dans NOM_PID :

$ coproc CALC { bc -l; }
$ echo '2/3' >&"${CALC[1]}"
$ read -r r <&"${CALC[0]}"; echo "$r"
.66666666666666666666
$ kill "$CALC_PID"

On garde ainsi un programme ouvert pour lui poser de nombreuses questions sans payer un processus à chacune. L'usage est rare et délicat : si le programme tamponne sa sortie, read attend indéfiniment ; et la section BUGS du manuel prévient qu'il ne peut y avoir qu'un coprocessus actif à la fois. Pour un dialogue suivi avec un programme, Python ou expect sont plus adaptés. Retenez surtout que la construction existe, pour la reconnaître dans un script hérité.

Détacher un traitement : pas avec nohup

La leçon 10 de Premiers pas a présenté nohup, disown et setsid, qui laissent un processus survivre à la fermeture de la session. Dans un script d'exploitation, ils ont tous le même défaut : le processus détaché n'a plus de parent qui attend son code de sortie, ses journaux finissent dans un fichier oublié, et rien ne borne ses ressources.

Pour lancer un long traitement qui doit survivre à votre connexion, confiez-le à systemd, qui en fait une unité temporaire :

$ sudo systemd-run --unit=reindexation --uid=signalements \
    -p MemoryMax=1G -p RuntimeMaxSec=2h \
    /opt/signalements/bin/reindexer
$ journalctl -u reindexation -f

Le traitement a un nom, son journal, une limite mémoire et une durée maximale (RuntimeMaxSec=, l'équivalent de timeout côté systemd), et systemctl status reindexation dit où il en est. Avec --wait, systemd-run attend la fin de l'unité et rend son code : on garde la sémantique d'une commande au premier plan avec l'isolation d'un service. La leçon 11 du cours d'administration détaille les limites de ressources, la leçon 10 du cours d'administration systemd-run --on-active=.

Dernier outil de cette famille : exec. En dernière ligne d'un script d'enveloppe (wrapper), exec programme "$@" remplace le shell par le programme au lieu de créer un enfant. Le programme garde le PID du script, reçoit directement les signaux que systemd ou Docker envoient à ce PID, et l'on économise un processus qui ne faisait qu'attendre.

Sous le capot

Compter les processus avec strace

strace -f suit un programme et ses enfants ; en ne gardant que clone (l'appel que la bibliothèque C utilise pour réaliser fork()) et execve, on voit exactement ce que Bash crée :

$ strace -f -e trace=clone,clone3,execve -o trace.txt \
    bash -c 'x=$(/bin/echo a); y=$(echo b); z=$(< hotes.txt); ( /bin/true ); { /bin/true; }; :'
$ grep -E 'clone|execve' trace.txt | cut -c1-70
1403678 execve("/usr/bin/bash", ["bash", "-c", "x=$(/bin/echo a); y=$(
1403678 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD
1403679 execve("/bin/echo", ["/bin/echo", "a"], 0x5ce08f984400 /* 67 v
1403678 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD
1403678 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD
1403682 execve("/bin/true", ["/bin/true"], 0x5ce08f9895c0 /* 67 vars *
1403678 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD
1403683 execve("/bin/true", ["/bin/true"], 0x5ce08f985390 /* 67 vars *

Quatre clone, pour six constructions :

  1. $(/bin/echo a) : un clone pour le sous-shell, puis un execve dans ce même enfant. Quand la dernière commande d'un sous-shell est externe, Bash ne crée pas de second processus : le sous-shell devient directement echo. Le fichier NEWS de Bash 5.1 mentionne le renforcement de cette optimisation : Bash attempts to optimize the number of times it forks when executing commands in subshells and from `bash -c'.
  2. $(echo b) : un clone, aucun execve. Le sous-shell exécute le builtin echo et se termine. On paie le fork, pas le chargement d'un programme : c'est le cas x=$(echo a) mesuré plus haut.
  3. $(< hotes.txt) : aucun processus. Cette forme, équivalente à $(cat hotes.txt) d'après le manuel, est lue par Bash 5.2 directement, sans sous-shell, comme le montre la trace.
  4. ( /bin/true ) : un clone et un execve dans l'enfant, même optimisation qu'en 1.
  5. { /bin/true; } : un clone et un execve. Le groupe ne crée pas de sous-shell, mais /bin/true est une commande externe : il faut bien un processus pour elle.
  6. : : commande interne, rien. Et c'est la dernière commande de bash -c : si elle avait été externe, Bash l'aurait lancée par execve à la place de lui-même, sans clone. C'est l'optimisation que fait aussi exec à la fin d'une enveloppe, mais automatiquement.

Bash 5.3, publié en juillet 2025, ajoute la forme ${ commande; }, qui capture la sortie d'une commande dans le shell courant, sans processus ni tube. Elle n'existe pas encore dans le Bash 5.2 d'Ubuntu 24.04 et de Debian 13 : à connaître, pas à utiliser dans un script destiné à ces distributions.

Comment wait retrouve ses enfants

Quand un enfant se termine, le noyau envoie SIGCHLD au parent et garde le zombie jusqu'à ce que le parent appelle waitpid() (page wait(2)). Bash intercepte SIGCHLD, ramasse aussitôt l'enfant par waitpid(), et range son code dans une table interne des tâches terminées, de taille bornée par CHILD_MAX. Le builtin wait consulte d'abord cette table, puis bloque si la tâche tourne encore. C'est pourquoi un wait "$pid" tardif fonctionne : le code attendait dans la table, pas dans le noyau. wait -n, lui, ne regarde que la table des tâches actives, d'où sa limite décrite plus haut. C'est aussi pourquoi wait ne peut attendre que ses propres enfants : Greg's Wiki le rappelle, seul le parent peut recueillir le code d'un processus, et un wait 1 répond wait: pid 1 is not a child of this shell, code 127.

Groupes de processus, Ctrl-C et timeout

Un groupe de processus rassemble des processus qui reçoivent ensemble les signaux du terminal (Ctrl-C envoie SIGINT à tout le groupe de premier plan) ou un signal envoyé par kill -- -PGID. Dans un shell interactif, Bash met chaque tâche dans son propre groupe (appel setpgid(2)), d'où le contrôle des tâches. Dans un script, il ne le fait pas : le script et toutes ses tâches d'arrière-plan partagent un groupe. On pourrait en conclure qu'un Ctrl-C les arrête toutes ; c'est faux. Le manuel de Bash le précise : quand le contrôle des tâches est inactif, les commandes asynchrones ignorent SIGINT et SIGQUIT. On le lit dans /proc/<pid>/status d'une tâche lancée par un script : SigIgn: 0000000000000006, les bits des signaux 2 et 3. À l'essai, un SIGINT envoyé au groupe tue le script et laisse vivre ses tâches, qui continuent, orphelines ; il en va de même si le script est tué par un kill PID ciblé. La leçon 10 montre comment les arrêter depuis un piège. Sous systemd, l'arrêt d'un service envoie par défaut SIGTERM à tous les processus de son cgroup, enfants compris.

timeout utilise ce mécanisme. Son code source (coreutils 9.4, src/timeout.c) commence par setpgid(0, 0) pour se placer, avec la commande qu'il va lancer, dans un nouveau groupe, avec ce commentaire : Ensure we're in our own group so all subprocesses can be killed. Au délai, il envoie le signal au groupe entier (kill(0, sig)), en s'ignorant lui-même pour ne pas boucler. Conséquences observables :

  • les enfants de la commande sont arrêtés avec elle : timeout 1 bash -c 'sleep 31 & sleep 30' ne laisse aucun sleep derrière lui ;
  • avec --foreground, timeout ne crée pas de groupe et ne signale que la commande directe ; sa page de manuel prévient que les enfants de la commande ne sont alors pas arrêtés. À l'essai, bash meurt, et ses deux sleep lui survivent ;
  • avec -k, le second signal est SIGKILL, qu'aucun processus ne peut ignorer, timeout compris : il meurt avec le groupe, d'où le code 137 et le message Killed de Bash.

Pièges courants

wait sans argument pour « vérifier » des tâches. Il rend toujours 0. Attendez chaque PID et comptez les échecs.

wait -n pour recueillir des codes. En Bash 5.2, une tâche dont le shell a déjà « notifié » la fin, typiquement une tâche tuée par un signal, échappe à wait -n, qui rend 127 sans désigner personne. wait -n pour attendre une place, wait "$pid" pour les codes.

set -e et wait. L'échec d'une tâche arrête le script au wait, avant les autres. code=0; wait "$pid" || code=$?.

(( n++ )) sous set -e. L'expression vaut 0 au premier passage, donc le code est 1, et le script s'arrête. n=$((n + 1)).

$! lu trop tard. Il désigne la dernière tâche lancée. Rangez-le aussitôt après le &.

Une boucle qui s'arrête après un tour. Une commande de la boucle (ssh, un script qui fait read) a lu l'entrée standard, c'est-à-dire la liste. ssh -n, < /dev/null, ou read -u 3 ... 3< liste.

Des variables perdues. Une affectation dans ( ), dans $( ), dans un élément de tube ou dans une tâche & ne remonte jamais. Pour récupérer un résultat d'une tâche parallèle : son code de sortie, ou un fichier par tâche.

$(commande) dans une boucle sur de gros volumes. Un processus, voire deux, par tour. Expansions de paramètres, read qui découpe, printf -v, printf '%(...)T', ou un seul awk pour tout le fichier.

Des tâches sans borne. for h in $(cat parc.txt); do deployer_un "$h" & done lance autant de connexions que de machines. Une réserve, xargs -P N, ou parallel -j N.

timeout sans -k sur une commande qui ignore SIGTERM. Elle n'est jamais arrêtée et timeout attend avec elle. -k donne la garantie.

Croire qu'arrêter ssh arrête la commande distante. Voir l'encadré plus haut : délai des deux côtés.

--foreground copié d'un exemple interactif. Utile pour une commande qui lit le terminal ; dans un script, il laisse survivre les petits-enfants.

Le mauvais parallel. Celui de moreutils et celui de GNU n'ont pas la même syntaxe ; une option inconnue peut produire un comportement inattendu plutôt qu'une erreur.

Sécurité

  • Les arguments d'une commande distante sont réinterprétés. ssh hote "mkdir -p $cible" envoie une chaîne que le shell distant découpe et développe à nouveau : une variable qui contiendrait ; rm -rf ~ serait exécutée là-bas. Validez ce qui vient de l'extérieur (l'expression régulière sur le nom de l'archive dans deployer), et protégez le reste par printf '%q' (leçon 2) ou ${*@Q} comme le fait distant. Attention : ces deux formes peuvent produire la notation $'...' pour une valeur qui contient un caractère de contrôle, notation que comprend Bash mais pas dash ; si le shell de connexion du compte distant est /bin/sh, la validation stricte reste la vraie protection.
  • BatchMode=yes dans tout script. Sans lui, une clé refusée fait demander un mot de passe à un ssh qui n'a personne en face, et la tâche reste bloquée jusqu'au délai, ou pour toujours. Avec lui, l'échec est immédiat et lisible.
  • Les arguments sont visibles de tous. Les tâches parallèles se voient dans ps avec leur ligne de commande complète, comme toutes les autres (leçon 8) : aucun secret en argument, même pour une tâche de deux secondes.
  • Les fichiers par tâche contiennent des sorties sensibles. Rangez-les dans un répertoire créé par mktemp -d (droits 0700), supprimé par le piège EXIT, jamais dans un nom prévisible sous /tmp qu'un autre compte pourrait créer avant vous.
  • Le parallélisme amplifie les erreurs. Une commande destructrice exécutée sur quarante machines à la fois ne laisse pas le temps de réagir. Les opérations qui modifient se font en série ou par petits lots, avec arrêt au premier échec ; c'est aussi une protection contre un script compromis ou une variable mal remplie.
  • Le compte de déploiement a des droits bornés. deploiement ne peut, par sudo, que lancer /opt/signalements/bin/activer-version (une règle sudoers précise, leçon 8 de Premiers pas), script installé sur chaque machine, propriété de root, qui valide lui-même le nom de version reçu ; et sa clé n'est acceptée que depuis sig-outils. Un script parallèle qui tourne avec une clé root sur tout le parc est une cible de choix.
  • Pas de PID dans un fichier pour piloter un processus. Greg's Wiki qualifie l'approche de fondamentalement fragile : un PID peut être réattribué à un autre processus entre l'écriture et le kill. Gardez les PID dans le script qui a lancé les tâches, et confiez les traitements de longue durée à systemd.

En production

  • Mesurez avant d'optimiser. time sur le script entier, puis sur la boucle suspecte ; strace -c -f compte les appels système par type et montre d'un coup d'œil un excès de clone. Un script qui tourne une fois par nuit en dix secondes n'a pas besoin d'être réécrit.
  • Choisissez la borne d'après la ressource la plus faible. Le nombre de cœurs pour un traitement local (nproc), la capacité du service visé pour des requêtes (une API externe limitée en débit), la bande passante pour des transferts. Une borne configurable (PARALLELE, -j) s'ajuste sans toucher au code.
  • Un code par élément, un résumé à la fin. Le rapport d'un déploiement sur vingt machines dit lesquelles ont échoué et pourquoi, pas seulement « 1 ». Le code global reste le signal pour l'automate (minuteur, CI, OnFailure=).
  • Paralléliser ce qui lit, sérialiser ce qui écrit. Sondes, collectes, vérifications, téléchargements : en parallèle. Bascules, migrations, redémarrages : en série, ou par lots avec arrêt au premier échec. Kubernetes applique la même idée avec maxUnavailable lors d'une mise à jour progressive (cours Kubernetes).
  • Des délais partout, cohérents entre eux. Le délai d'une sonde doit être nettement inférieur à l'intervalle du minuteur qui la lance ; le délai d'une étape inférieur au TimeoutStartSec= de l'unité systemd qui exécute le script ; sinon c'est systemd qui tue le script, sans votre message d'erreur.
  • En CI, le parallélisme est souvent ailleurs. Plutôt qu'un script qui lance dix tâches, une matrice de jobs (cours GitHub Actions) donne un journal, un statut et une relance par élément. La réserve de tâches en Bash reste utile sur une machine, ou dans une étape unique.
  • Quand la logique grossit, changez d'outil. Reprises sur erreur avec attente exponentielle, annulation des tâches restantes, limites de débit, résultats structurés : au-delà d'une réserve simple, Python (concurrent.futures) ou un outil dédié (Ansible pour un parc, avec serial: pour les déploiements progressifs) seront plus lisibles et mieux testés (leçon 12).

Exercices

1. Sous-shell ou pas (niveau 100). Sans l'exécuter, dites ce qu'affiche ce script, ligne par ligne, puis vérifiez.

#!/usr/bin/env bash
total=0
( total=1 )
echo "a : $total"
{ total=2; }
echo "b : $total"
printf '3\n' | read -r total
echo "c : $total"
total=$(total=4; echo 5)
echo "d : $total"
[[ $$ == "$BASHPID" ]] && echo "e : même processus"
( [[ $$ == "$BASHPID" ]] || echo "f : processus différent" )
Solution
a : 0
b : 2
c : 2
d : 5
e : même processus
f : processus différent

( total=1 ) se passe dans un sous-shell : perdu. { } s'exécute dans le shell courant : total vaut 2. read est le dernier élément d'un tube, donc dans un sous-shell (sans shopt -s lastpipe) : la valeur 3 est perdue. Dans $( ), l'affectation total=4 reste dans le sous-shell, mais la sortie 5 est capturée et affectée par le shell courant. Dans le script, $$ et $BASHPID coïncident ; dans le ( ), $$ reste celui du script et $BASHPID est celui du sous-shell.

2. Le déploiement qui ne touche qu'une machine (niveau 200). Ce script n'active la version que sur la première machine de hotes.txt, sans erreur. Expliquez pourquoi, et proposez deux corrections différentes.

while read hote; do
  echo "activation sur $hote"
  ssh "deploiement@$hote" sudo /opt/signalements/bin/activer-version signalements-1.4.0
done < hotes.txt
Solution

ssh lit son entrée standard pour la transmettre à la commande distante. L'entrée standard de la boucle est hotes.txt : le premier ssh consomme toutes les lignes restantes, et le read suivant trouve la fin du fichier. Corrections : ssh -n ... (ou ssh ... < /dev/null) ; ou lire la liste sur un autre descripteur : while IFS= read -r -u 3 hote; do ...; done 3< hotes.txt. Au passage, read sans -r ni IFS= déformerait un nom contenant une contre-oblique ou des espaces de bord (leçon 5), et il manque BatchMode=yes et ConnectTimeout : une machine injoignable bloquerait la boucle.

3. Lire les codes (niveau 200). Pour chaque commande, quel code de sortie obtient-on, et que signifie-t-il ?

  1. timeout 10 curl --max-time 5 http://172.16.8.11:8000/sante quand la machine jette les paquets ;
  2. timeout 5 curl http://172.16.8.11:8000/sante dans la même situation ;
  3. find ... -print0 | xargs -0 -n 1 -P 4 gzip -- quand un des fichiers est en lecture seule dans un répertoire protégé ;
  4. timeout -k 5 30 commande quand commande ignore SIGTERM et tourne indéfiniment ;
  5. wait après trois tâches & dont deux ont échoué.
Solution
  1. 28 : curl abandonne de lui-même après 5 secondes (délai dépassé), avant que timeout n'intervienne ; timeout rend le code de la commande.
  2. 124 : curl n'a pas de délai propre suffisant ; timeout l'arrête par SIGTERM au bout de 5 secondes et signale le dépassement.
  3. 123 : au moins un gzip a échoué (code 1 ou 2), xargs le résume par 123 sans dire lequel ; les messages de gzip sur la sortie d'erreur le disent.
  4. 137 : SIGTERM à 30 secondes, ignoré ; SIGKILL 5 secondes plus tard, qui tue le groupe, timeout compris (128 + 9).
  5. 0 : wait sans argument rend toujours 0. Pour connaître les échecs, il faut attendre chaque PID.

4. Une réserve de tâches réutilisable (niveau 300). Écrivez une fonction pour_chaque N DÉLAI commande qui lit des éléments (un par ligne) sur son entrée standard et lance commande élément pour chacun, avec au plus N exécutions simultanées et une durée maximale de DÉLAI secondes chacune. Elle affiche, pour chaque élément, une ligne élément : code, puis rend 0 si tout a réussi, 1 sinon. Les sorties de chaque commande sont affichées d'un bloc, préfixées par l'élément. Contraintes : fonctionner sous set -euo pipefail, ne pas laisser de fichier derrière elle, et ne pas être perturbée par une commande qui lit l'entrée standard.

Solution
pour_chaque() {
  local max=$1 delai=$2; shift 2
  local dossier i code echecs=0
  local -a elements=() pids=()
  mapfile -t elements                          # lire toute la liste d'abord
  dossier=$(mktemp -d)

  for i in "${!elements[@]}"; do
    while (( $(jobs -rp | wc -l) >= max )); do wait -n || true; done
    timeout -k 5 "$delai" "$@" "${elements[i]}" < /dev/null > "$dossier/$i" 2>&1 &
    pids[i]=$!
  done

  for i in "${!elements[@]}"; do
    code=0
    wait "${pids[i]}" || code=$?
    sed "s/^/[${elements[i]}] /" -- "$dossier/$i"
    printf '%s : %s\n' "${elements[i]}" "$code"
    (( code == 0 )) || echecs=$((echecs + 1))
  done

  rm -rf -- "$dossier"
  (( echecs == 0 ))
}

# Utilisation, la liste étant lue dans un fichier :
pour_chaque 2 10 ./sonder < hotes.txt

Points clés : la liste est lue en entier par mapfile avant de lancer quoi que ce soit, et chaque commande reçoit /dev/null en entrée : aucune ne peut voler la liste. timeout -k borne chaque exécution. Les éléments, les PID et les fichiers de sortie partagent le même indice, ce qui évite tout tableau associatif. wait -n ne sert qu'à attendre qu'une place se libère ; chaque code est recueilli par wait sur son PID, dans l'ordre de la liste, ce qui donne un rapport stable. Pour une fonction de bibliothèque, il faudrait aussi supprimer le dossier en cas d'interruption, avec les précautions de la leçon 10, et préfixer les variables locales pour éviter les collisions de la portée dynamique (leçon 6).

5. Quarante minutes chaque nuit (niveau 300). rapport-journaux contient la boucle ci-dessous, exécutée sur chaque /srv/donnees/journaux/<hôte>/syslog.log (environ 400 000 lignes par jour et par hôte). Estimez le nombre de processus créés, puis réécrivez le traitement pour qu'il prenne quelques secondes, en produisant le même résultat : pour chaque hôte, chaque heure et chaque code HTTP, le nombre de requêtes.

for fichier in /srv/donnees/journaux/*/syslog.log; do
  hote=$(basename "$(dirname "$fichier")")
  while IFS= read -r ligne; do
    code=$(echo "$ligne" | awk '{print $12}')
    heure=$(echo "$ligne" | cut -c12-13)
    echo "$hote $heure $code" >> /tmp/rapport.txt
  done < "$fichier"
done
sort /tmp/rapport.txt | uniq -c
Solution

Par ligne : deux substitutions (deux sous-shells), chacune avec un tube vers un programme externe (awk, cut), soit environ six processus et deux execve coûteux. Pour 800 000 lignes, près de cinq millions de processus : à 1 à 3 ms chacun, on retrouve l'ordre de grandeur de la nuit. S'y ajoutent 800 000 ouvertures de /tmp/rapport.txt (une par >>), et un nom de fichier prévisible dans /tmp.

Réécriture, un awk par fichier, aucun fichier temporaire :

for fichier in /srv/donnees/journaux/*/syslog.log; do
  hote=${fichier%/syslog.log}
  hote=${hote##*/}
  awk -v hote="$hote" '{ n[substr($1, 12, 2) " " $12]++ }
       END { for (c in n) print n[c], hote, c }' "$fichier"
done | sort -k2,2 -k3,3 -k4,4n

hote est extrait par expansions de paramètres (aucun processus). Pas de -- avant le nom de fichier : le awk par défaut de Debian et d'Ubuntu, mawk, le prendrait pour un nom de fichier ; ici les chemins commencent par /, sans risque. awk reçoit le nom d'hôte par -v et compte, par heure et par code, en mémoire. La sortie de la boucle entière passe par un seul sort. Il reste un awk par fichier, soit deux processus pour toute la nuit, et quelques secondes de calcul. Variante : un seul awk sur tous les fichiers, avec FILENAME pour retrouver l'hôte. Si le format des journaux change (champ 12), le rapport le dira par des codes absurdes : la leçon 12 ajoute un test sur un échantillon.

Récapitulatif

  • Une commande externe coûte un fork et un execve ; un builtin ou une fonction, rien. Dans une boucle sur des données, ce coût se multiplie par le nombre de lignes : expansions, read, printf -v, ou un seul awk.
  • ( ), $( ), les éléments d'un tube, &, <( ) et coproc créent un sous-shell : leurs variables ne remontent pas. { ; } regroupe sans processus. $$ reste le PID du script, $BASHPID est celui du processus courant.
  • & lance une tâche, $! donne son PID (à ranger tout de suite), wait "$pid" rend son code même tardivement. wait sans argument rend toujours 0.
  • Réserve de tâches bornée : while (( $(jobs -rp | wc -l) >= max )); do wait -n || true; done avant chaque lancement, le PID rangé par élément, puis wait "$pid" pour chaque code. wait -n (et -p, Bash 5.1) peut manquer une tâche déjà notifiée en Bash 5.2, par exemple tuée par un signal : il sert à attendre, pas à compter. Sous set -e, code=0; wait ... || code=$?.
  • xargs -0 -r -n 1 -P N parallélise des commandes externes, avec un code global (123 si l'une a échoué). GNU parallel ajoute préfixes, ordre, arrêt sur échec et journal ; attention à l'homonyme de moreutils.
  • Toute attente a une fin : délai natif d'abord (curl --max-time, ssh -o ConnectTimeout), sinon timeout -k (124 dépassé, 137 tué par SIGKILL). timeout signale tout son groupe de processus, sauf avec --foreground. Un délai côté client n'arrête pas la commande distante.
  • ssh dans une boucle while read avale la liste : ssh -n, < /dev/null ou read -u 3. BatchMode=yes dans tout script.
  • Sorties parallèles : un fichier par tâche affiché d'un bloc, ou un préfixe par ligne avec sed -u et pipefail.
  • On parallélise ce qui lit, on sérialise ce qui modifie, avec arrêt au premier échec. Un traitement à détacher se confie à systemd-run, pas à nohup.

Pour aller plus loin

  • Le manuel de Bash, sections Command Execution Environment (ce qui est copié dans un sous-shell), Coprocesses et Job Control Builtins (wait, disown), à relire avec les essais de cette leçon sous les yeux.
  • La page ProcessManagement de Greg's Wiki, qui démonte les fichiers de PID, ps | grep et les autres façons fragiles de piloter des processus, et BashFAQ/068 sur les délais.
  • Le tutoriel de GNU parallel, pour --joblog, --resume et l'exécution répartie sur plusieurs machines (--sshlogin).
  • Le code source de timeout dans coreutils (src/timeout.c) : deux cents lignes très commentées sur les groupes de processus et les signaux.
  • La leçon 12, qui trace l'exécution de ces scripts avec set -x, les analyse avec ShellCheck et teste deployer sans machine, en remplaçant ssh par une doublure.
+30 XP Carte du ciel →Mon cosmonaute →

Sources