Introduction

En lien avec mon prédécent article 1 et de certaines expérimentations, il m’arrive de vouloir superviser l’activité d’applications conteneurisées. Il se trouve que les métriques des conteneurs sont à prendre avec précautions. Dans cette optique, on va mettre en place un pod pourvu d’un shell, et comprendre pourquoi les utilitaires tels que top ne rendent pas correctement compte de la situation réelle.

Préparation de l’environnement

On se base sur Minikube, en suivant une partie du tutoriel présenté ici 2. Le pod qui exécute un process a une limite de consommation RAM et CPU. Un seul conteneur le composera, basé sur l’image progrium/stress 3.

Voici le manifest K8s qui définit ce petit déploiement.

apiVersion: v1
kind: Pod
metadata:
  name: stress-demo
spec:
  containers:
  - name: stress1
    image: progrium/stress
    resources:
        limits:
            cpu: "1.5"
            memory: "600M"
        requests:
            cpu: "1"
            memory: "500M"
    command: ["stress"]
    args: ["--vm", "2", "--vm-bytes", "256M", "--verbose"]
 $ kubectl apply -f pod.yml

On veillera à gérer la demande en RAM au regard des limits pour ne pas que le kernel stoppe le processus du conteneur qui en demande trop.

➡ On applique ce manifest pour mettre en place l’environnement, on a un pod qui s’exécute dans le cluster Minikube.

Observations

On déroule les outils classiques de diagnostic, et on note plusieurs différences importantes:

  • En faisant appel aux métriques Kubernetes
$ kubectl top pods
NAME          CPU(cores)   MEMORY(bytes)   
stress-demo   1501m        152Mi
  • En se plaçant dans le pod pour exécuter des commandes shell
$ kubectl exec -ti stress-demo -- free  -h
             total       used       free     shared    buffers     cached
Mem:          7.5G       6.9G       584M       750M        79M       2.8G
$ kubectl exec -ti stress-demo -- top  
Tasks:   4 total,   3 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s):  7.1 us, 35.6 sy,  0.0 ni, 57.2 id,  0.0 wa,  0.0 hi,  0.1 si,  0.0 st
KiB Mem:   7861644 total,  7327232 used,   534412 free,    82008 buffers
KiB Swap:  2097148 total,   311296 used,  1785852 free.  2895988 cached Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                         
      8 root      20   0  269468  30276    896 R  75.3  0.4   1:31.23 stress                                                                          
      9 root      20   0  269468 206988    896 R  75.0  2.6   1:31.04 stress                                                                          
      1 root      20   0    7320   1536   1536 S   0.0  0.0   0:00.01 stress                                                                          
     17 root      20   0   19864   2176   1920 R   0.0  0.0   0:00.00 top   

Les informations rendues par kubectl et top attestent de la limite CPU (1.5 CPU utilisé au maximum qui est la somme de 2 fois 75%, sur 2 demandés par le programme de stress). La liste des processus de top se limite à ceux du conteneur. Cependant on note une différence majeure entre kubectl et free concernant la mémoire utilisée.

➡ Autant l’utilisation CPU semble correspondre entre un diagnostic fait sur le pod et avec les API Kubernetes, autant l’utilisation mémoire ne semble pas alignée.

Solution

Les proc entries font partie de la hiérarchie de dossiers procfs qui sont partiellement altérés dans les environnements de conteneurs. Par exemple, l’utilitaire top n’a qu’une visibilité partielle sur les processus, tandis que l’utilitaire free lit le fichier /proc/meminfo, qui n’est pas transformé et contient les informations de la machine hôte.

# dans le container stress-demo
$ strace free 2>&1 | grep open
[...]
openat(AT_FDCWD, "/proc/meminfo", O_RDONLY) = 3
[...]

Il récupère l’utilisation mémoire non pas pour le conteneur dans lequel on lance la commande, mais pour la machine hôte. Cette commande ne rend donc pas compte de la réalité du conteneur.

La liste de toutes les entrées transformées dans le conteneur dépend de la version de la plateforme de conteneur, des politiques d’accès,… Il n’y a donc pas de liste exhaustive ! La vigilance est donc de vigueur.

➡ Il faudra donc rester critique sur les utilitaires en ligne de commande lancés dans un conteneur, car toutes les sources d’informations ne sont pas forcément intégrées au fonctionnement conteneurisé. On accordera notre confiance aux outils de gestion de conteneurs, tels que kubectl top pods, ou docker stats,…


