Qu’est-ce que les cgroups ?
A qui s’adresse cet article ?
Cet article s’adresse aux gens qui ont une connaissance relativement avancée de Docker (ou autre technique de conteneurisation) en tant qu’utilisateur, mais ne connaissent pas les mécanismes internes mis en oeuvre pour leur fonctionnement. Plus en précisemment, pour ceux qui ne savent pas ce que sont (voire même n’ont jamais entendu parler) des cgroups.
Il faut avoir une vague idée de comment le kernel linux fonctionne pour pouvoir comprendre facilement cet article.
Introduction
Le but de cet article est de fournir une petite introduction sommaire aux cgroups. N’étant moi-même pas un expert de Docker ou du kernel Linux, mais un simple amateur enthousiaste, j’ai récemment eu l’envie de découvrir un peu plus le fonctionnement interne de Docker et des conteneurs, après plusieurs années à utiliser Docker sans réellement comprendre ce qu’il se passe sous le capot. Si les noms d’overlayfs
, cgroups
, capabilities
et namespace
m’étaient familier, je n’avais cependant qu’une vague connaissance de ces mécanismes. J’ai donc cherché à en savoir plus sur les cgroups, pour répondre à quelques questions:
- Qu’est-ce que c’est ?
- A quoi ça sert ?
- A quoi ça sert pour Docker et les conteneurs ?
- Comment les manipuler ?
Présentation brève (style wikipédia) des cgroups
Le terme cgroups
signifie control groups
. La documentation de Linux précise qu’il existe la forme singulière, et que cgroups
ne doit jamais être capitalisé. En réalité, cgroup
est une espèce de raccourci pour cgroup controller
.
Cette fonctionnalité a été ajouté à Linux vers la fin 2007, pour la version 2.6.24.
Depuis la version 4.5 de linux, les cgroups existent également en version 2, mais cet article n’en parlera pas.
Le but des cgroups est donc d’établir un groupe de process sur lequel on va pouvoir excercer un contrôle via certaines règles et paramètres. Une des fonctionnalité offerte par les cgroups est la possibilité d’organiser ces groupes de process selon une hiérarchie.
Ainsi, un cgroup controller permet d’effectuer un contrôle sur un type de ressource, par exemple le cgroup controller memory permet de limiter l’utilisation totale de RAM ou de swap pour les process dans ce cgroup.
Un process peut être dans plusieurs cgroups, pour être sous le joug de plusieurs limites de différents types.
Le système hiérarchique des cgroups permet de limiter les ressources de plusieurs cgroups d’un coup, un cgroup situé en aval d’un autre ne peut dépasser les limites imposées par ce dernier.
Un peu de concret
man mount(8)
Les cgroups sont en réalité exposé via un filesystem particulier interne (de type … cgroup
). Pour les manipuler (en créer, les modifier, rajouter des process, etc), il faut ainsi passer par le filesystem.
Avec les OS récents disposants de systemd, ce dernier défini automatiquement des points de montages et monte les pseudo-filesystems requis pour utiliser tous les cgroups (de la version 1).
Ainsi, sur ma machine :
$ uname -a
Linux ... 5.2.5-arch1-1-ARCH #1
$ mount
[...]
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
[...]
On note que cgroup
ici est au singulier et non au pluriel. La documentation (man 7 cgroups
) explique que le terme cgroup
au singulier désigne le control group controller
, là où le pluriel désigne la fonctionnalité dans son ensemble.
On remarque que tous les cgroups montés sont chacun monté dans un dossier qui porte le nom de l’option de montage du fs. Ainsi on a un cgroup
dans le dossier blkio
avec l’option de montage blkio
. Comme on peut le deviner assez facilement, c’est cette option de montage qui permet d’activer un cgroup controller.
Il est possible de préciser plusieurs options de montage avec la commande mount en les séparants par des virgules. Si l’on précise plsuieurs cgroup controllers lors du montage, alors on obtiendra un cgroup disposant de chacun de ces controllers. On remarque que systemd l’utilise dans le dossier cpu,cpuacct
par exemple.
Créons notre cgroup
Préparons le terrain
Maintenant qu’on a vu que pour intéragir avec un croup controller, il suffit de monter dans un dossier un fs de type cgroup
et d’activer le cgroup controller
qui nous intéresse via l’option de montage, essayons de créer et manipuler un cgroup.
Tentons d’utiliser le controller memory
pour limiter la RAM d’un process :
Ces commandes sont faites en root
$ cd /tmp
$ mkdir cgroup
$ mount -t cgroup -o memory cgroup cgroup
$ cd cgroup
cgroup.clone_children memory.max_usage_in_bytes
cgroup.event_control memory.memsw.failcnt
cgroup.procs memory.memsw.limit_in_bytes
cgroup.sane_behavior memory.memsw.max_usage_in_bytes
docker/ memory.memsw.usage_in_bytes
init.scope/ memory.move_charge_at_immigrate
machine.slice/ memory.numa_stat
memory.failcnt memory.oom_control
memory.force_empty memory.pressure_level
memory.kmem.failcnt memory.soft_limit_in_bytes
memory.kmem.limit_in_bytes memory.stat
memory.kmem.max_usage_in_bytes memory.swappiness
memory.kmem.slabinfo memory.usage_in_bytes
memory.kmem.tcp.failcnt memory.use_hierarchy
memory.kmem.tcp.limit_in_bytes notify_on_release
memory.kmem.tcp.max_usage_in_bytes release_agent
memory.kmem.tcp.usage_in_bytes system.slice/
memory.kmem.usage_in_bytes tasks
memory.limit_in_bytes user.slice/
L’interface que nous propose le kernel pour intéragir avec un cgroup est donc remplie de plusieurs fichiers. Si on les catégorise, on a:
- Ceux qui commencent par
memory
. Ce sont les interfaces propre au controller que l’on a activé. - Ceux qui commencent par
cgroup
. Ce sont les interfaces communes à chacun des cgroups et qui permettent d’intéragir avec le groupe. - 3 fichiers un peu à part,
notify_on_release
,release_agent
ettasks
. Les deux premiers sont des mécanismes de notification, et le troisième sera évoqué un peu plus bas dans cet article. - Différents dossiers. Ce sont en réalité d’autre cgroups créé automatiquement par systemd et docker dans cet exemple.
Constatation immédiate
On peut déjà remarquer que même si nous venons de créer notre dossier dans /tmp
, et qu’on vient à peine de mount le cgroup memory, il y a déjà des cgroups fils (docker
, system.slice
, user.slice
, etc) qui sont présents. La raison est assez simple : les cgroups sont uniques au système. Mount un cgroup quelque part permet juste d’intéragir avec ce système, qui est unique. Ainsi, Docker et Systemd ont pu déjà créer leurs cgroups au démarrage de ma machine, et utilisant la même interface mais mount ailleurs (/sys/fs/cgroup/memory
en l’occurence, comme vu plus haut).
La création
La création en elle-même est triviale :
$ mkdir myapp
Grâce à un simple mkdir
, nous venons de créer un groupe appelé myapp
de type memory
.
Configuration de notre cgroup et ajout de process
Pour utiliser le cgroups, on va premièrement le configurer brièvement :
$ cd myapp
$ ls
cgroup.clone_children memory.memsw.failcnt
cgroup.event_control memory.memsw.limit_in_bytes
cgroup.procs memory.memsw.max_usage_in_bytes
memory.failcnt memory.memsw.usage_in_bytes
memory.force_empty memory.move_charge_at_immigrate
memory.kmem.failcnt memory.numa_stat
memory.kmem.limit_in_bytes memory.oom_control
memory.kmem.max_usage_in_bytes memory.pressure_level
memory.kmem.slabinfo memory.soft_limit_in_bytes
memory.kmem.tcp.failcnt memory.stat
memory.kmem.tcp.limit_in_bytes memory.swappiness
memory.kmem.tcp.max_usage_in_bytes memory.usage_in_bytes
memory.kmem.tcp.usage_in_bytes memory.use_hierarchy
memory.kmem.usage_in_bytes notify_on_release
memory.limit_in_bytes tasks
memory.max_usage_in_bytes
$ echo "104857600" > memory.limit_in_bytes # 100Kib
$ cat memory.limit_in_bytes
104857600
En écrivant la valeur 104857600 dans le fichier memory.limit_in_bytes
, on configure ce cgroup pour qu’il empêche la somme de la mémoire utilisée par les process rattachés à lui-même de dépasser 100Kib.
Ajoutons notre shell courant à ce cgroup :
$ cat cgroup.procs
$ echo 0 > cgroup.procs
$ cat cgroup.procs
173423
173493
En écrivant 0 dans cgroups.procs
on demande au kernel de déplacer le process actuel (donc mon shell) dans ce cgroup. L’opération revient à echo $$ > cgroups.procs
.
On vérifie ensuite quels sont les process qui sont dans ce cgroups avec un cat, et on tombe bien sur deux valeurs, le PID de mon shell, et le PID de cat
que je viens d’invoquer. En effet, comme cat
est mon process fils, il est par défaut dans le même cgroup que moi-même. Refaire un autre cat
nous donneras également “173423” mais le deuxième PID sera différent.
Test du cgroup
Maintenant que nous sommes dans un cgroup, et que celui ci nous impose une limite de RAM de 100Kib, essayons de voir ce qu’il se passe quand on la brise.
Dans un autre shell (important !), je prépare un petit code en C que je vais compiler :
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char str[] = "Memory exhausted";
while (1)
{
void *ptr = malloc(1024*1024); /* Allocate all the memory */
if (ptr == NULL) /* Memory exhausted */
{
write(2, str, sizeof(str)); /* Write the error message
(using write to avoid internal printf memory allocation) */
while (1)
continue;
}
}
return 0;
}
Ce script a pour but d’allouer plein de mémoire jusqu’à ce qu’il n’y en ai plus de disponible, puis de print un message d’erreur et d’attendre de se faire tuer.
$ gcc -O0 /tmp/test.c -o /tmp/test
Lancons le programme avec notre shell soumis au cgroup myapp
:
$ /tmp/test
[1] 173722 killed /tmp/test
$ /tmp/test
[1] 173795 killed /tmp/test
Notre process semble se faire tuer avant de pouvoir print ou de rentrer dans sa boucle infini.
Quelqu’un d’un peu familier avec le fonctionnement de linux va immédiatement suspecter l’OOM-killer d’être passé par là :
$ dmesg
[170370.111087] test invoked oom-killer: gfp_mask=0x400dc0(GFP_KERNEL_ACCOUNT|__GFP_ZERO), order=0, oom_score_adj=0
[170370.111090] CPU: 1 PID: 174205 Comm: test Tainted: G W 5.4.3-arch1-1 #1
[170370.111091] Call Trace:
[170370.111098] dump_stack+0x66/0x90
[170370.111101] dump_header+0x4a/0x1f7
[170370.111103] oom_kill_process.cold+0xb/0x10
[170370.111104] out_of_memory+0x197/0x440
[170370.111107] mem_cgroup_out_of_memory+0xba/0xd0
[170370.111109] try_charge+0x80b/0x880
[170370.111112] __memcg_kmem_charge_memcg+0x46/0xd0
[170370.111113] __memcg_kmem_charge+0x7d/0x1a0
[170370.111115] __alloc_pages_nodemask+0x258/0x320
[170370.111118] pte_alloc_one+0x14/0x40
[170370.111120] __pte_alloc+0x18/0x120
[170370.111122] __handle_mm_fault+0x12b9/0x14a0
[170370.111124] handle_mm_fault+0xce/0x200
[170370.111126] do_user_addr_fault+0x1ef/0x470
[170370.111128] page_fault+0x34/0x40
[170370.111130] RIP: 0033:0x7feee527cae2
[170370.111132] Code: ff ff ff b9 22 00 00 00 ba 03 00 00 00 4c 89 e6 e8 53 15 07 00 48 83 f8 ff 74 c3 4c 8d 40 10 a8 0f 0f 85 17 05 00 00 4c 89 e2 <48> c7 00 00 00 00 00 48 83 ca 02 48 89 50 08 ba 01 00 00 00 f0 0f
[170370.111132] RSP: 002b:00007ffd4002d8f0 EFLAGS: 00010246
[170370.111133] RAX: 00007fe312b8a000 RBX: 00007feee53b49e0 RCX: 00007feee52ee046
[170370.111134] RDX: 0000000000101000 RSI: 0000000000101000 RDI: 0000000000000000
[170370.111135] RBP: 0000000000100010 R08: 00007fe312b8a010 R09: 0000000000000000
[170370.111135] R10: 0000000000000022 R11: 0000000000000246 R12: 0000000000101000
[170370.111136] R13: 0000000000001000 R14: 0000000000010001 R15: 000000000000ffff
[170370.111138] memory: usage 102400kB, limit 102400kB, failcnt 457756
[170370.111139] memory+swap: usage 295612kB, limit 9007199254740988kB, failcnt 0
[170370.111139] kmem: usage 101976kB, limit 9007199254740988kB, failcnt 0
[170370.111140] Memory cgroup stats for /myapp:
[170370.111148] anon 421888
file 0
kernel_stack 36864
slab 4198400
sock 0
shmem 0
file_mapped 405504
file_dirty 0
file_writeback 270336
anon_thp 0
inactive_anon 368640
active_anon 569344
inactive_file 409600
active_file 0
unevictable 0
slab_reclaimable 974848
slab_unreclaimable 3223552
pgfault 1463748
pgmajfault 1023
workingset_refault 0
workingset_activate 0
workingset_nodereclaim 0
pgrefill 1414155
pgscan 2831737
pgsteal 1401281
pgactivate 1419
pgdeactivate 1414155
pglazyfree 0
pglazyfreed 0
thp_fault_alloc 0
thp_collapse_alloc 0
[170370.111148] Tasks state (memory values in pages):
[170370.111149] [ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[170370.111151] [ 173423] 0 173423 2906 1768 57344 123 0 zsh
[170370.111152] [ 174205] 0 174205 12396714 329 99405824 48209 0 test
[170370.111153] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0,oom_memcg=/myapp,task_memcg=/myapp,task=test,pid=174205,uid=0
[170370.111159] Memory cgroup out of memory: Killed process 174205 (test) total-vm:49586856kB, anon-rss:152kB, file-rss:1156kB, shmem-rss:8kB, UID:0 pgtables:99405824kB oom_score_adj:0
[170370.130845] oom_reaper: reaped process 174205 (test), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
En effet, notre programme s’est fait tuer avant de pouvoir print quoi que ce soit.
En regardant rapidement dans notre cgroup, on découvre le fichier memory.oom_control
. Écrivons “1” dedans pour désactiver l’OOM killer pour ce cgroup, et retentons notre expérience :
$ echo 1 > memory.oom_control
$ /tmp/test
Le programme tourne à l’infini, mais n’affiche rien. De plus, mon CPU n’est pas en train de cramer. Explorons un peu dans un autre shell :
$ ps aux | grep test
root 174337 0.9 0.0 49615640 1068 pts/8 D+ 15:28 0:00 /tmp/test
Le D+
sur la ligne du process indique que celui-ci est en état uninterruptable sleep (D), et au premier plan d’un process group (+). En regardant dans la documentation du kernel, on peut effectivement lire :
You can disable the OOM-killer by writing "1" to memory.oom_control file, as:
#echo 1 > memory.oom_control
If OOM-killer is disabled, tasks under cgroup will hang/sleep
in memory cgroup's OOM-waitqueue when they request accountable memory.
For running them, you have to relax the memory cgroup's OOM status by
* enlarge limit or reduce usage.
To reduce usage,
* kill some tasks.
* move some tasks to other group with account migration.
* remove some files (on tmpfs?)
Then, stopped tasks will work again.
Le comportement actuel est donc celui attendu, notre process a épuisé la mémoire disponible et est donc bloqué.
Conclusion
Les cgroups sont en réalité assez facile à comprendre et manipuler grâce à leur interface assez simple. Cependant, derrière cette apparente simplicité se cache beaucoup de complexité dans le kernel, et quelques incohérences (certains articles sur internet expliquerons mieux que moi pourquoi).
Ces mécanismes sont néanmoins à la base du fonctionnement d’outils de conteneurisation que l’on connait et utilise régulièrement aujourd’hui.
Il existe une volonté de faire bouger les cgroups vers une nouvelle interface, avec un fonctionnement assez différent, les cgroups v2, qui feront surement l’objet d’un nouvel article de blog.
Leave a Comment
Your email address will not be published. Required fields are marked *