Une application peut ralentir dans un conteneur limité à trois quarts de processeur, sans que rien ne paraisse anormal. Au démarrage, elle demande au système combien de processeurs sont disponibles, et elle crée ses fils d'exécution (ses tâches en parallèle) en fonction de la réponse. Elle reçoit alors le nombre de processeurs de la machine, pas celui de sa limite. C'est le cas d'une application qui lit nproc. D'autres, comme la machine virtuelle Java, lisent au contraire le quota du cgroup, le plafond que le noyau applique à un groupe de processus, et s'y adaptent.
Deux autres pannes ont une cause voisine. Une base de données limitée à 768 Mo en déclare 450 selon ses propres métriques, et le noyau la tue pourtant de temps en temps. Un service se plaint de manquer de descripteurs de fichiers sur une machine qui n'en manque pas. Un descripteur de fichier est le numéro que le noyau attribue à chaque fichier ou connexion qu'un processus ouvre.
Cette page vous apprend à lire et à poser trois réglages qu'on oublie souvent de regarder : --cpus, --shm-size et --ulimit. Elle explique aussi pourquoi ils ne sont pas de la même famille.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lire le quota de CPU qu'un cgroup déclare, et le comparer à ce que voit le conteneur
- Distinguer une limite de temps d'une restriction de processeurs
- Prouver qu'une limite a mordu avec le compteur d'étranglements
- Mesurer deux valeurs par défaut : la taille de
/dev/shmet la limite de descripteurs - Régler ces deux valeurs à la création d'un conteneur
- Comprendre pourquoi la mémoire partagée compte dans la mémoire du conteneur
Prérequis
Section intitulée « Prérequis »Il vous faut Docker installé (installation de Docker) sur une machine Linux en cgroups v2 (stat -fc %T /sys/fs/cgroup affiche cgroup2fs), et un compte membre du groupe docker. Avec lui, vous lancez docker sans sudo, et les fichiers de /sys/fs/cgroup se lisent, eux, sans aucun privilège. Ne le prenez pas pour un compte sans pouvoir : un membre du groupe docker a des droits équivalents à ceux de root, ce que la leçon Sous le capot rappelle. L'exemple de --cpuset-cpus demande une machine d'au moins deux processeurs (nproc affiche 2 ou plus). Savoir ce qu'est un cgroup aide : cette même leçon le présente depuis Docker, et Comprendre les cgroups Linux le détaille.
Les commandes sont écrites pour Docker Engine 29.9.2 et l'image alpine:3.24. La leçon voisine, la chaîne d'exécution, montre quels programmes lancent le conteneur sur la machine.
Qui limite quoi : le cgroup ou le processus ?
Section intitulée « Qui limite quoi : le cgroup ou le processus ? »Les options de limite reposent sur deux mécanismes différents, et vous ne les lisez pas au même endroit.
Un cgroup est un groupe de processus auquel le noyau applique des compteurs et des plafonds. C'est le noyau, de l'extérieur, qui mesure la consommation du groupe du conteneur, puis qui la retient ou qui tue le conteneur.
Les ulimits sont plus anciens que les cgroups. Chaque processus porte les siens et les transmet à ses enfants, sans qu'aucun cgroup n'intervienne.
Le schéma range les trois réglages de la page de part et d'autre de cette frontière. Regardez la flèche pointillée : elle dit qu'une limite de temps de CPU ne change pas le nombre de processeurs que le conteneur voit.
Pour les ulimits en général, hors conteneur, le guide Limiter les ressources d'un processus avec ulimit et systemd fait la référence. Pour poser à la main des limites de cgroup sur un conteneur fabriqué sans Docker, voir Limiter un conteneur : cgroups v2 et capabilities.
Que fait réellement l'option --cpus ?
Section intitulée « Que fait réellement l'option --cpus ? »Elle limite le temps de processeur accordé à chaque tranche de temps, et elle ne cache aucun processeur. Pour le constater, vous lancez un conteneur limité à trois quarts de processeur. Vous lisez ensuite ce que le cgroup déclare, puis ce que le conteneur croit avoir.
docker run -d --name plafonne --cpus 0.75 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sleep infinityID=$(docker container inspect plafonne --format '{{.Id}}')CG="/sys/fs/cgroup/system.slice/docker-$ID.scope"cat "$CG/cpu.max"docker exec plafonne nprocnprocLa première lecture donne le quota du cgroup, en deux nombres : un temps accordé et la durée de la tranche, en microsecondes. Ce chemin suppose le pilote cgroup systemd, qui place les conteneurs sous system.slice. Sur une machine réglée autrement, le chemin est différent.
75000 10000022Le quota vaut 75000 sur 100000 : le conteneur peut utiliser 75 ms de processeur toutes les 100 ms. Les deux 2 sont le résultat de nproc dans le conteneur, puis sur la machine. Ils sont égaux, donc --cpus n'a rien changé à ce que le conteneur compte.
Le noyau ne sait pas exprimer « 0,75 CPU » : il compte un temps par tranche. Surtout, le conteneur voit toujours tous les processeurs de la machine. Une application qui lit nproc pour dimensionner ses fils en lance donc beaucoup trop, et le noyau la retient sans arrêt. Une limite de temps ne réduit pas ce que le conteneur voit.
Comment faire voir moins de processeurs au conteneur ?
Section intitulée « Comment faire voir moins de processeurs au conteneur ? »Avec l'option --cpuset-cpus, qui épingle le conteneur sur une partie des processeurs. Il ne reçoit alors pas moins de temps, il reçoit moins de processeurs, et cette fois il le voit.
Dans l'exemple, vous épinglez le conteneur sur le seul processeur numéroté 1 (les processeurs se comptent à partir de 0).
Il faut donc que la machine en ait au moins deux. Si le processeur demandé n'existe pas, Docker refuse de créer le conteneur et affiche Requested CPUs are not available (code de sortie 125). Sur une machine à un seul processeur, visez le 0 : --cpuset-cpus 0 est accepté, mais nproc rend 1 avec ou sans l'option, et l'exemple ne montre plus rien.
docker run -d --name restreint --cpuset-cpus 1 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sleep infinitydocker exec restreint nprocID2=$(docker container inspect restreint --format '{{.Id}}')cat "/sys/fs/cgroup/system.slice/docker-$ID2.scope/cpuset.cpus"11nproc vaut 1 dans le conteneur, et le fichier cpuset.cpus contient la liste 1. Le tableau résume la différence entre les deux options.
| Option posée | Ce qui change dans le cgroup | Réponse de nproc dans le conteneur |
|---|---|---|
--cpus 0.75 | cpu.max : 75 ms de temps de processeur par tranche de 100 ms | le nombre de CPU de la machine |
--cpuset-cpus 1 | cpuset.cpus : seul le processeur 1 est utilisable | 1 |
Vous pouvez combiner les deux options, mais elles ne répondent pas à la même question. La première plafonne la consommation. La seconde change la réponse que le système donne à l'application. Si votre application lit nproc pour se dimensionner, la limite de temps ne la corrige pas : configurez l'application explicitement, ou utilisez --cpuset-cpus.
Comment prouver qu'une limite de CPU a mordu ?
Section intitulée « Comment prouver qu'une limite de CPU a mordu ? »Lisez le compteur d'étranglements du cgroup. Une limite dont vous n'avez jamais vu l'effet reste une hypothèse. Le fichier cpu.stat contient la ligne nr_throttled : c'est le nombre de fois où le noyau a retenu le groupe parce qu'il avait épuisé son quota.
Tant que le conteneur ne fait rien, le compteur reste à zéro. Vous mettez donc plafonne sous charge avec trois boucles qui ne s'arrêtent jamais, vous attendez quelques secondes, puis vous relisez. La variable CG vient de la première section : restez dans le même terminal.
grep -E 'nr_throttled|throttled_usec' "$CG/cpu.stat"docker exec -d plafonne sh -c 'while :; do :; done'docker exec -d plafonne sh -c 'while :; do :; done'docker exec -d plafonne sh -c 'while :; do :; done'sleep 5grep -E 'nr_throttled|throttled_usec' "$CG/cpu.stat"docker stats --no-stream --format '{{.Name}} {{.CPUPerc}}' plafonnenr_throttled 0throttled_usec 0nr_throttled 51throttled_usec 6315695plafonne 73.64%Les deux premières lignes montrent le compteur avant la charge : zéro. Les deux suivantes le montrent après. Dans cette sortie, le noyau a retenu le groupe 51 fois, pendant un peu plus de 6 secondes au total (throttled_usec est en microsecondes). C'est la preuve que la limite a mordu.
La dernière ligne vient de docker stats : 73,64 % de CPU, proche du plafond de 75 % qui correspond à 0,75 CPU. La limite tient. Le jour où une application est lente sans cause apparente, regardez tôt si nr_throttled augmente.
Quelles valeurs par défaut décident du sort d'un conteneur ?
Section intitulée « Quelles valeurs par défaut décident du sort d'un conteneur ? »Il y en a deux, que l'on oublie de regarder.
La première est la taille de /dev/shm, un espace de mémoire partagée entre les processus du conteneur. Une base de données s'en sert pour ses tampons, et elle peut y manquer de place.
La seconde est la limite de descripteurs de fichiers, qui est basse depuis Docker 29. Les notes de version indiquent que les conteneurs reprennent désormais la limite par défaut de systemd, bien en dessous du million de descripteurs qu'ils recevaient auparavant (notes de version de Docker Engine 29). Une application qui ouvre beaucoup de fichiers ou de connexions l'atteint vite.
Une limite plus basse que le défaut bride le conteneur, une limite plus haute l'élargit. Relevez donc le défaut de votre installation avant de régler quoi que ce soit. Lancez un conteneur sans aucune option : ulimit -n donne la limite souple, celle que le processus subit, et ulimit -Hn la limite dure, le plafond qu'il ne peut pas dépasser.
docker run --rm alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sh -c 'ulimit -n; ulimit -Hn'La première ligne de la sortie est la limite souple, la seconde la limite dure. Pour la taille par défaut de /dev/shm, lancez df -m /dev/shm dans un conteneur sans option et relevez-la : elle sert de point de comparaison à la section suivante.
Une application ancienne, écrite pour l'époque où Docker donnait plus d'un million de descripteurs, peut échouer sur Docker 29 sans que vous ayez rien changé à son image. Pour retrouver une limite haute, passez --ulimit à la création, ou posez default-ulimits dans /etc/docker/daemon.json (voir la configuration du démon).
Comment régler /dev/shm et les descripteurs à la création ?
Section intitulée « Comment régler /dev/shm et les descripteurs à la création ? »Ces deux réglages se posent à la création du conteneur et ne se changent pas ensuite : pour les modifier, il faut recréer le conteneur.
Vous demandez ici un /dev/shm de 128 Mo et une limite de 512 descripteurs, souple comme dure. Ces valeurs diffèrent du défaut que vous venez de relever. Sans cela, la mesure ne prouverait pas que le réglage s'applique. La limite de descripteurs est en plus plus basse que le défaut, donc elle bride le conteneur.
docker run -d --name partage --shm-size 128m --ulimit nofile=512:512 alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a sleep infinitydocker container inspect partage --format 'shm={{.HostConfig.ShmSize}}'docker exec partage df -m /dev/shmdocker exec partage sh -c 'ulimit -n; ulimit -Hn'shm=134217728Filesystem 1M-blocks Used Available Use% Mounted onshm 128 0 128 0% /dev/shm512512La taille de /dev/shm apparaît à deux endroits : dans l'inspection (shm=134217728, soit 128 Mo en octets) et dans df, qui affiche 128 Mo. Les deux 512 sont les limites souple et dure de descripteurs.
Ces deux réglages ne se lisent pas au même endroit, parce qu'ils sont de nature différente. La taille de la mémoire partagée est un montage : l'inspection du conteneur la déclare (.HostConfig.ShmSize, en octets), et le conteneur la voit comme un système de fichiers. La limite de descripteurs est un ulimit : elle appartient au processus, donc vous la lisez de l'intérieur du conteneur, là où le processus la subit.
La syntaxe --ulimit nofile=souple:dure fixe les deux bornes. Avec un seul nombre, les deux bornes prennent cette valeur : sur Engine 29.9.2, --ulimit nofile=2048 donne 2048 en limite souple et 2048 en limite dure.
Pourquoi la mémoire partagée compte-t-elle dans la mémoire du conteneur ?
Section intitulée « Pourquoi la mémoire partagée compte-t-elle dans la mémoire du conteneur ? »Parce que /dev/shm est un tmpfs, un système de fichiers qui vit en mémoire vive, et que le noyau compte cette mémoire dans le cgroup du conteneur. Pour l'application, /dev/shm ressemble à un système de fichiers. Pour le noyau, c'est de la mémoire du conteneur.
Le fichier memory.current du cgroup donne la mémoire utilisée à cet instant. Vous allez la voir augmenter quand vous écrivez dans /dev/shm.
IDE=$(docker container inspect partage --format '{{.Id}}')CGE="/sys/fs/cgroup/system.slice/docker-$IDE.scope"echo "avant : $(( $(cat "$CGE/memory.current") / 1024 / 1024 )) Mo"docker exec partage dd if=/dev/zero of=/dev/shm/remplissage bs=1M count=96docker exec partage df -m /dev/shmecho "après : $(( $(cat "$CGE/memory.current") / 1024 / 1024 )) Mo"avant : 0 Mo96+0 records in96+0 records out100663296 bytes (96.0MB) copied, 0.036112 seconds, 2.6GB/sFilesystem 1M-blocks Used Available Use% Mounted onshm 128 96 32 75% /dev/shmaprès : 96 MoÉcrire 96 Mo dans /dev/shm fait passer la mémoire du conteneur de 0 à 96 Mo. df confirme que 96 Mo sont occupés dans /dev/shm.
En production, une limite mémoire (posée avec --memory) doit donc tenir compte de la taille de /dev/shm. Sinon, le conteneur est tué alors que les métriques de l'application restent sous la limite. C'est l'incident de la base de données du début : les tampons qu'elle écrit en mémoire partagée s'ajoutent aux 450 Mo que mesurent ses propres métriques. Pour reconnaître un conteneur tué par manque de mémoire (OOM, pour out of memory), lisez la leçon Encadrer ressources et redémarrage d'une stack, qui montre le champ OOMKilled de l'état du conteneur.
Comment nettoyer après les mesures ?
Section intitulée « Comment nettoyer après les mesures ? »Trois conteneurs de cette page exécutent sleep infinity ou une boucle, et ils gardent leurs limites tant que vous ne les supprimez pas. L'option -f les arrête avant de les retirer.
docker rm -f plafonne restreint partageLa commande affiche les trois noms. Les conteneurs lancés avec --rm se sont supprimés seuls.
Mettre en pratique
Section intitulée « Mettre en pratique »Deux labs, dans l'ordre.
Le premier part d'un conteneur déjà limité en CPU. Vous renseignez le quota que son cgroup déclare, tel quel, et le nombre de CPU qu'il voit de l'intérieur. Vous lancez ensuite un second conteneur qui en voit moins avec l'autre mécanisme, puis vous mettez le premier sous charge. La validation relit le cgroup au moment de la notation et compare vos réponses à ce qu'il déclare. Elle vérifie que le second conteneur voit moins de CPU que la machine, et elle exige que nr_throttled soit supérieur à zéro : la limite doit avoir mordu.
Dans le second lab, aucun conteneur ne tourne. Vous mesurez les deux valeurs par défaut sur un conteneur sans option et vous les consignez. Vous lancez ensuite un conteneur dont l'énoncé fixe le nom et les deux réglages, puis vous écrivez des données dans sa mémoire partagée. La validation remesure les valeurs par défaut sur un conteneur neuf et contrôle les deux réglages dans le conteneur. Elle compare enfin l'occupation de /dev/shm à la mémoire du cgroup.
À retenir
Section intitulée « À retenir »-
Les cgroups sont appliqués par le noyau, alors que les ulimits appartiennent au processus. Vous ne les cherchez pas au même endroit.
-
--cpus 0.75limite un temps, pas une vue :cpu.maxs'écrit en deux nombres, et le conteneur voit toujours tous les CPU de la machine. -
--cpuset-cpuschange la réponse denproc, parce qu'il restreint l'ensemble des processeurs utilisables. La limite de temps ne le fait pas. -
Une limite se prouve avec
nr_throttled: tant que ce compteur decpu.statreste à zéro, elle n'a jamais mordu. -
/dev/shmet la limite de descripteurs se règlent à la création (--shm-size,--ulimit nofile=souple:dure) et se lisent à deux endroits différents. -
La limite de descripteurs est basse par défaut depuis Docker 29. Relevez celle de votre installation avant de la régler. Une application qui ouvre beaucoup de fichiers ou de connexions doit la relever avec
--ulimitoudefault-ulimits. -
/dev/shmest de la mémoire, comptée dans le cgroup. Une limite mémoire doit en tenir compte, sinon le conteneur est tué alors que les métriques de l'application restent sous la limite.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »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
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
Vérification
(0/0)Score par domaine
Un domaine regroupe plusieurs guides et reçoit plusieurs questions : son score est plus fiable qu'un score par thème, qui porte souvent sur une seule question.
Bilan
Points forts
Aucun sujet réussi à 80 % sur au moins deux questions pour l’instant.
Points faibles
Aucun sujet sous le seuil de réussite.
À faire ensuite
Des indices pour retenter votre chance ?
Continuer le cours : Plafonner la mémoire et le nombre de tâches
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications