Aller au contenu
Administration Linux medium

Comprendre les cgroups Linux

23 min de lecture

Un processus part en boucle et sature un cœur. Un service fuit et mange toute la RAM. Les cgroups (control groups) sont le mécanisme du noyau Linux qui empêche cela : ils regroupent des processus et plafonnent ce que ce groupe a le droit de consommer en CPU, en mémoire, en entrées/sorties disque et en nombre de processus. Cette page vous montre comment poser ces limites, comment prouver qu'elles s'appliquent, et surtout pourquoi, sur un serveur moderne, la bonne interface n'est pas /sys/fs/cgroup mais systemd. Les exemples sont exécutés sur Ubuntu 24.04 (noyau 6.8), mais le comportement est identique sur Debian 12 et RHEL 10, qui utilisent toutes cgroups v2 par défaut.

  • Vérifier en une commande si votre machine est en cgroups v1 ou v2.
  • Créer un cgroup, y placer un processus et le limiter à 20 % d'un cœur, preuve à l'appui.
  • Plafonner la mémoire et observer l'OOM kill déclenché par le cgroup.
  • Brider les I/O disque et le nombre de processus (pids.max).
  • Limiter un service avec systemctl set-property, la seule méthode qui survit à un redémarrage.
  • Comprendre ce que Docker et Kubernetes font réellement quand vous écrivez --memory=128m.

Deux générations de cgroups coexistent dans le noyau et elles n'ont ni les mêmes fichiers ni les mêmes chemins. Mélanger les deux est la première source de commandes qui ne marchent pas : un tutoriel qui vous fait écrire dans /sys/fs/cgroup/cpu/cpu.cfs_quota_us parle de la v1, morte sur les distributions actuelles. La commande qui tranche interroge le type du système de fichiers monté.

Fenêtre de terminal
stat -fc %T /sys/fs/cgroup
cgroup2fs

La réponse cgroup2fs signifie cgroups v2 unifié : une seule hiérarchie, tous les contrôleurs au même endroit. Une réponse tmpfs signifierait v1 ou mode hybride. Toute cette page suppose la v2, qui est le défaut d'Ubuntu 22.04+, Debian 11+ et RHEL 9+.

Un contrôleur (cpu, memory, io, pids…) est le module du noyau qui applique une catégorie de limite. La racine vous dit lesquels sont compilés et disponibles.

Fenêtre de terminal
cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

Le meilleur moyen de comprendre les cgroups est d'en fabriquer un et de le voir mordre. Un cgroup est un simple répertoire sous /sys/fs/cgroup : le créer suffit à le faire exister, et le noyau y dépose immédiatement les fichiers de contrôle. Toutes ces opérations exigent les droits root, et comme la redirection > est exécutée par votre shell (pas par sudo), il faut passer par sudo tee pour écrire dans ces fichiers.

Fenêtre de terminal
sudo mkdir /sys/fs/cgroup/demo
cat /sys/fs/cgroup/demo/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc

Ces contrôleurs sont utilisables ici parce que le parent (la racine) les a délégués à ses enfants. Les fichiers de limite sont donc déjà présents, en position ouverte.

Fenêtre de terminal
cat /sys/fs/cgroup/demo/cpu.max
cat /sys/fs/cgroup/demo/memory.max
cat /sys/fs/cgroup/demo/pids.max
max 100000
max
max

Lançons maintenant une boucle infinie qui sature un cœur, puis rattachons-la au cgroup. Le fichier cgroup.procs contient la liste des PID (identifiants de processus) du groupe : y écrire un PID déplace le processus, qui hérite aussitôt des limites en vigueur.

Fenêtre de terminal
while :; do :; done &
# PID=718500
echo 718500 | sudo tee /sys/fs/cgroup/demo/cgroup.procs
cat /proc/718500/cgroup
0::/demo

La ligne 0::/demo confirme le rattachement. Un processus n'appartient qu'à un seul cgroup à la fois : l'y placer le retire automatiquement du précédent. Sans limite, il consomme 100 % d'un cœur, ce que confirme le compteur usage_usec de cpu.stat mesuré sur 5 secondes.

Fenêtre de terminal
top -b -n 1 -p 718500 | tail -1
718500 bob 20 0 7348 1808 1512 R 100,0 0,0 0:05.23 bash

Le fichier cpu.max attend deux nombres : un quota et une période, en microsecondes. Écrire 20000 100000 autorise 20 ms de temps CPU par tranche de 100 ms, soit 20 % d'un cœur. La limite s'applique immédiatement aux processus déjà rattachés, sans redémarrage ni signal.

