Les processus et les signaux
Pourquoi
Lundi, 9 h 40. Le service d'astreinte vous transfère un message de la mairie : Signalements répond très lentement depuis une heure. Vous vous connectez en SSH sur sig-app-1. Il faut répondre vite à trois questions : l'application tourne-t-elle encore ? Qu'est-ce qui occupe la machine ? Et, s'il faut la relancer, comment le faire sans couper les requêtes en cours ni corrompre ce qu'elle était en train d'écrire ?
Ces trois questions portent sur des processus. Tout ce qui s'exécute sur une machine Linux, l'API Signalements, la base PostgreSQL, le serveur SSH qui vous a laissé entrer, le shell dans lequel vous tapez, est un processus. Savoir les observer et leur parler est le premier geste de tout diagnostic, et c'est aussi le geste qui fait le plus de dégâts quand il est mal fait : un kill -9 lancé par réflexe sur une base de données peut transformer une lenteur en perte de données.
Sans ces notions, on redémarre la machine entière, faute de savoir ce qui se passe à l'intérieur. Cela marche parfois, cela efface toujours les indices, et cela ne dit jamais pourquoi la panne reviendra.
Les concepts
Programme et processus
Un programme est un fichier : du code exécutable posé sur le disque, par exemple /usr/bin/python3. Il ne fait rien tant qu'on ne le lance pas. Un processus est un programme en cours d'exécution : le noyau lui a donné un espace mémoire, une liste de fichiers ouverts, une identité (un utilisateur, un groupe), un répertoire courant, des variables d'environnement, et du temps de processeur. Le même programme peut donner naissance à de nombreux processus en même temps : sur sig-app-1, Gunicorn, le serveur qui fait tourner l'application Flask, lance un processus maître et deux processus de travail (workers), tous issus du même programme Python.
Chaque processus porte un numéro unique sur la machine, le PID (process identifier). Il porte aussi le PID du processus qui l'a créé, le PPID (parent process identifier). La page de manuel credentials(7) le résume : le PPID identifie le processus qui a créé celui-ci, et il est conservé quand le processus remplace son programme par un autre. Les processus forment donc un arbre, dont la racine est le processus de PID 1, lancé par le noyau au démarrage : sur Ubuntu et Debian, c'est systemd, sujet de la leçon 11.
flowchart TB
s["systemd (PID 1)"] --> sshd["sshd"]
s --> g["gunicorn maître<br/>(utilisateur signalements)"]
s --> pg["postgres"]
sshd --> sh["sshd : camille"] --> bash["bash (votre shell)"] --> ps["ps"]
g --> w1["gunicorn worker 1"]
g --> w2["gunicorn worker 2"]
Chaque processus a aussi un propriétaire (l'utilisateur sous lequel il s'exécute), qui détermine ce qu'il a le droit de faire (leçons 8 et 9). L'API tourne sous le compte système signalements : si elle est compromise, l'attaquant hérite de ses droits, pas de ceux de root.
Les états d'un processus
À un instant donné, un processus est dans l'un de quelques états. ps les note par une lettre, que la page de manuel ps(1) définit :
| Lettre | État | Ce que cela veut dire en pratique |
|---|---|---|
R | running or runnable | en train de s'exécuter sur un processeur, ou prêt et en attente d'un processeur |
S | sommeil interruptible | attend un événement (une requête réseau, une touche, une minuterie). C'est l'état normal d'un serveur au repos |
D | sommeil non interruptible | attend le plus souvent une entrée-sortie (disque, réseau de stockage). Il ne réagit à aucun signal tant que l'opération n'est pas terminée |
T | arrêté | suspendu par un signal de contrôle de tâche (Ctrl-Z, SIGSTOP) |
Z | zombie (defunct) | terminé, mais son parent n'a pas encore recueilli son code de sortie |
I | inactif | fil d'exécution du noyau au repos ; on ne s'en occupe pas |
Avec les formats de sortie hérités de BSD (ps aux), d'autres caractères s'ajoutent : s pour un chef de session, l pour un processus à plusieurs fils d'exécution, + pour un processus au premier plan de son terminal, N pour une priorité basse, < pour une priorité haute.
Le zombie, un processus déjà mort
Le nom fait peur, la réalité est plus banale. Quand un processus se termine, le noyau libère sa mémoire et ses fichiers, mais garde une toute petite fiche : son PID, son code de sortie et ses statistiques d'utilisation. Cette fiche attend que le parent la réclame avec l'appel système wait(). La page wait(2) le formule ainsi : un enfant qui se termine sans avoir été attendu devient un « zombie ».
Un zombie ne consomme ni processeur ni mémoire. Il occupe seulement une entrée dans la table des processus. Un ou deux zombies passagers sont normaux. Des centaines de zombies qui s'accumulent signalent un parent défectueux, qui ne fait pas son travail de ramassage, et peuvent finir par épuiser les PID disponibles. On ne « tue » pas un zombie, il est déjà mort : on corrige ou on redémarre son parent. Quand le parent disparaît, ses enfants (zombies compris) sont adoptés par le PID 1 (ou par le processus ancêtre le plus proche qui s'est déclaré « sous-ramasseur »), et le PID 1 les attend automatiquement. C'est l'une des raisons pour lesquelles le premier processus d'un conteneur a un rôle particulier, comme l'a montré la leçon Cycle de vie et diagnostic du cours Docker (voir aussi PID 1).
Les signaux
Un signal est un message très court, un simple numéro, que le noyau délivre à un processus pour l'interrompre dans ce qu'il fait. Un processus peut en envoyer à un autre (s'il en a le droit : le même propriétaire, ou root), le noyau en envoie de lui-même (division par zéro, accès mémoire interdit, fils terminé), et le terminal en envoie quand vous tapez certaines touches.
Pour chaque signal, le processus a trois possibilités : laisser l'action par défaut (souvent : se terminer), l'ignorer, ou l'intercepter avec une fonction à lui, par exemple pour terminer proprement. Deux signaux échappent à cette règle : selon signal(7), SIGKILL et SIGSTOP ne peuvent être ni interceptés, ni bloqués, ni ignorés. Le noyau les applique directement.
Les signaux à connaître, avec leurs numéros sur x86 et ARM (les numéros diffèrent sur quelques architectures rares, d'où l'habitude d'utiliser les noms) :
| Signal | Numéro | Action par défaut | Usage courant |
|---|---|---|---|
SIGHUP | 1 | terminer | à l'origine, « le terminal a raccroché » ; beaucoup de démons l'utilisent pour recharger leur configuration |
SIGINT | 2 | terminer | Ctrl-C au clavier |
SIGQUIT | 3 | terminer avec image mémoire | Ctrl-\ au clavier |
SIGKILL | 9 | terminer | arrêt immédiat et brutal, impossible à intercepter |
SIGTERM | 15 | terminer | demande polie d'arrêt ; le signal par défaut de kill et de systemd |
SIGCHLD | 17 | ignorer | envoyé au parent quand un enfant change d'état |
SIGCONT | 18 | reprendre | reprend un processus arrêté |
SIGSTOP | 19 | arrêter | suspension, impossible à intercepter |
SIGTSTP | 20 | arrêter | Ctrl-Z au clavier ; peut être intercepté |
La différence entre SIGTERM et SIGKILL est le cœur de cette leçon. SIGTERM demande au processus de s'arrêter : s'il l'intercepte, il peut finir les requêtes en cours, vider ses tampons sur le disque, fermer proprement ses connexions à la base, supprimer ses fichiers temporaires. SIGKILL ne demande rien : le noyau retire le processus de la machine au milieu de ce qu'il faisait. Pas de nettoyage, pas de dernière écriture.
Les signaux de Gunicorn
Chaque programme décide de ce qu'il fait des signaux qu'il intercepte ; la documentation de chaque serveur le précise. Celle de Gunicorn, qui fait tourner Signalements, décrit pour le processus maître :
| Signal | Effet sur Gunicorn |
|---|---|
TERM | arrêt gracieux : attend que les workers terminent leurs requêtes, dans la limite de graceful_timeout (30 secondes par défaut) |
INT, QUIT | arrêt rapide |
HUP | recharge la configuration, démarre de nouveaux workers et arrête gracieusement les anciens ; le code de l'application est rechargé aussi, sauf si elle est préchargée (--preload) |
TTIN, TTOU | ajoute ou retire un worker |
USR1 | rouvre les fichiers de journaux |
On envoie ces signaux au maître, pas aux workers : c'est lui qui orchestre.
La charge moyenne, sans contresens
uptime et top affichent trois nombres, la charge moyenne (load average) sur 1, 5 et 15 minutes. On lit souvent qu'ils mesurent l'occupation du processeur. Sous Linux, c'est faux, et ce contresens fait perdre des heures.
Brendan Gregg a retracé l'histoire de ce calcul dans un article de 2017. Sur la plupart des Unix, la charge compte les processus qui veulent le processeur (état R). Linux, depuis un correctif de Matthias Urlichs en 1993, compte aussi les processus en état D, ceux qui attendent une entrée-sortie non interruptible. Son argument : un système qui rame parce que son disque est lent est chargé, du point de vue humain, même si son processeur dort. Résultat : sous Linux, la charge mesure la demande sur le système, processeur et disque confondus.
Deux autres précisions :
- Ce ne sont pas de vraies moyennes sur 1, 5 et 15 minutes, mais des moyennes à amortissement exponentiel : une tâche qui occupe un processeur à plein depuis exactement une minute ne fait monter la charge « à 1 minute » qu'à environ 0,62.
- La charge se lit par rapport au nombre de processeurs. Une charge de 4 sur une machine à 16 cœurs est tranquille ; sur une machine à 2 cœurs, des processus attendent leur tour.
nprocdonne le nombre de processeurs.
La tendance compte autant que la valeur : 1 minute bien au-dessus de 15 minutes, la charge monte ; l'inverse, elle redescend.
Les tâches du shell
Le shell gère lui-même des tâches (jobs) : les commandes que vous lancez depuis lui. Une commande suivie de & s'exécute en arrière-plan : le shell vous rend la main tout de suite. Ctrl-Z suspend la tâche au premier plan (signal SIGTSTP) ; bg la relance en arrière-plan, fg la ramène au premier plan, jobs les liste. On les désigne par %1, %2, et le manuel de Bash appelle cela le contrôle des tâches (job control).
Ces tâches restent attachées à votre session. Quand vous fermez le terminal ou que la connexion SSH tombe, le shell reçoit SIGHUP et le relaie à ses tâches, qui s'arrêtent. nohup commande & fait ignorer SIGHUP à la commande et redirige sa sortie vers nohup.out ; disown retire une tâche de la liste du shell, qui ne lui relaiera plus le signal. Ces outils servent à finir un long traitement ponctuel. Ils ne servent pas à faire tourner un service : rien ne relancera le processus s'il plante, rien ne le démarrera au prochain redémarrage, ses journaux finiront dans un fichier oublié, et il tournera sous votre compte personnel. Un service se confie à systemd (leçon 11).
La priorité
Le noyau partage le processeur entre les processus prêts. La gentillesse (niceness) d'un processus pondère ce partage : de −20 (le plus favorisé) à 19 (le moins favorisé), 0 par défaut. nice -n 10 commande lance une commande plus « gentille » envers les autres ; renice change la valeur d'un processus existant. Un utilisateur ordinaire peut seulement augmenter la gentillesse de ses propres processus ; la diminuer demande des privilèges. C'est utile pour une compression de sauvegarde ou une réindexation, qui ne doivent pas ralentir l'API.
En pratique
Les sorties de cette section ont été produites sur une machine Ubuntu 24.04 avec LC_ALL=C.UTF-8, pour obtenir les messages en anglais tels qu'ils apparaissent dans les journaux ; seul le nom d'utilisateur a été remplacé par camille. Les numéros de PID seront différents chez vous. Pour ne dépendre d'aucune application, nous utilisons deux remplaçants inoffensifs : sleep, qui attend sans rien faire, et le petit serveur web intégré à Python, python3 -m http.server, qui écoute sur le port 8000 comme le ferait Signalements.
Lister les processus avec ps
ps photographie la table des processus. Il a deux familles d'options historiques : ps -ef (style System V) et ps aux (style BSD, sans tiret). Les deux listent tous les processus de la machine. Lancez le serveur de test en arrière-plan, puis regardez-le :
$ python3 -m http.server 8000 --bind 127.0.0.1 >/dev/null 2>&1 &
$ ps u -p $!
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
camille 603157 4.9 0.0 32160 19928 ? S 11:14 0:00 python3 -m http.server 8000 --bind 127.0.0.1
$! contient le PID de la dernière commande lancée en arrière-plan. Les colonnes :
USER,PID: le propriétaire et le numéro.%CPU: la part de temps processeur consommée depuis le démarrage du processus, pas l'instantané. Un processus qui a beaucoup travaillé au démarrage garde une valeur élevée un moment.%MEM,RSS: la mémoire physique effectivement occupée (resident set size), en Kio : ici environ 19 Mio.VSZ: la taille de la mémoire virtuelle en Kio, c'est-à-dire tout ce que le processus a réservé ou projeté, y compris ce qu'il n'utilise pas. Elle est toujours plus grande queRSS, et presque toujours sans intérêt pour un diagnostic.TTY: le terminal de rattachement ;?pour un processus sans terminal, comme un service.STAT: l'état, iciS, en sommeil, en attente de connexions.TIME: le temps processeur cumulé.
Sur un serveur, ps aux produit des centaines de lignes. On choisit plutôt ses colonnes avec -o, et on trie :
$ ps -eo pid,ppid,user,stat,%cpu,rss,cmd --sort=-%cpu | head -6
$ ps -eo pid,ppid,user,stat,rss,cmd --sort=-rss | head -6
La première commande montre les cinq processus les plus gourmands en processeur, la seconde en mémoire. Sur sig-app-1, vous y verriez le maître Gunicorn et ses deux workers sous l'utilisateur signalements, et les processus postgres.
Trouver un processus par son nom
pgrep cherche des processus et affiche leurs PID ; avec -a, il affiche aussi la ligne de commande complète, et -u filtre par utilisateur :
$ pgrep -a -u signalements gunicorn
Sur sig-app-1, la commande affiche trois lignes : le maître et les deux workers, avec leur PID. Pour voir qui est le parent de qui, pstree dessine l'arbre ; -p ajoute les PID. Sur la machine de test, avec un petit script travail.sh qui boucle sur sleep 1 :
$ pstree -p 597051
bash(597051)---sleep(597143)
Sur sig-app-1, pstree -p <PID du maître> montre le maître et ses deux workers comme deux branches.
Warning
pgrep -f motif cherche le motif dans toute la ligne de commande, y compris celle du shell qui lance pgrep, ou d'un script qui contient ce motif. Un pkill -f http.server lancé depuis un script dont la ligne de commande contient http.server peut tuer le script lui-même. Préférez un nom de processus exact (pgrep -x gunicorn), un utilisateur (-u), ou mieux, le PID que donne systemd (systemctl show -p MainPID signalements).
Regarder dans /proc
Le répertoire /proc n'existe pas sur le disque : c'est une vue que le noyau fabrique à la demande, décrite dans proc(5). Chaque processus y a un répertoire à son PID :
$ grep -E '^(Name|State|Pid|PPid|Uid|Threads|VmRSS)' /proc/603157/status
Name: python3
State: S (sleeping)
Pid: 603157
PPid: 603153
Uid: 1000 1000 1000 1000
VmRSS: 19928 kB
Threads: 1
$ tr '\0' ' ' < /proc/603157/cmdline; echo
python3 -m http.server 8000 --bind 127.0.0.1
$ readlink /proc/603157/fd/3
socket:[18171169]
statusrésume le processus : nom, état, parent, identifiants d'utilisateur (réel, effectif, sauvegardé, système de fichiers), mémoire, nombre de fils d'exécution.cmdlinecontient la ligne de commande, arguments séparés par des octets nuls, d'où letr.fd/contient un lien par descripteur de fichier ouvert. Le descripteur 3 est ici une socket réseau : celle sur laquelle le serveur écoute. Sur Signalements,ls -l /proc/<pid>/fdmontrerait les connexions à PostgreSQL et les fichiers ouverts.
Deux autres entrées servent souvent : /proc/<pid>/environ (les variables d'environnement au lancement, lisibles seulement par le propriétaire ou root) et /proc/<pid>/cwd (le répertoire courant).
Qui écoute sur le port 8000 ?
Question classique quand un service refuse de démarrer avec Address already in use. ss, qui remplace l'ancien netstat, liste les sockets ; -l les sockets en écoute, -t TCP, -n sans résolution de noms, -p avec le processus :
$ ss -ltnp 'sport = :8000'
State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0 5 127.0.0.1:8000 0.0.0.0:* users:(("python3",pid=603157,fd=3))
Le processus python3 de PID 603157 écoute sur 127.0.0.1:8000 avec son descripteur 3, celui que nous avons vu dans /proc. lsof (list open files) donne la même information sous un autre angle :
$ lsof -nP -iTCP:8000 -sTCP:LISTEN
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 589720 camille 3u IPv4 18089613 0t0 TCP 127.0.0.1:8000 (LISTEN)
(Cette sortie vient d'une exécution précédente, d'où un autre PID.) Attention : sans sudo, ss -p et lsof ne montrent le processus que s'il vous appartient. Pour Gunicorn, qui tourne sous signalements, il faut sudo ss -ltnp.
Observer en continu avec top
top rafraîchit la liste toutes les trois secondes. Son en-tête résume la machine ; voici celui de la machine de test, obtenu en mode non interactif avec top -bn1 | head -5 :
top - 11:14:05 up 6 days, 18:17, 1 user, load average: 1.02, 0.98, 1.47
Tasks: 512 total, 2 running, 510 sleeping, 0 stopped, 0 zombie
%Cpu(s): 1.0 us, 1.0 sy, 0.0 ni, 98.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 31451.4 total, 4953.3 free, 10041.3 used, 17966.8 buff/cache
MiB Swap: 8192.0 total, 4798.4 free, 3393.6 used. 21410.1 avail Mem
- Ligne 1 : heure, durée depuis le démarrage, nombre de sessions, et la charge moyenne. Sur cette machine à 16 processeurs, une charge de 1 est faible.
- Ligne 2 : le nombre de processus par état, zombies compris. C'est le premier endroit où l'on repère une accumulation.
- Ligne 3 : la répartition du temps processeur.
usle code des applications,syle noyau,niles processus de priorité abaissée,idl'inactivité,wal'attente d'entrées-sorties,stle temps « volé » par l'hyperviseur sur une machine virtuelle. Unwaélevé avec une charge élevée et un processeur peu occupé : cherchez du côté du disque. - Lignes 4 et 5 : la mémoire.
buff/cacheest la mémoire que le noyau utilise comme cache disque et rend dès qu'on la demande ; c'estavail Memqui dit ce qui reste réellement disponible.
Dans top, quelques touches : P trie par processeur, M par mémoire, 1 détaille chaque processeur, u filtre un utilisateur, k envoie un signal, q quitte. htop, souvent installé à part (sudo apt install htop), offre la même chose en plus lisible, avec une vue en arbre (F5).
Envoyer des signaux
kill envoie un signal à un PID. Malgré son nom, il ne fait pas que tuer : sans option, il envoie SIGTERM. Le script suivant montre l'effet de quelques signaux sur le serveur de test et sur des sleep :
$ ps -o pid,stat,cmd -p $P
PID STAT CMD
596763 S python3 -m http.server 8000 --bind 127.0.0.1
$ kill -STOP $P; ps -o pid,stat,cmd -p $P
PID STAT CMD
596763 T python3 -m http.server 8000 --bind 127.0.0.1
$ kill -CONT $P; ps -o pid,stat,cmd -p $P
PID STAT CMD
596763 S python3 -m http.server 8000 --bind 127.0.0.1
$ kill -TERM $P; wait $P; echo "code de sortie : $?"
code de sortie : 143
$ sleep 100 & kill -KILL $!; wait $!; echo "code de sortie : $?"
code de sortie : 137
$ sleep 100 & kill -HUP $!; wait $!; echo "code de sortie : $?"
code de sortie : 129
SIGSTOP fait passer le processus en T : il ne répond plus du tout, mais il est toujours là, avec sa mémoire et ses connexions ; SIGCONT le reprend. Le code de sortie d'un processus tué par un signal vaut, pour le shell, 128 plus le numéro du signal : 143 pour SIGTERM (15), 137 pour SIGKILL (9), 129 pour SIGHUP (1). C'est un réflexe à acquérir : un code 137 dans un journal veut dire « tué par SIGKILL », souvent par le OOM killer (voir OOM killer) ou par un gestionnaire qui a perdu patience.
kill -l affiche la table des noms et numéros. Deux variantes de kill visent des noms au lieu de PID : pkill gunicorn et killall gunicorn. Elles sont pratiques et dangereuses pour la même raison : elles frappent tout ce qui correspond. Sur un serveur partagé, vérifiez d'abord la cible avec pgrep -a.
Intercepter un signal
Pour voir ce qu'un processus peut faire d'un SIGTERM, écrivez ce petit script travail.sh, qui intercepte le signal avec la commande trap de Bash :
#!/usr/bin/env bash
trap 'echo "SIGTERM reçu : je termine proprement"; exit 0' TERM
echo "travail démarré, PID $$"
while true; do sleep 1; done$ bash travail.sh & T=$!
travail démarré, PID 597051
$ kill -TERM $T; wait $T; echo "code de sortie : $?"
SIGTERM reçu : je termine proprement
code de sortie : 0
Le script a reçu le signal, exécuté son nettoyage, et s'est terminé avec le code 0 qu'il a choisi. Envoyez-lui SIGKILL à la place : aucun message, code 137. Remarquez aussi que Bash n'exécute le trap qu'une fois la commande en cours terminée (ici, le sleep 1) : un programme peut mettre un moment à réagir à SIGTERM, et c'est normal.
Arrêter Signalements proprement
Sur sig-app-1, l'API est un service systemd, et c'est systemd qu'on utilise pour l'arrêter ou la redémarrer (leçon 11) : sudo systemctl restart signalements. systemd envoie SIGTERM au processus principal, attend, puis envoie SIGKILL s'il ne s'est pas arrêté au bout du délai TimeoutStopSec (90 secondes par défaut). Le bon ordre est donc respecté sans y penser.
Pour recharger la configuration de Gunicorn sans interrompre le service, on envoie HUP au maître, dont systemd connaît le PID :
$ systemctl show -p MainPID signalements
$ sudo kill -HUP "$(systemctl show -p MainPID --value signalements)"
Gunicorn démarre deux nouveaux workers, puis arrête gracieusement les anciens : les requêtes en cours se terminent. Si l'unité déclare ExecReload= (leçon 11), sudo systemctl reload signalements fait la même chose plus lisiblement.
Les tâches du shell
$ set -m # active le contrôle des tâches dans un script ; déjà actif dans un terminal
$ sleep 300 &
$ sleep 400 &
$ jobs -l
[1]- 603155 Running sleep 300 &
[2]+ 603156 Running sleep 400 &
$ kill %1
$ jobs -l
[1]- Terminated sleep 300
[2]+ 603156 Running sleep 400 &
%1 désigne la tâche 1 ; + marque la tâche courante (celle que fg reprendrait), - la précédente. Dans un terminal interactif, lancez sleep 300 sans &, tapez Ctrl-Z : le shell affiche Stopped. bg la relance en arrière-plan, fg la ramène, Ctrl-C l'interrompt (SIGINT).
Pour un long traitement qui doit survivre à la fermeture de votre session SSH, par exemple une copie de sauvegarde ponctuelle :
$ nohup ./exporter-signalements.sh > export.log 2>&1 &
Pour le travail interactif de longue durée, un multiplexeur de terminal comme tmux ou screen est plus confortable : la session continue sur le serveur, et vous vous y rattachez plus tard.
Baisser la priorité
$ nice -n 10 sleep 30 &
$ ps -o pid,ni,pri,cmd -p $!
PID NI PRI CMD
597164 10 9 sleep 30
$ renice -n 15 -p 597164
597164 (process ID) old priority 10, new priority 15
$ renice -n 5 -p 597164
renice: failed to set priority for 597164 (process ID): Permission denied
La colonne NI est la gentillesse ; la remonter à 15 est permis, la redescendre à 5 est refusé à un utilisateur ordinaire, même pour son propre processus.
Sous le capot
fork, exec, wait. Sous Unix, un processus ne naît que d'un autre, en deux temps. L'appel système fork() duplique le processus appelant : le noyau crée un enfant, copie conforme du parent (mémoire, fichiers ouverts, environnement), qui ne diffère que par son PID et son PPID. Puis l'enfant appelle execve(), qui remplace son programme par un autre, en gardant son PID, ses fichiers ouverts et son PPID. Quand vous tapez ps dans Bash, Bash fait un fork(), l'enfant fait un execve("/usr/bin/ps"), et Bash attend sa fin avec wait(), qui lui rend le code de sortie, celui que $? affiche. Ce découpage en deux, que Stevens et Rago détaillent dans Advanced Programming in the UNIX Environment, est ce qui permet les redirections : entre le fork() et l'execve(), l'enfant réarrange ses descripteurs de fichier (leçon 6). Le zombie est l'enfant qui a fini mais dont le parent n'a pas encore fait wait().
Nous l'avons fabriqué volontairement sur la machine de test, avec un programme Python dont l'enfant se termine aussitôt pendant que le parent dort trente secondes sans l'attendre :
$ ps -o pid,ppid,stat,cmd --ppid 596193
PID PPID STAT CMD
596208 596193 Z [python3] <defunct>
Dès que le parent s'est terminé, l'enfant a été adopté puis ramassé, et la ligne a disparu.
Comment un signal arrive. Envoyer un signal, c'est demander au noyau de poser un drapeau dans la structure du processus cible. Le noyau vérifie les droits (même utilisateur, ou privilège), puis, la prochaine fois que le processus repasse du noyau vers son propre code (fin d'un appel système, interruption d'horloge), il regarde ses drapeaux et applique l'action : action par défaut, ou détour par la fonction d'interception que le programme a enregistrée. C'est pourquoi un processus en état D, bloqué dans le noyau sur une opération non interruptible, ne réagit même pas à SIGKILL : il ne repasse pas par le point où le signal serait traité tant que l'opération n'est pas finie. Depuis Linux 2.6.25, un état intermédiaire, TASK_KILLABLE, permet à certaines attentes de céder au seul SIGKILL ; beaucoup d'attentes de systèmes de fichiers réseau l'utilisent, pas toutes.
Le délai entre TERM et KILL. systemd, Docker (docker stop attend 10 secondes) et Kubernetes (terminationGracePeriodSeconds, 30 secondes par défaut) appliquent tous le même protocole : SIGTERM, une période de grâce, puis SIGKILL. Une application qui ignore SIGTERM subit toujours l'arrêt brutal, avec un retard en plus. Le cours Docker a montré l'effet de ce protocole sur un conteneur dont le PID 1 ne relaie pas les signaux (SIGTERM).
Pièges courants
kill -9 en premier réflexe. C'est l'erreur la plus fréquente, et la plus coûteuse. Une base de données tuée ainsi doit rejouer son journal au redémarrage ; une application qui écrivait un fichier le laisse à moitié écrit ; un processus qui tenait un verrou laisse un fichier de verrou que le prochain démarrage refusera. Commencez toujours par SIGTERM, attendez quelques secondes (le délai de grâce de l'application), vérifiez avec ps, et seulement ensuite SIGKILL.
Tuer un worker au lieu du maître. kill sur un worker Gunicorn : le maître le remplace aussitôt, et rien ne change. Ciblez le maître, ou mieux, passez par systemd.
Un processus en D qui ne meurt pas. Ce n'est pas que kill -9 est mal tapé : le processus attend le noyau, souvent un disque ou un montage réseau (NFS) qui ne répond plus. Cherchez la cause avec dmesg ou le journal du noyau (journalctl -k), et regardez ce que fait le stockage. Parfois, seul un redémarrage de la machine libère ces processus.
Confondre charge et processeur. Charge à 12 sur une machine à 4 processeurs, mais top montre 90 % id : le processeur dort. La charge vient de processus en D, presque toujours des entrées-sorties. Ajouter des processeurs ne servira à rien.
Croire que %CPU de ps est instantané. C'est une moyenne sur toute la vie du processus. Pour l'instant présent, top.
Lancer un service à la main avec nohup ou &. Il tournera jusqu'au premier plantage ou au prochain redémarrage, sous votre compte, sans journal centralisé. C'est le symptôme d'un service qui n'a pas encore son unité systemd.
Tuer des zombies. kill -9 sur un zombie n'a aucun effet : il est déjà mort. Regardez son PPID et occupez-vous du parent.
Sécurité
- Les lignes de commande sont publiques. Par défaut, tout utilisateur de la machine peut lire la ligne de commande de tous les processus (
ps aux,/proc/<pid>/cmdline). Un mot de passe passé en argument (psql postgresql://signalements:motdepasse@...,curl -u admin:secret) est visible de tous pendant l'exécution. Passez les secrets par un fichier ou une variable d'environnement :/proc/<pid>/environn'est lisible que par le propriétaire etroot. L'option de montagehidepid=de/proc, présentée dansproc(5), permet en plus de masquer les processus des autres utilisateurs ; le cours Durcissement Linux y revient. - Le droit de signaler suit le propriétaire. Un utilisateur ne peut envoyer de signal qu'à ses propres processus ;
rootpeut tout signaler. Faire tourner l'API sous un compte dédié (signalements) empêche donc aussi un autre service compromis de l'arrêter. - Un processus inconnu est une alerte. Un processus au nom anodin qui consomme tout le processeur sous le compte de l'application (
kworkerd,[kthreadd]imité, binaire dans/tmpou/dev/shm) est le symptôme classique d'un mineur de cryptomonnaie installé après une compromission.ls -l /proc/<pid>/exedit quel fichier il exécute réellement. Ne le tuez pas tout de suite : notez son PID, sa ligne de commande, ses connexions (ss -tnp), puis isolez la machine ; le cours Réponse à incident de sécurité explique pourquoi. SIGKILLsur l'agent de sécurité. Un outil de surveillance tué n'alerte plus. Les gestionnaires de services le relancent, et les agents sérieux signalent leur propre disparition.
En production
- On n'administre pas les services par des signaux à la main. On passe par systemd (
systemctl restart,reload), qui connaît le bon processus, applique le délai de grâce et le consigne dans le journal (leçon 11). Les signaux manuels restent pour le diagnostic. - Le délai de grâce se règle de bout en bout. Si le répartiteur de charge retire une instance en 5 secondes, que Gunicorn attend 30 secondes et que systemd en accorde 90, l'arrêt est cohérent. Si l'orchestrateur ne laisse que 10 secondes à une application qui en demande 30, les requêtes longues seront coupées à chaque déploiement.
- Surveillez les tendances, pas les instantanés. La charge, le nombre de processus, le nombre de zombies et la mémoire résidente des workers se collectent en continu (cours Prometheus) : une fuite mémoire se voit sur une courbe de plusieurs jours, jamais dans un
topponctuel. - Une charge élevée se décompose avant de se combattre : processeur (
us,sy), attente disque (wa, processus enD), vol de l'hyperviseur (st) sur une VM partagée. Le cours Performance et diagnostic sous Linux donne la méthode complète. - Chez Lyneko, les applications tournent en conteneurs sur Kubernetes, où le protocole est le même :
SIGTERMau PID 1 du conteneur, puisSIGKILLà la fin du délai de grâce. Une application qui gère correctementSIGTERMsur un serveur le gère aussi dans un pod.
Exercices
1. Lire un ps (niveau 100). Voici une ligne de ps aux sur sig-app-1 : signalements 2214 0.3 2.1 95232 43120 ? S 08:02 0:41 /opt/signalements/venv/bin/python3 /opt/signalements/venv/bin/gunicorn --workers 2 app:app. Que savez-vous de ce processus ? Comment savoir s'il s'agit du maître ou d'un worker ?
Solution
Il appartient à l'utilisateur signalements, a le PID 2214, utilise en moyenne 0,3 % du processeur depuis son démarrage à 8 h 02 et 41 secondes de temps processeur cumulé, occupe environ 42 Mio de mémoire physique (43 120 Kio) pour 93 Mio de mémoire virtuelle, n'a pas de terminal (?, c'est un service) et dort (S) en attendant des requêtes. Maître et workers ont la même ligne de commande. Pour les distinguer, regardez le PPID : ps -o pid,ppid,cmd -p 2214. Le maître a pour parent systemd (PID 1), les workers ont pour parent le maître. pstree -p ou systemctl show -p MainPID signalements le montrent aussi.
2. Les codes de sortie (niveau 100). Le journal indique que signalements.service s'est terminé avec le code 137. Que s'est-il passé ? Et avec 143 ?
Solution
128 + 9 = 137 : le processus a été tué par SIGKILL. Soit quelqu'un a fait kill -9, soit systemd l'a tué faute d'arrêt dans le délai TimeoutStopSec, soit le noyau l'a tué par manque de mémoire (le journal du noyau, journalctl -k, contient alors une ligne Out of memory: Killed process). 128 + 15 = 143 : terminé par SIGTERM sans l'intercepter, ce qui est le comportement attendu lors d'un arrêt normal par un programme qui ne gère pas ce signal.
3. Le zombie (niveau 200). top indique 47 zombie et ce nombre augmente d'un par minute. Écrivez la commande qui liste les zombies avec leur parent, puis décrivez votre démarche.
Solution
ps -eo pid,ppid,stat,cmd | awk '$3 ~ /^Z/' liste les processus dont l'état commence par Z, avec leur PPID. Si tous ont le même parent, c'est lui le coupable : il crée des enfants (par exemple, une tâche planifiée par minute) sans les attendre. Regardez qui il est (ps -o pid,user,cmd -p <PPID>). On ne tue pas les zombies ; on redémarre proprement le parent (SIGTERM, ou systemctl restart si c'est un service) : ses zombies sont alors adoptés et ramassés par le PID 1. Puis on signale le défaut aux développeurs du programme, car il reviendra.
4. Arrêter sans rien casser (niveau 200). Un script d'export lancé à la main par un collègue (nohup ./export.sh &, sous son compte dominique) tourne depuis trois heures et sature le disque. Décrivez, commande par commande, comment l'arrêter en limitant les dégâts.
Solution
Identifier précisément le processus et ses enfants : pgrep -a -u dominique export.sh, puis pstree -p <PID>. Si l'urgence le permet, baisser d'abord sa priorité pour soulager le système (sudo renice -n 19 -p <PID>, et ionice -c3 -p <PID> pour les entrées-sorties). Pour l'arrêter : sudo kill -TERM <PID> (sudo, car le processus n'est pas à vous), puis attendre une dizaine de secondes et vérifier avec ps -p <PID>. Seulement s'il est toujours là, sudo kill -KILL <PID>, en sachant que le fichier d'export en cours est probablement incomplet et doit être supprimé. Vérifier ensuite qu'aucun enfant n'est resté (pgrep -a -u dominique). Enfin, le message au collègue : un export de trois heures mérite une unité systemd ou un minuteur, avec une priorité basse (Nice=, IOSchedulingClass=).
5. Port occupé (niveau 200). Après une mise à jour, signalements.service ne démarre plus, et son journal contient [ERROR] Connection in use: ('127.0.0.1', 8000). Comment trouvez-vous le coupable, et quelles sont les causes probables ?
Solution
sudo ss -ltnp 'sport = :8000' (ou sudo lsof -nP -iTCP:8000 -sTCP:LISTEN) donne le processus et son PID. ps -o pid,ppid,user,lstart,cmd -p <PID> dit depuis quand il tourne et qui l'a lancé. Causes probables : une ancienne instance de Gunicorn lancée à la main par quelqu'un pendant un dépannage (avec nohup, sous un compte personnel) et jamais arrêtée ; un ancien maître resté orphelin après un arrêt brutal ; un autre service configuré sur le même port. On arrête l'intrus par SIGTERM, on relance le service avec systemd, et on retrouve qui l'avait lancé.
Récapitulatif
- Un processus est un programme en exécution, avec un PID, un PPID, un propriétaire et un état. Les processus forment un arbre dont la racine est systemd, PID 1.
psphotographie,pgrep -acherche,pstree -pmontre la filiation,topobserve en continu,/proc/<pid>/montre tout ce que le noyau sait d'un processus.- États :
Rprêt ou en exécution,Sen attente,Den attente non interruptible,Tarrêté,Zzombie, déjà mort, que seul son parent peut ramasser. - La charge moyenne Linux compte les processus en
Ret enD; elle se lit par rapport au nombre de processeurs, et ne mesure pas l'occupation du processeur. - Un signal est délivré par le noyau ;
SIGTERMdemande un arrêt propre,SIGKILLarrête sans nettoyage et ne peut pas être intercepté. ToujoursTERMd'abord. Le code de sortie d'un processus tué vaut 128 + numéro du signal. ss -ltnpetlsof -idisent qui écoute sur un port.&, Ctrl-Z,bg,fg,nohupservent au travail interactif ; un service se confie à systemd.
Pour aller plus loin
- Les pages de manuel
ps(1),signal(7)etproc(5): longues, mais ce sont les références exactes. - L'article de Brendan Gregg, Linux Load Averages: Solving the Mystery, pour l'histoire complète de la charge moyenne et les outils qui la décomposent.
- Advanced Programming in the UNIX Environment (Stevens et Rago), chapitres 8 à 10, pour les processus et les signaux vus du programmeur.
- La leçon suivante, Services et journaux, qui confie Signalements à systemd.
Sources
- Linux man-pages, ps(1)
- Linux man-pages, signal(7)
- Linux man-pages, wait(2)
- Linux man-pages, credentials(7)
- Linux man-pages, proc(5)
- Linux man-pages, nice(1)
- Brendan Gregg, Linux Load Averages: Solving the Mystery (2017)
- Gunicorn, documentation : Signal Handling
- GNU Bash Reference Manual, Job Control
- W. Richard Stevens, Stephen Rago, Advanced Programming in the UNIX Environment, 3e éd. (Addison-Wesley, 2013), chapitres 8 à 10