containerd containerd

Fonctionnement interne du runtime : cgroups, runc et la place de containerd

Ce qui isole et limite réellement un conteneur, de la fonctionnalité du noyau jusqu'au daemon avec lequel Kubernetes dialogue.

12 min Intermédiaire

« Conteneur » désigne un ensemble de fonctionnalités du noyau Linux reliées entre elles, et non un objet du noyau à part entière. Ce guide parcourt la pile de bas en haut : la fonctionnalité du noyau qui limite les ressources, l’outil qui assemble un conteneur à partir d’elle, et le daemon qui gère cet outil à grande échelle.

cgroups : le contrôle des ressources au niveau du noyau

Les groupes de contrôle (cgroups) sont une fonctionnalité du noyau Linux, qui remonte à 2008, permettant d’allouer et de limiter des ressources système (CPU, mémoire, E/S) entre un ensemble de processus. Un type de ressource dans ce système s’appelle un contrôleur (ou sous-système), et les processus qu’il gouverne sont organisés en une hiérarchie qui reflète un système de fichiers : un cgroup est un répertoire, et un processus le rejoint en écrivant son PID dans ce répertoire.

Deux règles déterminent la façon dont les cgroups se composent :

  • Une même hiérarchie peut avoir plusieurs contrôleurs rattachés, mais un contrôleur donné (le CPU, par exemple) ne peut être rattaché qu’à une seule hiérarchie à la fois.
  • Un processus appartient à exactement un cgroup au sein d’une hiérarchie donnée. Le déplacer vers un autre cgroup de cette même hiérarchie le retire de l’ancien ; il n’est jamais à deux endroits dans la même hiérarchie simultanément.

Les cgroups existent en deux versions, v1 et v2, qui diffèrent principalement par la façon dont hiérarchies et contrôleurs sont reliés ; la v2 unifie les contrôleurs sous une hiérarchie unique plutôt que d’autoriser l’agencement plus flexible (et plus complexe) que permettait la v1. Les distributions modernes et Kubernetes utilisent la v2 par défaut.

À eux seuls, les cgroups ne gèrent que les limites de ressources. Ils ne disent rien des systèmes de fichiers, des formats d’image, ni de l’isolation de la vue qu’un processus a du réseau ou de l’arbre des processus, qui sont pourtant ce qui donne réellement l’impression d’un conteneur isolé plutôt que d’un simple processus aux ressources limitées.

runc : assembler un conteneur à partir de primitives du noyau

runc est l’implémentation de référence de la spécification de runtime OCI : un outil bas niveau dont tout le travail consiste à prendre un système de fichiers et un fichier de configuration pour les transformer en un processus isolé en cours d’exécution. Il a besoin de deux choses pour exécuter un conteneur :

  • Un système de fichiers racine (rootfs) : le contenu du système de fichiers du conteneur, extrait d’une image.
  • Un config.json : généré avec runc spec, qui décrit tout ce qui concerne l’exécution du processus : capacités Linux, points de montage, namespaces à créer ou à partager, limites de ressources à appliquer via les cgroups.
mkdir my-container && cd my-container
mkdir rootfs
# remplir rootfs à partir des couches du système de fichiers exporté d'une image
runc spec
runc run my-container-id

Si vous avez déjà écrit une spec de Pod Kubernetes, la forme de config.json vous semblera familière : les capacités, les montages et la configuration des namespaces sont les mêmes concepts sous-jacents que Kubernetes expose via sa propre API, simplement une couche plus près du noyau. runc est volontairement étroit : il démarre et arrête le processus d’un seul conteneur selon cette configuration. Il n’a aucune notion de récupération d’images, de daemon de longue durée, ni de dialogue avec Kubernetes.

containerd : gérer runc à grande échelle

containerd est le daemon qui s’occupe de tout ce qui entoure runc : récupérer et décompresser les images, gérer les snapshots de stockage dans lesquels ces images sont extraites, suivre les conteneurs en cours d’exécution, et exposer une API gRPC que les clients directs comme Kubernetes (via CRI) peuvent utiliser. C’est le composant avec lequel le kubelet dialogue réellement.

containerd n’exécute pas directement le processus de votre conteneur : il délègue cela à un shim, un petit processus de longue durée qui s’intercale entre containerd et le processus réel du conteneur. Le rôle du shim est de maintenir le conteneur en fonctionnement indépendamment de containerd lui-même, afin que le redémarrage ou la mise à niveau du daemon containerd ne tue pas tous les conteneurs en cours d’exécution sur le nœud. containerd 1.x était livré avec trois implémentations de shim ; containerd 2.0 a supprimé les deux plus anciennes, ne laissant que le shim v2 basé sur runc, qui était déjà la valeur par défaut depuis des années.

kubelet ── CRI (gRPC) ──▶ containerd ── shim ──▶ runc ──▶ processus du conteneur
                              │
                              └── gère les images, snapshots, stockage

Comme il s’agit d’une architecture en couches, containerd n’est pas lié à runc en particulier. Un shim peut placer devant lui un moteur d’exécution entièrement différent, gVisor pour un sandboxing renforcé, Kata Containers pour une isolation au niveau de la VM, et ainsi de suite, tandis que la gestion des images de containerd, son implémentation CRI et tout ce qui fait face à Kubernetes restent identiques.

Reconstituer le chemin complet

Le conteneur d’un Pod, de bout en bout : le kubelet envoie une requête CRI à containerd. containerd récupère l’image si elle n’est pas déjà présente, la décompresse dans un snapshot sur le système de fichiers du nœud, et passe la main à un shim. Le shim invoque runc avec un config.json généré qui pointe vers ce snapshot comme rootfs. runc crée les namespaces Linux, applique les limites de cgroups issues des requests et limits de ressources du Pod, et démarre le processus. À partir de là, le shim supervise le processus en cours d’exécution et remonte son statut via containerd jusqu’au kubelet, ce que lit en définitive kubectl get pods.

Chaque couche résout un problème : les cgroups limitent les ressources, runc assemble un conteneur conforme à OCI à partir d’un rootfs et d’une configuration, les shims maintiennent ce conteneur supervisé indépendamment du daemon, et containerd orchestre le tout en exposant l’API dont Kubernetes a besoin. Aucune de ces pièces ne fait le travail d’une autre, ce qui est précisément ce qui permet de remplacer runc par un autre runtime bas niveau sans toucher à rien au-dessus de la couche shim.

Guides populaires