Fenêtre de terminal
echo "20000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
top -b -n 1 -p 718500 | tail -1
718500 bob 20 0 7348 1808 1512 R 18,2 0,0 0:06.69 bash

Le processus est tombé de 100 % à 18 %, sans avoir été modifié ni redémarré. Le fichier cpu.stat raconte l'histoire côté noyau : il compte les périodes pendant lesquelles le groupe a été étranglé (throttled), c'est-à-dire mis en pause parce qu'il avait épuisé son quota.

Fenêtre de terminal
cat /sys/fs/cgroup/demo/cpu.stat
usage_usec 6677619
user_usec 6676621
system_usec 997
nr_periods 72
nr_throttled 72
throttled_usec 5719555

nr_throttled 72 sur nr_periods 72 : toutes les périodes ont été étranglées. C'est la signature d'un processus qui tape en permanence dans son plafond. Sur un service en production, un nr_throttled qui grimpe est le symptôme d'un quota trop serré, pas d'une machine surchargée : c'est la métrique à surveiller avant d'accuser le matériel.

La règle qui fait échouer la plupart des tutoriels

Section intitulée « La règle qui fait échouer la plupart des tutoriels »

Le fichier cgroup.subtree_control est le piège numéro un de cgroups v2, et il mérite qu'on s'y arrête avant d'aller plus loin. Beaucoup de guides vous font écrire +cpu +memory dedans juste avant d'y placer un processus. Cette séquence échoue systématiquement, et voici pourquoi.

Pour revenir en arrière, retirez les contrôleurs avec le préfixe -, et le cgroup redevient capable d'accueillir des processus.

Fenêtre de terminal
echo "-cpu -memory" | sudo tee /sys/fs/cgroup/demo/cgroup.subtree_control

La limite mémoire est la plus brutale des trois, parce qu'on ne peut pas « ralentir » une allocation : quand un processus demande une page et qu'il n'y a plus de quota, le noyau tue quelqu'un dans le cgroup. Le fichier memory.max fixe ce plafond et il accepte les suffixes K, M, G, ce qui évite de compter les octets à la main. La relecture, elle, renvoie toujours des octets.

Fenêtre de terminal
echo "512M" | sudo tee /sys/fs/cgroup/demo/memory.max
cat /sys/fs/cgroup/demo/memory.max
536870912

Pour observer le déclenchement, descendons le plafond à 64 Mo et coupons le swap du groupe (sinon le noyau se contente de gagner du temps en swappant au lieu de tuer). Un petit programme Python alloue ensuite la mémoire mégaoctet par mégaoctet.

Fenêtre de terminal
echo "64M" | sudo tee /sys/fs/cgroup/demo/memory.max
echo "0" | sudo tee /sys/fs/cgroup/demo/memory.swap.max

Le processus est rattaché au cgroup avant de commencer à allouer, puis il compte à voix haute.

58 Mo alloues
59 Mo alloues
60 Mo alloues
bash: 719360 Killed ./oom.sh
code de sortie : 137

Le processus meurt à 60 Mo, tué par le noyau, avec le code de sortie 137 (128 + signal 9). Ce chiffre est exactement celui que vous verrez sur un conteneur OOMKilled. Le fichier memory.events garde la trace de l'incident, et c'est lui qu'il faut lire pour un diagnostic après coup.

Fenêtre de terminal
cat /sys/fs/cgroup/demo/memory.events
low 0
high 0
max 35
oom 1
oom_kill 1
oom_group_kill 0

Trois compteurs comptent vraiment. max 35 signifie que le groupe a buté 35 fois sur son plafond, forçant le noyau à récupérer de la mémoire : un max qui grimpe sans oom_kill révèle un cgroup trop juste, qui passe son temps à recycler des pages au lieu de travailler. oom 1 indique qu'une allocation n'a pas pu être satisfaite, et oom_kill 1 qu'un processus a été abattu pour cela. Le journal du noyau nomme le coupable et le contexte.

Fenêtre de terminal
sudo dmesg | tail -2
oom-kill:constraint=CONSTRAINT_MEMCG,oom_memcg=/demo,task_memcg=/demo,task=python3,pid=719360
Memory cgroup out of memory: Killed process 719360 (python3) total-vm:80560kB, anon-rss:65152kB

CONSTRAINT_MEMCG est le mot-clé qui distingue un OOM de cgroup (une seule application dépasse son quota, le reste de la machine va bien) d'un OOM système (la machine entière est à court de RAM). Confondre les deux fait chercher le problème au mauvais endroit pendant des heures.

