Borner les ressources : limites et cgroups
Pourquoi
Mardi, 7 h 15. La mairie n'a pas reçu son export CSV de la nuit, et la sauvegarde de sig-outils vers le bucket sig-sauvegardes est en échec. Vous ouvrez le journal du noyau de la nuit sur sig-outils et vous y trouvez ceci (les valeurs sont illustratives, le format est celui du noyau) :
kernel: restic invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0
kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/system.slice/sig-sauvegarde.service,task=restic,pid=51877,uid=0
kernel: Out of memory: Killed process 51877 (restic) total-vm:2215408kB, anon-rss:1268712kB, file-rss:1024kB, shmem-rss:0kB, UID:0 pgtables:2944kB oom_score_adj:0La machine a manqué de mémoire, et le noyau a tué restic, l'outil de sauvegarde. Pourtant, le vrai responsable n'est pas la sauvegarde : c'est l'export de Signalements, réécrit par Camille quelques semaines avant son départ pour répartir le travail sur quatre processus Python qui chargent chacun une partie de la table en mémoire. Aucun de ces quatre processus n'était le plus gros pris isolément ; ensemble, ils occupaient la moitié de la machine. Le noyau a choisi sa victime processus par processus, il a pris le plus gros, et c'est la sauvegarde qui a payé. L'export, lui, est mort quelques minutes plus tard pour la même raison, et rsyslog, qui reçoit les journaux de sig-app-1 et sig-app-2, a perdu des messages pendant que la machine passait son temps à chercher de la mémoire.
Ce scénario a une cause unique : sur sig-outils, rien ne dit combien chaque tâche a le droit de consommer. Par défaut, Linux partage tout entre tout le monde, et quand une ressource s'épuise, il arbitre en catastrophe. Il existe pourtant deux mécanismes pour poser des bornes à l'avance :
- les limites par processus (rlimits), anciennes, héritées d'Unix, qui plafonnent un processus à la fois : nombre de fichiers ouverts, taille d'un fichier, temps processeur ;
- les groupes de contrôle (cgroups, voir cgroup), qui plafonnent un ensemble de processus, c'est-à-dire un service entier avec tous ses enfants, pour la mémoire, le processeur, les entrées-sorties et le nombre de tâches.
Cette leçon apprend à se servir des deux, et surtout à savoir lequel employer. À la fin, l'export de Signalements aura un budget mémoire qu'il ne pourra pas dépasser, la sauvegarde et rsyslog seront à l'abri, et vous saurez lire, après coup, ce qui s'est passé quand une limite a été atteinte.
Les concepts
Les limites par processus (rlimits)
Chaque processus porte une série de limites de ressources (resource limits, ou rlimits), lues et fixées par les appels système getrlimit() et setrlimit() (voir appel système). Chaque limite a deux valeurs :
- la limite souple (soft limit) : celle que le noyau applique réellement ;
- la limite stricte (hard limit) : le plafond jusqu'auquel le processus peut relever lui-même sa limite souple.
La page getrlimit(2) résume les règles : un processus ordinaire peut fixer sa limite souple n'importe où entre 0 et sa limite stricte, et peut abaisser sa limite stricte, de façon irréversible. Seul un processus doté de la capability CAP_SYS_RESOURCE (voir capability) peut relever une limite stricte. Les limites sont héritées par les enfants à chaque fork() et conservées à travers execve() : c'est ce qui permet à un shell ou à systemd de les fixer une fois pour tout ce qu'ils lancent.
Les limites qui comptent en exploitation :
| Ressource | ulimit | Ce qu'elle plafonne | Erreur quand on l'atteint |
|---|---|---|---|
RLIMIT_NOFILE | -n | le nombre de descripteurs de fichiers ouverts (fichiers, sockets, tubes) | EMFILE, « Too many open files » |
RLIMIT_NPROC | -u | le nombre de processus (en fait de threads) appartenant au même UID réel | fork() échoue avec EAGAIN, « Resource temporarily unavailable » |
RLIMIT_CORE | -c | la taille d'un fichier d'image mémoire (core dump) | fichier tronqué ou absent |
RLIMIT_FSIZE | -f | la taille maximale d'un fichier écrit | signal SIGXFSZ |
RLIMIT_CPU | -t | le temps processeur consommé, en secondes | signal SIGXCPU, puis SIGKILL |
RLIMIT_MEMLOCK | -l | la mémoire verrouillée en RAM (mlock()) | ENOMEM ou EPERM |
RLIMIT_AS | -v | l'espace d'adressage virtuel | allocation refusée |
Deux pièges se lisent déjà dans ce tableau. RLIMIT_NPROC compte les processus de l'utilisateur, pas ceux d'un service : tous les processus du compte signalements, d'où qu'ils viennent, partagent la même limite. Et RLIMIT_AS plafonne l'espace d'adressage virtuel, pas la mémoire réellement utilisée : un programme Python ou Java réserve beaucoup plus d'adresses qu'il n'occupe de mémoire, et une limite AS le fait échouer bien avant qu'il ne consomme quoi que ce soit. La page systemd.exec(5) est explicite sur ces deux points, et pour LimitRSS=, l'équivalent de ulimit -m : « No effect on Linux ». Pour plafonner la mémoire, les rlimits sont le mauvais outil.
Qui fixe les limites ?
Les rlimits ne tombent pas du ciel : quelqu'un les fixe au début de la chaîne, et tout le reste en hérite. Sur un serveur, il y a deux chaînes bien distinctes.
Les sessions interactives (SSH, console, su, cron) passent par PAM, le mécanisme d'authentification que la leçon 13 détaille. Le module pam_limits lit /etc/security/limits.conf et les fichiers /etc/security/limits.d/*.conf, puis appelle setrlimit() au moment où la session s'ouvre. Une ligne a quatre champs :
# <domaine> <type> <ressource> <valeur>
dominique soft nofile 4096
@exploitation hard nproc 2048
* - core 0Le domaine est un utilisateur, un groupe (@groupe) ou * ; le type est soft, hard ou - pour les deux. La page limits.conf(5) précise que les limites de groupe et le joker * ne s'appliquent pas à root : il faut le nommer explicitement.
Les services sont lancés par systemd, PID 1, qui ne passe pas par PAM (sauf s'il en reçoit l'ordre avec PAMName=, ce que l'on ne fait pas pour une application). limits.conf n'est donc jamais lu pour eux. Leurs limites viennent des directives Limit...= de l'unité, et à défaut des valeurs DefaultLimit...= de /etc/systemd/system.conf. Red Hat le rappelle d'ailleurs en tête de son propre limits.conf : le fichier ne concerne que les utilisateurs connectés par PAM, pas les services du système.
C'est l'erreur la plus fréquente sur ce sujet : on ajoute signalements soft nofile 65536 dans limits.conf, on vérifie avec sudo -u signalements bash -c 'ulimit -n' qui affiche bien 65536 (parce que sudo ouvre une session PAM), et le service, lui, continue de tourner avec 1024.
Les valeurs par défaut de systemd
D'après systemd-system.conf(5), systemd 255 donne à tous les processus qu'il lance DefaultLimitNOFILE=1024:524288 : 1 024 descripteurs en limite souple, 524 288 en limite stricte. Ce couple n'est pas un hasard. La limite souple reste à 1 024 parce que l'ancienne interface select() ne sait pas manipuler un descripteur de numéro supérieur à 1023 : un programme qui l'utilise encore planterait de façon imprévisible si on lui en ouvrait davantage. La limite stricte, elle, est haute, et la page systemd.exec(5) recommande que les programmes modernes, qui utilisent poll() ou epoll(), relèvent eux-mêmes leur limite souple jusqu'à la stricte. Python le fait si on le lui demande (resource.setrlimit), nginx avec sa directive worker_rlimit_nofile.
La même page conseille de ne plus abaisser la limite stricte : la mémoire des descripteurs est désormais comptée comme le reste de la mémoire du service, et c'est MemoryMax= qui doit la borner. Debian 13 (systemd 257) garde les mêmes valeurs par défaut.
Les groupes de contrôle v2
Un cgroup est un ensemble de processus auquel le noyau applique des règles de comptabilité et de limitation. Les cgroups sont organisés en arbre, exposé comme un système de fichiers sous /sys/fs/cgroup : chaque répertoire est un groupe, chaque fichier un réglage ou un compteur. Un processus appartient à exactement un groupe, et ses enfants y naissent.
Il a existé deux versions. La première (v1) avait une hiérarchie séparée par type de ressource, ce qui rendait les règles incohérentes entre elles. La version 2, dite hiérarchie unifiée, n'a qu'un seul arbre, où chaque ressource est gérée par un contrôleur activé ou non à chaque niveau. Ubuntu 24.04 et Debian 13 n'utilisent que la v2 ; systemd a déclaré la v1 obsolète, et les versions récentes ne la prennent plus en charge.
Les contrôleurs qui nous intéressent :
| Contrôleur | Fichiers principaux | Rôle |
|---|---|---|
memory | memory.current, memory.high, memory.max, memory.events | mesurer et plafonner la mémoire, y compris le cache de pages du groupe |
cpu | cpu.weight, cpu.max, cpu.stat | partager le processeur par poids, ou le plafonner par quota |
io | io.weight, io.max, io.stat | partager ou plafonner les entrées-sorties disque |
pids | pids.current, pids.max | plafonner le nombre de tâches (processus et threads) |
Deux règles structurent l'arbre. Un contrôleur n'est disponible pour les enfants d'un groupe que s'il est activé dans le fichier cgroup.subtree_control de ce groupe. Et, sauf à la racine, un groupe qui répartit des ressources entre ses enfants ne contient lui-même aucun processus : les processus vivent dans les feuilles. Vous n'aurez presque jamais à écrire dans ces fichiers vous-même : systemd s'en charge.
Les tranches systemd
systemd organise l'arbre avec trois types d'unités (voir unité systemd) :
- une tranche (slice, extension
.slice) est un nœud intérieur de l'arbre, qui sert à regrouper ; - un service est une feuille qui contient les processus lancés par systemd ;
- une portée (scope, extension
.scope) est une feuille qui contient des processus lancés par quelqu'un d'autre et confiés à systemd, comme une session SSH.
L'arbre par défaut :
-.slice la racine
├── system.slice les services du système
│ ├── signalements-export.service
│ ├── sig-sauvegarde.service
│ ├── rsyslog.service
│ └── ssh.service
├── user.slice les sessions des utilisateurs
│ └── user-1000.slice
│ ├── session-42.scope votre connexion SSH et votre shell
│ └── user@1000.service le gestionnaire systemd de l'utilisateur
└── machine.slice les machines virtuelles et conteneurs (systemd-nspawn, libvirt)Le nom d'une tranche encode sa place dans l'arbre : le tiret est un séparateur. taches-fond.slice est donc automatiquement une sous-tranche de taches.slice. C'est commode quand on le sait, et déroutant sinon.
Chaque réglage Memory...=, CPU...=, IO...=, Tasks...= d'une unité est traduit par systemd en écriture dans le fichier correspondant de son répertoire de cgroup. La page systemd.resource-control(5) précise qu'il n'y a rien à activer : quand une unité porte un réglage pour un contrôleur, systemd active ce contrôleur pour elle, ses parents et ses voisins. Sur systemd 255, la comptabilité de la mémoire, du processeur et des tâches est active par défaut (DefaultMemoryAccounting=yes, DefaultCPUAccounting=yes, DefaultTasksAccounting=yes) ; celle des entrées-sorties ne l'est pas.
Mémoire : protéger, freiner, couper
Le contrôleur memory offre quatre seuils, du plus doux au plus brutal. systemd les expose sous ces noms :
| Directive | Fichier | Effet |
|---|---|---|
MemoryMin= | memory.min | protection absolue : la mémoire du groupe sous ce seuil n'est jamais récupérée |
MemoryLow= | memory.low | protection souple : récupérée seulement s'il n'y a plus rien d'autre à prendre |
MemoryHigh= | memory.high | frein : au-delà, le groupe est ralenti et sa mémoire récupérée agressivement ; jamais d'OOM |
MemoryMax= | memory.max | mur : si la récupération ne suffit pas, l'OOM killer est appelé dans le groupe |
La documentation du noyau insiste : franchir memory.high « n'invoque jamais l'OOM killer », et dans des conditions extrêmes la limite peut être dépassée. systemd.resource-control(5) en tire la recommandation qui guidera toute cette leçon : utiliser MemoryHigh= comme mécanisme principal, et MemoryMax= comme « dernière ligne de défense ». Un service freiné finit son travail plus lentement ; un service tué ne le finit pas.
Les valeurs s'écrivent en octets avec les suffixes K, M, G, T (en base 1024), en pourcentage de la mémoire physique (MemoryMax=40%), ou infinity. MemorySwapMax= plafonne séparément l'usage du swap (l'espace d'échange sur disque), qui sinon permet au groupe de dépasser MemoryMax= en y repoussant ses pages ; sur les instances cloud, qui n'ont souvent pas de swap (vérifiez avec swapon --show), ce réglage n'a pas d'objet.
Point essentiel : le compteur memory.current inclut le cache de pages des fichiers lus et écrits par le groupe, et les fichiers écrits dans un tmpfs. Le cache est récupérable : quand le groupe approche de sa limite, le noyau le vide avant de songer à tuer. Mais un export qui écrit un gros fichier temporaire dans /tmp, monté en tmpfs par défaut sur Debian 13 (voir la leçon 6), consomme de la vraie mémoire, comptée à son groupe, et que rien ne peut récupérer.
Processeur : poids et quotas
Le contrôleur cpu offre deux mécanismes de nature différente.
- Le poids (
CPUWeight=, fichiercpu.weight, de 1 à 10 000, 100 par défaut) répartit le processeur quand il est disputé, au prorata entre groupes frères. Un service de poids 20 face à un service de poids 100 obtient un sixième du temps disponible quand les deux veulent tourner ; si l'autre est inactif, il prend tout. C'est un mécanisme qui ne gaspille rien (work-conserving). La valeur spécialeidlene donne au groupe que le temps dont personne ne veut. - Le quota (
CPUQuota=, fichiercpu.max) est un plafond strict, exprimé en pourcentage d'un processeur :CPUQuota=50%donne au plus la moitié d'un cœur,CPUQuota=200%deux cœurs. Il est appliqué par période de 100 ms par défaut (CPUQuotaPeriodSec=) : un groupe à 50 % peut tourner 50 ms, puis il est mis à l'arrêt jusqu'à la période suivante, même si la machine est inoccupée. On appelle cela l'étranglement (throttling).
Pour une tâche de fond, le poids suffit presque toujours : il ne la ralentit que lorsque quelqu'un d'autre a besoin du processeur. Le quota sert à garantir qu'un service ne dépasse jamais une consommation donnée, par exemple pour respecter un contrat, ou pour rendre le comportement prévisible.
Entrées-sorties
Le contrôleur io répartit les accès aux disques de la même façon : IOWeight= (fichier io.weight, de 1 à 10 000, 100 par défaut) pour un partage par poids, IOReadBandwidthMax=, IOWriteBandwidthMax=, IOReadIOPSMax=, IOWriteIOPSMax= (fichier io.max) pour des plafonds stricts par périphérique. La syntaxe associe un chemin et un débit : IOWriteBandwidthMax=/srv 20M, où le chemin peut être un périphérique ou n'importe quel fichier, dont systemd retrouve le disque sous-jacent ; les suffixes sont ici en base 1000.
Une précision que la documentation du noyau donne et que l'on oublie souvent : les plafonds de io.max fonctionnent sur tous les périphériques, mais le partage par poids n'est réalisé que par l'ordonnanceur d'entrées-sorties BFQ ou par le contrôleur de coût io.cost. Sur un disque virtuel dont l'ordonnanceur est none ou mq-deadline, ce qui est courant sur les instances cloud (cat /sys/block/vda/queue/scheduler), IOWeight= n'a aucun effet visible.
Tâches
Le contrôleur pids plafonne le nombre de tâches d'un groupe, chaque thread comptant pour une. TasksMax= en est l'interface. C'est la parade aux fork bombs (un programme qui se duplique sans fin) et aux fuites de threads, et c'est un bien meilleur outil que LimitNPROC=, qui compte par utilisateur et ne s'applique pas à root. systemd 255 applique par défaut DefaultTasksMax=15% à chaque service : 15 % du plus petit de kernel.pid_max, kernel.threads-max et de la limite de la racine. C'est le nombre que vous voyez dans Tasks: 1 (limit: ...) de systemctl status. Les tranches des utilisateurs reçoivent TasksMax=33%, par un fichier livré avec systemd (/usr/lib/systemd/system/user-.slice.d/10-defaults.conf).
Trois OOM killers
Quand la mémoire manque, trois mécanismes différents peuvent intervenir, et il faut savoir lequel a agi.
- L'OOM killer global du noyau (voir OOM killer) : la machine entière n'a plus de mémoire. Le noyau calcule pour chaque processus un score proportionnel à sa mémoire résidente, ses tables de pages et son swap, corrigé par
oom_score_adj(de -1000, jamais tué, à 1000, tué en premier), et tue le plus gros processus, où qu'il soit. C'est ce qui s'est passé avecrestic. - L'OOM d'un cgroup : un groupe a atteint son
memory.max. Le noyau choisit une victime dans ce groupe seulement. Le reste de la machine n'est pas touché. - systemd-oomd, un démon en espace utilisateur qui surveille la pression mémoire et l'usage du swap, et qui tue un cgroup entier avant que le noyau n'en arrive là. Il n'agit que sur les unités qui le demandent (
ManagedOOMMemoryPressure=kill,ManagedOOMSwap=kill). Ubuntu l'installe et l'active sur le bureau depuis 22.04 ; sur Ubuntu Server 24.04 comme sur Debian 13, il vit dans un paquet séparé,systemd-oomd, qui n'est pas installé par défaut.
Dans les trois cas, systemd remarque qu'un processus d'un service a été tué et applique la politique OOMPolicy= de l'unité : stop par défaut (DefaultOOMPolicy=stop dans system.conf), qui arrête proprement tout le service et le marque en échec ; kill, qui demande au noyau de tuer tout le groupe d'un coup (memory.oom.group=1) ; ou continue, qui se contente de journaliser.
La pression : mesurer avant que ça casse
L'utilisation d'une ressource ne dit pas si elle manque. Une machine à 95 % de mémoire occupée peut aller très bien (c'est du cache) ; une machine à 70 % peut passer son temps à récupérer des pages. Le noyau mesure directement ce qui compte, le temps perdu à attendre une ressource : c'est le PSI (Pressure Stall Information). Il est exposé globalement dans /proc/pressure/cpu, /proc/pressure/memory et /proc/pressure/io, et par groupe dans les fichiers cpu.pressure, memory.pressure et io.pressure. Le PSI est compilé et actif dans les noyaux d'Ubuntu 24.04 et de Debian 13 (CONFIG_PSI=y, sans désactivation par défaut).
En pratique
Les commandes qui suivent concernent sig-outils (Debian 13) et sig-app-1 (Ubuntu 24.04). Les sorties sont reconstituées d'après le format exact des outils, avec des valeurs illustratives ; aucune n'est une capture.
Lire les limites d'un processus
Dans votre shell, ulimit (une commande interne de Bash) affiche et modifie les limites du shell lui-même, dont hériteront les commandes que vous lancerez :
$ ulimit -n # limite souple des descripteurs
1024
$ ulimit -Hn # limite stricte
524288
$ ulimit -Sn 4096 # relever la souple, permis jusqu'à la stricte
$ ulimit -a # toutes les limites souples
Sortie typique de ulimit -a (extrait) :
core file size (blocks, -c) 0
file size (blocks, -f) unlimited
max locked memory (kbytes, -l) 8192
open files (-n) 4096
max user processes (-u) 15421
virtual memory (kbytes, -v) unlimitedLa valeur de -u dépend de la mémoire de la machine. Notez les 1 024 et 524 288 de départ : votre session SSH les hérite de ssh.service, qui a reçu les valeurs par défaut de systemd.
Pour un autre processus, deux sources. Le fichier /proc/<pid>/limits, lisible par le propriétaire du processus et par root, donne la vérité telle que le noyau l'applique :
$ systemctl show -p MainPID --value signalements
1873
$ sudo cat /proc/1873/limits
Sortie typique (extrait) :
Limit Soft Limit Hard Limit Units
Max cpu time unlimited unlimited seconds
Max processes 15421 15421 processes
Max open files 1024 524288 files
Max locked memory 8388608 8388608 bytesprlimit, de util-linux, lit et modifie les limites d'un processus en cours d'exécution :
$ sudo prlimit --pid 1873 --nofile
RESOURCE DESCRIPTION SOFT HARD UNITS
NOFILE max number of open files 1024 524288 files
$ sudo prlimit --pid 1873 --nofile=8192:524288
--nofile=souple:stricte fixe les deux valeurs ; --nofile=8192: ne fixe que la souple. C'est un geste de dépannage, pour sortir d'une crise sans redémarrer : la modification disparaît au prochain redémarrage du service. La correction durable est dans l'unité.
Enfin, pour compter les descripteurs qu'un processus utilise réellement :
$ sudo ls /proc/1873/fd | wc -l
Régler LimitNOFILE= pour un service
Signalements, sous forte charge, a écrit dans son journal OSError: [Errno 24] Too many open files. Chaque connexion cliente, chaque connexion à sig-db, chaque fichier de pièce jointe ouvert consomme un descripteur, et le maître Gunicorn et chacun de ses workers ont leur propre limite de 1 024. La correction passe par un drop-in, comme à la leçon 11 de Linux : premiers pas :
$ sudo systemctl edit signalements
[Service]
LimitNOFILE=16384Une valeur seule fixe la limite souple et la stricte ; LimitNOFILE=16384:524288 les fixerait séparément. Avant de relever la limite souple au-delà de 1 024, assurez-vous que l'application et ses bibliothèques n'appellent pas select() sur des descripteurs quelconques : les serveurs Python actuels passent par le module selectors, qui choisit epoll sous Linux, mais une vieille bibliothèque peut réserver une surprise, d'où l'intérêt d'un essai sous charge avant la production. Redémarrez le service (les limites sont posées au lancement du processus, pas à chaud), puis vérifiez sur le processus lui-même, jamais dans votre shell :
$ sudo systemctl restart signalements
$ sudo prlimit --pid "$(systemctl show -p MainPID --value signalements)" --nofile
Explorer l'arbre des cgroups
$ stat -fc %T /sys/fs/cgroup
cgroup2fs
cgroup2fs confirme la hiérarchie unifiée. systemd-cgls affiche l'arbre avec les processus de chaque groupe :
$ systemd-cgls --no-pager /system.slice/signalements-export.service
Sortie typique :
CGroup /system.slice/signalements-export.service:
├─48190 /opt/signalements/venv/bin/python -m signalements.export
├─48211 /opt/signalements/venv/bin/python -m signalements.export
├─48212 /opt/signalements/venv/bin/python -m signalements.export
├─48213 /opt/signalements/venv/bin/python -m signalements.export
└─48214 /opt/signalements/venv/bin/python -m signalements.exportLe processus maître et ses quatre workers sont tous là, dans le même groupe : c'est sur ce groupe, et non sur chaque processus, que porteront les limites. Pour savoir à quel groupe appartient un processus quelconque :
$ cat /proc/48211/cgroup
0::/system.slice/signalements-export.service
Le 0:: signale la hiérarchie unifiée (une seule ligne, contrôleurs non nommés).
systemd-cgtop est l'équivalent de top par groupe, trié par défaut sur le processeur :
$ systemd-cgtop -m --depth=2 # trié par mémoire, deux niveaux de profondeur
$ systemd-cgtop -1 -b -m # une seule itération, sans interface, pour un script
Sortie typique (extrait) :
CGroup Tasks %CPU Memory Input/s Output/s
/ 187 312.4 3.4G - -
/system.slice 104 305.9 3.1G - -
/system.slice/signalements-export.service 5 288.7 2.2G - -
/system.slice/sig-sauvegarde.service 12 14.1 612.0M - -
/system.slice/rsyslog.service 4 2.3 48.5M - -Les colonnes Input/s et Output/s restent vides tant que la comptabilité des entrées-sorties n'est pas activée.
Lire les compteurs d'un service
Le répertoire du groupe contient les compteurs bruts. Pour l'export, après une nuit :
$ cd /sys/fs/cgroup/system.slice/signalements-export.service
$ cat memory.current memory.peak
$ cat memory.events
Sortie typique de memory.events :
low 0
high 1834
max 27
oom 1
oom_kill 1
oom_group_kill 0Ligne par ligne, d'après la documentation du noyau :
low: nombre de fois où le groupe a été récupéré alors qu'il était sous sa protectionmemory.low(signe d'une protection surréservée) ;high: nombre de fois où les processus ont été freinés et forcés à récupérer de la mémoire parce quememory.highétait dépassé. Une valeur qui grimpe est attendue siMemoryHigh=est le mécanisme de contrôle ;max: nombre de fois où le groupe allait franchirmemory.max; si la récupération échoue, il passe en OOM ;oom: nombre de fois où la limite a été atteinte et une allocation allait échouer ;oom_kill: nombre de processus du groupe tués par un OOM killer, quel qu'il soit ;oom_group_kill: nombre d'OOM qui ont tué le groupe entier.
memory.peak donne le maximum atteint depuis la création du groupe, une donnée précieuse pour dimensionner une limite. memory.stat détaille la consommation : anon (la mémoire des programmes), file (le cache), shmem (dont le tmpfs).
Plus simplement, systemctl status résume ces informations dès qu'une limite est posée. Sortie typique de la ligne mémoire :
Memory: 1.1G (high: 1.0G max: 1.5G available: 389.2M peak: 1.4G)Borner l'export de Signalements
Voici la politique retenue pour sig-outils, qui a 4 Gio de mémoire et deux processeurs : l'export peut prendre jusqu'à 1 Gio confortablement, il est freiné au-delà, et il ne dépassera jamais 1,5 Gio ; il passe après les autres pour le processeur et le disque ; il ne peut pas créer plus de 64 tâches.
$ sudo systemctl edit signalements-export.service
[Service]
MemoryHigh=1G
MemoryMax=1536M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Nice=10
IOSchedulingClass=idle
OOMPolicy=stopChaque ligne :
MemoryHigh=1G: au-delà de 1 Gio, le noyau freine les processus de l'export et récupère leur cache en priorité. L'export ralentit, il ne meurt pas.MemoryMax=1536M: le mur. Si l'export dépasse malgré le frein, l'OOM killer agit dans son groupe, jamais sur la sauvegarde ni surrsyslog.MemorySwapMax=0: interdit de repousser ses pages dans le swap s'il en existe un, pour que la limite soit réelle.CPUWeight=20: face à des services au poids par défaut de 100, l'export n'obtient qu'un sixième du processeur quand il y a concurrence, et tout le processeur la nuit quand personne d'autre n'en veut.TasksMax=64: un garde-fou contre une boucle de création de processus ou de threads ; l'export en utilise cinq.Nice=10etIOSchedulingClass=idle: les vieux mécanismes de priorité vus dans la leçon 10 de Linux : premiers pas, qui s'appliquent processus par processus. La classe d'entrées-sortiesidlen'est respectée que par les ordonnanceurs BFQ et mq-deadline.OOMPolicy=stop: la valeur par défaut, écrite pour qu'elle soit visible. Si un worker est tué, tout l'export est arrêté et marqué en échec, plutôt que de produire un CSV auquel il manque un quart des lignes.
systemctl edit recharge systemd, et pour les réglages de cgroup, la nouvelle valeur s'applique immédiatement, même au service en cours d'exécution : systemd écrit dans memory.max sans redémarrer quoi que ce soit. Nice= et IOSchedulingClass=, propriétés du processus, attendent le prochain lancement. Vérifiez :
$ systemctl show signalements-export -p MemoryHigh -p MemoryMax -p CPUWeight -p TasksMax
MemoryHigh=1073741824
MemoryMax=1610612736
CPUWeight=20
TasksMax=64
$ cat /sys/fs/cgroup/system.slice/signalements-export.service/memory.max
1610612736
Lire le fichier du noyau est la seule preuve que la limite est en place : ce que dit l'unité est une intention, ce que contient memory.max est la réalité.
Changer une limite à chaud : systemctl set-property
Une nuit de fin de mois, l'export doit traiter deux fois plus de données. Sans éditer de fichier :
$ sudo systemctl set-property --runtime signalements-export.service MemoryMax=2G MemoryHigh=1536M
La modification s'applique tout de suite. Avec --runtime, elle est écrite sous /run/systemd/system.control/ et disparaît au redémarrage de la machine ; sans --runtime, elle est écrite sous /etc/systemd/system.control/signalements-export.service.d/ et devient permanente. Ce second répertoire est prioritaire sur /etc/systemd/system/ : un set-property oublié l'emporte sur votre drop-in versionné, ce qui peut dérouter. systemctl cat affiche tous les fichiers, avec leur chemin ; prenez l'habitude de le lire.
Borner une commande ponctuelle : systemd-run
Vous devez relancer l'export à la main, dans la journée, pendant que rsyslog reçoit le trafic de sig-app-1 et sig-app-2. Plutôt que de le lancer nu dans votre shell, où il échapperait à toute limite (il naîtrait dans votre session-N.scope), confiez-le à systemd avec des bornes :
$ sudo systemd-run --unit=export-manuel --wait --pipe --collect \
-p User=signalements -p WorkingDirectory=/opt/signalements \
-p MemoryHigh=1G -p MemoryMax=1536M -p CPUWeight=20 \
/opt/signalements/venv/bin/python -m signalements.export --date 2026-10-06
--unit=export-manuel: le nom de l'unité transitoire, pour la retrouver danssystemctl statusetjournalctl -u.--wait: attendre la fin et afficher un bilan.--pipe: brancher l'entrée et les sorties de la commande sur votre terminal.--collect: décharger l'unité à la fin, même en cas d'échec, pour ne pas laisser d'unitéfailedderrière soi.-p: n'importe quelle propriété, dans la syntaxe deset-property.
Sortie typique en fin d'exécution :
Running as unit: export-manuel.service; invocation ID: 5d0c1f8e2b5a4f0e9d6c7b8a91e2f3a4
Finished with result: success
Main processes terminated with: code=exited/status=0
Service runtime: 18min 42.311s
CPU time consumed: 31min 5.902s
Memory peak: 1.3GMemory peak est exactement l'information qu'il vous faut pour fixer les limites de l'unité permanente. Pour une commande interactive que l'on veut garder attachée à son terminal (une compression, un rsync), --scope crée une portée au lieu d'un service : sudo systemd-run --scope -p MemoryMax=512M -p CPUQuota=50% tar czf /srv/archive.tgz /srv/donnees/pieces-jointes.
Regrouper les tâches de fond dans une tranche
Plutôt que de régler chaque tâche, on peut donner un budget commun à toutes les tâches de fond de sig-outils. Créez /etc/systemd/system/taches.slice :
[Unit]
Description=Tâches de fond de sig-outils (export, sauvegarde, purge)
[Slice]
MemoryHigh=2G
MemoryMax=2560M
CPUWeight=30Puis rattachez chaque service à la tranche par un drop-in :
[Service]
Slice=taches.sliceAprès daemon-reload et redémarrage des services concernés, l'export et la sauvegarde vivent sous /sys/fs/cgroup/taches.slice/. Ensemble, ils ne dépasseront pas 2,5 Gio, et ils partagent leur part de processeur entre eux. rsyslog et ssh, restés dans system.slice, gardent de quoi travailler quoi qu'il arrive.
Repérer l'étranglement du processeur
Sur sig-app-1, quelqu'un a posé CPUQuota=100% sur Signalements, « pour être sûr ». Les temps de réponse sont devenus irréguliers. Lisez cpu.stat :
$ cat /sys/fs/cgroup/system.slice/signalements.service/cpu.stat
Sortie typique :
usage_usec 81234567890
user_usec 70123456789
system_usec 11111111101
nr_periods 2419200
nr_throttled 386112
throttled_usec 9876543210
nr_bursts 0
burst_usec 0nr_periods compte les périodes de 100 ms écoulées avec le quota actif, nr_throttled celles où le groupe a épuisé son quota avant la fin et a été mis à l'arrêt, throttled_usec le temps total passé à l'arrêt, en microsecondes. Ici, une période sur six environ se termine par un arrêt. Avec deux workers Gunicorn qui tournent en parallèle sur deux cœurs, le quota d'un cœur par 100 ms est épuisé en 50 ms, et toutes les requêtes en cours attendent les 50 ms restantes : c'est de la latence pure, alors que la machine a du processeur libre. Retirez le quota (CPUQuota= vide dans le drop-in) ou remplacez-le par un poids.
Lire la pression
$ cat /proc/pressure/memory
Sortie typique, pendant l'export de la nuit avant correction :
some avg10=42.17 avg60=35.80 avg300=18.02 total=914237765
full avg10=21.66 avg60=17.12 avg300=8.45 total=402331190La ligne some donne la part du temps où au moins une tâche attendait de la mémoire ; la ligne full, la part du temps où toutes les tâches actives attendaient en même temps, c'est-à-dire du temps processeur entièrement perdu. Les moyennes portent sur 10, 60 et 300 secondes, total est cumulé en microsecondes. Une valeur full durablement au-dessus de quelques pour cent signale une machine qui s'épuise à récupérer de la mémoire : c'est l'alerte à poser, bien plus parlante qu'un pourcentage de mémoire occupée. Le même format existe par groupe : cat /sys/fs/cgroup/taches.slice/memory.pressure.
Lire le journal après un OOM
Le journal du noyau dit quel OOM a agi :
$ journalctl -k -b -1 --grep 'oom-kill|Killed process'
Out of memory: Killed process ...etglobal_oomdans la ligneoom-kill:: l'OOM killer global, la machine entière manquait de mémoire.Memory cgroup out of memory: Killed process ...etoom_memcg=/system.slice/signalements-export.service: un OOM de cgroup, le groupe nommé a atteint sonmemory.max. Le noyau affiche aussi la lignememory: usage ...kB, limit ...kB, failcnt ...du groupe.
Le journal de l'unité dit ce que systemd en a fait :
$ journalctl -u signalements-export -b -1
Sortie typique :
systemd[1]: signalements-export.service: A process of this unit has been killed by the OOM killer.
systemd[1]: signalements-export.service: Failed with result 'oom-kill'.Ces deux messages viennent du code de systemd 255 ; le second est la conséquence de OOMPolicy=stop. Pour une alerte, systemctl show -p Result signalements-export renvoie Result=oom-kill.
Sous le capot
Comment pam_limits pose les limites. Une connexion SSH crée un processus sshd pour la session, qui appelle les modules PAM de la pile session. pam_limits y lit limits.conf, puis appelle setrlimit() sur ce processus, avant qu'il ne lance votre shell. Le shell hérite, et tout ce que vous lancez hérite du shell. Un service, lui, est créé par PID 1 : systemd fait fork(), applique dans l'enfant les Limit...= de l'unité (ou les valeurs par défaut) par setrlimit(), puis appelle execve() sur le programme. À aucun moment PAM n'intervient. systemd relève d'ailleurs ses propres limites de PID 1 bien plus haut, et systemd-system.conf(5) précise qu'il les ramène aux valeurs par défaut pour chacun de ses enfants.
Comment systemd parle aux cgroups. Démarrer un service, pour systemd, c'est créer le répertoire /sys/fs/cgroup/system.slice/signalements-export.service/, y écrire les limites (echo 1610612736 > memory.max), activer les contrôleurs nécessaires dans les cgroup.subtree_control des parents, puis placer le processus enfant dans le groupe en écrivant son PID dans cgroup.procs, avant execve(). Tous les descendants naissent ensuite dans ce groupe et ne peuvent pas en sortir sans privilèges. systemctl set-property réécrit simplement les fichiers du groupe existant, d'où l'effet immédiat.
Comment memory.max est appliqué. La mémoire est comptée au groupe à chaque page allouée, qu'elle soit anonyme (le tas d'un programme), de cache (un fichier lu) ou de tmpfs. Quand une allocation ferait dépasser memory.max, le noyau tente d'abord de récupérer dans le groupe : il libère le cache propre, écrit le cache modifié sur disque, repousse des pages dans le swap si c'est permis. C'est l'événement max de memory.events. Seulement si cela échoue, il déclenche l'OOM dans le groupe (oom), choisit une victime parmi les processus du groupe selon le même score que l'OOM global, et la tue par SIGKILL (oom_kill). Si memory.oom.group vaut 1, ce que systemd fait avec OOMPolicy=kill, il tue tout le groupe d'un coup. memory.high utilise le même mécanisme de récupération, mais au lieu de tuer, le noyau fait dormir le processus fautif proportionnellement au dépassement : d'où le ralentissement.
Comment le quota CPU est appliqué. Le fichier cpu.max contient deux nombres, quota période, en microsecondes : CPUQuota=50% y écrit 50000 100000. L'ordonnanceur décompte le temps consommé par tous les threads du groupe ; dès que le quota de la période est épuisé, il retire le groupe des files d'exécution jusqu'au début de la période suivante. Avec plusieurs threads, le quota s'épuise d'autant plus vite. Le poids (cpu.weight), lui, n'arrête jamais personne : il pèse seulement dans le choix de la prochaine tâche à exécuter quand plusieurs groupes en ont une de prête.
Comment systemd sait qu'il y a eu un OOM. Le fichier memory.events émet une notification de modification à chaque changement (la documentation du noyau le précise pour chacun de ses champs). systemd surveille ce fichier pour chaque unité, voit oom_kill augmenter, journalise « A process of this unit has been killed by the OOM killer », puis applique OOMPolicy=. systemd-oomd, de son côté, lit périodiquement les fichiers memory.pressure des groupes qu'il surveille, et tue un groupe enfant quand la pression dépasse son seuil (par défaut 60 % pendant 30 secondes, d'après oomd.conf(5)).
Pièges courants
limits.conf modifié, service inchangé. Le symptôme : ulimit -n sous sudo -u signalements affiche la nouvelle valeur, /proc/<pid>/limits du service affiche toujours 1024. limits.conf n'est lu que par pam_limits, à l'ouverture d'une session PAM. Réglez LimitNOFILE= dans l'unité et redémarrez le service.
OSError: [Errno 24] Too many open files (ou accept4() failed (24: Too many open files) chez nginx). Le processus a atteint sa limite souple RLIMIT_NOFILE. Avant de relever la limite, comptez les descripteurs (ls /proc/<pid>/fd | wc -l) et regardez lesquels s'accumulent (ls -l /proc/<pid>/fd) : s'ils grimpent sans redescendre, c'est une fuite (fichiers jamais fermés, connexions jamais rendues au pool), et relever la limite ne fait que repousser la panne.
bash: fork: retry: Resource temporarily unavailable. fork() a reçu EAGAIN. Deux causes possibles : RLIMIT_NPROC de l'utilisateur, ou le pids.max du groupe. Le journal du noyau tranche : la présence de cgroup: fork rejected by pids controller in ... (texte de kernel/cgroup/pids.c, écrit une seule fois par groupe, à la première occurrence) désigne le groupe, et cat pids.current pids.max dans son répertoire confirme. Une fork bomb lancée dans une session reste confinée par le TasksMax=33% de sa tranche utilisateur ; elle n'emporte pas la machine.
MemoryMax= trop bas : l'OOM à répétition. Un service redémarré par Restart=on-failure, tué par l'OOM de son groupe toutes les quelques minutes. Le journal montre Failed with result 'oom-kill' en boucle, memory.events un oom_kill qui grimpe. Une limite se fixe d'après une mesure : memory.peak ou Memory peak de systemd-run --wait sur une charge réelle, plus une marge, et MemoryHigh= en dessous pour freiner avant le mur.
Une limite qui « ne marche pas » parce qu'elle porte sur la mauvaise chose. LimitAS=2G sur un service Python : il échoue au démarrage par MemoryError alors qu'il n'utilisait que 300 Mio, parce que l'espace d'adressage virtuel n'est pas la mémoire utilisée. LimitRSS= : aucun effet sous Linux. Pour la mémoire, toujours MemoryHigh= et MemoryMax=.
CPUQuota= et la latence. Un quota bas sur une application multithread provoque des arrêts de plusieurs dizaines de millisecondes à chaque période, invisibles dans la consommation moyenne. Le diagnostic est nr_throttled dans cpu.stat. Pour une application interactive, préférez un poids ; si un quota est obligatoire, laissez de la marge.
IOWeight= sans effet. L'ordonnanceur du disque est none ou mq-deadline, qui ne réalisent pas le partage par poids. Utilisez un plafond (IOWriteBandwidthMax=), ou passez le disque en BFQ si la charge le justifie, en mesurant l'effet.
Le set-property fantôme. Une limite que personne ne retrouve dans les drop-ins versionnés, et qui l'emporte sur eux : elle a été posée par systemctl set-property sans --runtime, dans /etc/systemd/system.control/. systemctl cat la montre, avec son chemin.
Une tranche au nom composé. Slice=taches-fond.slice crée, sans le dire, une tranche taches.slice parente, avec ses propres réglages éventuels. Choisissez des noms sans tiret, ou assumez la hiérarchie.
L'OOM killer accusé à tort, ou pas accusé du tout. Un code de sortie 137, ou status=9/KILL dans systemctl status, signifie seulement SIGKILL. Cela peut être l'OOM killer, mais aussi le délai d'arrêt dépassé (TimeoutStopSec=) ou un humain. Seul journalctl -k avec Killed process <pid> le prouve. À l'inverse, la victime de l'OOM global n'est pas forcément la coupable : c'était la leçon de restic.
Sécurité
- Les limites sont une défense contre le déni de service. Une application compromise, ou simplement boguée, qui alloue sans fin ou se duplique en boucle, ne doit pas pouvoir rendre la machine inutilisable.
MemoryMax=etTasksMax=sur chaque service exposé transforment une panne de la machine en panne d'un service, que systemd relance. C'est une mesure de durcissement à part entière, à inscrire dans la démarche de la leçon 14. - Protégez ce qui sert à reprendre la main. Si la machine sature, il faut encore pouvoir s'y connecter et lire les journaux.
sshdetsystemd-journaldrestent danssystem.slice, hors des tranches de tâches. Une protectionMemoryLow=modeste surssh.serviceetrsyslog.serviceles met à l'abri de la récupération, à condition de poser aussi une protection au moins égale à leur somme sursystem.slice:systemd.resource-control(5)rappelle qu'une protection n'est généralement effective que si tous les ancêtres en ont une (la racine exceptée). Ne mettez jamaisOOMScoreAdjust=-1000partout : si plus rien n'est tuable, c'est le noyau qui s'arrête. RLIMIT_COREet les secrets. Une image mémoire (core dump) contient tout ce que le processus avait en mémoire : jetons, mots de passe de base de données, données personnelles des signalements. Sur un serveur de production,LimitCORE=0sur les services qui manipulent des secrets, ou une collecte maîtrisée parsystemd-coredumpavec une rétention courte, sont des réglages à décider explicitement, notamment au regard du RGPD.CAP_SYS_RESOURCEest une capability puissante. Elle permet de relever les limites strictes et de passer outre certaines réservations du noyau. Un service ne doit pas l'avoir ; la leçon 14 montre comment vider l'ensemble des capabilities d'une unité.- Le cloisonnement des ressources n'est pas une isolation. Un cgroup limite ce qu'un service consomme, il ne l'empêche ni de lire des fichiers, ni de voir les autres processus. L'isolation passe par les namespaces, les options de durcissement de systemd et AppArmor.
- Délégation.
Delegate=yesdonne à un service le droit de gérer lui-même son sous-arbre de cgroups (c'est ce que font Docker oucontainerd). Ce service peut alors y redistribuer comme il l'entend les ressources qu'on lui a données : ne déléguez qu'à des logiciels qui en ont besoin.
En production
- Une politique de ressources par rôle de machine, écrite et versionnée. Sur
sig-outils: une tranchetaches.slicebornée, des services de réception et d'accès protégés. Sursig-app-1etsig-app-2:MemoryMax=etTasksMax=sur Signalements, pas de quota CPU. Ces drop-ins vivent dans le même dépôt que les unités et sont déployés par l'outil d'automatisation (cours Ansible : les fondamentaux), jamais posés à la main. - Mesurer avant de borner. Une limite fixée au jugé est soit inutile, soit dangereuse. Exécutez la charge réelle sous
systemd-run --wait, relevezmemory.peaksur plusieurs semaines, ajoutez une marge, puis posezMemoryHigh=au niveau habituel etMemoryMax=au niveau inacceptable. - Surveiller les bons signaux. Les métriques à collecter par service :
memory.currentrapporté àmemory.max, les compteurshigh,maxetoom_killdememory.events,nr_throttledetthrottled_usecdecpu.stat,pids.current. Pour la machine : la pressionfullde/proc/pressure/memoryet/proc/pressure/io. Le cours Performance et diagnostic montre comment les exporter. - systemd-oomd, sur un serveur ? Il agit plus tôt que le noyau, avant que la machine ne s'enlise, mais il tue des groupes entiers sur un critère de pression, et Ubuntu a dû ajuster ses réglages sur le bureau après des arrêts intempestifs de navigateurs. Sur un serveur, on ne l'installe que pour des charges bien identifiées (des tâches relançables), avec
ManagedOOMMemoryPressure=killposé sur leur seule tranche, et après des essais. - Les conteneurs, c'est la même chose.
docker run --memory --cpus --pids-limitécrit dans les mêmes fichiers (voir Docker, leçon 12). Dans Kubernetes, les requests et limits d'un conteneur deviennent un poids (cpu.weight), un quota (cpu.max) et une limite mémoire (memory.max) dans le cgroup du pod, et la documentation de Kubernetes rappelle que les limites CPU sont appliquées par étranglement et les limites mémoire par OOM. Les nœuds de Kapsule sont des machines Linux oùkubeletorganise ces cgroups sous sa propre tranche (voir Kubernetes, leçon 8). Ce que vous lisez dansmemory.eventssursig-outils, vous le lirez sur un nœud pour comprendre unOOMKilled. - Dimensionner plutôt que comprimer. Une limite protège les voisins ; elle ne crée pas de mémoire. Un export qui a besoin de 3 Gio et qu'on borne à 1,5 Gio échouera toutes les nuits. La bonne réponse est parfois de corriger le programme (traiter la table par lots au lieu de la charger en entier), parfois de changer de type d'instance Scaleway, parfois de déplacer la tâche sur une machine dédiée.
Exercices
1. Lire memory.events (niveau 100). Sur sig-outils, memory.events de sig-sauvegarde.service contient low 0, high 0, max 0, oom 0, oom_kill 1, oom_group_kill 0, et l'unité ne porte aucune limite mémoire. Qu'est-ce que cela signifie, et quelle commande confirme votre hypothèse ?
Solution
Un processus du groupe a été tué par un OOM killer (oom_kill 1), mais le groupe n'a jamais atteint de limite propre (max 0, oom 0), ce qui est cohérent avec l'absence de limite. C'est donc l'OOM killer global qui a frappé : la machine entière manquait de mémoire, et un processus de la sauvegarde a été choisi comme victime. journalctl -k --grep 'Killed process' montre la ligne Out of memory: Killed process ... (restic), et la ligne oom-kill: précédente contient global_oom. Le coupable est à chercher ailleurs, dans le groupe qui consommait le plus à ce moment (systemd-cgtop -m, ou memory.peak des autres services).
2. Une limite qui ne s'applique pas (niveau 200). Sur sig-app-1, pour corriger des Too many open files, une personne a ajouté signalements - nofile 65536 dans /etc/security/limits.d/signalements.conf et redémarré le service. L'erreur persiste. Expliquez pourquoi, corrigez, et prouvez que la correction est en place.
Solution
limits.d n'est lu que par pam_limits, à l'ouverture d'une session PAM (SSH, su, sudo, cron). Le service est lancé par systemd sans PAM et garde DefaultLimitNOFILE=1024:524288. Correction : sudo systemctl edit signalements, puis
[Service]
LimitNOFILE=16384et sudo systemctl restart signalements. Preuve sur le processus lui-même : sudo prlimit --pid "$(systemctl show -p MainPID --value signalements)" --nofile, ou sudo grep 'open files' /proc/<pid>/limits, et pour un worker, le PID d'un enfant (pgrep -P <pid maître>). Supprimez aussi le fichier de limits.d, qui ne sert à rien et induira la prochaine personne en erreur. Enfin, vérifiez qu'il ne s'agit pas d'une fuite : ls /proc/<pid>/fd | wc -l à plusieurs heures d'intervalle.
3. Latence irrégulière (niveau 200). Depuis une modification, le 99e centile des temps de réponse de Signalements a triplé, alors que top montre sig-app-1 à 40 % d'occupation. Comment vérifiez-vous l'hypothèse d'un quota CPU, et que proposez-vous ?
Solution
systemctl show signalements -p CPUQuotaPerSecUSec (une valeur autre que infinity signale un quota) et systemctl cat signalements pour trouver qui l'a posé (drop-in ou /etc/systemd/system.control/). Puis lire deux fois cpu.stat à une minute d'intervalle : si nr_throttled et throttled_usec augmentent, le groupe est mis à l'arrêt à chaque période malgré le processeur libre. Les workers Gunicorn en parallèle épuisent le quota en début de période, et les requêtes attendent la suivante. Proposition : retirer le quota (CPUQuota= vide dans le drop-in, ou sudo systemctl set-property signalements CPUQuota=), et si l'intention était de protéger d'autres services, utiliser CPUWeight=, qui ne ralentit Signalements que lorsqu'il y a vraiment concurrence.
4. Une politique pour sig-outils (niveau 300). Concevez la répartition des ressources de sig-outils (4 Gio, deux processeurs) pour que : l'export, la purge et la sauvegarde ne puissent jamais empêcher la réception des journaux ni l'accès SSH ; la sauvegarde soit prioritaire sur l'export si les deux tournent en même temps ; un export qui dérape s'arrête proprement plutôt que de produire un fichier incomplet. Donnez les fichiers et justifiez chaque valeur.
Solution
Une tranche commune pour les tâches, /etc/systemd/system/taches.slice :
[Slice]
MemoryHigh=2G
MemoryMax=2560M
CPUWeight=50Elle laisse environ 1,5 Gio au système, à rsyslog, ssh et journald, et réduit la part de processeur des tâches face à system.slice en cas de concurrence (50 contre 100).
Pour chaque tâche, un drop-in Slice=taches.slice. Puis, à l'intérieur de la tranche :
sig-sauvegarde.service:CPUWeight=200,MemoryMax=1G(d'après la mesure du pic derestic). Prioritaire sur l'export pour le processeur.signalements-export.service:CPUWeight=50,MemoryHigh=1G,MemoryMax=1536M,OOMPolicy=stop(l'export entier s'arrête si un worker est tué, l'échec est visible et aucun CSV partiel n'est livré ; le script doit écrire dans un fichier temporaire et ne le renommer qu'à la fin),TasksMax=64.signalements-purge.service:CPUWeight=50,MemoryMax=600M.
Protection des services d'accès : un drop-in MemoryLow=128M sur rsyslog.service et ssh.service, et un drop-in MemoryLow=512M sur system.slice (sudo systemctl edit system.slice), sans lequel la protection des enfants n'aurait pas d'effet. Leur mémoire n'est alors pas récupérée en premier sous pression.
Justification d'ensemble : les limites de mémoire sont des murs par groupe, donc aucun OOM ne peut sortir de la tranche ; les poids ne gaspillent rien la nuit, quand les tâches sont seules ; pas de CPUQuota=, inutile ici et source de lenteur. Vérifications : systemd-cgls /taches.slice, cat /sys/fs/cgroup/taches.slice/memory.max, puis surveillance de memory.events de la tranche et de chaque service, et de /proc/pressure/memory. Enfin, le tout est versionné et déployé par l'outil d'automatisation.
Récapitulatif
- Les rlimits plafonnent un processus à la fois (descripteurs, processus par utilisateur, core dumps) ; souple appliquée, stricte comme plafond ; on les lit dans
/proc/<pid>/limitset on les change à chaud avecprlimit. limits.confne concerne que les sessions PAM. Un service prend ses limites dansLimit...=de son unité, et par défautLimitNOFILE=1024:524288sous systemd 255 et 257.- Pour la mémoire, les rlimits sont le mauvais outil :
LimitAS=limite des adresses,LimitRSS=n'a aucun effet. On utilise les cgroups v2. - systemd range chaque service dans un cgroup, sous une tranche (
system.slice,user.slice,machine.slice, ou les vôtres) ;systemd-cglsetsystemd-cgtoples montrent,/sys/fs/cgrouples expose. MemoryHigh=freine,MemoryMax=coupe dans le groupe seulement ;CPUWeight=partage sans gaspiller,CPUQuota=plafonne et étrangle ;IOWeight=exige BFQ ouio.cost,IO...BandwidthMax=marche partout ;TasksMax=arrête les fork bombs.systemctl set-property(avec ou sans--runtime) change une limite à chaud ;systemd-run -p ... --waitborne et mesure une commande ponctuelle.- Trois OOM : global (la machine, victime choisie par processus), de cgroup (le groupe a atteint
memory.max), systemd-oomd (sur pression, non installé par défaut sur les serveurs).journalctl -kdit lequel,memory.eventscompte,OOMPolicy=décide de la suite. - On surveille
memory.events,cpu.stat(nr_throttled) et la pression/proc/pressure/*, plus parlante qu'un taux d'occupation.
Pour aller plus loin
- Les pages
systemd.resource-control(5)etsystemd.exec(5), qui décrivent chaque directive et ses conséquences. - La documentation du noyau Control Group v2, en particulier les sections sur le contrôleur
memoryet le fichiermemory.events, et PSI : Pressure Stall Information. - Chris Down, In defence of swap: common misconceptions (2018), cité par
systemd-oomd.service(8), pour comprendre le rôle du swap dans la gestion de la pression mémoire. - Le cours systemd en profondeur, pour la délégation de cgroups, les tranches utilisateur et l'activation des unités transitoires ; le cours Performance et diagnostic, pour exporter et interpréter ces métriques sur un parc.
- La leçon suivante, Les journaux d'un parc de serveurs.
Sources
- systemd 255, page de manuel systemd.resource-control(5) (Ubuntu noble)
- systemd 255, page de manuel systemd.exec(5), section Process Properties (Ubuntu noble)
- systemd 255, page de manuel systemd-system.conf(5) (Ubuntu noble)
- systemd 255, page de manuel systemd.service(5), OOMPolicy= (Ubuntu noble)
- systemd 255, pages de manuel systemd-run(1), systemctl(1) et systemd-cgtop(1) (Ubuntu noble)
- systemd 255, pages de manuel systemd-oomd.service(8) et oomd.conf(5) (Ubuntu noble)
- systemd, code source v255 : src/core/service.c, src/core/cgroup.c, src/core/system.conf.in, units/user-.slice.d/10-defaults.conf
- Documentation du noyau Linux, Control Group v2
- Documentation du noyau Linux, PSI : Pressure Stall Information
- Noyau Linux v6.8, code source : mm/oom_kill.c, kernel/cgroup/pids.c, fs/proc/base.c
- Linux man-pages, getrlimit(2)
- util-linux, page de manuel prlimit(1) et code source sys-utils/prlimit.c
- Linux-PAM, page de manuel limits.conf(5)
- Debian, configuration du noyau 6.12 pour trixie (CONFIG_PSI)
- Ubuntu, liste ubuntu-devel : systemd-oomd activé sur le bureau, pas sur le serveur (2022)
- Kubernetes, Resource Management for Pods and Containers
- Paquets Ubuntu noble et Debian trixie : systemd, systemd-oomd, util-linux