cgroup v1 a disparu. Migrez proprement.
systemd 258 a supprimé cgroup v1 purement et simplement : vos hôtes démarrent désormais en hiérarchie unifiée, que quelqu’un l’ait demandé ou non. Voici l’audit, la conversion fichier par fichier, et les quatre affirmations sur cette migration qui sont tout simplement fausses.
- Linux
- cgroups
- systemd
- Conteneurs
La plupart des migrations commencent par une décision. Celle-ci commence par un fait : en septembre 2025, systemd 258 a supprimé le support de cgroup v1 dans son intégralité, y compris la porte de sortie SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 introduite un an plus tôt par la version 256. La hiérarchie unifiée est désormais montée au démarrage, sur toute machine faisant tourner ce systemd ou plus récent, et il n’existe plus aucun moyen supporté de demander autre chose. Quoi qu’en pense votre plateforme de conteneurs, le système d’exploitation en dessous a déjà tranché.

Ce qui suit, c’est la migration telle qu’elle se passe vraiment sur des parcs réels : comment déterminer la hiérarchie d’un hôte sans se faire piéger par la disposition hybride, une table de conversion fichier par fichier avec les directives systemd correspondantes, les deux traductions qui changent silencieusement le comportement si vous recopiez les chiffres tels quels, la formule de poids CPU discrètement remplacée dans les runtimes OCI en 2025, et un compte rendu honnête de ce que Docker et Kubernetes ont fait — et n’ont pas fait — parce que sur ce dernier point la plupart de ce qui s’écrit est faux, et agir en conséquence vous coûte soit une panne, soit un an de panique inutile.
Vous n’avez rien choisi, et c’est justement le sujet
Les symptômes sont désagréablement indirects, parce que rien ne s’annonce comme un problème de cgroups. Un agent de supervision se met à remonter des zéros. La limite mémoire d’un conteneur cesse d’être appliquée. Une charge rootless qui tournait très bien sur l’ancienne machine refuse de démarrer. Un script qui lit /sys/fs/cgroup/memory/memory.usage_in_bytes depuis huit ans commence à journaliser un fichier introuvable que personne ne remarque, parce qu’il écrit dans un log que personne ne lit. Tout cela, c’est le même événement vu sous des angles différents : les chemins ont bougé, la sémantique a changé en dessous, et rien n’a échoué assez fort pour arrêter un déploiement.[sd258]
| Ce que vous constatez | Ce que cela veut généralement dire | Où c’est traité |
|---|---|---|
| Un agent de supervision remonte zéro mémoire ou zéro CPU pour tous les conteneurs | Il lit /sys/fs/cgroup/memory/… ou cpu,cpuacct, qui n’existent pas sous la hiérarchie unifiée | La table de conversion |
write error: Device or resource busy en ajoutant un PID à un cgroup | La contrainte de non-processus-interne : ce cgroup délègue déjà des contrôleurs à ses enfants | Trois règles |
| Un conteneur swappe bien plus qu’avant, à limites identiques | memory.memsw.limit_in_bytes a été recopié dans memory.swap.max, qui désigne le swap seul | Sémantique mémoire |
Podman rootless accepte --memory et ne l’applique pas | systemd n’a pas délégué le contrôleur mémoire au gestionnaire utilisateur | Conteneurs |
| Les conteneurs obtiennent moins de CPU en contention qu’avant | La conversion linéaire parts-vers-poids plaçait une requête d’un CPU au poids 39 face à un défaut de 100 | Poids CPU |
kubelet refuse de démarrer après une mise à jour d’image de nœud | Nœud en cgroup v1, kubelet v1.35 ou plus récent, failCgroupV1 à sa valeur par défaut true | Kubernetes |
--oom-kill-disable n’a silencieusement aucun effet | Ignoré en cgroup v2. Il n’existe aucun équivalent et aucun n’est prévu | Conteneurs |
Il y a une habitude à abandonner avant toute autre chose. Si l’un de vos outils écrit directement dans /sys/fs/cgroup sur un hôte systemd, il ne survivra pas à cette migration sous une forme qui vous convienne. systemd est propriétaire de cette arborescence et y réimpose sa vision dès qu’une unité change ; le document de délégation amont est explicite : un seul écrivain par sous-arbre, c’est une règle, pas une suggestion. Sous v1 on pouvait généralement l’enfreindre impunément. Sous v2, avec sa délégation stricte de haut en bas, non.[sddeleg]
Sur quelle hiérarchie tourne réellement cet hôte ?
Commencez par établir la vérité, parce que les commandes du folklore mentent d’une manière précise et systématique. La vérification documentée par Kubernetes est la bonne, et tient en une commande : demandez le type de système de fichiers de /sys/fs/cgroup. Sous la hiérarchie unifiée, ce point de montage est un système de fichiers cgroup2, donc stat renvoie cgroup2fs. Sous v1 — et, c’est là l’essentiel, sous l’ancienne disposition hybride aussi — c’est un tmpfs avec des répertoires de contrôleurs montés à l’intérieur.[k8scg]
# The only check that cannot lie. /sys/fs/cgroup is a tmpfs under v1 and under
# the old hybrid layout, and a cgroup2 filesystem under the unified hierarchy.
stat -fc %T /sys/fs/cgroup/
# cgroup2fs -> unified, cgroup v2 only
# tmpfs -> cgroup v1, or hybrid: v2 mounted under a v1 tmpfs
# Why `mount | grep cgroup2` is not enough: hybrid mounts a cgroup2 hierarchy
# too, at /sys/fs/cgroup/unified, with no controllers attached to it. Grepping
# for the string finds it and tells you the opposite of the truth.
mount | grep -E '^cgroup' | sed 's/ (.*//'
# cgroup2 on /sys/fs/cgroup type cgroup2 <- unified: good
# cgroup2 on /sys/fs/cgroup/unified type cgroup2 <- hybrid: not good
# What the kernel will actually let you control here. Under the unified
# hierarchy this file exists at the top of the tree and lists the controllers.
# Under hybrid it is not here at all - it is one level down, at
# /sys/fs/cgroup/unified/cgroup.controllers, and it is empty. Its absence from
# the top level is the clearest single tell that you are not on v2.
cat /sys/fs/cgroup/cgroup.controllers 2>/dev/null
# cpuset cpu io memory hugetlb pids rdma misc
# Where a given process ended up. Under v2 there is exactly one line and it
# starts with `0::`. More than one line means controllers are still split.
cat /proc/self/cgroup
# 0::/user.slice/user-1000.slice/session-3.scope
# And the two userspace pieces that have to agree with the kernel:
systemctl --version | head -1
docker info --format 'driver={{.CgroupDriver}} version={{.CgroupVersion}}' 2>/dev/nullLe cas hybride est précisément la raison pour laquelle mount | grep cgroup2 est activement trompeur, et pas seulement incomplet : l’hybride monte une vraie hiérarchie cgroup2 sur /sys/fs/cgroup/unified, sans aucun contrôleur rattaché. Le grep remonte une ligne, vous en concluez que vous êtes en v2, et chaque limite que vous configurez ensuite atterrit sur un contrôleur v1. Le second indice est /proc/self/cgroup : sous v2 il contient exactement une ligne commençant par 0::, sous v1 ou hybride il contient une ligne par contrôleur. Passez l’audit ci-dessous sur tout le parc avant de planifier quoi que ce soit, parce que d’expérience la réponse n’est jamais homogène.[cgman]
#!/usr/bin/env bash
# Fleet audit. Read-only: it changes nothing. Run it before you plan anything,
# because the answer is almost never uniform across a real estate of servers.
set -u
host=$(hostname -s)
ver=$(stat -fc %T /sys/fs/cgroup/ 2>/dev/null)
case "$ver" in
cgroup2fs) mode=unified ;;
tmpfs) [ -d /sys/fs/cgroup/unified ] && mode=hybrid || mode=legacy ;;
*) mode=unknown ;;
esac
kernel=$(uname -r)
sd=$(systemctl --version 2>/dev/null | awk 'NR==1{print $2}')
printf '%-16s mode=%-8s kernel=%-14s systemd=%s\n' "$host" "$mode" "$kernel" "$sd"
# --- the things that will break, rather than the things that will complain ---
# a) v1-only controllers with no v2 equivalent. If anything you run writes to
# these, it needs an eBPF replacement, not a path change.
for c in net_cls net_prio devices; do
[ -d "/sys/fs/cgroup/$c" ] && echo " uses v1-only controller: $c"
done
# b) anything with a hardcoded v1 path. This is the single most common cause of
# a migration failing three weeks later in an agent nobody remembered.
grep -rIl --exclude-dir=.git \
-e '/sys/fs/cgroup/memory/' \
-e '/sys/fs/cgroup/cpu,cpuacct/' \
-e 'memory.limit_in_bytes' \
-e 'cpu.cfs_quota_us' \
/etc /opt /usr/local 2>/dev/null | sed 's/^/ hardcoded v1 path: /'
# c) kernel command line pinning the old hierarchy. On systemd 258 and later
# this parameter no longer does anything, which is its own kind of trap:
# the host silently boots unified and the runbook still says otherwise.
grep -o 'systemd\.unified_cgroup_hierarchy=[01]' /proc/cmdline \
| sed 's/^/ kernel cmdline: /'
grep -o 'SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1' /proc/cmdline \
| sed 's/^/ legacy force flag (removed in systemd 258): /'
# d) container runtimes and their drivers, which have to match the kernel
command -v docker >/dev/null && docker info 2>/dev/null \
| grep -E 'Cgroup (Driver|Version)' | sed 's/^/ /'
command -v podman >/dev/null && podman info --format \
' podman cgroupVersion={{.Host.CgroupsVersion}} manager={{.Host.CgroupManager}}' 2>/dev/null
[ -f /var/lib/kubelet/config.yaml ] && \
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml | sed 's/^/ kubelet /'Lancez-le sur chaque hôte, pas sur un hôte représentatif. Les machines encore en v1 en 2026 sont, presque par définition, celles auxquelles personne n’a touché : l’appliance, l’agent de build monté à la main par quelqu’un en 2019, le nœud de base de données volontairement exclu de la gestion de configuration. Ce sont exactement les hôtes où un chemin
/sys/fs/cgroup/memory/codé en dur vous attend, et exactement ceux où personne ne verra qu’il a cassé.
Qui a supprimé quoi, et qui a seulement annoncé
Quatre projets sont impliqués et leurs calendriers n’ont rien à voir entre eux, ce qui est de loin la première source de confusion sur cette migration. systemd a bougé le premier et le plus brutalement. La version 256, en juin 2024, a cessé de démarrer cgroup v1 par défaut tout en laissant une porte de sortie. La version 258, en septembre 2025, a supprimé le code — les notes de version sont sans ambiguïté : le support de cgroup v1 (hiérarchies « legacy » et « hybrid ») a été supprimé, et cgroup v2 sera toujours monté au démarrage du système. Cette version relève aussi le noyau minimum à 5.4, avec 5.7 recommandé.[sdnews][sd256]
| Projet | Ce qui s’est réellement passé | Quand | Ce que cela implique pour vous |
|---|---|---|---|
| systemd 256 | Arrête de démarrer cgroup v1 par défaut ; ajoute SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 | Juin 2024 | La 257 honore encore ce drapeau : c’est donc la 257 la dernière version où vous pouvez demander v1 et l’obtenir |
| systemd 258 | Supprime le support de cgroup v1 en entier, porte de sortie comprise ; noyau minimum relevé à 5.4, 5.7 recommandé | Septembre 2025 | C’est ça, l’échéance. L’unifié est le seul mode que le code connaisse |
| Kubernetes 1.35 | Déprécie cgroup v1 ; le kubelet refuse de démarrer sur un nœud v1 par défaut | Décembre 2025 | Contournable avec failCgroupV1: false. Ce n’est pas une suppression |
| Kubernetes 1.38+ | Version la plus précoce où KEP-5573 supprimera le code | Non planifié | Vous avez plus de temps que les titres ne le laissent croire |
| Docker Engine 29.0 | Déprécie cgroup v1 ; aucune version de suppression fixée | Novembre 2025 | Dépréciation seulement. La documentation indique que le support est maintenu jusqu’en mai 2029 |
| runc / crun | Remplacent la conversion linéaire parts-vers-poids par une conversion logarithmique | Courant 2025 | Modifie la priorité CPU sur des nœuds déjà migrés |
Les trois autres n’ont pas fait ce que vous avez probablement lu, et la nuance compte pour planifier. Kubernetes a déprécié cgroup v1 en v1.35 — le kubelet refuse désormais de démarrer sur un nœud v1 par défaut, mais ce défaut est un champ de configuration que vous pouvez basculer, et KEP-5573 dit noir sur blanc que la suppression du code ne sera pas faite avant 1.38, sans qu’aucune date y soit associée. Docker a déprécié cgroup v1 dans Engine v29.0, sorti en novembre 2025, sans fixer la moindre version de suppression ; la page de dépréciation de Docker indique que le support est maintenu jusqu’en mai 2029, date à laquelle les distributions d’entreprise qui en ont encore besoin arrivent en fin de vie. Donc : le système d’exploitation l’a déjà supprimé, les orchestrateurs n’ont fait que l’annoncer. Planifiez sur le système d’exploitation.[kep5573][dockdep]
| Distribution | Hiérarchie par défaut | Remarque |
|---|---|---|
| Fedora 31 et suivantes | Unifiée (v2) | Première distribution grand public à basculer ; Fedora 43 hérite de la suppression de systemd 258 |
| Debian 11 et suivantes | Unifiée (v2) | Debian 13 livre systemd 257 : c’est donc la dernière à conserver le moindre chemin legacy |
| Ubuntu 21.10 et suivantes | Unifiée (v2) | 22.04 LTS et suivantes sont celles que vous faites probablement encore tourner |
| RHEL 9 et suivantes | Unifiée (v2) | RHEL 9.4 a formellement déprécié v1 ; RHEL 10 ne démarrera pas du tout en v1 |
| SLES 15 SP6 et suivantes | Unifiée (v2) | SP3 à SP5 étaient en hybride par défaut, la disposition qui trompe grep |
| Tout ce qui est plus ancien | v1 ou hybride | Et aussi, à ce stade, hors support. La question des cgroups n’est pas l’urgence |
Ce qui veut dire que la vraie échéance sur une machine donnée, c’est la version de systemd que livre sa distribution, pas la feuille de route d’une plateforme de conteneurs. La plupart des parcs sont déjà unifiés, et le sont depuis des années sans que personne ne l’ait remarqué : Fedora depuis la 31, Debian depuis la 11, Ubuntu depuis la 21.10, RHEL depuis la 9. Le travail se concentre sur ce qui reste, et sur l’outillage qui continue de présupposer des chemins v1 quoi que fasse l’hôte.[rhel10][moby51111]
Trois règles qui cassent les arborescences bricolées
Trois règles structurelles distinguent v2 de v1, et chacune d’elles cassera une arborescence construite à la main sous v1. La première est la hiérarchie unifiée elle-même : un processus occupe une position dans un seul arbre, et tous les contrôleurs lisent cette même position — d’où /proc/self/cgroup réduit d’une douzaine de lignes à une seule. La deuxième est la délégation de haut en bas : un contrôleur n’existe dans un cgroup enfant que si le parent le lui transmet explicitement via cgroup.subtree_control. La troisième est celle qui fait vraiment mal.[kdoc][knoint]
# DEMONSTRATION ONLY. This writes into the cgroup tree by hand, which is the
# exact thing the rest of this article tells you not to do on a systemd host.
# Read it to understand the rules, then set limits through systemd.
# Rule 1 - one tree. Under v1 a process had a position in each controller's
# hierarchy independently, which is why /proc/PID/cgroup had a dozen lines.
# Under v2 it has one, and every controller reads the same position.
cd /sys/fs/cgroup
mkdir -p demo/worker demo/batch
# Rule 2 - a controller only exists in a child if the parent hands it down.
# cgroup.controllers is what you HAVE; cgroup.subtree_control is what you GIVE.
cat demo/cgroup.controllers # what the parent has delegated so far
echo '+cpu +memory +io' > cgroup.subtree_control # root delegates to demo
cat demo/cgroup.controllers # cpu io memory
echo '+cpu +memory' > demo/cgroup.subtree_control # demo delegates to its kids
ls demo/worker/ | grep -E '^(cpu|memory)\.' # the knobs now exist
# Rule 3 - no internal processes. A cgroup may hold processes, or hand
# resources to children, never both. This is the rule that breaks hand-built
# v1 layouts, and it fails at write() time with a very unhelpful error.
echo $$ > demo/cgroup.procs
# bash: echo: write error: Device or resource busy
#
# ... because demo already has subtree_control set. Processes live on leaves:
echo $$ > demo/worker/cgroup.procs # fine
# The root cgroup is exempt from rule 3, which is why the mistake survives
# testing at the top level and only shows up one directory down.La contrainte de non-processus-interne dit qu’un cgroup non racine peut contenir des processus, ou distribuer des ressources à ses enfants, mais jamais les deux. Elle existe pour lever une véritable ambiguïté de v1, où les processus d’un parent concouraient avec ses enfants sans règle définie. En pratique, cela signifie qu’une arborescence v1 avec des limites à tous les niveaux ne se traduit pas : il faut pousser les processus vers les feuilles et garder les nœuds intermédiaires vides. Et l’échec se produit de la manière la moins utile possible — un laconique write error: Device or resource busy renvoyé par un echo dans cgroup.procs, sans rien qui indique laquelle des deux règles vous avez enfreinte. Le cgroup racine est exempté, ce qui explique précisément que l’erreur survive à un test rapide fait au niveau du haut de l’arbre.[kdeleg]
La table de conversion, fichier par fichier
Voici la correspondance, avec la directive systemd à côté de chaque paire, parce que sur un hôte systemd c’est la directive qu’il faut réellement positionner. Les noms changent plus que les valeurs, mais trois lignes changent de sens et pas seulement d’orthographe, et elles sont signalées. Notez en particulier que cpu.max fusionne deux fichiers v1 en un seul fichier à deux valeurs, et que l’échelle du poids CPU n’est pas celle des parts CPU — les valeurs par défaut à elles seules diffèrent d’un facteur dix.[sdresctl]
| cgroup v1 | cgroup v2 | Directive systemd | Remarque |
|---|---|---|---|
memory.limit_in_bytes | memory.max | MemoryMax= | Renommage. Même sens : limite dure, OOM kill au dépassement |
memory.soft_limit_in_bytes | memory.high | MemoryHigh= | Amélioration. La limite souple était surtout ignorée ; memory.high freine réellement |
| — | memory.low / memory.min | MemoryLow= / MemoryMin= | Nouveau. Planchers de protection, sans équivalent v1 |
memory.memsw.limit_in_bytes | memory.swap.max | MemorySwapMax= | Sens différent. memsw, c’était mémoire+swap ; ici c’est le swap seul |
memory.usage_in_bytes | memory.current | — | Renommage |
memory.failcnt | memory.events | — | Mieux : compteurs distincts pour low, high, max, oom et oom_kill |
cpu.shares | cpu.weight | CPUWeight= | Échelle différente. Le défaut 1024 devient un défaut de 100 ; voir la conversion |
cpu.cfs_quota_us + cpu.cfs_period_us | cpu.max | CPUQuota= + CPUQuotaPeriodSec= | Deux fichiers n’en font plus qu’un, écrit "$MAX $PERIOD". CPUQuota= ne règle que la moitié quota |
cpuacct.usage | cpu.stat | — | Inclut désormais nr_throttled et throttled_usec |
blkio.weight | io.weight | IOWeight= | Renommage, mais la comptabilité sous-jacente est enfin correcte |
blkio.throttle.*_bps_device | io.max | IOReadBandwidthMax= etc. | Un fichier à clés imbriquées au lieu de quatre ; couvre maintenant les écritures bufferisées |
pids.max | pids.max | TasksMax= | Inchangé |
freezer.state | cgroup.freeze | — | Renommage ; on y écrit 1 ou 0 |
devices.allow / devices.deny | — (eBPF) | DeviceAllow= | Aucun contrôleur. Remplacé par BPF_PROG_TYPE_CGROUP_DEVICE |
net_cls.classid / net_prio.* | — (eBPF) | — | Ni contrôleur ni fichier de remplacement. Utilisez eBPF sur les chemins cgroup |
| — | cpu.pressure / memory.pressure / io.pressure | — | Nouveau. PSI : la raison de migrer, pas le coût de la migration |
Trois contrôleurs v1 n’ont aucun équivalent en v2, et c’est la ligne qui transforme une migration mécanique en tâche d’ingénierie. net_cls et net_prio ont été supprimés purement et simplement plutôt que réimplémentés ; la classification et le lissage de trafic par cgroup se font maintenant avec des programmes eBPF attachés à des chemins cgroup v2, avec le support correspondant dans iptables et nftables. Le contrôleur devices a suivi le même chemin : au lieu d’un fichier de liste blanche, un programme eBPF de type BPF_PROG_TYPE_CGROUP_DEVICE reçoit les numéros majeur et mineur, le type de périphérique et le type d’accès, et renvoie autorisé ou -EPERM. Si votre plateforme utilisait directement l’un de ces contrôleurs, provisionnez du vrai travail plutôt qu’une substitution de chemin.[cgman][bpfdev]
C’est la mémoire dont la sémantique a vraiment changé
C’est sur la mémoire qu’une migration négligente fait ses dégâts silencieux, parce que les chiffres passent toujours et que le comportement, lui, change. Sous v1 vous aviez une limite dure et une limite souple que la plupart des noyaux ignoraient de fait. Sous v2 vous avez quatre niveaux, et un seul peut tuer quoi que ce soit : memory.max est la limite dure et déclenche un OOM kill interne au cgroup ; memory.high est un frein qui place le cgroup sous forte pression de récupération et, selon les termes mêmes du noyau, n’invoque jamais le tueur OOM ; memory.low est une protection au mieux ; memory.min est une protection dure, jamais récupérée.[kmem][kv1mem]
# --- cgroup v1: two numbers, and the second one is not what people think ---
# memory.limit_in_bytes = 2G -> hard limit on memory
# memory.memsw.limit_in_bytes = 3G -> hard limit on memory PLUS swap
# (so: 2G RAM + up to 1G of swap)
# memory.soft_limit_in_bytes = 1G -> best-effort, and widely ignored
#
# --- cgroup v2: four memory tiers, plus a separate swap cap ---------------
cd /sys/fs/cgroup/demo/worker
echo 2G > memory.max # hard limit. Over this, OOM kill inside the cgroup.
echo 1800M > memory.high # throttle. Over this, heavy reclaim - never an OOM kill.
echo 512M > memory.low # best-effort protection. Reclaimed only as a last resort.
echo 256M > memory.min # hard protection. Never reclaimed, at all.
echo 1G > memory.swap.max # SWAP ONLY. Not memory+swap. Read that twice.
# The migration trap, stated as arithmetic:
# v1: memsw.limit=3G with limit=2G -> 2G RAM, 1G swap
# v2: memory.swap.max=3G -> memory.max RAM, 3G swap
# Copying 3G across gives the workload three times the swap it used to have.
# The correct translation is (memsw.limit - limit), and if that is zero you
# want memory.swap.max=0, not "unset".
# What is actually happening, rather than what you configured:
cat memory.current # bytes in use right now
cat memory.events # low high max oom oom_kill oom_group_kill
# low 0
# high 148 <- throttled 148 times: memory.high is doing work
# max 0
# oom 0
# oom_kill 0 <- and it never had to kill anything
cat memory.pressure # PSI: how much time was lost waiting on memory
# some avg10=0.42 avg60=0.31 avg300=0.11 total=9214430
# full avg10=0.00 avg60=0.00 avg300=0.00 total=118221
# memory.high plus memory.events is the pair that turns "the box OOMs at 3am"
# into a number you can alert on before it happens. v1 could not do this.| Fichier v2 | Ce qu’il fait | Peut-il tuer ? | Équivalent v1 le plus proche |
|---|---|---|---|
memory.min | Protection dure : la mémoire en dessous n’est jamais récupérée, quelle que soit la pression | Indirectement | Aucun |
memory.low | Protection au mieux : récupérée seulement quand il ne reste plus rien de non protégé | Non | Aucun |
memory.high | Frein : au-delà, le cgroup est placé sous forte récupération et ses processus ralentissent | Non | memory.soft_limit_in_bytes, en gros |
memory.max | Limite dure : au-delà et sans récupération possible, le tueur OOM interne au cgroup s’exécute | Oui | memory.limit_in_bytes |
memory.swap.max | Plafond sur l’usage du swap uniquement, indépendant de memory.max | Indirectement | memory.memsw.limit_in_bytes moins la limite mémoire |
memory.events | Compteurs : combien de fois chacun des seuils ci-dessus a été atteint, dont oom_kill | — | memory.failcnt, sans aucun détail |
Le piège, c’est memory.swap.max, et il vaut la peine d’être direct là-dessus parce que recopier le chiffre est le réflexe naturel. En v1, memory.memsw.limit_in_bytes plafonnait la mémoire plus le swap ensemble ; en v2, memory.swap.max plafonne le swap seul. Un conteneur avec limit=2G, memsw=3G avait droit à 2 Gio de RAM et 1 Gio de swap. Mettez memory.swap.max=3G et vous venez de lui donner trois fois le swap qu’il avait, et le mode de défaillance n’est pas un crash mais une machine qui ralentit sous charge d’une manière qui n’apparaît sur aucun graphe mémoire. La traduction correcte, c’est la différence entre les deux chiffres v1, et si cette différence est nulle vous voulez un 0 explicite, pas un fichier non défini. En échange vous récupérez memory.events, qui détaille les compteurs que v1 agrégeait dans un unique failcnt, et memory.pressure, qui n’a strictement aucun équivalent en v1 : une mesure de blocage qui vous permet d’alerter sur une charge en difficulté bien avant que le tueur OOM ne s’exécute.[psi]
Quota CPU, et un contrôleur d’E/S qui compte enfin juste
Le CPU se scinde proprement en deux idées que v1 mélangeait. cpu.weight est une part relative du temps CPU en contention — il ne fait absolument rien sur une machine au repos, et c’est le bon outil pour la priorité. cpu.max est un plafond absolu écrit "$MAX $PERIOD" en microsecondes, qui remplace les deux fichiers v1 distincts dont la relation était régulièrement comprise à l’envers. Le fichier cpu.stat est celui qu’il faut brancher sur la supervision : nr_throttled et throttled_usec répondent à la question « ce service est-il lent parce qu’on l’a plafonné ? », qui est autrement à peu près impossible à trancher depuis l’extérieur.[kio]
# --- CPU: two knobs, and only one of them is a limit ---------------------
cd /sys/fs/cgroup/demo/worker
# Weight: relative share of contended CPU. Default 100, range 1-10000.
# It does nothing at all while the machine is idle.
echo 200 > cpu.weight
# Quota: an absolute ceiling, written as "$MAX $PERIOD" in microseconds.
# 150000 out of every 100000us = 1.5 CPUs. "max" removes the ceiling.
echo '150000 100000' > cpu.max
echo 20000 > cpu.max.burst # allow short bursts above quota (v2 only)
# The v1 equivalents were three files, and the period was easy to forget:
# cpu.shares = 200 -> but the scale was different: default 1024
# cpu.cfs_quota_us = 150000
# cpu.cfs_period_us = 100000
# Throttling, which is the number people actually need and rarely find:
cat cpu.stat
# usage_usec 918422311
# nr_periods 41822
# nr_throttled 219 <- how often the quota was hit
# throttled_usec 411920 <- and how much time was lost to it
# --- IO: blkio became io, and the numbers finally mean something ----------
# v1's blkio.throttle.* only saw direct IO; buffered writes were charged to
# whatever kernel thread flushed them, so the accounting was fiction.
echo '259:0 rbps=104857600 wbps=52428800 riops=max wiops=2000' > io.max
echo 'default 100' > io.weight
cat io.stat
# 259:0 rbytes=2841579520 wbytes=1120043008 rios=48211 wios=22103 dbytes=0 dios=0
cat io.pressure
# some avg10=1.94 avg60=0.88 avg300=0.31 total=41822193
# Device numbers, because io.max will not take a path:
lsblk -no MAJ:MIN,NAME /dev/nvme0n1| Fichier v2 | Type | Défaut | À quoi ça sert |
|---|---|---|---|
cpu.weight | Relatif | 100 (plage 1–10000) | Priorité en contention. Aucun effet sur une machine au repos |
cpu.max | Absolu | max 100000 | Un plafond. 150000 100000 vaut 1,5 CPU |
cpu.max.burst | Absolu | 0 | Autorise de brefs dépassements du quota au lieu d’un freinage immédiat |
cpu.stat | Lecture seule | — | nr_throttled et throttled_usec : la preuve qu’un plafond fait mal |
io.weight | Relatif | default 100 | Part relative du temps disque, par périphérique ou globale |
io.max | Absolu | non défini | rbps, wbps, riops, wiops par MAJ:MIN |
io.latency | Cible | non défini | Protège un objectif de latence plutôt qu’un chiffre de bande passante |
Le contrôleur d’E/S est la partie de v2 qui constitue une vraie amélioration plutôt qu’un renommage. Le throttling blkio de v1 ne voyait jamais que les E/S directes ; les écritures bufferisées étaient imputées au thread noyau qui finissait par les vider, si bien que la comptabilité d’écriture par cgroup relevait, poliment dit, de la fiction. Le contrôleur io de v2 comprend le writeback et le rattache au cgroup qui a sali les pages. Ce seul changement rend io.max et io.weight dignes d’être configurés sur un hôte de base de données, là où leurs équivalents v1 ne l’étaient globalement pas. io.latency et io.cost vont plus loin, en protégeant l’objectif de latence d’une charge plutôt que sa bande passante — et si vous hésitez sur le point de départ, io.latency est le moins invasif des deux.[kdoc]
Le changement de poids CPU que personne ne vous a annoncé
Celui-ci mérite sa propre section parce qu’il a déplacé la priorité CPU de toutes les charges conteneurisées, et qu’il l’a fait dans un composant dont la plupart des gens ne lisent pas le changelog. Kubernetes dérive depuis toujours les parts CPU de la requête, par milliCPU × 1024 / 1000 : un conteneur demandant un CPU obtenait donc 1024 parts. Le runtime OCI convertissait ensuite ces parts en poids v2, et la conversion d’origine était linéaire sur la plage de parts [2, 262144] du noyau. Faites le calcul pour 1024 parts et vous obtenez un poids de 39 — contre une valeur par défaut de 100 en cgroup v2.[k8scpu][runcissue]
# A quiet change that moved every containerised workload's CPU priority, with
# no release note in most people's changelog because it happened in the OCI
# runtime rather than in the orchestrator.
# Kubernetes derives shares from the CPU request, and always has:
# cpu.shares = milliCPU * 1024 / 1000
# request 1000m -> 1024 shares request 100m -> 102 shares
# runc then converted shares to a v2 weight. The original conversion was
# linear over the kernel's [2, 262144] share range:
# weight = 1 + ((shares - 2) * 9999) / 262142
python3 -c 'print(1 + ((1024 - 2) * 9999) // 262142)'
# 39
#
# 39. Against a cgroup v2 default of 100. Every container asking for a full CPU
# was scheduled at roughly a third of the weight of anything not in a
# container - including the kubelet and the runtime themselves.
# The replacement is log-based, and is chosen so that one CPU lands on the
# default rather than well below it:
python3 - <<'PY'
import math
def weight(shares):
if shares == 0: return 0
if shares <= 2: return 1
if shares >= 262144: return 10000
l = math.log2(shares)
return math.floor(10 ** ((l*l + 125*l) / 612.0 - 7/34) + 0.99)
for req, sh in (("100m",102), ("500m",512), ("1",1024), ("4",4096), ("16",16384)):
print("%-6s shares=%-6d weight=%d" % (req, sh, weight(sh)))
PY
# 100m shares=102 weight=17
# 500m shares=512 weight=59
# 1 shares=1024 weight=100
# 4 shares=4096 weight=303
# 16 shares=16384 weight=942
#
# 1024 lands on exactly 100 because the curve is fitted through three fixed
# points: 2 -> 1, 1024 -> 100, and 262144 -> 10000.
# Check what your nodes are doing, because this depends on the runtime version
# and not on the Kubernetes version. The new conversion ships in runc 1.3.2 and
# later, and in crun 1.23 and later:
runc --version; crun --version 2>/dev/null
cat /sys/fs/cgroup/kubepods.slice/*/*/cpu.weight 2>/dev/null | sort -n | uniq -cConséquence : un conteneur demandant un CPU entier concourait à environ un tiers de la priorité de tout ce qui n’était pas dans un conteneur, sur le même nœud, y compris les démons système et le kubelet lui-même. Le correctif remplace la correspondance linéaire par une courbe logarithmique passant par trois points fixes — 2 parts pour un poids de 1, 1024 pour 100, et 262144 pour 10000 — de sorte qu’une requête d’un CPU tombe désormais exactement sur la valeur par défaut de cgroup v2. Il est livré dans runc 1.3.2 et suivants et dans crun 1.23 et suivants. Deux conséquences faciles à manquer. D’abord, il s’agit d’un changement de runtime, pas de Kubernetes : il arrive quand vous mettez à jour runc ou crun, ce qui peut coïncider ou non avec une montée de version du cluster ; vérifiez donc la version du runtime plutôt que celle du cluster. Ensuite, il modifie les priorités relatives entre charges sur des nœuds que vous avez déjà migrés : si vous aviez mesuré le comportement CPU en v2 avant ce changement, votre référence est périmée.[runcpr]
Passez par systemd, pas par /sys
Sur un hôte systemd, la bonne interface est systemd, pas le système de fichiers. Ce n’est pas une question de style. systemd crée l’arborescence de cgroups, et il réapplique sa propre vision des réglages de ressources d’une unité dès que celle-ci est rechargée, redémarrée ou reconfigurée — une valeur que vous avez poussée par echo dans memory.max survit donc exactement jusqu’au prochain changement sans rapport, puis disparaît sans la moindre ligne de log. Le document de délégation amont énonce la règle sans détour : un seul écrivain par sous-arbre. Passer par systemd vous offre en prime la persistance au redémarrage, ce que l’édition à la main ne fait jamais.[sddeleg][sdresctl]
# Writing into /sys/fs/cgroup by hand works exactly until systemd next touches
# that unit, at which point your values are overwritten without warning.
# systemd owns the tree; ask it, and the setting also survives a reboot.
# Try a limit on something already running, for this boot only:
systemctl set-property --runtime nginx.service MemoryHigh=1G IOWeight=50
# Make it permanent. This writes a drop-in for you - under
# /etc/systemd/system.control/nginx.service.d/, not /etc/systemd/system/, which
# is why hand-searching for your setting in the obvious place turns up nothing.
# No daemon-reload needed.
systemctl set-property nginx.service MemoryMax=2G MemoryHigh=1800M CPUWeight=200
# Or write the drop-in yourself, which is what you want in configuration
# management: /etc/systemd/system/nginx.service.d/50-resources.conf
#
# [Service]
# MemoryMax=2G # -> memory.max
# MemoryHigh=1800M # -> memory.high
# MemoryMin=256M # -> memory.min
# MemorySwapMax=0 # -> memory.swap.max
# CPUWeight=200 # -> cpu.weight
# CPUQuota=150% # -> cpu.max (150% of one CPU)
# IOWeight=50 # -> io.weight
# IOReadBandwidthMax=/dev/nvme0n1 100M
# TasksMax=512 # -> pids.max
#
# Note CPUQuota is a percentage of ONE CPU, not of the machine: 150% is 1.5
# cores. This is the systemd unit that trips people most often.
# Put a limit on a command you are about to run, without writing a unit:
systemd-run --scope --user -p MemoryMax=4G -p CPUQuota=200% -- ./import-job.sh
# And look at the tree systemd actually built, not the one you configured:
systemd-cgls --unit nginx.service
systemd-cgtop --order=memory --iterations=1
# Reading the values back. Note that there is no CPUQuota property to query:
# the unit-file setting CPUQuota= is exposed as CPUQuotaPerSecUSec, and asking
# for the name you wrote is the usual reason this returns nothing.
systemctl show nginx.service -p MemoryMax -p MemoryHigh -p CPUQuotaPerSecUSecTrois commandes couvrent l’essentiel du quotidien. systemctl set-property applique une limite immédiatement et l’écrit sur disque pour les démarrages suivants, sauf si vous passez --runtime pour la rendre temporaire. systemd-run --scope -p … pose une limite autour d’une commande que vous vous apprêtez à lancer, ce qui est la façon honnête de contraindre un import ou une sauvegarde ponctuels au lieu de croiser les doigts. Et systemd-cgtop montre la consommation par cgroup plutôt que par processus, ce qui est la vue que vous voulez réellement quand un hôte de conteneurs est chargé et que top vous affiche deux cents processus sans aucune structure. Une directive piège tout le monde : CPUQuota= est un pourcentage d’un seul CPU, donc 150% veut dire un cœur et demi, pas 150 % de la machine.[sdctl][sdcgtop]
Docker, Podman, et les limites rootless qui marchent enfin
Pour les conteneurs, la nouvelle est plutôt bonne, parce que le runtime fait la traduction. --memory, --cpus, --memory-reservation et --pids-limit gardent tous le sens qu’ils avaient ; ils atterrissent simplement sur memory.max, cpu.max, memory.low et pids.max désormais. Sur un hôte unifié, Docker utilise par défaut le pilote de cgroups systemd et un espace de noms cgroup privé, et ce sont dans les deux cas les bons défauts. L’exception à connaître est --oom-kill-disable, dont la documentation de Docker dit elle-même qu’il est ignoré en v2 — pas traduit, pas signalé par un avertissement, ignoré. Il n’y a pas d’équivalent v2 et c’est délibéré : tout ce qui en dépend doit être repensé plutôt que porté.[dockrun]
# --- Docker ---------------------------------------------------------------
docker info --format 'version={{.CgroupVersion}} driver={{.CgroupDriver}}'
# version=2 driver=systemd <- the defaults on a unified host
# Most flags are unchanged, because the daemon translates them for you:
docker run --memory 2g --memory-reservation 1g --cpus 1.5 --pids-limit 512 nginx
# -> memory.max memory.low cpu.max pids.max
# Two that are not:
# --oom-kill-disable is discarded on cgroup v2. Not translated - discarded.
# There is no v2 equivalent, by design.
# --kernel-memory removed from the Engine in v23.0. It is gone, not moved.
# Setting the driver explicitly, in /etc/docker/daemon.json. Use systemd unless
# something specific stops you: it is the default on v2 and it is the only
# option that keeps one writer per subtree.
# { "exec-opts": ["native.cgroupdriver=systemd"] }
# --- Podman rootless: this is the part that only works on v2 --------------
# Under v1, an unprivileged user could not be given controllers at all, so
# rootless resource limits silently did nothing. Under v2 they work, but only
# once systemd delegates the controllers to the user manager:
#
# /etc/systemd/system/user@.service.d/delegate.conf
# [Service]
# Delegate=cpu cpuset io memory pids
#
sudo systemctl daemon-reload # then log out and back in
# Verify from inside the user session, before blaming the container:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers
# cpuset cpu io memory pids <- if memory is missing, --memory does nothing
podman info --format '{{.Host.CgroupsVersion}} {{.Host.CgroupManager}} {{.Host.OCIRuntime.Name}}'
# v2 systemd crunLes conteneurs rootless sont le seul endroit où v2 n’est pas une taxe mais une fonctionnalité. Déléguer des contrôleurs v1 à un utilisateur non privilégié n’a jamais été considéré comme sûr, si bien que la plupart des implémentations rootless ne géraient tout simplement pas les limites de ressources sur un hôte v1. Sous v2, la délégation sûre de sous-arbre les fait fonctionner correctement — mais seulement une fois que systemd a délégué les contrôleurs au gestionnaire utilisateur, ce qui tient à un fichier drop-in pour user@.service et à une reconnexion, et qui est l’étape manquante derrière la quasi-totalité des rapports « Podman rootless ignore --memory ». Vérifiez cgroup.controllers dans votre propre slice utilisateur avant d’accuser le conteneur : si memory n’y figure pas, aucun drapeau que vous passerez ne sera appliqué. Déléguer cpuset en plus demande systemd 244 ou plus récent.[podman][crun]
Kubernetes : ce qui est vrai, et ce que vous lisez partout
Passons à la partie la plus souvent mal rapportée, énoncée avec soin. Kubernetes n’a pas supprimé cgroup v1. La documentation le marque déprécié depuis la v1.35, et la conséquence pratique est que le kubelet refuse de démarrer sur un nœud en cgroup v1 par défaut. Ce défaut est un champ de KubeletConfiguration, failCgroupV1, et le passer à false restaure l’ancien comportement. KEP-5573 — la proposition d’amélioration qui procédera à terme à la suppression — dit que celle-ci ne sera pas faite avant 1.38. Si un billet de blog vous a raconté que la 1.36 avait supprimé cgroup v1, il avait tort, et l’écart entre « déprécié avec une option de contournement » et « supprimé » est l’écart entre une migration planifiée et un week-end.[k8scg][kep5573]
# What the cluster thinks it is standing on. Run this first; mixed node pools
# are the normal case, not the exception.
kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,KERNEL:.status.nodeInfo.kernelVersion,'\
'RUNTIME:.status.nodeInfo.containerRuntimeVersion,OS:.status.nodeInfo.osImage'
# The kernel version alone does not tell you the hierarchy. Ask each node.
# Note the -it: without it, kubectl debug does not attach, the output goes to
# the debug pod's log instead of your terminal, and you are left with one
# orphaned pod per node.
kubectl get nodes -o name | while read -r n; do
printf '%-40s ' "${n#node/}"
kubectl debug "$n" -it --image=busybox --profile=general -- \
stat -fc %T /host/sys/fs/cgroup/ 2>/dev/null || echo '(debug unavailable)'
done
# Clean up afterwards - the debug pods are not removed for you:
kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug 2>/dev/null
# On the node itself - the three files that have to agree:
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
grep -E '^(cgroupDriver|failCgroupV1):' /var/lib/kubelet/config.yaml
grep -A2 'runc.options' /etc/containerd/config.toml # SystemdCgroup = true
# --- what is actually true about Kubernetes and cgroup v1 -----------------
# cgroup v1 is DEPRECATED as of v1.35, not removed. The kubelet refuses to
# start on a v1 node by default, and that default is overridable:
#
# /var/lib/kubelet/config.yaml
# apiVersion: kubelet.config.k8s.io/v1beta1
# kind: KubeletConfiguration
# cgroupDriver: systemd
# failCgroupV1: false # <- the escape hatch. Buys time, not a fix.
#
# KEP-5573 states the code removal will happen no earlier than 1.38.
# The v2-only features you get in exchange, and how to see them:
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl get --raw "/api/v1/nodes/$NODE/proxy/metrics/cadvisor" \
| grep -E '^container_pressure_(cpu|memory|io)_' | head
# container_pressure_memory_stalled_seconds_total{...}
# container_pressure_memory_waiting_seconds_total{...}Les prérequis côté nœud sont modestes et méritent d’être vérifiés plutôt que supposés : noyau 5.8 ou plus récent, containerd v1.4+ ou CRI-O v1.20+, et le kubelet et le runtime configurés pour utiliser le pilote de cgroups systemd en particulier — et pas simplement un pilote identique de part et d’autre. Cette dernière condition était une source récurrente de nœuds à moitié fonctionnels, parce qu’elle dépendait de la concordance de deux fichiers de configuration ; depuis la v1.34, le kubelet demande directement au runtime CRI quel pilote il utilise, ce qui élimine toute cette classe de problèmes pour quiconque dispose d’un runtime assez récent. Ce que la migration vous rapporte, ce sont des fonctionnalités qui n’existent qu’en v2 :[k8sdriver][k8spsi]
- Métriques PSI, GA en v1.36. Le kubelet lit
cpu.pressure,memory.pressureetio.pressurepar cgroup et les expose via l’API Summary et l’endpoint de métriques cAdvisor. Ces fichiers n’existent pas sous v1 : ce n’est donc pas une fonctionnalité que vous pouvez rétroporter, c’est une raison de migrer. - Memory QoS. Le kubelet peut positionner
memory.highà partir dememoryThrottlingFactor, de sorte qu’un conteneur soit fortement récupéré avant d’être tué, et, séparément, sousmemoryReservationPolicy: TieredReservation, il peut posermemory.minpour les pods Guaranteed etmemory.lowpour les Burstable. En v1.36, c’est encore en alpha et désactivé par défaut : traitez-le comme quelque chose à tester, pas sur quoi s’appuyer — mais sa forme est exactement ce à quoi servait le modèle mémoire à quatre niveaux. - Des charges rootless et en espace de noms utilisateur qui appliquent vraiment les limites. Tout ce qui est dit dans la section Podman vaut aussi pour un nœud Kubernetes, et c’est la raison pour laquelle le passage en GA des espaces de noms utilisateur et cgroup v2 sont deux histoires liées plutôt que concomitantes.
- Une comptabilité d’E/S honnête par pod. Les écritures bufferisées sont imputées au cgroup qui les a causées, ce qui fait de la consommation disque par pod un chiffre sur lequel agir plutôt qu’un chiffre dont on s’excuse.
Une note de planification facile à rater sur un parc hétérogène. Comme le facteur déclenchant est systemd et non Kubernetes, les pools de nœuds ont tendance à se migrer tout seuls au fil des images de base, bien avant toute décision au niveau du cluster. C’est très bien, mais cela veut dire que vous pouvez vous retrouver avec un cluster dont la moitié des nœuds est en v2 et l’autre non, faisant tourner les mêmes charges avec des comportements mémoire et CPU sensiblement différents — et aucune alerte, nulle part, ne vous le dira. Auditez les nœuds, ne les déduisez pas de la version du cluster.[k8sqos]
Activer v2 là où ce n’est pas encore fait
S’il vous reste des hôtes en v1 ou en hybride, voici la partie mécanique, et elle est courte. Notez d’abord que sur systemd 258 et suivants il n’y a rien à activer ni à désactiver : l’unifié est le seul mode que le code sait faire. Ne laissez pas pour autant traîner l’ancien paramètre noyau sur ces hôtes. systemd 258 n’y réagit plus, mais si un initrd le fait encore et monte une hiérarchie v1, PID 1 refuse de démarrer et vous demande de retirer cette option de ligne de commande obsolète — ce qui vaut bien mieux qu’un échec silencieux, mais reste un démarrage à réparer depuis une console. Tout ce qui suit ne concerne que les hôtes assez anciens pour avoir encore le choix — et sur ceux-là, le choix doit se faire dans une fenêtre de maintenance qui fait aussi bouger les runtimes de conteneurs, parce qu’un nœud qui redémarre en v2 avec un pilote cgroupfs est un nœud qui revient dans un état intéressant.[sd258]
# Only needed on hosts old enough to still default to v1 or hybrid. On
# systemd 258 and later there is nothing to enable: unified is the only mode.
# 1. Check you can. Kubernetes wants kernel 5.8+; systemd 258 needs 5.4 as an
# absolute floor and recommends 5.7. Below that, upgrade the OS instead.
uname -r
# 2. Set the kernel parameter. Debian and Ubuntu. The grep guard matters:
# without it, running this twice adds the parameter twice.
grep -q 'systemd.unified_cgroup_hierarchy' /etc/default/grub || \
sudo sed -i 's/^GRUB_CMDLINE_LINUX="/&systemd.unified_cgroup_hierarchy=1 /' \
/etc/default/grub
sudo update-grub
# RHEL, Fedora, Rocky, Alma - grubby, and note ALL rather than the running
# kernel, or the setting vanishes at the next kernel update:
sudo grubby --update-kernel=ALL --args="systemd.unified_cgroup_hierarchy=1"
# 3. Line up the container runtimes in the SAME maintenance window. A node
# that reboots into v2 with a cgroupfs driver is a node that does not come
# back cleanly.
# /etc/docker/daemon.json -> "exec-opts": ["native.cgroupdriver=systemd"]
# /etc/containerd/config.toml -> SystemdCgroup = true
# /var/lib/kubelet/config.yaml -> cgroupDriver: systemd
sudo reboot
# 4. Verify, in this order. If step one disagrees with step three, stop.
stat -fc %T /sys/fs/cgroup/ # cgroup2fs
cat /sys/fs/cgroup/cgroup.controllers # non-empty
systemctl --failed
docker info --format '{{.CgroupVersion}}/{{.CgroupDriver}}' # 2/systemd
# Rolling back is removing the parameter and rebooting - but only while your
# systemd is older than 258. After that the parameter is inert and the only
# way back is downgrading the OS, which is not a rollback plan.| Ce qui casse | Pourquoi | Quoi faire à la place |
|---|---|---|
Les scripts qui lisent /sys/fs/cgroup/memory/… | Les répertoires par contrôleur n’existent pas sous la hiérarchie unifiée | Lire les chemins v2 à plat, ou demander la valeur à systemctl show |
| Les outils qui écrivent dans des cgroups gérés par systemd | systemd réimpose ses réglages à tout changement d’unité, silencieusement | systemctl set-property, ou un fichier drop-in |
Le marquage de trafic basé sur net_cls | Contrôleur supprimé sans fichier de remplacement | eBPF attaché au chemin du cgroup, avec correspondance depuis nftables |
Les listes blanches devices.allow | Contrôleur remplacé par un type de programme eBPF | DeviceAllow= dans une unité, ou un programme eBPF de type device |
| Les arborescences avec des limites à tous les niveaux | Contrainte de non-processus-interne | Pousser les processus sur les feuilles ; garder les cgroups intermédiaires vides |
--oom-kill-disable | Ignoré en v2, et c’est délibéré | Dimensionner correctement memory.max, et utiliser memory.high pour être averti d’abord |
| Les limites de swap recopiées telles quelles | memory.swap.max, c’est le swap seul, pas mémoire plus swap | Poser la différence entre les deux chiffres v1, ou un 0 explicite |
Deux remarques sur le retour arrière, puisque personne ne planifie une migration sans. Tant que votre systemd est en 257 ou antérieur, revenir en arrière consiste à retirer le paramètre noyau et à redémarrer, et cela ne coûte vraiment rien. Une fois en 258 ou plus récent, il n’existe plus aucun chemin de retour supporté en dehors d’une rétrogradation du système d’exploitation, ce qui n’est pas un plan de retour arrière : c’est une réinstallation. Séquencez votre parc en conséquence : faites d’abord les hôtes qui ont encore une porte de sortie, apprenez sur eux, et seulement ensuite déplacez ceux qui n’en ont plus.[dockrun]
Vérifier, plutôt qu’espérer
La vérification n’est pas affaire de goût. L’intérêt du script ci-dessous est que chaque ligne affiche soit OK, soit une explication, et que le code de sortie est le nombre d’échecs : il peut donc partir directement dans ce qui s’exécute après un redémarrage. Deux parties y méritent leur place. La première affiche l’inventaire de toutes les unités actives qui ont effectivement un MemoryMax défini, que vous comparez à la liste de celles que vous vouliez configurer — une unité absente de cette sortie a son drop-in dans le mauvais répertoire, et il n’y a aucun moyen de le détecter sans connaître l’état visé. La seconde parcourt toute l’arborescence à la recherche de freinages, parce qu’un nr_throttled qui grimpe sur un service dont personne ne se plaint est le signe classique d’une limite traduite trop serré.[sdcgls]
#!/usr/bin/env bash
# Post-migration verification. Every check either prints OK or explains
# itself; nothing here is judged by eye. Exit code is the number of failures.
fail=0
chk() { if eval "$2" >/dev/null 2>&1; then printf 'OK %s\n' "$1";
else printf 'FAIL %s\n' "$1"; fail=$((fail+1)); fi; }
chk 'unified hierarchy' '[ "$(stat -fc %T /sys/fs/cgroup/)" = cgroup2fs ]'
chk 'controllers available' '[ -s /sys/fs/cgroup/cgroup.controllers ]'
chk 'memory controller' 'grep -qw memory /sys/fs/cgroup/cgroup.controllers'
chk 'io controller' 'grep -qw io /sys/fs/cgroup/cgroup.controllers'
chk 'single cgroup line' '[ "$(wc -l < /proc/self/cgroup)" -eq 1 ]'
chk 'no v1 leftovers mounted' '! mount | grep -q "type cgroup "'
chk 'no failed units' '[ -z "$(systemctl list-units --state=failed --no-legend)" ]'
chk 'PSI available' '[ -r /sys/fs/cgroup/cpu.pressure ]'
# Limits are actually applied, rather than merely configured. A unit whose
# MemoryMax reads "infinity" after you set it is a unit whose drop-in is in
# the wrong place - a very common outcome of hand-editing.
for u in $(systemctl list-units --type=service --state=running \
--no-legend --plain | awk '{print $1}'); do
m=$(systemctl show "$u" -p MemoryMax --value)
[ "$m" != "infinity" ] && printf ' %-34s MemoryMax=%s\n' "$u" "$m"
done
# Nothing is being silently throttled. nr_throttled climbing on a service that
# is not busy means cpu.max is too tight, and it will not appear in load
# average. Search the whole tree, not just the top-level slices: throttling
# happens on the leaf that holds the process.
find /sys/fs/cgroup -name cpu.stat -exec \
awk '/^nr_throttled/ && $2>0 {print FILENAME": "$0}' {} + 2>/dev/null
# Containers agree with the host.
command -v docker >/dev/null && \
chk 'docker on v2/systemd' '[ "$(docker info -f "{{.CgroupVersion}}/{{.CgroupDriver}}")" = 2/systemd ]'
printf '\n%d failure(s)\n' "$fail"; exit "$fail" Lancez-le aussi avant la migration, et gardez la sortie. Une bonne partie de ce qu’on impute à une migration de cgroups était déjà vrai avant, et le seul moyen de le savoir est de l’avoir mesuré. Si nr_throttled grimpait déjà sur ce service la semaine dernière, la nouvelle hiérarchie n’y est pour rien.
L’ordre dans lequel faire tout ça
La décision, en condensé. Le résumé honnête, c’est que la plupart des gens ont déjà migré sans projet, et que ce qui reste ce ne sont pas les hôtes mais l’outillage : les scripts, agents et tableaux de bord qui lisent encore des chemins v1 et continueront de renvoyer discrètement des zéros jusqu’à ce que quelqu’un vérifie. Kubernetes vous laisse jusqu’à 1.38 au plus tôt, et Docker nettement plus longtemps que ça, mais aucune de ces dates n’est votre échéance. Votre échéance, c’est le moment où la prochaine montée d’image de base fera passer systemd au-delà de 258 — et sur la plupart des parcs c’est déjà arrivé.[kep5573]
| Si votre situation est… | Alors l’échéance est… | Et le travail consiste à… |
|---|---|---|
Tout renvoie déjà cgroup2fs | Déjà passée, sans bruit | Uniquement l’outillage : trouver les chemins v1 encore lus et les corriger |
| Une poignée de vieux hôtes en v1 ou hybride | Leur prochaine montée de version d’OS | Paramètre noyau plus pilotes de runtime, une fenêtre de maintenance chacun |
| Des nœuds Kubernetes sur des images de base mélangées | Au fil des images de nœud, pas à la montée du cluster | Auditer nœud par nœud ; ne pas déduire la hiérarchie de la version du cluster |
| Des hôtes Docker, sans orchestrateur | Engine v29.0 l’a déprécié ; la suppression est à des années | Peu d’urgence côté Docker, mais systemd fera bouger l’hôte avant de toute façon |
| De l’outillage maison qui écrit directement dans les fichiers cgroup | Maintenant, et c’est ça le vrai projet | Réécrire sur les interfaces de systemd avant que les hôtes ne bougent sous vos pieds |
| Une appliance ou un agent éditeur que vous ne pouvez pas modifier | Le calendrier de l’éditeur, qui n’est pas le vôtre | L’obtenir par écrit, et isoler l’hôte si la réponse ne satisfait pas |
- Auditez avant de planifier. Lancez le script de parc sur chaque hôte et gardez la sortie. Vous cherchez deux choses : les hôtes encore en v1 ou en hybride, et les chemins v1 codés en dur dans
/etc,/optet/usr/local. La seconde liste est presque toujours plus longue que la première, et c’est elle le vrai travail. - Corrigez l’outillage d’abord, tant que les deux hiérarchies existent encore. Tout ce qui lit
/sys/fs/cgroupdoit savoir gérer les deux dispositions, ou passer parsystemctl show. Le faire avant de déplacer les hôtes vous permet de tester le correctif contre ce qu’il est censé corriger. - Traduisez les limites par le sens, pas par le nom. Deux lignes de la table de conversion changent de comportement : le swap, où
memory.swap.maxdésigne le swap seul, et le poids CPU, dont l’échelle diffère de celle des parts par plus d’un facteur dix. Toutes les autres lignes sont de simples renommages. - Déplacez les runtimes et les hôtes dans la même fenêtre. Paramètre noyau, pilote Docker,
SystemdCgroupde containerd,cgroupDriverdu kubelet — les quatre, un seul redémarrage, puis vérification avant de passer au lot suivant. - Encaissez le bénéfice. Branchez
memory.highetmemory.events, mettez le PSI sur un tableau de bord, et activez les limites d’E/S par cgroup qui ne valaient pas la peine sous v1. Cette migration n’a aucune fonctionnalité au bout, sauf si vous allez la chercher.
Ce chantier avance de pair avec trois autres facettes de la même bascule, et elles se cumulent : migrer les scripts SysV init et rc.local vers des unités systemd, parce que les deux changements arrivent dans les mêmes versions de systemd et sur les mêmes serveurs ; la mise à niveau serveur d’Ubuntu 24.04 vers 26.04, qui est le moment où la plupart des parcs franchiront réellement la ligne ; et les changements incompatibles de Docker Engine 29, dont la dépréciation de cgroup v1 constitue la moitié « conteneurs » de cette histoire. Et si vous vous demandez de quelle part de cette complexité vous avez besoin, quand ne pas utiliser Kubernetes est l’autre versant de l’argument.
Questions fréquentes
Comment savoir si je suis en cgroup v1 ou v2 ?
Lancez stat -fc %T /sys/fs/cgroup/. Si la commande affiche cgroup2fs, vous êtes sur la hiérarchie unifiée ; si elle affiche tmpfs, vous êtes en v1 ou sur la disposition hybride. N’utilisez pas mount | grep cgroup2 : l’hybride monte une hiérarchie cgroup2 sur /sys/fs/cgroup/unified sans aucun contrôleur rattaché, si bien que le grep remonte une ligne et vous dit l’inverse de la vérité. Deuxième confirmation : /proc/self/cgroup, qui contient exactement une ligne commençant par 0:: en v2.
Est-ce que Kubernetes 1.36 a supprimé cgroup v1 ?
Non. cgroup v1 est déprécié depuis Kubernetes v1.35, et l’effet pratique est que le kubelet refuse de démarrer sur un nœud en cgroup v1 par défaut. Ce défaut est un champ de KubeletConfiguration, failCgroupV1, et le passer à false restaure le comportement précédent. KEP-5573, la proposition d’amélioration qui supprimera à terme le code, indique que la suppression n’aura pas lieu avant la v1.38. Plusieurs billets très partagés disent le contraire ; c’est le KEP qui fait autorité.
memory.swap.max est-il l’équivalent de memory.memsw.limit_in_bytes ?
Non, et c’est le malentendu le plus coûteux de toute cette migration. En cgroup v1, memory.memsw.limit_in_bytes plafonnait la mémoire et le swap ensemble : un cgroup avec limit=2G et memsw=3G pouvait utiliser 2 Gio de RAM plus 1 Gio de swap. En cgroup v2, memory.swap.max plafonne le swap seul. Recopier 3G accorde trois fois l’allocation de swap précédente. La traduction correcte, c’est la différence entre les deux valeurs v1, et là où cette différence est nulle, posez un 0 explicite.
Quelle est la différence entre memory.high et memory.max ?
memory.max est une limite dure : quand un cgroup l’atteint et ne peut plus récupérer de mémoire, le tueur OOM s’exécute à l’intérieur de ce cgroup. memory.high est un frein : le dépasser place le cgroup sous forte pression de récupération et ralentit ses processus, et la documentation du noyau est explicite — le dépassement n’invoque jamais le tueur OOM. En pratique vous placez memory.high un peu en dessous de memory.max et vous alertez sur le compteur high de memory.events, ce qui vous donne un avertissement avant que quoi que ce soit ne soit tué. cgroup v1 n’avait aucun mécanisme équivalent.
Pourquoi mes conteneurs ont-ils moins de CPU depuis le passage en cgroup v2 ?
À cause de la conversion parts-vers-poids, pas à cause de v2 lui-même. Kubernetes dérive cpu.shares de la requête CPU par milliCPU × 1024 / 1000 : une requête d’un CPU produisait donc 1024 parts. La conversion d’origine dans les runtimes OCI projetait cela linéairement sur la plage de poids v2 et donnait 39, contre une valeur par défaut de 100 en cgroup v2 — les conteneurs concouraient donc à environ un tiers de la priorité des processus hors conteneur. La conversion logarithmique qui remplace cela place 1024 parts sur exactement 100, et elle est livrée dans runc 1.3.2 et suivants et dans crun 1.23 et suivants. Vérifiez la version de votre runtime, pas celle de Kubernetes : cela arrive avec une mise à jour d’image de nœud ou de runtime, pas avec une montée du plan de contrôle.
Puis-je encore forcer cgroup v1 avec systemd.unified_cgroup_hierarchy=0 ?
Seulement sur systemd 257 et antérieurs, et il y faut à la fois systemd.unified_cgroup_hierarchy=0 et SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 sur la ligne de commande du noyau. systemd 256 a cessé de démarrer cgroup v1 par défaut en introduisant cette porte de sortie ; la 257 l’honore encore ; systemd 258 a supprimé le support de cgroup v1 en entier, porte de sortie comprise. À partir de 258, systemd ne réagit plus à cette option — mais retirez-la plutôt que de la laisser en place, car si votre initrd y réagit encore et monte une hiérarchie v1, PID 1 refuse de s’exécuter et vous demande de supprimer cette option de ligne de commande obsolète.
Par quoi sont remplacés les contrôleurs devices, net_cls et net_prio ?
Par eBPF, dans les trois cas. Le contrôle d’accès aux périphériques est désormais un programme eBPF de type BPF_PROG_TYPE_CGROUP_DEVICE, qui reçoit les numéros majeur et mineur, le type de périphérique et le type d’accès, et renvoie autorisé ou -EPERM ; sur un hôte systemd, la directive d’unité DeviceAllow= le pilote pour vous. La classification et la priorisation réseau n’ont ni contrôleur v2 ni fichier d’interface de remplacement : vous attachez un programme eBPF au chemin du cgroup et vous faites la correspondance depuis iptables ou nftables. Ce sont les trois lignes de la table de conversion qui demandent de l’ingénierie plutôt qu’une substitution de chemin.
Pourquoi Podman rootless ignore-t-il ma limite mémoire en cgroup v2 ?
Presque toujours parce que systemd n’a pas délégué le contrôleur mémoire à votre gestionnaire utilisateur. Créez un drop-in pour user@.service contenant Delegate=cpu cpuset io memory pids, rechargez systemd, puis déconnectez-vous et reconnectez-vous. Vérifiez avec cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers : si memory n’est pas dans la liste, aucun drapeau passé à Podman ne pourra être appliqué. Déléguer cpuset en particulier exige systemd 244 ou plus récent. Sous cgroup v1, déléguer des contrôleurs à un utilisateur non root n’était pas considéré comme sûr et la plupart des implémentations rootless ne le géraient pas, ce qui fait des limites de ressources rootless une capacité que v2 apporte plutôt qu’une régression que v2 introduit.
Dois-je modifier mes commandes Docker ?
Globalement non. --memory, --cpus, --memory-reservation et --pids-limit gardent tous leur sens et sont traduits vers memory.max, cpu.max, memory.low et pids.max. Deux exceptions comptent : --oom-kill-disable est ignoré en cgroup v2, sans équivalent, et --kernel-memory a été retiré de l’Engine dès la v23.0. Sur un hôte unifié, Docker utilise par défaut le pilote de cgroups systemd et un espace de noms cgroup privé, et vous devriez laisser les deux tranquilles sauf contrainte particulière.
Y a-t-il un gain de performance, ou est-ce un pur coût de migration ?
Il y a un vrai gain, concentré sur les E/S et sur l’observabilité. Le contrôleur blkio de v1 ne comptabilisait que les E/S directes : les écritures bufferisées étaient imputées au thread noyau qui les vidait, et les limites d’écriture par cgroup étaient largement décoratives. Le contrôleur io de v2 comprend le writeback et l’impute correctement, ce qui rend io.max et io.latency dignes d’être configurés sur des hôtes de base de données et de build. À cela s’ajoute le PSI — cpu.pressure, memory.pressure, io.pressure — qui n’existe qu’en v2, et qui fait la différence entre savoir qu’une machine est sous pression et l’apprendre quand quelque chose meurt.
Le runtime en dessous suit la même horloge : containerd 1.7 quitte le support étendu en septembre 2026, et pour Kubernetes 1.36 la matrice de support du projet ne liste que 2.3.0+ et 2.2.0+, sans aucune entrée 1.x. la migration de containerd 1.7 vers 2.x détaille la réécriture de la configuration en version 3, la conversion des registres qui empêche le plugin CRI de charger, et pourquoi 2.3 est la seule branche à viser.
Le plan de données des Services sur ces mêmes nœuds suit sa propre horloge : Kubernetes 1.37 a déprécié le mode ipvs de kube-proxy derrière un feature gate, 1.40 le désactive par défaut et 1.43 supprime le code. le passage de kube-proxy d’IPVS à nftables détaille le plancher noyau 5.13, le comportement des NodePort qui change sans prévenir, et le kube-ipvs0 résiduel qui avale le trafic si personne ne le nettoie.
Une note au niveau de la version, car le bilan de la 1.37 n’est pas celui que l’on lit partout : ce qui peut réellement laisser des pods en ContainerCreating, c’est le passage de SELinuxMount en GA - l’échec cgroup v1 date de la 1.35, la restriction des pods statiques de la 1.34, et la falaise containerd est encore devant vous, en 1.38. ce qui casse réellement lors du passage à Kubernetes 1.37 sépare les trois colonnes et donne l’audit à mener avant la mise à niveau, pas après.
Sources
Les sources primaires d’abord : la documentation du noyau définit chacun des fichiers d’interface cités ici, et les notes de version et propositions d’amélioration des projets eux-mêmes sont le seul énoncé fiable de ce qui a été supprimé et quand. Là où cet article contredit la couverture secondaire — sur Kubernetes et sur Docker en particulier — le désaccord porte sur le titre, pas sur la source primaire.
- Linux kernel — Control Group v2: the normative document. Every interface file, default value and range quoted in this article was checked here, including the fact that memory.max defaults to "max" and cpu.max defaults to "max 100000"
- Control Group v2 — Memory interface files: memory.min, memory.low, memory.high and memory.max, and the sentence that going over memory.high never invokes the OOM killer. This is the four-tier model cgroup v1 did not have
- Control Group v2 — IO interface files: io.weight, io.max with its rbps/wbps/riops/wiops keys, io.latency and io.cost. The v2 io controller is also the first one that accounts for writeback correctly
- Control Group v2 — No Internal Process Constraint: non-root cgroups can only distribute resources to children when they hold no processes of their own. This single rule is what breaks hand-rolled v1 layouts on contact
- Control Group v2 — Delegation: the model that makes rootless containers possible, and the reason a delegated subtree must not be allowed to write its own resource-control files
- Linux kernel — Memory Resource Controller (cgroup v1): the source for what memory.limit_in_bytes and memory.memsw.limit_in_bytes actually meant, which is the only way to see how different memory.swap.max is
- cgroups(7) — the manual page, including the statement that there is no direct equivalent of the net_cls and net_prio controllers, and that iptables gained support for eBPF filters hooking on cgroup v2 pathnames instead
- BPF_PROG_TYPE_CGROUP_DEVICE — the eBPF program type that replaced the v1 devices controller: it receives major, minor, device type and access type, and returns allow or -EPERM
- systemd — NEWS: the upstream changelog and the authoritative statement of what happened in which release. The v258 section carries both the cgroup v1 removal and the kernel baseline bump quoted here
- systemd v258 release notes — "Support for cgroup v1 ('legacy' and 'hybrid' hierarchies) has been removed", and the bump of the minimum kernel baseline to v5.4 with v5.7 recommended
- systemd v256 release notes — the release that stopped booting cgroup v1 by default and introduced the SYSTEMD_CGROUP_ENABLE_LEGACY_FORCE=1 escape hatch that v258 then took away
- systemd.resource-control(5) — MemoryMax=, MemoryHigh=, MemoryLow=, MemoryMin=, MemorySwapMax=, CPUWeight=, CPUQuota=, IOWeight=, IOReadBandwidthMax= and TasksMax=: the directive names for every interface file in the conversion table
- systemctl(1) — set-property, and the fact that it applies changes immediately and stores them on disk for future boots unless --runtime is passed
- systemd-run(1) — --scope and --property=, the pair that lets you put a limit on a command you are about to run without writing a unit file first
- systemd-cgls(1) — recursively show control group contents: the fastest way to see the tree systemd actually built, as opposed to the one you think you configured
- systemd-cgtop(1) — top control groups by resource usage, which is the per-cgroup view that plain top cannot give you
- systemd — Control Group APIs and Delegation: upstream's own rules for who owns which part of the tree, and why writing into systemd's cgroups from outside systemd is a bug rather than a technique
- Kubernetes — About cgroup v2: the requirements (kernel 5.8 or later, containerd v1.4+, cri-o v1.20+, systemd cgroup driver), the stat -fc %T check, and the deprecation notice marking cgroup v1 deprecated as of v1.35
- KEP-5573, Remove cgroup v1 support — the document that says removal "will be done no earlier than 1.38". Worth reading before believing any headline that says Kubernetes has already removed it
- Kubernetes blog — New Conversion from cgroup v1 CPU Shares to v2 CPU Weight: why a container requesting 1 CPU ended up below the default weight on v2, and the replacement formula
- runc pull request 4785 — the dependency bump that pulls the new shares-to-weight conversion into runc. The conversion itself lives in the opencontainers/cgroups library, which is where the change reaches everyone regardless of orchestrator
- runc issue 4772 — the report behind that change: the linear conversion gave 1024 shares a weight of 39 against a default of 100, so containers lost CPU to everything not in a container
- Kubernetes blog — Autoconfiguration for Node Cgroup Driver Goes GA: the kubelet now asks the CRI runtime which cgroup driver it uses instead of trusting two files to agree
- Kubernetes — Understand PSI metrics: pressure stall information read from cpu.pressure, memory.pressure and io.pressure, which exist only under cgroup v2
- Kubernetes blog — Tiered Memory Protection with Memory QoS: the kubelet writing memory.high and, under memoryReservationPolicy, memory.min and memory.low. None of this has a cgroup v1 equivalent
- Docker — Runtime metrics: the cgroup v2 requirements (containerd v1.4+, kernel v4.15+ with v5.2+ recommended), the default driver being systemd on v2 and cgroupfs on v1, and the sentence that --oom-kill-disable is discarded on v2
- Docker Engine — Deprecated features: the table row recording that support for cgroup v1 was deprecated in Engine v29.0, with no removal version set, and that the kernel memory limit was removed back in v23.0
- moby issue 51111 — the proposal to deprecate cgroup v1 while maintaining it until the enterprise distributions that still need it reach end of life. This is why Docker's deadline is much later than systemd's
- Rootless Containers — cgroup v2: the systemd user-manager Delegate= drop-in that gives an unprivileged user real cpu, memory, io and pids limits, and the note that delegating cpuset needs systemd 244 or newer
- crun — the OCI runtime with native cgroup v2 support and the default on current Podman installations, which matters because runc reached v2 later and older builds handle it badly
- Red Hat Enterprise Linux 10 release notes — the release where systemd no longer supports booting in cgroup v1 mode at all, for readers whose deadline is an enterprise distribution rather than upstream
- Linux kernel — PSI, Pressure Stall Information: what the numbers in cpu.pressure, memory.pressure and io.pressure mean, and why "some" and "full" are different questions
Cet article vous a été utile ?