Le contrôleur io plafonne le débit disque via le fichier io.max, qui attend le numéro majeur:mineur du périphérique bloc suivi des limites voulues : rbps et wbps pour les octets par seconde en lecture et en écriture, riops et wiops pour le nombre d'opérations. Ces numéros s'obtiennent avec lsblk.

Fenêtre de terminal
lsblk -o NAME,MAJ:MIN,TYPE
NAME MAJ:MIN TYPE
nvme0n1 259:0 disk
├─nvme0n1p3 259:3 part
└─ubuntu--vg-ubuntu--lv 252:0 lvm

Le piège est ici : il faut viser le disque physique (259:0), pas le volume logique LVM (252:0) sur lequel le système de fichiers est monté. Une limite posée sur le mauvais numéro s'écrit sans erreur et ne freine strictement rien, ce qui fait perdre un temps considérable. Limitons la lecture à 1 Mo/s, puis relisons un fichier en I/O direct (iflag=direct) pour contourner le cache page, sans quoi la lecture serait servie par la RAM et ne toucherait jamais le disque.

Fenêtre de terminal
echo "259:0 rbps=1048576" | sudo tee /sys/fs/cgroup/demo/io.max
dd if=fichier-test of=/dev/null bs=1M count=20 iflag=direct
20971520 bytes (21 MB, 20 MiB) copied, 19,9714 s, 1,1 MB/s

La même lecture montait à 1,2 Go/s sans limite : le plafond est respecté au pourcent près. Le contrôleur pids joue dans un autre registre : il ne rationne pas une ressource matérielle mais le nombre de processus, ce qui en fait le rempart le plus simple contre une fork bomb ou une application qui fuit ses threads. Le fichier pids.max prend un entier.

Fenêtre de terminal
echo 5 | sudo tee /sys/fs/cgroup/demo/pids.max

Toute création de processus au-delà de la cinquième est alors refusée par le noyau, et l'échec remonte jusqu'au shell.

./pids.sh: fork: retry: Resource temporarily unavailable
./pids.sh: fork: Resource temporarily unavailable

Le compteur pids.events enregistre les refus (max 5 ici), ce qui permet de repérer une application qui frôle sa limite avant qu'elle ne casse.

Une croyance très répandue veut que les cgroups limitent aussi la bande passante réseau. C'est faux, et cette confusion vient de la v1. Les anciens contrôleurs net_cls et net_prio se contentaient de marquer ou de prioriser les paquets ; ils ne plafonnaient aucun débit, et ils n'ont aucun équivalent en cgroups v2. Le fichier que vous cherchez n'existe pas.

Pour brider un débit réseau, il faut sortir des cgroups et employer tc (traffic control), le sous-système de mise en forme du trafic du noyau, ou un programme eBPF attaché au cgroup, ce que fait Cilium sur Kubernetes. Retenez la frontière : les cgroups gèrent CPU, mémoire, I/O bloc, PID et périphériques, et s'arrêtent là.

Voilà le point que la plupart des tutoriels passent sous silence, et c'est le plus important de cette page. Sur toute distribution moderne, systemd est le gestionnaire de cgroups de la machine. Il en revendique la propriété exclusive, place chaque service dans son propre cgroup et réapplique ses valeurs dès qu'il en a l'occasion. Tout ce que vous avez écrit à la main dans /sys/fs/cgroup pour un service géré par systemd est donc temporaire.

La démonstration est sans appel. Prenons un service dont systemd a fixé le quota CPU à 20 %, écrasons cpu.max directement dans /sys, puis lançons un simple daemon-reload.

Fenêtre de terminal
cat /sys/fs/cgroup/system.slice/demo-burn.service/cpu.max
echo "80000 100000" | sudo tee /sys/fs/cgroup/system.slice/demo-burn.service/cpu.max
sudo systemctl daemon-reload
cat /sys/fs/cgroup/system.slice/demo-burn.service/cpu.max
20000 100000
80000 100000
20000 100000

Votre modification a vécu quelques secondes. Un daemon-reload, un set-property sur n'importe quelle autre propriété, un redémarrage du service ou de la machine, et systemd restaure sa vision du monde. Ajoutez que /sys/fs/cgroup est un système de fichiers virtuel du noyau, recréé vide à chaque démarrage : votre mkdir n'a jamais rien persisté.

La commande systemctl set-property applique la limite immédiatement et l'écrit dans un fichier de configuration qui survit au redémarrage. Elle accepte les mêmes directives que les fichiers d'unité.

Fenêtre de terminal
sudo systemctl set-property demo-burn.service CPUQuota=20% MemoryMax=256M

Systemd traduit ces directives en écritures dans le cgroup du service, exactement celles que vous auriez faites à la main, mais dont il garde désormais la mémoire.