Pour aller plus loin

D’après la documentation de Kubernetes, le concept de cgroups (pour control groups) est utilisé4 pour gérer la distribution des ressources des pods. Les cgroups sont décrits comme un mécanisme d’organisation et de hiérarchie des processus, notamment pour leur assignation des ressources système. Les configurations sont stockées dans des dossiers et des fichiers “d’interfaces”5.

Plongeons dans les cgroups

Succintement, on peut mentionner que :

  • Il y a à ce jour deux versions majeures, la v1 et la v2 6,
  • L’implémentation des cgroups v2 est native dans le kernel Linux >= 5.8,
  • Pour fonctionner avec K8s7, doit être installée la bibliothèque de gestion de conteneur (container runtime) containerd >= v1.4 ou cri-o >= v1.20.

Et que pour la version v2,

  • Chaque processus appartient à un seul cgroup,
  • Tous les threads d’un même processus appartiennent au même cgroup,
  • Lors de la création, les processus sont placés dans le même cgroup que leur processus parent. Ils peuvent être migrés ensuite.

Premièrement, déterminons quelle version notre système utilise :

$ stat -fc %T /sys/fs/cgroup/
cgroup2fs

C’est actuellement la version v2.

Maintenant créons un pod dans un namespace dédié stressed-ns.

$ kubectl create namespace stressed-ns
$ kubectl --namespace stressed-ns apply -f pod.yml

Récupérons l’identifiant du processus et inspectons la configuration du système. On filtre la liste des processus en ne gardant que ceux qui contiennent “stress”.

$ ps aux | grep stress
root       37140  0.0  0.0   7320  1664 ?        Ss   10:17   0:00 stress --vm 2 --vm-bytes 256M --verbose
root       37153 74.7  2.0 269468 162136 ?       R    10:17   4:18 stress --vm 2 --vm-bytes 256M --verbose
root       37154 75.1  3.0 269468 241684 ?       R    10:17   4:20 stress --vm 2 --vm-bytes 256M --verbose
antoine    42883  0.0  0.0  11780  2560 pts/0    S+   10:22   0:00 grep --color=auto stress

Vérifions quand même que ces processus fassent bien partie du pod que l’on configure. Sélectionnons le pid du processus dont le status est sleeping.

$ nsenter -t 37140 -u hostname
stress-demo

Parfait, on travaille bien sur un processus conteneurisé.

Lien entre namespace K8s et cgroup

Un des moyens est de récupérer le nom du cgroup associé au pid:

$ cat /proc/37140/cgroup
0::/system.slice/docker-0fef63ebdbef160d586f3b6e4a271aeb3329030xxxx.scope/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podxxxx.slice/docker-xxxx.scope

On note un chemin, qui est à parcourir dans la hiérarchie /sys/fs/cgroup/. Allons voir ce qu’il s’y trouve !

Informations d’un cgroup

$ cd /sys/fs/cgroup/system.slice/docker-0fef63ebdbef160d586f3b6e4a271aeb3329030xxxx.scope/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podxxxx.slice/docker-xxxx.scope/
$ ls
cgroup.controllers      cgroup.type            cpu.uclamp.max            hugetlb.2MB.current       io.weight            memory.pressure       misc.events
cgroup.events           cpu.idle               cpu.uclamp.min            hugetlb.2MB.events        memory.current       memory.reclaim        misc.max
cgroup.freeze           cpu.max                cpu.weight                hugetlb.2MB.events.local  memory.events        memory.stat           pids.current
cgroup.kill             cpu.max.burst          cpu.weight.nice           hugetlb.2MB.max           memory.events.local  memory.swap.current   pids.events
cgroup.max.depth        cpu.pressure           hugetlb.1GB.current       hugetlb.2MB.numa_stat     memory.high          memory.swap.events    pids.max
cgroup.max.descendants  cpuset.cpus            hugetlb.1GB.events        hugetlb.2MB.rsvd.current  memory.low           memory.swap.high      pids.peak
cgroup.pressure         cpuset.cpus.effective  hugetlb.1GB.events.local  hugetlb.2MB.rsvd.max      memory.max           memory.swap.max       rdma.current
cgroup.procs            cpuset.cpus.partition  hugetlb.1GB.max           io.max                    memory.min           memory.swap.peak      rdma.max
cgroup.stat             cpuset.mems            hugetlb.1GB.numa_stat     io.pressure               memory.numa_stat     memory.zswap.current
cgroup.subtree_control  cpuset.mems.effective  hugetlb.1GB.rsvd.current  io.prio.class             memory.oom.group     memory.zswap.max
cgroup.threads          cpu.stat               hugetlb.1GB.rsvd.max      io.stat                   memory.peak          misc.current

