Signaux, nettoyage et verrous
Pourquoi
Un matin, sig-outils alerte : /tmp est plein. Sur Debian 13, /tmp est un tmpfs, donc de la mémoire vive, et il contient plusieurs centaines de fichiers export-12345.csv.gz, export-12345.csv.gz.sha256, tous laissés par le script de publication de Camille. Chacun correspond à une exécution qui n'est pas allée jusqu'au bout : un systemctl stop pendant une maintenance, un redémarrage de noyau, une session SSH coupée alors que quelqu'un relançait l'export à la main, un Ctrl+C impatient. Voici le cœur du script d'origine :
#!/bin/bash
CSV=/srv/donnees/exports/signalements-$(date +%F).csv
TMP=/tmp/export-$$.csv.gz
gzip -c $CSV > $TMP
sha256sum $TMP > $TMP.sha256
aws s3 cp $TMP s3://sig-exports-mairie/$(date +%Y/%m)/signalements-$(date +%F).csv.gz --endpoint-url https://s3.fr-par.scw.cloud
aws s3 cp $TMP.sha256 s3://sig-exports-mairie/$(date +%Y/%m)/signalements-$(date +%F).csv.gz.sha256 --endpoint-url https://s3.fr-par.scw.cloud
cp $TMP /srv/donnees/exports/publies/signalements-$(date +%F).csv.gz
rm $TMP $TMP.sha256Les leçons précédentes ont déjà corrigé les guillemets, la validation du CSV et la gestion des erreurs. Reste une famille de défauts qu'aucune d'elles ne traite, parce qu'ils ne concernent pas ce que fait le script mais ce qui lui arrive :
- une interruption laisse des déchets. Le
rmfinal n'est atteint que si tout s'est bien passé. Un signal, une erreur, et les fichiers temporaires restent pour toujours ; - une interruption laisse des fichiers à moitié écrits. Si le script est tué pendant le
cpverspublies/, l'archive locale, celle que l'on renverra à la mairie si elle perd la sienne, est tronquée, et rien ne le signale ; - deux exécutions simultanées se marchent dessus. Le minuteur lance la publication à 5 h ; à 5 h 02, quelqu'un qui a vu passer une alerte la relance à la main. Les deux écrivent le même fichier local en même temps et déposent deux fois chez la mairie ;
- le fichier temporaire a un nom prévisible dans un répertoire ouvert à tous, ce qui est une faiblesse de sécurité classique ;
- et, détail qui n'a rien à voir avec les signaux mais que le nettoyage fera disparaître :
sha256sum $TMPécrit le chemin/tmp/export-12345.csv.gzdans le fichier de contrôle. La mairie, qui vérifie avecsha256sum -c, ne trouve jamais ce fichier chez elle.
Cette leçon apprend à écrire un script qui se termine proprement quoi qu'il arrive : il nettoie derrière lui, n'expose jamais de fichier incomplet, ne tourne jamais en double et peut être relancé sans réfléchir. Ce sont ces propriétés, bien plus que l'élégance du code, qui permettent de dormir pendant qu'il tourne.
Les concepts
Ce qui peut interrompre un script
Un signal est une notification asynchrone que le noyau remet à un processus : « arrête-toi », « ton terminal a disparu », « tu as écrit dans un tube que plus personne ne lit ». La leçon 10 de Premiers pas les a présentés du point de vue de celui qui les envoie avec kill ; ici, on se place du côté du script qui les reçoit. En exploitation, un script de sig-outils peut en recevoir de plusieurs sources :
| Événement | Signal | Qui le reçoit |
|---|---|---|
Ctrl+C dans le terminal | SIGINT (2) | tout le groupe de processus au premier plan : le script et la commande qu'il exécute |
Ctrl+\ dans le terminal | SIGQUIT (3) | idem ; Bash l'ignore toujours |
| fermeture du terminal, coupure de la session SSH | SIGHUP (1) | le shell de la session, qui le relaie à ses tâches |
kill <pid> sans option | SIGTERM (15) | le seul processus visé |
systemctl stop, dépassement de TimeoutStartSec=, arrêt de la machine | SIGTERM, puis SIGKILL après TimeoutStopSec= (90 secondes par défaut) | tous les processus du cgroup de l'unité |
timeout 10m commande | SIGTERM par défaut | la commande lancée par timeout |
kill -9, tueur de l'OOM | SIGKILL (9) | le processus, sans aucune possibilité de réagir |
| le lecteur d'un tube s'arrête | SIGPIPE (13) | l'écrivain, à sa prochaine écriture |
Un groupe de processus (process group) est un ensemble de processus que le noyau peut viser d'un seul coup ; le terminal envoie Ctrl+C à son groupe « au premier plan ». C'est pour cela qu'un Ctrl+C arrête à la fois votre script et le gzip qu'il exécutait.
Pour systemd, la page systemd.kill(5) décrit le comportement par défaut, KillMode=control-group : à l'arrêt d'une unité, tous les processus restants de son cgroup reçoivent le signal d'arrêt (KillSignal=, SIGTERM par défaut, immédiatement suivi de SIGCONT pour réveiller les processus suspendus), puis, s'il en reste après le délai, SIGKILL. La valeur par défaut de ce délai, DefaultTimeoutStopSec=, est de 90 secondes d'après systemd-system.conf(5).
Deux signaux sont à part : SIGKILL et SIGSTOP ne peuvent être ni interceptés ni ignorés. Aucun script ne nettoie après un SIGKILL ; on verra qu'il faut donc aussi savoir démarrer proprement après un arrêt brutal.
Ce que Bash fait d'un signal, sans consigne
Sans instruction particulière, un script Bash (un shell non interactif) se comporte ainsi, d'après la section Signals du manuel :
- SIGTERM, SIGHUP, SIGINT, SIGPIPE, SIGUSR1... le terminent, avec le comportement par défaut du signal ;
- SIGQUIT est toujours ignoré par Bash (mais pas par les commandes qu'il lance) ;
- les commandes lancées en arrière-plan par
&, quand le contrôle des tâches n'est pas actif (c'est le cas dans un script), ignorent SIGINT et SIGQUIT. UnCtrl+Cn'arrête donc pas un processus que votre script a lancé avec&; - les commandes externes héritent des dispositions de signal qu'avait Bash à son démarrage, pas des pièges qu'il a posés.
Et le point qui surprend le plus : quand Bash attend une commande au premier plan et reçoit SIGINT, il attend la fin de cette commande avant de décider. Si la commande est morte de SIGINT, Bash conclut que l'utilisateur voulait tout arrêter et s'arrête à son tour ; si la commande a absorbé le signal (un éditeur qui utilise Ctrl+C pour annuler une action, par exemple), Bash continue. Greg's Wiki appelle ce mécanisme la sortie coopérative (wait and cooperative exit). On y revient plus bas, parce qu'il impose une règle de conduite à vos propres scripts.
trap : brancher du code sur un signal
La commande interne trap associe une action à un ou plusieurs signaux. Le manuel l'écrit trap [-lp] [[action] signal ...] :
trap 'commande à exécuter' INT TERM # poser un piège
trap '' HUP # chaîne vide : ignorer le signal
trap - INT TERM # tiret : rétablir le comportement d'origine
trap -p # afficher les pièges en place
trap -l # lister les noms et numéros de signauxLes noms s'écrivent avec ou sans le préfixe SIG et sans distinction de casse ; les numéros fonctionnent aussi, mais ne sont pas les mêmes sur toutes les architectures : préférez les noms. L'action est une chaîne, évaluée comme par eval au moment où le signal arrive. Retenez trois règles :
- Un seul piège par signal. Un second
trap ... TERMremplace le premier, il ne s'y ajoute pas. D'où l'intérêt de confier tout le travail à une seule fonction. - Les pièges sont globaux. Un
trapposé dans une fonction vaut pour tout le script, après le retour de la fonction. - Les sous-shells ne les héritent pas. POSIX le prescrit : à l'entrée dans un sous-shell, les pièges qui ne sont pas ignorés reviennent au comportement par défaut. Seule exception, pour que
$(trap -p)reste utile : une substitution de commande peut afficher les pièges du parent, sans pour autant les exécuter.
Enfin, une contrainte que POSIX et le manuel de Bash formulent de la même façon : un signal ignoré à l'entrée d'un shell non interactif ne peut être ni intercepté ni rétabli. Un script lancé avec nohup (qui ignore SIGHUP) ou en arrière-plan d'un autre script (SIGINT ignoré) ne pourra jamais réagir à ces signaux, quoi que contienne son trap.
Le pseudo-signal EXIT
En plus des vrais signaux, trap connaît des événements propres au shell : ERR (une commande échoue, leçon 9), DEBUG (avant chaque commande, leçon 12), RETURN (fin d'une fonction ou d'un fichier chargé par source), et surtout EXIT (ou 0) : l'action s'exécute quand le shell se termine.
C'est l'outil central de cette leçon. Le manuel de Bash précise que exit exécute le piège EXIT avant de terminer le shell, et l'on vérifiera en pratique qu'avec Bash, le piège EXIT s'exécute dans tous les cas de fin du script :
- arrivée à la dernière ligne ;
exitexplicite, quel que soit le code ;- arrêt sur erreur par
set -e; - signal mortel non intercepté (SIGTERM, SIGINT, SIGHUP, SIGPIPE...).
Seul SIGKILL y échappe, par construction. Ce comportement est propre à Bash : Greg's Wiki rappelle que dash et zsh n'exécutent pas le piège EXIT quand le shell meurt d'un signal, et POSIX se contente de dire qu'il « peut » se produire dans ce cas. Un script destiné à /bin/sh doit donc intercepter les signaux explicitement ; un script Bash peut s'appuyer sur EXIT seul pour les cas simples.
D'où le principe : un seul point de nettoyage, branché sur EXIT, plutôt qu'un rm à la fin du script et des copies de ce rm dans chaque branche d'erreur.
128 + n : mourir du bon signal
Un processus tué par le signal n n'a pas de code de sortie à proprement parler : le noyau rapporte à son parent qu'il est mort de ce signal, et le shell parent le traduit en code 128 + n (130 pour SIGINT, 143 pour SIGTERM, 129 pour SIGHUP), comme l'a montré la leçon 6 de Premiers pas.
La nuance compte à cause de la sortie coopérative décrite plus haut. Imaginez une boucle qui appelle votre script pour chaque date d'un mois :
for jour in 2026-09-{01..30}; do
publier-export --date "$jour"
doneSi vous appuyez sur Ctrl+C et que publier-export intercepte SIGINT, nettoie, puis se termine par exit 130, la boucle voit un script qui s'est terminé normalement, avec le code 130 : elle passe au jour suivant. Vous devez appuyer trente fois. Si, au contraire, le script nettoie puis se renvoie SIGINT après avoir rétabli le comportement par défaut, le shell de la boucle voit un enfant mort de SIGINT et s'arrête à son tour. Greg's Wiki le formule comme une règle : un processus qui se termine en réponse à SIGINT doit se tuer lui-même avec SIGINT. La même logique vaut pour SIGTERM et SIGHUP.
Écrire sans jamais exposer un fichier à moitié écrit
Écrire un fichier prend du temps ; pendant ce temps, il existe dans un état intermédiaire. Si quelqu'un le lit à ce moment (la mairie, un autre script, une sauvegarde) ou si le script est interrompu, cet état intermédiaire devient le résultat.
La parade s'appuie sur une garantie du noyau. La page rename(2) l'énonce : si le nom de destination existe déjà, il est remplacé atomiquement, si bien qu'aucun autre processus qui accède à ce nom ne le trouvera jamais absent. Une écriture atomique se fait donc en trois temps :
- écrire le contenu complet dans un fichier temporaire, dans le même répertoire (ou au moins dans le même système de fichiers) que la destination ;
- vérifier ce contenu ;
- renommer le fichier temporaire vers le nom définitif, avec
mv.
Un lecteur voit soit l'ancienne version complète, soit la nouvelle version complète, jamais un mélange. Une interruption avant le renommage laisse un fichier temporaire, que le nettoyage supprime, et la destination intacte.
La condition « même système de fichiers » est essentielle. rename(2) échoue avec l'erreur EXDEV entre deux systèmes de fichiers, et le manuel de GNU Coreutils explique ce que fait alors mv : il copie, comme le ferait cp -a, puis supprime l'original. Une copie n'a rien d'atomique. Or sur Debian 13, /tmp est un tmpfs et /srv/donnees un disque à part : un fichier préparé dans /tmp puis « déplacé » dans /srv/donnees/exports/publies/ est en réalité recopié octet par octet sous les yeux des lecteurs.
Idempotence : pouvoir relancer sans réfléchir
Une opération est idempotente quand l'exécuter deux fois donne le même résultat que l'exécuter une fois. Pour un script de production, c'est la propriété qui transforme une interruption en non-événement : après un arrêt brutal, on relance, point.
publier-export le devient si chacune de ses étapes l'est : le nom des fichiers dépend de la date de l'export, pas de l'heure de l'exécution ni du PID ; un dépôt dans S3 sous la même clé remplace l'objet précédent ; le renommage local remplace l'archive précédente ; la purge supprime « ce qui a plus de 30 jours », jamais « les N plus anciens ». Rien ne s'accumule, rien ne se duplique.
L'idempotence a un corollaire souvent oublié : au démarrage, le script nettoie ce qu'une exécution précédente tuée par SIGKILL aurait laissé. Puisque le piège EXIT ne s'exécute pas dans ce cas, c'est l'exécution suivante qui fait le ménage. On verra que le verrou rend ce ménage sûr.
Un seul à la fois : le verrou dans le script
La leçon 10 du cours d'administration a montré qu'un service systemd ne se chevauche jamais lui-même : tant que signalements-publication.service est actif, son minuteur ne le relance pas. Mais cette protection ne vaut que pour les exécutions qui passent par l'unité. Un humain qui lance /opt/signalements/bin/publier-export dans un terminal, un autre script qui l'appelle, une tâche cron oubliée : aucun ne demande l'avis de systemd.
Le verrou doit donc être pris par le script lui-même, au tout début. On utilise flock, déjà rencontré en préfixe de commande dans le cours d'administration : un verrou consultatif posé par le noyau sur un fichier ouvert, libéré automatiquement à la fermeture du dernier descripteur, donc à la mort du processus, même par SIGKILL. L'angle de cette leçon est différent : prendre le verrou de l'intérieur, sur un descripteur que le script ouvre lui-même, et comprendre ce que ce descripteur devient quand le script lance d'autres programmes.
En pratique
Les essais de cette section ont été faits avec Bash 5.2.21 dans un répertoire de test, avec une fausse commande aws qui se contente d'attendre ; les sorties reproduites sont celles de ces essais, débarrassées des chemins longs ; les PID et les heures ont été remplacés par des valeurs plus lisibles. Travaillez dans un répertoire jetable, ~/essais-bash/l10. Les messages de suivi des tâches qu'affiche un shell interactif ([1]+ Terminated ...), ainsi que les lignes Terminated, Hangup ou Killed qu'il ajoute quand une commande au premier plan meurt d'un signal, sont omis.
Un répertoire de travail qui disparaît toujours
Première règle : ne jamais inventer soi-même un nom de fichier temporaire. mktemp crée un fichier (ou, avec -d, un répertoire) au nom imprévisible, de façon atomique, et affiche son chemin :
$ mktemp
/tmp/tmp.19goNO19Kv
$ mktemp -d -p . export.XXXXXX
./export.a6SyEK
$ mktemp -p . --suffix=.csv.gz export.XXXXXX
./export.Xia0Qp.csv.gz
$ ls -ld /tmp/tmp.19goNO19Kv ./export.a6SyEK
-rw------- 1 vous vous 0 oct. 8 14:45 /tmp/tmp.19goNO19Kv
drwx------ 2 vous vous 4096 oct. 8 14:45 ./export.a6SyEK
- sans argument, le modèle est
tmp.XXXXXXXXXXdans$TMPDIR, ou/tmpsi cette variable est vide ; -p <répertoire>choisit le répertoire ; c'est l'option qui permet de préparer un fichier à côté de sa destination ;- les
Xfinaux (au moins trois, d'aprèsmktemp(1)) sont remplacés par des caractères aléatoires ;--suffix=ajoute une extension après eux ; - le fichier est créé en
0600, le répertoire en0700: personne d'autre ne peut y lire ni y écrire.
Avec un modèle trop court, mktemp refuse et renvoie 1 :
$ mktemp -p . ab.XX
mktemp: too few X's in template ‘ab.XX’
Préférez un répertoire temporaire à plusieurs fichiers : un seul chemin à mémoriser, un seul rm -rf pour tout nettoyer. Voici le squelette minimal :
#!/usr/bin/env bash
set -euo pipefail
rep_travail=$(mktemp -d)
trap 'rm -rf -- "$rep_travail"' EXIT
gzip -c -- "$1" > "$rep_travail/donnees.gz"
sha256sum -- "$rep_travail/donnees.gz"Notez les apostrophes autour de l'action : $rep_travail sera développé quand le piège se déclenchera, pas quand on le pose. Avec des guillemets doubles, la variable est développée immédiatement. Ici, cela fonctionnerait par chance, puisque la variable est déjà remplie, mais il suffit d'inverser deux lignes pour que le piège supprime... rien du tout :
$ bash -c 'trap "echo suppression de [$rep]" EXIT; rep=/srv/donnees/travail'
suppression de []
$ bash -c 'trap '\''echo suppression de [$rep]'\'' EXIT; rep=/srv/donnees/travail'
suppression de [/srv/donnees/travail]
ShellCheck signale ce défaut sous le code SC2064 : Use single quotes, otherwise this expands now rather than when signalled.
Vérifions maintenant que le piège EXIT s'exécute dans tous les cas. Chaque ligne lance un petit script qui pose un piège sur EXIT, puis se termine d'une façon différente ; le shell parent affiche ensuite le code qu'il a reçu :
$ bash -c 'trap "echo nettoyage" EXIT; exit 4'; echo "code=$?"
nettoyage
code=4
$ bash -c 'set -e; trap "echo nettoyage" EXIT; false; echo jamais'; echo "code=$?"
nettoyage
code=1
$ bash -c 'trap "echo nettoyage" EXIT; kill -TERM $$; echo jamais'; echo "code=$?"
nettoyage
code=143
$ bash -c 'trap "echo nettoyage" EXIT; kill -HUP $$; echo jamais'; echo "code=$?"
nettoyage
code=129
$ bash -c 'trap "echo nettoyage" EXIT; kill -KILL $$; echo jamais'; echo "code=$?"
code=137
Le nettoyage a lieu après exit, après une erreur sous set -e, après SIGTERM et SIGHUP non interceptés, et pas après SIGKILL. Observez aussi que le piège ne modifie pas le code : après SIGTERM, le parent reçoit bien 143, le script est mort du signal.
Le piège qui attend la fin de la commande
Intercepter explicitement SIGTERM semble plus propre : on écrit un message, on nettoie, on sort. Essayons, avec un sleep qui joue le rôle d'un long dépôt vers S3 :
#!/usr/bin/env bash
# differe.sh
trap 'echo "SIGTERM traité à $(date +%T)"; exit 143' TERM
echo "début à $(date +%T)"
sleep 30
echo "fin du sleep à $(date +%T)"Lancez-le dans un terminal, puis, depuis un second terminal, envoyez SIGTERM au script seul :
$ pgrep -f differe.sh
48211
$ kill -TERM 48211
Dans le premier terminal :
début à 10:02:00
SIGTERM traité à 10:02:30Le signal est arrivé à 10 h 02 min 03 s ; le piège ne s'est exécuté qu'à la fin du sleep, vingt-sept secondes plus tard. Ce n'est pas un défaut, c'est le comportement documenté : « si Bash attend la fin d'une commande et reçoit un signal pour lequel un piège est posé, le piège ne sera exécuté qu'à la fin de la commande ». Remplacez sleep 30 par un dépôt de 2 Go vers S3 et TimeoutStopSec= par ses 90 secondes : systemd finira par envoyer SIGKILL, et le nettoyage n'aura jamais lieu.
Sans piège sur TERM, c'est pire d'une autre façon. Reprenons le script en ne gardant qu'un piège sur EXIT :
#!/usr/bin/env bash
# orphelin.sh
trap 'echo "nettoyage à $(date +%T)"' EXIT
echo "début à $(date +%T)"
sleep 30
echo "jamais"début à 10:05:00
nettoyage à 10:05:03Cette fois, Bash meurt immédiatement, son piège EXIT s'exécute aussitôt... et le sleep continue de tourner, adopté par le processus init (ou par le gestionnaire systemd de la session) :
$ ps -o pid,ppid,cmd -C sleep
PID PPID CMD
48244 1203 sleep 30
C'est un processus orphelin. Si ce processus écrivait dans le répertoire de travail que le piège vient de supprimer, ou s'il était en train de déposer un fichier chez la mairie, le script a « nettoyé » sous les pieds d'un travail encore en cours.
Note
Ce cas ne se produit que si le signal ne vise que le script. Un Ctrl+C atteint tout le groupe de processus au premier plan, et systemctl stop tout le cgroup : la commande en cours reçoit le signal en même temps que le script et meurt avec lui. Le problème apparaît avec kill <pid> à la main, avec un outil de supervision qui ne vise qu'un PID, ou avec KillMode=mixed, qui n'envoie SIGTERM qu'au processus principal.
La solution vient d'une autre phrase de la même section du manuel : quand Bash attend une commande en arrière-plan avec la commande interne wait, l'arrivée d'un signal intercepté fait revenir wait immédiatement, avec un code supérieur à 128, et le piège s'exécute aussitôt. Il suffit donc de lancer la commande longue en arrière-plan et de l'attendre avec wait :
#!/usr/bin/env bash
# interruptible.sh
enfant=""
trap 'echo "SIGTERM traité à $(date +%T)"; [[ -n $enfant ]] && kill -TERM "$enfant"; exit 143' TERM
echo "début à $(date +%T)"
sleep 30 &
enfant=$!
wait "$enfant"
echo "fin à $(date +%T)"début à 10:08:00
SIGTERM traité à 10:08:03Le piège s'exécute dans l'instant, et c'est lui qui arrête l'enfant, dont il connaît le PID par $!. Plus d'orphelin.
Deux précautions accompagnent cette technique. D'abord, une commande lancée par & dans un script a son entrée standard redirigée vers /dev/null et ignore SIGINT : réservez-la aux commandes non interactives, et faites arrêter l'enfant par le piège, puisque Ctrl+C ne l'atteindra plus. Ensuite, wait "$enfant" renvoie le code de l'enfant : sous set -e, un échec arrête le script comme l'aurait fait la commande au premier plan, ce qui est le comportement voulu. On l'enveloppera plus bas dans une petite fonction, lancer_interruptible.
Arrêter aussi ses enfants
Quand un script lance plusieurs commandes en arrière-plan, jobs -p donne la liste des PID de celles qui tournent encore. Un piège peut les arrêter toutes, puis attendre leur fin :
arreter_enfants() {
local pids
pids=$(jobs -p)
if [[ -n $pids ]]; then
# shellcheck disable=SC2086 # découpage voulu : un PID par mot
kill -TERM $pids 2>/dev/null
fi
wait
}
trap 'arreter_enfants; exit 143' TERMLe wait final n'est pas décoratif : un enfant qui reçoit SIGTERM peut mettre un moment à s'arrêter (aws interrompt un téléversement en plusieurs parties, par exemple). Si le script supprime son répertoire de travail pendant ce temps, l'enfant finit son arrêt dans un répertoire qui n'existe plus. On attend, puis on nettoie.
Une limite à connaître : tuer un enfant n'atteint pas ses propres enfants. Si aws était un script shell qui lançait lui-même une commande au premier plan, on retrouverait exactement l'orphelin de tout à l'heure, un niveau plus bas. La vraie commande aws est un unique processus Python, et sous systemd le cgroup rattrape les éventuels survivants ; c'est l'une des raisons de faire tourner ces scripts dans une unité (leçon 11 pour les tâches parallèles).
Mourir du bon signal
Combinons maintenant le piège EXIT (nettoyage dans tous les cas) et des pièges sur INT, TERM et HUP (arrêt immédiat des enfants). Un piège naïf nettoie deux fois :
$ bash -c 'n(){ echo nettoyage; }; trap n EXIT; trap "n; exit 143" TERM; kill -TERM $$'
nettoyage
nettoyage
exit 143 dans le piège TERM déclenche à son tour le piège EXIT. Ce n'est pas grave si le nettoyage est idempotent (supprimer un répertoire déjà supprimé ne fait rien avec rm -rf), mais c'est inélégant, et surtout le script se termine par exit, pas par le signal. La forme correcte retire les pièges devenus inutiles, puis se renvoie le signal :
$ bash -c 'n(){ echo nettoyage; }; trap n EXIT
> trap "n; trap - TERM EXIT; kill -TERM \$\$" TERM; kill -TERM $$; echo jamais'; echo "code=$?"
nettoyage
code=143
trap - TERM rétablit le comportement par défaut du signal (terminer le processus) ; trap - EXIT retire le piège de sortie, puisque le nettoyage est fait ; kill -TERM $$ envoie le signal au script lui-même. Le parent voit un enfant mort de SIGTERM, ce que confirme le code 143 calculé par le shell. Avec SIGINT, une boucle appelante s'arrêtera au premier Ctrl+C.
Préserver le code de sortie dans le nettoyage
Le piège EXIT s'exécute après la dernière commande ; à son entrée, $? contient le code avec lequel le script s'apprête à se terminer. Avec Bash, si le piège ne fait pas d'exit lui-même, ce code est conservé, même si la dernière commande du piège échoue :
$ bash -c 'trap "echo \"\$? à l entrée du piège\"; false" EXIT; exit 4'; echo "code=$?"
4 à l entrée du piège
code=4
Mais set -e reste actif dans le piège, et c'est un piège dans le piège :
$ bash -c 'set -e; n(){ rm /inexistant/a; echo "suite du nettoyage"; }; trap n EXIT; exit 0'; echo "code=$?"
rm: cannot remove '/inexistant/a': No such file or directory
code=1
Le premier rm qui échoue interrompt le nettoyage (la suite n'est jamais exécutée) et transforme un succès en échec. Un nettoyage doit donc être tolérant : on y désactive set -e, on y retire le piège ERR de la leçon 9 pour ne pas afficher une pile d'appels à chaque fichier déjà supprimé, on mémorise $? dès la première ligne, et l'on termine par exit avec ce code :
sur_sortie() {
local code=$?
nettoyer
exit "$code"
}Écrire atomiquement
Reprenons l'archive locale de publier-export. Le répertoire de travail est créé dans /srv/donnees/exports/publies/ (la variable publies), sous un nom caché, pour que le renommage final reste dans le même système de fichiers :
rep_travail=$(mktemp -d -p "$publies" .travail.XXXXXX)
nom="signalements-$date_export.csv.gz"
gzip -9 -c -- "$csv" > "$rep_travail/$nom"
(cd -- "$rep_travail" && sha256sum -- "$nom") > "$rep_travail/$nom.sha256"
# ... dépôt chez la mairie ...
mv -f -- "$rep_travail/$nom" "$publies/$nom"
mv -f -- "$rep_travail/$nom.sha256" "$publies/$nom.sha256"- le
cddans un sous-shell fait quesha256sumécrit le nom seul, sans chemin : la mairie peut vérifier avecsha256sum -c signalements-2026-10-08.csv.gz.sha256dans n'importe quel répertoire ; - les deux
mvsont deux renommages : chacun est atomique, mais pas la paire. On renomme d'abord l'archive, puis le fichier de contrôle. Un lecteur qui attend la présence du.sha256pour lire l'archive ne verra donc jamais un fichier de contrôle sans son archive ; - le point initial de
.travail.XXXXXXcache le répertoire aux motifssignalements-*et aux scripts qui listentpublies/.
Le contenu de l'archive est-il pour autant sur le disque ? Pas forcément : le renommage est atomique pour les autres processus, mais le noyau peut garder les données en mémoire quelques secondes. Après une coupure de courant à ce moment, on peut retrouver le nouveau nom pointant vers un fichier vide. Si cela compte, sync -- "$rep_travail/$nom" avant le mv force l'écriture du fichier sur le disque. Pour une archive que l'on peut régénérer depuis le CSV, on s'en passe.
Le verrou
Un descripteur de fichier ouvert par exec reste ouvert jusqu'à la fin du script. On l'ouvre sur un fichier de verrou, puis on demande à flock de verrouiller ce descripteur :
exec {fd_verrou}>>"$FICHIER_VERROU"
flock -n "$fd_verrou" ||
mourir -c "$EX_VERROU" "une autre exécution est en cours (verrou $FICHIER_VERROU)"{fd_verrou}>>demande à Bash de choisir lui-même un numéro de descripteur libre (10 ou plus, d'après le manuel) et de le ranger dans la variablefd_verrou. C'est préférable au9>que l'on trouve partout, qui suppose que le descripteur 9 n'est pas déjà utilisé ;>>ouvre en ajout, crée le fichier s'il n'existe pas et, contrairement à>, ne le vide pas : on ne touche pas au contenu d'un fichier que l'on ne fait que verrouiller ;flock -n(non-blocking) échoue immédiatement si le verrou est déjà pris, avec le code 1 par défaut, au lieu d'attendre. On le traduit en code 5, celui que la leçon 8 a réservé à ce cas pourpublier-export.flock -w 30attendrait au plus trente secondes ;- on ne libère rien à la fin : le verrou tombe quand le descripteur se ferme, c'est-à-dire quand le script se termine, de quelque façon que ce soit.
Vérification, avec un script de démonstration qui prend le verrou puis attend deux secondes :
$ ./verrou.sh 2 & sleep 0.3; ./verrou.sh 0; echo "second code=$?"
verrou pris par 969327
verrou déjà pris
second code=5
Où placer le fichier de verrou ? Dans un répertoire qui appartient au compte du script et où personne d'autre ne peut écrire : /var/lib/signalements/publier-export.lock sur sig-outils, ou le répertoire que systemd crée avec RuntimeDirectory= pour un service. Pas dans /tmp ni dans /run/lock, ouverts à tous : n'importe quel compte pourrait y créer le fichier avant vous et le verrouiller pour toujours, ou y placer un lien symbolique.
Le verrou rend aussi sûr le ménage de démarrage évoqué plus haut. Tant que l'on détient le verrou, aucune autre exécution ne tourne : un répertoire .travail.* présent dans publies/ ne peut être que le reste d'une exécution tuée par SIGKILL, et on peut le supprimer sans risque :
find "$publies" -maxdepth 1 -name '.travail.*' -exec rm -rf -- {} +Sans verrou, ce même ménage supprimerait le répertoire de travail d'une exécution en cours.
Le verrou qui ne veut pas tomber
Le descripteur ouvert par exec est hérité par tous les processus que le script lance, comme n'importe quel descripteur. Si l'un d'eux survit au script, il garde le verrou. Démonstration avec un script qui lance une commande en arrière-plan et se termine sans l'attendre :
#!/usr/bin/env bash
# fuite.sh
exec {fd}>>./verrou.lock
flock -n "$fd" || exit 5
sleep 3 & # hérite du descripteur, donc du verrou
echo "script terminé"$ ./fuite.sh; ./verrou.sh 0; echo "code=$?"
script terminé
verrou déjà pris
code=5
Le script est terminé, mais sleep tient encore le verrou ; trois secondes plus tard, il est libre. La page flock(2) explique pourquoi : le verrou appartient à la description de fichier ouverte, partagée par tous les descripteurs copiés par fork ou dup, et il n'est libéré que lorsque tous ces descripteurs sont fermés. Pour qu'un enfant n'en hérite pas, fermez le descripteur dans sa redirection :
sleep 3 {fd}>&- &Faut-il toujours le fermer ? Non. Pour publier-export, que aws hérite du verrou est plutôt souhaitable : si le script est tué par SIGKILL pendant un dépôt, la commande aws orpheline continue de déposer, et le verrou qu'elle tient empêche une nouvelle exécution de déposer le même fichier en même temps. Fermez le descripteur pour les processus qui doivent survivre au script (un démon que le script démarre, par exemple), gardez-le pour ceux qui font partie de son travail. Quand un verrou semble coincé, lsof /var/lib/signalements/publier-export.lock (ou fuser -v) montre qui le tient.
Le masque de création
Les fichiers créés par une redirection ou par gzip prennent les droits que leur donne le umask du processus, souvent 0022 (fichiers en 0644, lisibles par tous) ou 0002. L'export contient des données personnelles : on fixe le masque en tête de script, une fois pour toutes :
umask 027 # fichiers en 0640, répertoires en 0750 : rien pour les « autres »C'est plus sûr qu'un chmod après coup, qui laisse une fenêtre pendant laquelle le fichier est lisible. Pour un fichier encore plus sensible (une clé), umask 077 dans un sous-shell limite la portée :
$ (umask 077; : > secret.txt); ls -l secret.txt; umask
-rw------- 1 vous vous 0 oct. 8 14:45 secret.txt
0002
Sous systemd, UMask= dans l'unité joue le même rôle ; le mettre aussi dans le script protège les exécutions lancées à la main.
publier-export après cette leçon
Voici les parties de publier-export que modifie cette leçon, à partir de la version de la leçon 9. Les constantes, la configuration et l'analyse des options (leçon 8), le piège ERR et sa fonction sur_erreur, verifier_export et compter_lignes (leçon 9) ne changent pas et sont résumés par un commentaire. mourir -c CODE MESSAGE est la fonction de lib/commun.sh (leçon 6).
#!/usr/bin/env bash
# publier-export : dépose l'export quotidien de Signalements pour la mairie.
set -Eeuo pipefail
shopt -s inherit_errexit
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"
readonly VERSION=1.4.0
readonly FICHIER_VERROU=${PUBLIER_EXPORT_VERROU:-/var/lib/signalements/publier-export.lock}
# ... EX_OK à EX_VERROU, REPERTOIRE_EXPORTS, POINT_ACCES, usage, erreur_usage,
# lire_configuration : leçon 8 ; sa boucle d'analyse des options est rangée
# dans une fonction analyser_options, qui remplit les globales date_export,
# destination, simulation, VERBEUX, csv et le tableau aws_s3
# ... sur_erreur et trap ERR, EN_TETE, compter_lignes, verifier_export : leçon 9
# Globales, et non locales à main : le piège EXIT doit encore les voir à la sortie.
rep_travail=""
enfant=""
# Tolérante et idempotente : peut être appelée deux fois, ne s'arrête sur aucune erreur.
nettoyer() {
trap - ERR
set +e
if [[ -n $enfant ]]; then
kill -TERM "$enfant" 2>/dev/null
wait "$enfant" 2>/dev/null
enfant=""
fi
if [[ -n $rep_travail ]]; then
rm -rf -- "$rep_travail"
rep_travail=""
fi
}
sur_sortie() {
local code=$?
nettoyer
exit "$code"
}
sur_signal() {
local signal=$1
avertir "signal SIG$signal reçu : arrêt et nettoyage"
nettoyer
trap - "$signal" EXIT
kill -s "$signal" "$$"
}
# lancer_interruptible COMMANDE...
# Lance une commande longue en arrière-plan et l'attend avec wait,
# pour qu'un signal soit traité sans attendre la fin de la commande.
lancer_interruptible() {
"$@" &
enfant=$!
local code=0
wait "$enfant" || code=$?
enfant=""
return "$code"
}
prendre_verrou() {
exec {fd_verrou}>>"$FICHIER_VERROU"
flock -n "$fd_verrou" ||
mourir -c "$EX_VERROU" "une autre exécution est en cours (verrou $FICHIER_VERROU)"
}
deposer() {
local fichier=$1 cible=$2 code
lancer_interruptible "${aws_s3[@]}" "$fichier" "$cible" || {
code=$?
mourir -c "$EX_DEPOT" "échec du dépôt de ${fichier##*/} (aws, code $code)"
}
# ... contrôle de la taille de l'objet déposé : leçon 9
}
publier() {
local publies="$REPERTOIRE_EXPORTS/publies"
local annee=${date_export:0:4} mois=${date_export:5:2}
local nom="signalements-$date_export.csv.gz" nb
local prefixe="$destination/$annee/$mois"
prendre_verrou
verifier_export "$csv"
nb=$(compter_lignes "$csv")
(( nb > 0 )) || mourir -c "$EX_EXPORT" "aucune ligne de données dans $csv"
# Restes d'une exécution tuée par SIGKILL : sûr, puisque l'on détient le verrou.
find "$publies" -maxdepth 1 -name '.travail.*' -exec rm -rf -- {} +
# Tout se prépare dans le système de fichiers de la destination.
rep_travail=$(mktemp -d -p "$publies" .travail.XXXXXX)
gzip -9 -c -- "$csv" > "$rep_travail/$nom"
gzip -t -- "$rep_travail/$nom"
( cd -- "$rep_travail" && sha256sum -- "$nom" ) > "$rep_travail/$nom.sha256"
if (( simulation )); then
journaliser "simulation : $nom et $nom.sha256 seraient déposés dans $prefixe/"
return 0
fi
# L'archive d'abord, l'empreinte ensuite : sa présence signale un dépôt complet.
deposer "$rep_travail/$nom" "$prefixe/$nom"
deposer "$rep_travail/$nom.sha256" "$prefixe/$nom.sha256"
# Publication locale par renommage, puis purge par âge.
mv -f -- "$rep_travail/$nom" "$publies/$nom"
mv -f -- "$rep_travail/$nom.sha256" "$publies/$nom.sha256"
find "$publies" -maxdepth 1 -name 'signalements-*.csv.gz*' -mtime +30 -delete
journaliser "export du $date_export publié, lignes de données : $nb"
}
main() {
analyser_options "$@"
umask 027
trap sur_sortie EXIT
trap 'sur_signal INT' INT
trap 'sur_signal TERM' TERM
trap 'sur_signal HUP' HUP
publier
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
exit
fiCe qui a changé par rapport à la leçon 9 :
- Le masque
027est fixé avant toute écriture. - Le verrou est pris avant même de lire l'export, et son échec donne le code 5.
- L'archive et son empreinte ne sont plus écrites à côté du CSV, où une autre tâche pouvait les ramasser à moitié écrites, mais dans un répertoire de travail créé par
mktempdanspublies/, que le piègeEXITsupprime quoi qu'il arrive. Ce qui doit survivre en sort par renommage. - Les pièges sur
EXIT, INT, TERM et HUP s'ajoutent au piègeERRde la leçon 9 :sur_erreursignale l'erreur et sort avec 1, ce qui déclenchesur_sortieet le nettoyage. Ils sont posés dansmain, pas au chargement du fichier : un test qui charge le script parsource(leçon 12) ne récupère pas ces pièges dans son propre shell. Les variables qu'ils utilisent,rep_travailetenfant, restent en revanche globales (voir les pièges courants). awstourne en arrière-plan, attendu parwait: un SIGTERM pendant un dépôt est traité tout de suite, et le piège arrêteawsavant de nettoyer.- La simulation (
--dry-run) change de nature : à la leçon 8, la fonctionexecuterse contentait d'afficher chaque commande, parce que rien de ce que le script écrivait ne pouvait être nettoyé à coup sûr. Maintenant que tout se prépare dans un répertoire de travail supprimé par le piègeEXIT, le script compresse et calcule l'empreinte pour de bon, puis s'arrête avant le dépôt. La simulation vérifie ainsi l'archive elle-même sans rien envoyer ni rien laisser derrière elle, etexecuterdisparaît.
Essais sur une copie de travail, avec HORODATER=0 et une doublure de aws placée en tête du PATH (on y reviendra avec les tests de la leçon 12). La doublure note ses arguments, échoue si on le lui demande, et sinon simule un dépôt de cinq secondes :
#!/usr/bin/env bash
# faux/aws : doublure de la commande aws pour les essais.
printf 'aws %s\n' "$*" >> "${FAUX_AWS_JOURNAL:-/dev/null}"
if [[ -n ${FAUX_AWS_ECHEC:-} ]]; then
echo "upload failed: simulated" >&2
exit 1
fi
exec sleep "${FAUX_AWS_DUREE:-5}" # exec : un seul processus, comme le vrai awsLe exec final n'est pas un détail. Sans lui, la doublure est un script Bash qui attend un sleep au premier plan : le SIGTERM envoyé par nettoyer tue la doublure, mais pas son sleep, qui devient orphelin et garde le descripteur du verrou hérité du script. C'est exactement la limite décrite plus haut (« tuer un enfant n'atteint pas ses propres enfants ») : une doublure doit avoir la même forme de processus que l'outil qu'elle remplace, sinon l'essai mesure la doublure.
$ publier-export --date 2026-10-08; echo "code=$?"
publier-export : export du 2026-10-08 publié, lignes de données : 3
code=0
$ cat /srv/donnees/exports/publies/signalements-2026-10-08.csv.gz.sha256
58a8229bcf7f82b67f321d84d69bac863058a58c0309a0b3f491691ca6c14c8a signalements-2026-10-08.csv.gz
(L'empreinte dépend de la date de modification du CSV, que gzip inscrit dans l'archive : la vôtre sera différente.)
Puis on lance une exécution en arrière-plan, on en tente une seconde pendant le dépôt, et l'on envoie SIGTERM à la première :
$ publier-export --date 2026-10-08 & sleep 1
$ publier-export --date 2026-10-08; echo "code=$?"
publier-export : erreur : une autre exécution est en cours (verrou /var/lib/signalements/publier-export.lock)
code=5
$ kill -TERM %1; wait %1; echo "code=$?"
publier-export : attention : signal SIGTERM reçu : arrêt et nettoyage
code=143
$ ls -A /srv/donnees/exports/publies/
signalements-2026-10-08.csv.gz signalements-2026-10-08.csv.gz.sha256
Aucun processus de la doublure ne reste, aucun répertoire .travail.* ne subsiste dans publies/, et les fichiers de l'exécution réussie précédente sont intacts : l'exécution interrompue n'a rien renommé. Le même essai avec kill -INT donne le message signal SIGINT reçu et le code 130. Avec FAUX_AWS_ECHEC=1, le script se termine par le code 4 et le message échec du dépôt de signalements-2026-10-08.csv.gz (aws, code 1), et le répertoire de travail est supprimé.
Sous le capot
Un gestionnaire minimal et des pièges en attente
Un gestionnaire de signal, côté noyau, interrompt le programme n'importe où : au milieu d'une allocation de mémoire, d'une modification de variable. Exécuter du code shell arbitraire à cet endroit serait dangereux. Bash installe donc pour les signaux interceptés un gestionnaire qui ne fait presque rien : il note que le signal est arrivé (dans un tableau de pièges en attente, pending_traps, dans le fichier trap.c du code source). Le shell consulte cette note à des points sûrs, entre deux commandes, et exécute alors l'action du piège.
Quand Bash attend une commande au premier plan, il est bloqué dans un appel système qui attend la fin d'un enfant. L'arrivée du signal interrompt cet appel, Bash note le signal, puis reprend son attente : le point sûr suivant n'arrive qu'à la fin de la commande. La commande interne wait est traitée différemment : son attente est justement conçue pour être interrompue, d'où le retour immédiat avec un code supérieur à 128.
Pour un signal mortel non intercepté, Bash installe aussi son propre gestionnaire, ce qui explique qu'il puisse exécuter le piège EXIT. Tout se passe comme les essais l'ont montré : le shell exécute le piège de sortie, rétablit le comportement par défaut du signal, puis se le renvoie, si bien que son parent le voit mourir du signal d'origine.
Les signaux ignorés à l'entrée
La règle « un signal ignoré à l'entrée ne peut être ni intercepté ni rétabli » découle de la façon dont execve traite les signaux : les signaux interceptés reviennent à leur comportement par défaut dans le nouveau programme (le code du gestionnaire n'existe plus), mais les signaux ignorés le restent. Bash, à son démarrage, regarde ce dont il a hérité et respecte les signaux ignorés. C'est ce qui fait fonctionner nohup : il ignore SIGHUP puis lance la commande, qui hérite de cette disposition.
Conséquence pratique, déjà vue : un script lancé avec & depuis un autre script ignore SIGINT, et son trap ... INT est sans effet. Pour l'arrêter, envoyez-lui SIGTERM.
rename(2), et ce que mv fait entre deux systèmes de fichiers
Un répertoire est une table qui associe des noms à des numéros d'inodes. Renommer, dans un même système de fichiers, c'est modifier cette table : le contenu du fichier ne bouge pas, ce qui explique à la fois la rapidité et l'atomicité. La page rename(2) ajoute deux précisions utiles : les descripteurs déjà ouverts sur l'ancien nom ne sont pas affectés (un lecteur qui a ouvert l'ancienne archive la lit jusqu'au bout), et il peut exister un court instant où les deux noms désignent le fichier renommé.
Entre deux systèmes de fichiers, aucune table commune ne permet ce tour de passe-passe. L'appel échoue avec EXDEV, et GNU mv se rabat sur une copie suivie d'une suppression, en prenant soin de retirer la copie partielle si elle échoue. Pendant la copie, le fichier de destination existe, incomplet.
flock(2) et la description de fichier ouverte
Le noyau distingue le descripteur (un numéro dans la table d'un processus) de la description de fichier ouverte (l'objet créé par open, avec sa position et ses options). fork et dup copient des descripteurs qui pointent vers la même description ; deux open du même fichier créent deux descriptions distinctes.
flock(2) attache le verrou à la description. D'où les trois comportements observés :
- les enfants héritent du verrou, puisqu'ils partagent la description ;
- le verrou survit à
execve(une commande lancée par le script le garde) ; - il tombe quand le dernier descripteur qui pointe vers la description est fermé, ou sur un
flock -uexplicite.
Les verrous de flock(2) sont consultatifs : ils n'empêchent personne d'écrire dans le fichier de verrou, ils n'arrêtent que les processus qui demandent eux aussi le verrou. Et sur un partage NFS, la page précise que, depuis Linux 2.6.12, ils sont émulés par des verrous fcntl sur l'ensemble du fichier ; un fichier de verrou se place sur un disque local.
Pièges courants
Un piège entre guillemets doubles. trap "rm -rf $rep" EXIT développe $rep au moment du trap. Si la variable est encore vide, le piège exécutera rm -rf sans argument (sans effet) ; si elle contient une espace, il supprimera deux chemins au lieu d'un. Apostrophes, toujours (SC2064).
Un second trap qui efface le premier. Une bibliothèque qui pose trap ... EXIT pour ses propres besoins écrase celui du script, ou l'inverse. Un seul piège par signal, une seule fonction de nettoyage, et des structures de données (un tableau de chemins à supprimer, par exemple) que chaque partie du script complète.
rm -rf "$rep_travail" avec une variable vide. Une faute de frappe dans le nom de la variable, et sous set -u le script s'arrête ; sans set -u, rm -rf "" ne fait rien, mais rm -rf "$rep_travail/"* devient rm -rf /*. Ne concaténez jamais un motif à un chemin à supprimer, testez que la variable est non vide ([[ -n $rep_travail ]]), ou utilisez ${rep_travail:?}, qui arrête le script si la variable est vide (leçon 3).
Un piège EXIT qui vise une variable locale. Le réflexe de la leçon 6, tout déclarer local dans main, se retourne ici contre vous :
main() {
local rep
rep=$(mktemp -d -p .)
trap 'echo "piège : rep=[$rep]"; rm -rf -- "$rep"' EXIT
echo "créé : $rep"
[[ ${1:-} == sortir ]] && exit 3
return 0
}
main "$@"$ bash piege-local.sh; echo "code=$?"
créé : ./tmp.u8qcxcJpGE
piège : rep=[]
code=0
$ ls -d ./tmp.*
./tmp.u8qcxcJpGE
$ bash piege-local.sh sortir; echo "code=$?"
créé : ./tmp.90nHt0qFeO
piège : rep=[./tmp.90nHt0qFeO]
code=3
Quand main se termine normalement, sa variable locale disparaît avec elle ; le piège s'exécute plus tard, à la fin du script, et $rep est vide : rm -rf -- "" ne supprime rien, le répertoire reste. Quand le script sort par exit depuis main (un mourir, une erreur sous set -e), le piège s'exécute alors que main est encore active et voit la variable : le défaut n'apparaît donc que dans le cas qui marche, ce qui le rend difficile à remarquer. Sous set -u, la fin normale produit en plus rep: unbound variable dans le piège. Les variables utilisées par un piège sont globales, initialisées à vide en tête de script.
Un nettoyage qui s'arrête au premier échec. set -e reste actif dans le piège : le premier rm qui échoue interrompt le nettoyage et change le code de sortie. set +e en tête de la fonction de nettoyage.
exit 130 au lieu de mourir de SIGINT. Une boucle appelante continue après chaque Ctrl+C. Rétablir le signal et se le renvoyer (trap - INT; kill -s INT "$$").
Un Ctrl+C qui n'arrête pas une commande en arrière-plan. Dans un script, les commandes lancées avec & ignorent SIGINT : c'est le piège du script qui doit les arrêter, avec SIGTERM.
Un trap sur INT sans effet. Le script a été lancé en arrière-plan par un autre script, ou par nohup pour HUP : le signal était ignoré à l'entrée, Bash refuse de l'intercepter. Envoyez SIGTERM.
Un fichier temporaire dans /tmp « déplacé » vers sa destination. mv entre deux systèmes de fichiers copie : la destination est visible incomplète pendant la copie. mktemp -p "$(dirname -- "$destination")".
Un verrou dans /tmp ou /run/lock. N'importe quel compte peut créer le fichier avant vous, le verrouiller, ou y placer un lien symbolique. Un répertoire au compte du script.
exec 9> sur un descripteur déjà utilisé, ou exec 9> qui vide un fichier. {fd}>> choisit un descripteur libre et ne tronque pas.
Un verrou retenu après la fin du script. Un processus lancé par le script a hérité du descripteur et vit encore. lsof sur le fichier de verrou le révèle ; {fd}>&- dans la redirection des enfants qui doivent survivre.
Les fichiers .pid et mkdir comme verrous. « Si le fichier existe, sortir ; sinon, le créer » laisse une fenêtre entre le test et la création : c'est une condition de concurrence. mkdir est atomique (BashFAQ 045 le recommande quand flock n'est pas disponible), mais un script tué par SIGKILL laisse le répertoire derrière lui, et toutes les exécutions suivantes refusent de démarrer. Un fichier .pid ajoute un problème : après un redémarrage, le PID noté peut avoir été réattribué à un tout autre processus. flock n'a aucun de ces défauts, puisque le noyau libère le verrou à la mort du processus.
Sécurité
- Fichiers temporaires prévisibles.
/tmp/export-$$.csv.gzse devine : le PID est visible de tous et les valeurs se suivent. Un autre compte de la machine peut créer ce nom à l'avance, comme fichier qu'il pourra lire, ou comme lien symbolique vers un fichier à écraser. C'est la faiblesse répertoriée par MITRE sous CWE-377 (Insecure Temporary File). Sur Debian et Ubuntu, les réglagesfs.protected_symlinksetfs.protected_regular, actifs par défaut, bloquent les formes les plus classiques de l'attaque dans les répertoires ouverts à tous, mais ils ne protègent pas contre la lecture.mktempcrée un nom imprévisible, de façon exclusive, en0600: il n'y a aucune raison de faire autrement. - Le répertoire temporaire hérite de ses voisins. Un répertoire de travail créé dans
/srv/donnees/exports/publies/est protégé par les droits de ce répertoire (0750 signalements:signalements) en plus des siens. C'est un avantage de plus de travailler près de la destination plutôt que dans/tmp. - Le masque de création avant la première écriture.
umask 027en tête de script : un fichier de données personnelles ne doit jamais exister, ne serait-ce qu'un instant, avec des droits de lecture pour tous. - Les données sensibles dans les fichiers temporaires. Un fichier temporaire non nettoyé est une copie non inventoriée des données : il échappe à la purge RGPD et finit dans les sauvegardes. C'est l'argument le plus fort en faveur d'un nettoyage systématique, y compris au démarrage suivant pour les restes d'un SIGKILL.
rm -rfdans un piège exécuté en root. Le chemin supprimé doit venir demktemp, jamais d'une variable que l'environnement ou un argument pourrait remplir. Une variableTMPDIRfournie par l'appelant est acceptable pourmktemp, qui crée un sous-répertoire neuf dedans ; elle ne l'est pas pour unrm -rf "$TMPDIR"/*.- Le verrou comme déni de service. Un fichier de verrou modifiable par un autre compte lui permet d'empêcher la tâche de tourner, indéfiniment et sans trace. Répertoire au compte du script, et une alerte si le code 5 se répète plusieurs nuits de suite.
En production
Sous systemd
Quand publier-export est lancé par un service, systemd fait une partie du travail :
systemctl stopet le dépassement deTimeoutStartSec=envoient SIGTERM à tout le cgroup : le script etawsle reçoivent ensemble, et aucun orphelin ne survit au-delà deTimeoutStopSec=, après lequel vient SIGKILL. Le piège du script doit donc finir son nettoyage bien avant ce délai : pas de dépôt de « dernière chance » dans un piège ;PrivateTmp=yesdonne au service un/tmpprivé, détruit à l'arrêt de l'unité, ce qui limite les dégâts d'un script qui y laisse des fichiers ;RuntimeDirectory=signalementscrée/run/signalements/au compte du service et le supprime à l'arrêt : un bon emplacement pour un fichier de verrou propre au service. Mais un verrou qui doit aussi bloquer les exécutions manuelles va dans/var/lib/signalements/, accessible aux deux ;UMask=0027fixe le masque, et le service ne se chevauche jamais lui-même.
Rien de tout cela ne protège une exécution lancée à la main dans un terminal : le script doit rester correct seul. Et pour relancer à la main dans les conditions du service, systemctl start signalements-publication.service vaut mieux qu'un appel direct ; pour une opération longue lancée d'une session SSH, systemd-run évite le SIGHUP d'une coupure (on compare ces approches à nohup dans la leçon 11).
Borner la durée
Un dépôt bloqué sur un réseau qui ne répond plus ne reçoit aucun signal : il attend. Bornez la durée de chaque commande externe qui touche le réseau, avec les options de l'outil (--cli-read-timeout et --cli-connect-timeout pour aws, -m pour curl) ou avec timeout 10m commande, qui envoie SIGTERM au bout du délai et renvoie alors le code 124 ; -k 30s ajoute un SIGKILL si la commande ne s'est pas arrêtée trente secondes plus tard. La leçon 11 détaille timeout et ses pièges.
Plusieurs machines
flock ne vaut que pour une machine. Si un second sig-outils est un jour ajouté pour la haute disponibilité, deux verrous locaux ne se voient pas, et la mairie recevra deux dépôts. Il faut alors un verrou partagé : un verrou consultatif PostgreSQL dans sig-db (pg_try_advisory_lock), ou un objet de verrou dans le stockage objet avec une écriture conditionnelle. C'est souvent le moment de se demander si la tâche ne devrait pas plutôt tourner dans un ordonnanceur qui garantit l'unicité, comme un CronJob Kubernetes avec concurrencyPolicy: Forbid.
Côté consommateur
L'atomicité locale ne dit rien de ce que voit la mairie. L'API S3 ne rend un objet visible qu'une fois son envoi terminé, donc un objet n'est jamais lu à moitié écrit ; mais l'archive et son fichier de contrôle sont deux objets, déposés l'un après l'autre. Convenez avec le destinataire d'un marqueur de fin : ici, la présence du .sha256, déposé en dernier. Un script interrompu entre les deux dépôts laisse une archive sans contrôle, que la mairie ignore ; la relance dépose à nouveau les deux, sous les mêmes noms. C'est l'idempotence vue de l'extérieur.
Revue d'un script de production
Avant de mettre un script en service, posez ces questions :
- Que reste-t-il après
Ctrl+C, aprèskill, aprèskill -9, après une coupure de courant ? - Un lecteur peut-il voir un fichier incomplet ? Où sont créés les fichiers temporaires, et sur quel système de fichiers ?
- Que se passe-t-il si deux exécutions démarrent en même temps ? Si l'exécution d'hier est encore en cours ?
- Peut-on relancer sans réfléchir après n'importe quelle interruption ?
- Combien de temps le script peut-il durer au pire, et qui l'arrête au-delà ?
Exercices
1. Prévoir l'issue (niveau 100). Pour chacune de ces lignes, dites ce qui s'affiche et le code que reçoit le shell parent, puis vérifiez.
bash -c 'trap "echo fin" EXIT; exit 3'
bash -c 'trap "echo fin" EXIT; kill -TERM $$'
bash -c 'trap "echo fin" EXIT; kill -KILL $$'
bash -c 'trap "echo [\$x]" EXIT; x=1'
bash -c "trap \"echo [\$x]\" EXIT; x=1"Solution
fin, code 3 : le piègeEXITs'exécute aprèsexitet ne change pas le code.fin, code 143 : Bash exécute le piègeEXITmême quand il meurt d'un signal non intercepté, puis meurt de SIGTERM (128 + 15).- Rien, code 137 : SIGKILL ne peut pas être intercepté, aucun piège ne s'exécute (128 + 9).
[1]: la chaîne du piège est entre guillemets doubles dans un argument entre apostrophes ; pour le shell interne, l'action estecho [$x], développée au déclenchement, quandxvaut 1.[]: cette fois, c'est le shell extérieur qui lit l'argument entre guillemets doubles. Il transmettrap "echo [$x]" EXITau shell interne, qui développe$xau moment dutrap, alors quexest vide. C'est le défaut que signale SC2064.
2. Un nettoyage qui résiste à tout (niveau 200). Écrivez un script compter-photos qui prend un répertoire en argument, copie dans un répertoire de travail créé par mktemp -d la liste des photos de plus de 5 Mo (find "$1" -type f -size +5M), la trie, l'affiche, et garantit la suppression du répertoire de travail dans tous les cas. Vérifiez-le : fin normale, répertoire inexistant (erreur sous set -e), Ctrl+C pendant un sleep 30 ajouté pour l'essai, kill -TERM depuis un autre terminal.
Solution
#!/usr/bin/env bash
set -euo pipefail
rep_travail=$(mktemp -d)
nettoyer() {
local code=$?
set +e
rm -rf -- "$rep_travail"
exit "$code"
}
trap nettoyer EXIT
find "$1" -type f -size +5M > "$rep_travail/liste"
sleep 30 # pour l'essai seulement
sort -- "$rep_travail/liste"Vérification après chaque essai : ls -d /tmp/tmp.* ne doit rien montrer de nouveau. Pour le répertoire inexistant, find échoue, set -e arrête le script, le piège nettoie et le code reste celui de find (1). Pour Ctrl+C, le sleep et le script reçoivent SIGINT, le piège EXIT s'exécute, le code est 130. Pour kill -TERM sur le script seul, le piège EXIT s'exécute immédiatement, mais le sleep reste orphelin pendant trente secondes : sans conséquence ici, puisqu'il n'écrit rien dans le répertoire de travail. Pour faire mieux, il faudrait sleep 30 & wait $! et un piège sur TERM qui arrête l'enfant.
3. Une purge qui ne tourne jamais deux fois (niveau 200). purger-pieces-jointes peut durer plusieurs heures sur un gros volume. Ajoutez-lui un verrou : une seconde exécution doit attendre au plus 10 secondes, puis abandonner avec un message clair et un code non nul. Où placez-vous le fichier de verrou, et pourquoi pas dans /tmp ?
Solution
readonly FICHIER_VERROU=/var/lib/signalements/purger-pieces-jointes.lock
exec {fd_verrou}>>"$FICHIER_VERROU"
flock -w 10 "$fd_verrou" ||
mourir -c 5 "purge déjà en cours depuis plus de 10 secondes (verrou $FICHIER_VERROU), abandon"Le fichier va dans un répertoire qui appartient au compte signalements et où personne d'autre n'écrit. Dans /tmp (ou /run/lock), ouvert à tous, n'importe quel compte pourrait créer le fichier avant la purge et le garder verrouillé, ce qui l'empêcherait de tourner, sans erreur visible autre que le code 5 ; il pourrait aussi y placer un lien symbolique. Sur Debian 13, /tmp est en outre vidé au redémarrage, ce qui n'est pas un problème pour un verrou (le noyau le libère de toute façon), mais rappelle que /tmp n'est pas un lieu pour l'état d'un service.
4. Le verrou fantôme (niveau 300). Un administrateur a interrompu publier-export par kill <pid> pendant un dépôt. Une minute plus tard, la relance échoue avec « une autre exécution est en cours », alors que pgrep -f publier-export ne renvoie rien. Expliquez ce qui s'est passé avec la version de Camille complétée d'un simple exec 9>... ; flock -n 9 mais sans pièges sur les signaux ni fonction lancer_interruptible, la commande pour le confirmer, et ce que change la version de la leçon.
Solution
kill <pid> n'a visé que le script. Sans piège sur TERM, Bash est mort immédiatement (en exécutant son éventuel piège EXIT), mais la commande aws qui tournait au premier plan n'a pas reçu le signal : elle continue son dépôt, orpheline. Or elle a hérité du descripteur 9, donc de la description de fichier ouverte qui porte le verrou : le verrou ne tombera qu'à sa fin. pgrep -f publier-export ne la trouve pas, puisque sa ligne de commande est celle de aws.
Confirmation : lsof /var/lib/signalements/publier-export.lock (ou fuser -v sur le fichier) montre le processus aws et son PID, et ps -o pid,ppid,cmd -p <pid> un parent qui est désormais 1 ou le gestionnaire de session.
La version de la leçon lance aws en arrière-plan et l'attend avec wait : SIGTERM interrompt wait immédiatement, le piège arrête l'enfant par son PID, attend sa fin, supprime le répertoire de travail, puis le script se renvoie SIGTERM. Le verrou tombe avec le dernier processus. Que l'orphelin garde le verrou n'était d'ailleurs pas le vrai problème : il empêchait une seconde exécution de déposer en même temps que lui. Le problème était qu'il existe.
5. Relancer sans réfléchir (niveau 300). Pour chacun de ces instants d'interruption de publier-export par SIGKILL, décrivez l'état laissé localement et chez la mairie, puis ce que fait l'exécution suivante : (a) pendant le gzip ; (b) entre le dépôt de l'archive et celui du .sha256 ; (c) entre les deux mv ; (d) pendant la purge des archives de plus de 30 jours. Le script est-il idempotent ? Que faudrait-il changer si la mairie lisait l'archive dès son arrivée, sans attendre le .sha256 ?
Solution
(a) Un répertoire .travail.XXXXXX avec une archive partielle ; rien chez la mairie. L'exécution suivante, une fois le verrou pris (le noyau l'a libéré à la mort du processus), supprime ce répertoire et recommence.
(b) Localement, le répertoire de travail complet ; chez la mairie, l'archive sans son .sha256, que la mairie ignore puisque le contrôle sert de marqueur de fin. La relance supprime le répertoire de travail, régénère l'archive, la redépose (l'objet est remplacé, même clé), puis dépose le contrôle.
(c) L'archive est en place dans publies/, le .sha256 encore dans le répertoire de travail ; chez la mairie, tout est complet. La relance redépose (sans dommage, les objets sont remplacés par des contenus identiques ; gzip -c d'un même fichier produit la même archive, puisque le nom et la date de modification stockés dans l'en-tête sont ceux du CSV) et réécrit les deux fichiers locaux par renommage.
(d) Quelques archives anciennes supprimées, d'autres non ; la purge suivante finit le travail, puisque son critère est l'âge et non un nombre.
Le script est donc idempotent : chaque relance converge vers le même état. Si la mairie lisait l'archive dès son arrivée, le cas (b) poserait problème uniquement si elle exigeait le contrôle ; mais un consommateur pressé pourrait lire une archive du jour J alors qu'une relance la remplace : il lirait l'ancienne ou la nouvelle version complète (S3 ne montre pas d'objet partiel), identiques ici. Pour un contenu qui peut changer entre deux exécutions, il faudrait déposer sous un nom temporaire puis copier côté serveur vers le nom définitif, ou mieux, convenir d'un marqueur de fin, comme ici.
Récapitulatif
- Un script reçoit des signaux de plusieurs sources :
Ctrl+C(SIGINT, à tout le groupe), coupure SSH (SIGHUP),kill(SIGTERM, au seul PID visé),systemctl stop(SIGTERM à tout le cgroup, puis SIGKILL après 90 secondes par défaut), le tueur de l'OOM (SIGKILL). SIGKILL ne s'intercepte pas. trap 'action' SIGNAL: action entre apostrophes, un seul piège par signal, global, non hérité par les sous-shells. Un signal ignoré à l'entrée ne peut pas être intercepté.- Le pseudo-signal EXIT s'exécute à toute fin de script Bash, y compris sur erreur et sur signal mortel, sauf SIGKILL : c'est le point unique de nettoyage. Fonction tolérante (
set +e,trap - ERR), qui mémorise$?et finit parexit "$code". mktemp -d -p <répertoire>pour tout fichier temporaire : nom imprévisible, création exclusive,0700.- Un piège attend la fin de la commande au premier plan ;
commande & wait $!rend le script interruptible, et le piège arrête l'enfant par son PID. Les enfants lancés par&ignorent SIGINT. - Après un signal, nettoyer puis se renvoyer le signal (
trap - SIG EXIT; kill -s SIG "$$"), pour que l'appelant voie 128 + n et puisse s'arrêter aussi. - Écriture atomique : écrire à côté, dans le même système de fichiers, puis
mv(rename(2)). Entre deux systèmes de fichiers,mvcopie. - Idempotence : noms dérivés des données, remplacement plutôt qu'ajout, purge par critère, et ménage au démarrage des restes d'un SIGKILL, sûr sous verrou.
- Verrou :
exec {fd}>>fichier; flock -n "$fd" || exit 5, dans un répertoire au compte du script. Le verrou suit la description de fichier ouverte : les enfants en héritent ({fd}>&-pour l'éviter), il tombe à la mort du dernier. umask 027avant la première écriture.
Pour aller plus loin
- La section Signals du manuel de Bash, courte et dense : chaque phrase y décrit un comportement que l'on finit par rencontrer.
- La page SignalTrap de Greg's Wiki, pour la sortie coopérative et ses conséquences sur les boucles et les programmes interactifs, et la BashFAQ 045 pour l'histoire des verrous en shell.
- Les pages
flock(2)etflock(1), dont l'exemple de script qui se verrouille lui-même par la variableFLOCKER, etrename(2)pour tous les cas limites du renommage. - La page
systemd.kill(5), pourKillMode=,KillSignal=etSendSIGHUP=, à relire avant d'écrire l'unité d'un script qui doit s'arrêter proprement. - La leçon suivante, Processus, parallélisme et délais, qui fait tourner
deployersur les deux machines en parallèle et reprendwait,timeoutet les orphelins à plus grande échelle.
Sources
- GNU Bash Reference Manual, Signals
- GNU Bash Reference Manual, Bourne Shell Builtins (trap, exit)
- GNU Bash Reference Manual, Redirections (allocation {varname})
- POSIX.1-2024, Shell Command Language, trap
- Greg's Wiki, Sending and Trapping Signals (SignalTrap)
- Greg's Wiki, BashFAQ 045 : empêcher plusieurs instances d'un script
- Linux man-pages, flock(2)
- Linux man-pages, rename(2)
- Linux man-pages, mktemp(1)
- Debian, page de manuel flock(1), util-linux
- Debian, page de manuel timeout(1), coreutils
- Debian, page de manuel systemd.kill(5)
- Debian, page de manuel systemd-system.conf(5), DefaultTimeoutStopSec=
- GNU Coreutils, manuel, mv invocation
- Bash, code source : trap.c (pending_traps, run_pending_traps)
- ShellCheck, SC2064
- MITRE, CWE-377 : Insecure Temporary File