Fenêtre de terminal
cat /sys/fs/cgroup/system.slice/demo-burn.service/cpu.max
cat /sys/fs/cgroup/system.slice/demo-burn.service/memory.max
20000 100000
268435456

La persistance est assurée par un drop-in, un fragment de configuration qui complète l'unité sans la modifier. Systemd le dépose dans un répertoire dédié aux propriétés posées en ligne de commande.

Fenêtre de terminal
cat /etc/systemd/system.control/atd.service.d/50-CPUQuota.conf
# This is a drop-in unit file extension, created via "systemctl set-property"
# or an equivalent operation. Do not edit.
[Service]
CPUQuota=10%

Pour un réglage que vous voulez versionner avec le reste de votre configuration, préférez un drop-in écrit à la main via sudo systemctl edit nom.service, qui contient les mêmes directives. CPUQuota=20% correspond à cpu.max, MemoryMax=256M à memory.max, TasksMax=100 à pids.max, et IOReadBandwidthMax=/dev/nvme0n1 5M à io.max. Pour annuler proprement une propriété posée par set-property, sudo systemctl revert nom.service supprime le drop-in et remet le service dans son état d'origine.

Attention au nom des propriétés : systemctl show nom.service -p CPUQuota ne renvoie rien. CPUQuota est une directive d'unité, pas une propriété exposée par systemd. La propriété à interroger porte un autre nom et s'exprime en temps CPU par seconde.

Fenêtre de terminal
systemctl show demo-burn.service -p CPUQuotaPerSecUSec -p MemoryMax
CPUQuotaPerSecUSec=200ms
MemoryMax=268435456

200ms par seconde, ce sont bien les 20 % demandés. La commande systemctl status donne la même information sous une forme plus lisible, en affichant le drop-in actif, la consommation courante et le cgroup du service.

● demo-burn.service - Demo cgroups
Drop-In: /run/systemd/transient/demo-burn.service.d
└─50-CPUQuota.conf, 50-MemoryMax.conf
Memory: 344.0K (max: 256.0M available: 255.6M peak: 848.0K)
CGroup: /system.slice/demo-burn.service
└─722975 /bin/bash -c "while :; do :; done"

Systemd fournit deux commandes qui remplacent avantageusement les explorations à coups de cat dans /sys. systemd-cgls affiche l'arborescence des cgroups avec les processus qu'ils contiennent, ce qui répond immédiatement à la question « quel service ce processus appartient-il ».

Fenêtre de terminal
systemd-cgls --no-pager -u system.slice
Unit system.slice (/system.slice):
├─containerd.service …
│ ├─ 1287 /usr/bin/containerd
│ ├─ 2335 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 259166de4274f28…
├─systemd-networkd.service
│ └─603 /usr/lib/systemd/systemd-networkd

systemd-cgtop est le pendant dynamique : un top par cgroup, qui trie les services par consommation. C'est l'outil qui répond en quelques secondes à la question « quel service mange le CPU de ce serveur », là où top ne montre que des processus isolés sans dire à qui ils appartiennent.

Fenêtre de terminal
systemd-cgtop -b -n 1 --depth=2
CGroup Tasks %CPU Memory Input/s Output/s
/ 2469 - 11.5G - -
system.slice 1146 - 10.2G - -
system.slice/atd.service 1 - 504.0K - -
init.scope 1 - 9.9M - -

Quand vous écrivez docker run --cpus=0.5 --memory=128m, Docker ne fait rien d'autre que ce que vous venez de faire à la main. Il crée un cgroup pour le conteneur et y écrit vos valeurs. La preuve tient en trois commandes. Le moteur annonce d'abord qu'il délègue à systemd et travaille en v2.

Fenêtre de terminal
docker info | grep -i cgroup
Cgroup Driver: systemd
Cgroup Version: 2

Lançons un conteneur limité, puis allons lire son cgroup sur l'hôte. Le nom du cgroup contient l'ID complet du conteneur, dans une scope rangée sous system.slice.

Fenêtre de terminal
docker run -d --rm --name demo-cg --cpus=0.5 --memory=128m alpine:3.22 sleep 120
cat /sys/fs/cgroup/system.slice/docker-8920e22899cd....scope/cpu.max
cat /sys/fs/cgroup/system.slice/docker-8920e22899cd....scope/memory.max
50000 100000
134217728