➡ On retrouve les fichiers d’interface décrits dans la documentation des cgroups.

On pourra par exemple afficher les informations relatives au CPU et à la mémoire.

L’interprétation des fichiers sera grandement aidée par la consultation de la documentation. C’est ainsi que l’on pourra retrouver la limite CPU:

Inspectons par exemple le fichier cpu.max, qui d’après la documentation contient la limite d’allocation CPU.

cpu.max: A read-write two value file which exists on non-root cgroups. The default is “max 100000”. The maximum bandwidth limit. It’s in the following format: $MAX $PERIOD which indicates that the group may consume up to $MAX in each $PERIOD duration. “max” for $MAX indicates no limit. If only one number is written, $MAX is updated.

$ cat cpu.max
150000 100000

Pour une période unitaire d’une seconde, ce cgroup peut consommer 1,5 CPU. Mettons à jour la définition du pod pour autoriser 1,8 CPU. On devra supprimer, recréer un pod et réitérer ces dernières étapes pour voir la nouvelle valeur.

$ ps aux | grep stress
$ nsenter ...
$ cd /sys/fs/cgroup/system.slice/...
$ cat cpu.max
180000 100000

➡ On constate que la configuration utilisateur dans K8s se répercute sur la configuration du cgroup.

cgroups et namespaces linux

La distribution des ressources système via les cgroups n’est pas le seul procédé mis en place. Les cgroups sont un type, un sous-ensemble de restrictions, d’isolations concrètes réalisées par les namespaces linux. Et d’autres types sont effectivement appliqués.

On note le double sens du mot namespace, qui définit ici deux concepts différents : l’isolation des ressources dans le contexte de Kubernetes et l’isolation des processus dans le contexte du système Linux.

Vu depuis l’extérieur, on peut lister les namespaces linux actuels, notamment celui contenant le container stress-demo. On invoque la commande suivante sur le node choisi.

$ lsns | grep stress
4026533745 mnt         3  79723 root             stress --vm 2 --vm-bytes 256M --verbose
4026533746 uts         3  79723 root             stress --vm 2 --vm-bytes 256M --verbose
4026533747 pid         3  79723 root             stress --vm 2 --vm-bytes 256M --verbose
4026533748 cgroup      3  79723 root             stress --vm 2 --vm-bytes 256M --verbose

Les processus enfants du parent stress --vm 2 --vm-bytes 256M --verbose vivront dans le même namespace, tel que mentionné pour la version cgroup v2. À cette échelle, il n’y a plus de notion de namespace Kubernetes, on ne parle que de cgroup et namespace linux.

On note les différents types de namespace linux: mnt, uts, pid, cgroup. On peut résumer chaque fonctionnalité de type via la table suivante8.

NamespaceIsolation
cgroupInformations relatives à chaque cgroup
ipcMessages et ressources IPC
netRessources réseau
mntPoints de montage de fichiers
pidIdentification des processus
timeFonctionnalité de démarrage et accès aux horloges système
userPermissions utilisateurs
utsHostname et nom de domaine NIS

C’est pour cela que lancer la commande top dans le conteneur stress-demo ne montre que les processus du pod. L’appel aux APIs de namespaces telles que unshare9 remplit ce rôle en créant ce namespace linux dont la visibilité est limitée. Plus d’info avec man unshare.

Conclusion

Nous ne descendrons pas jusqu’au kernel, même si une description de l’implémentation est disponible 10, et que cette vidéo 11 reste à ce jour encore très informative.

➡ Le concept de namespace Kubernetes est très puissant pour isoler les pods sur un même système ; cependant leur supervision peut s’avérer délicate si l’on n’a pas conscience des différentes couches de bas en haut : les namespaces linux dont les cgroups sont un sous-ensemble, puis les namespaces Kubernetes.