--cpus=0.5 est devenu 50000 100000, et --memory=128m est devenu 134217728 octets. Rien de magique : un conteneur, c'est un ensemble de processus dans un cgroup (pour les ressources) et dans des namespaces (pour l'isolation de la vue système). Kubernetes applique la même mécanique un cran plus haut : les requests d'un Pod deviennent des poids (cpu.weight), les limits deviennent des plafonds (cpu.max, memory.max). C'est pour cela qu'un conteneur qui dépasse sa limits.memory finit en OOMKilled avec le code 137 : c'est le même OOM kill de cgroup que celui observé plus haut, avec le même CONSTRAINT_MEMCG dans dmesg.

Les cgroups échouent presque toujours en silence ou avec des messages trompeurs. Voici les erreurs réellement rencontrées en construisant cette page, avec leur cause et leur correctif.

SymptômeCauseSolution
mkdir: cannot create directory '/sys/fs/cgroup/demo': Permission denied/sys/fs/cgroup appartient à rootPréfixer par sudo
bash: /sys/fs/cgroup/demo/cpu.max: Permission denied avec sudo echo ... >La redirection > est faite par votre shell, pas par sudoUtiliser echo "..." | sudo tee /sys/fs/cgroup/demo/cpu.max
tee: /sys/fs/cgroup/demo/cgroup.procs: Device or resource busyDes contrôleurs sont activés dans le cgroup.subtree_control du même cgroup (règle « no internal processes »)Les retirer (echo "-cpu -memory" | sudo tee .../cgroup.subtree_control) ou placer les processus dans un sous-groupe
rmdir: failed to remove '/sys/fs/cgroup/demo': Device or resource busyLe cgroup contient encore des processusVider cgroup.procs (arrêter ou déplacer les processus) avant le rmdir
io.max écrit, mais aucun ralentissementMauvais major:minor (volume LVM au lieu du disque physique), ou lecture servie par le cache pageViser le disque physique (lsblk -o NAME,MAJ:MIN) et tester avec dd iflag=direct
fork: Resource temporarily unavailablepids.max atteintRelever pids.max ou corriger la fuite de processus
La valeur écrite dans /sys revient à son ancienne valeursystemd réapplique les propriétés de l'unité au daemon-reload, au restart et au bootPasser par systemctl set-property ou un drop-in
Le cgroup créé à la main a disparu après un redémarrage/sys/fs/cgroup est un système de fichiers noyau, recréé vide au bootDéclarer la limite dans une unité systemd
systemctl show svc -p CPUQuota ne renvoie rienCPUQuota est une directive d'unité, pas une propriété exposéeLire CPUQuotaPerSecUSec

Un peu de contexte aide à comprendre pourquoi deux versions coexistent. Les cgroups sont entrés dans le noyau Linux en 2007, poussés par des ingénieurs de Google qui devaient faire cohabiter des milliers d'applications sur les mêmes serveurs. Cette v1 donnait à chaque contrôleur sa propre hiérarchie indépendante, ce qui menait à des arbres incohérents et à des règles impossibles à raisonner globalement.

La v2, stabilisée en 2016, a tout ramené sous une hiérarchie unique montée sur /sys/fs/cgroup, avec des noms de fichiers cohérents (cpu.max, memory.max, io.max). C'est aujourd'hui le seul mode utilisé par défaut. La conséquence pratique est simple : toute documentation antérieure à 2019 parle probablement de la v1 et ses chemins n'existent plus chez vous. Vérifiez avec stat -fc %T /sys/fs/cgroup avant de suivre un tutoriel.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

6 questions
6 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

  • stat -fc %T /sys/fs/cgroup doit renvoyer cgroup2fs ; sinon vous êtes en v1 et les chemins de cette page ne s'appliquent pas.
  • Un cgroup est un répertoire ; on y place un processus en écrivant son PID dans cgroup.procs, et les limites s'appliquent aussitôt.
  • cgroup.subtree_control configure les enfants, jamais le cgroup lui-même. Un cgroup qui délègue des contrôleurs ne peut plus contenir de processus (Device or resource busy).
  • cpu.max étrangle (nr_throttled dans cpu.stat), memory.max tue (oom_kill dans memory.events, code de sortie 137), io.max ralentit, pids.max refuse les fork.
  • cgroups v2 ne limite aucun débit réseau : net_cls et net_prio ne faisaient que marquer le trafic. Utilisez tc ou eBPF.
  • systemd est propriétaire des cgroups : tout echo dans /sys/fs/cgroup pour un service est écrasé au premier daemon-reload et perdu au reboot.
  • La bonne méthode est systemctl set-property nom.service CPUQuota=20%, vérifiée avec CPUQuotaPerSecUSec (et non CPUQuota, qui ne renvoie rien).
  • systemd-cgls et systemd-cgtop sont les outils d'exploration ; Docker et Kubernetes ne font qu'écrire dans ces mêmes fichiers.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn