Un runtime OCI bas niveau comme runc ne fait que ce que vous avez fait à la
main : il lit un dossier appelé bundle, composé d'un système de fichiers
(rootfs) et d'un fichier config.json, puis crée le processus isolé qui y
est décrit. Cette leçon s'adresse aux débutants qui ont isolé un processus
avec unshare et veulent voir comment un outil le fait pour eux. Vous allez
fabriquer un bundle, lire son config.json champ par champ, lancer un
conteneur, suivre son cycle de vie, rejouer le bundle avec crun et le produire
depuis une image avec umoci. Tout a été joué sur Debian 13 avec runc 1.1.15.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Fabriquer un bundle OCI avec
runc specet le comprendre. - Relier chaque champ de
config.jsonau geste manuel qu'il remplace. - Lancer un conteneur avec
runc run, puis suivre son cycle de vie. - Rejouer le même bundle avec crun, et en produire un depuis une image avec umoci.
- Diagnostiquer les erreurs les plus courantes de runc.
Prérequis
Section intitulée « Prérequis »- Avoir isolé un processus à la main avec les namespaces : Un conteneur à la main avec unshare et nsenter.
- Avoir limité ses ressources avec les cgroups et retiré des capabilities : Limiter un conteneur.
- Savoir qu'une image OCI est un layout de fichiers (manifeste, couches), vu dans
Lire une image OCI. Le layout
utilisé ici est
/srv/oci, obtenu avec skopeo depuisalpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6. - Une machine virtuelle jetable Debian 13 avec les droits root, et les
paquets installés par
apt install runc crun umoci jq. Les versions jouées : runc 1.1.15 (spec 1.2.0), crun 1.21, umoci 0.4.7, jq 1.7.
Qu'est-ce qu'un runtime OCI et un bundle ?
Section intitulée « Qu'est-ce qu'un runtime OCI et un bundle ? »Un runtime OCI est un programme qui crée un conteneur à partir d'un
bundle, c'est-à-dire d'un dossier respectant la Runtime Specification
de l'Open Container Initiative (OCI), l'organisme
qui normalise les formats de conteneurs. Un bundle contient au minimum deux
choses : un dossier rootfs (le système de fichiers que le conteneur verra
comme sa racine, nommé rootfs par convention) et un fichier config.json
(la description de ce qu'il faut lancer et comment l'isoler). Un outil peut
ajouter ses propres fichiers à côté, comme le fait umoci plus bas.
Deux runtimes courants sont runc, écrit en Go, issu du code que Docker a donné à l'OCI en 2015 (il en reste le runtime historique), et crun, une implémentation écrite en C. Les dépôts officiels sont opencontainers/runc et containers/crun ; la spécification se lit sur opencontainers/runtime-spec. Tous deux acceptent le même bundle, ce que la section consacrée à crun vérifie.
Il faut retenir ce que runc ne fait pas. Il ne télécharge aucune
image, ne gère ni le réseau entre conteneurs ni le stockage des
images, et n'offre aucune commande pull ou build. Ces fonctions
appartiennent aux moteurs de niveau supérieur, présentés dans
Runtime, moteur et orchestrateur.
Kubernetes demande le démarrage par la
CRI (Container
Runtime Interface, l'interface par laquelle il parle aux moteurs) et un
composant intermédiaire fournit à runc un bundle déjà prêt. Les runtimes
sandboxés comme gVisor ou Kata Containers remplacent runc par un autre
programme : ils sont décrits dans
Runtime Sandboxes.
Regardez le bundle, mis en avant au centre : c'est la frontière entre le monde des images (à gauche) et celui de l'exécution (à droite). Le runtime n'a besoin de rien d'autre pour créer le processus.
Comment fabriquer un bundle avec runc spec ?
Section intitulée « Comment fabriquer un bundle avec runc spec ? »On fabrique un bundle en extrayant la couche de l'image dans un dossier
rootfs, puis en demandant à runc spec d'écrire un config.json par défaut.
Cette méthode manuelle montre qu'un bundle n'a rien de magique : c'est un
tar décompressé et un fichier texte.
La première commande lit l'index du layout pour trouver le manifeste,
puis le digest de la première couche. La deuxième extrait cette couche (une archive
tar compressée) dans rootfs. La troisième génère le config.json.
-
Extraire la couche dans
rootfs.Fenêtre de terminal B=/srv/bundlemkdir -p "$B/rootfs"LAYER=$(jq -r '.layers[0].digest' /srv/oci/blobs/sha256/$(jq -r '.manifests[0].digest' /srv/oci/index.json | cut -d: -f2) | cut -d: -f2)tar -xzf /srv/oci/blobs/sha256/$LAYER -C "$B/rootfs" -
Générer le
config.jsonpar défaut.Fenêtre de terminal cd "$B"runc specls -la "$B"
Le listing doit montrer un dossier rootfs et un fichier config.json
de 2 588 octets, soit 177 lignes selon wc -l config.json. Le bundle se
représente ainsi :
Répertoirebundle/
- config.json
Répertoirerootfs/
Répertoirebin/
- …
Répertoiredev/
- …
Répertoireetc/
- …
Répertoireproc/
- …
Répertoiresys/
- …
Répertoireusr/
- …
L'arborescence de rootfs est abrégée : elle montre les dossiers d'un
Alpine minimal. Le point important est la présence de config.json à côté de
rootfs, et non dedans : le conteneur ne voit jamais ce fichier, il ne
décrit que son environnement.
Que contient config.json, champ par champ ?
Section intitulée « Que contient config.json, champ par champ ? »config.json décrit en JSON tout ce que vous faisiez à la main : le
processus à lancer, le système de fichiers racine, les montages,
les namespaces et les droits. Chaque champ remplace un geste des leçons
précédentes. On le lit par blocs avec jq, ce qui évite de parcourir 177
lignes d'un coup.
Le premier bloc regroupe le processus et la racine.
jq '{ociVersion, process: {terminal: .process.terminal, user: .process.user, args: .process.args, cwd: .process.cwd, env: .process.env, caps_bounding: .process.capabilities.bounding, noNewPrivileges: .process.noNewPrivileges}, root, hostname}' config.jsonLa sortie, abrégée, affiche "ociVersion": "1.2.0", "terminal": true, un
utilisateur uid 0 et gid 0, "args": ["sh"], "cwd": "/", un PATH et
TERM=xterm dans env, trois capabilities conservées (CAP_AUDIT_WRITE,
CAP_KILL, CAP_NET_BIND_SERVICE), l'option noNewPrivileges à true, une
racine {"path": "rootfs", "readonly": true} donc en lecture seule, et un
hostname valant runc.
Le deuxième bloc liste les montages et la partie linux.
jq '[.mounts[] | {destination, type}]' config.jsonjq '.linux | {namespaces, resources, maskedPaths: (.maskedPaths|length), readonlyPaths: (.readonlyPaths|length)}' config.jsonLes sept montages par défaut sont /proc (type proc), /dev (tmpfs),
/dev/pts (devpts), /dev/shm (tmpfs), /dev/mqueue (mqueue), /sys
(sysfs) et /sys/fs/cgroup (cgroup). Les six namespaces sont pid,
network, ipc, uts, mount et cgroup. Les ressources contiennent une
règle de périphériques {"allow": false, "access": "rwm"} (tout refuser), et
jq compte 10 chemins masqués et 5 chemins en lecture seule.
Le tableau ci-dessous relie chaque champ au geste manuel équivalent, avec la
leçon où il a été fait. Il se lit seul pour retrouver ce que remplace un champ
de config.json, ses valeurs par défaut et le geste équivalent.
Champ de config.json | Valeur par défaut de runc spec | Geste manuel équivalent |
|---|---|---|
process.args, process.cwd, process.env | ["sh"], /, PATH et TERM | La commande passée après chroot ou unshare, avec son répertoire et son environnement (à la main) |
process.user | uid 0, gid 0 | Le compte sous lequel on lançait le shell : root |
process.capabilities.bounding | 3 capabilities | setpriv --bounding-set (limiter) |
process.noNewPrivileges | true | Aucun dans les leçons précédentes : le noyau interdit alors au processus de regagner des droits en lançant un binaire setuid (limiter) |
root.path, root.readonly | rootfs, true | --root=/srv/alpine d'unshare, ou chroot puis pivot_root vers le rootfs (à la main). Le rootfs fait main restait modifiable : la lecture seule est un ajout de runc |
hostname | runc | La commande hostname exécutée dans un namespace UTS (--uts) (à la main) |
mounts | 7 montages | --mount-proc d'unshare ou mount -t proc pour /proc (à la main). Les autres montages (/dev, /sys, devpts…) n'avaient pas d'équivalent complet : vous n'aviez que des mknod |
linux.namespaces | pid, network, ipc, uts, mount, cgroup | Les options de unshare |
linux.resources | périphériques refusés (deny rwm) | Les écritures dans le cgroup, comme memory.max pour la mémoire (limiter). La règle de périphériques par défaut n'a pas d'équivalent manuel |
linux.maskedPaths, linux.readonlyPaths | 10 et 5 chemins | Aucun : maskedPaths masque des chemins sensibles de /proc et /sys pour qu'ils soient illisibles, readonlyPaths les rend accessibles en lecture seule. Le runtime l'applique à votre place |
Une ligne mérite une remarque : le namespace user est absent de la liste.
Par défaut, runc ne crée donc pas de user namespace, et le root du
conteneur est le root de l'hôte, restreint par les capabilities. Les notions
de namespaces, de
cgroups et de
capabilities sont
détaillées dans les pages Linux correspondantes.
Comment lancer le conteneur avec runc run ?
Section intitulée « Comment lancer le conteneur avec runc run ? »runc run <identifiant> lit le config.json du dossier courant, crée le
conteneur, exécute le processus au premier plan et le supprime dès que ce
processus se termine. C'est la commande la plus simple : elle enchaîne seule
création, démarrage et nettoyage.
Avant de lancer, on adapte le fichier. L'option terminal passe à
false (pas de console interactive dans un script), la commande devient un
petit script qui affiche ce que le conteneur voit, et la mémoire est
limitée à 64 Mio (67108864 octets) dans linux.resources.memory.limit.
jq '.process.terminal=false | .process.args=["/bin/sh","-c","echo hostname: $(hostname); id; ps -o pid,comm; cat /etc/alpine-release; ls /dev; grep -E \"CapEff|CapBnd\" /proc/self/status; cat /sys/fs/cgroup/memory.max 2>&1; touch /fichier 2>&1 || true"] | .linux.resources.memory={"limit":67108864}' config.json > config.tmp && mv config.tmp config.jsonrunc run demoLa sortie doit montrer, dans cet ordre : le hostname runc,
uid=0(root) gid=0(root), une liste de processus où sh porte le PID 1 et
ps un PID à un chiffre (9 lors du rejeu, il varie), la version 3.24.2
d'Alpine, le contenu de /dev, puis CapEff et CapBnd valant tous deux
0000000020000420, 67108864 et enfin
touch: /fichier: Read-only file system. Ensuite runc list ne montre plus
rien : le conteneur a disparu avec son processus.
Chaque ligne se relie à un champ du tableau précédent. Le nom runc vient
de hostname, le PID 1 du namespace pid. La valeur 0000000020000420
est le masque hexadécimal des trois capabilities conservées (CAP_KILL,
CAP_NET_BIND_SERVICE et CAP_AUDIT_WRITE). Le 67108864 lu dans
memory.max vient de la limite ajoutée, et le refus d'écriture vient de
root.readonly.
Le contenu de /dev est peuplé (null, zero, random, urandom,
pts, shm, tty et d'autres), contrairement à celui du conteneur fabriqué
à la main, qui n'a que ce que vous y montez. Le champ mounts et la création
des fichiers spéciaux font ce travail fastidieux à votre place.
Quel est le cycle de vie d'un conteneur runc ?
Section intitulée « Quel est le cycle de vie d'un conteneur runc ? »La Runtime Specification définit quatre états : creating (transitoire,
pendant create), created (le processus attend), running (il exécute
la commande) et stopped (il est terminé mais son état reste lisible). Après
delete, plus aucun état n'existe. Elle définit cinq opérations : state,
create, start, kill et delete. runc run, vu à la section précédente,
enchaîne create, start et delete. runc exec n'en fait pas partie :
c'est une commande propre à runc.
Suivez le trajet de gauche à droite : l'état running est mis en avant, et
runc exec (extension de runc, absente de la spécification) y boucle, car
il ajoute un processus sans changer l'état. Pour le jouer, on remplace la commande du bundle par sleep 300, de façon à avoir un
processus durable.
jq '.process.args=["sleep","300"]' config.json > config.tmp && mv config.tmp config.jsonrunc create --pid-file /tmp/demo.pid demorunc listrunc state demo | jq '{ociVersion, id, status, pid, bundle}'Après runc create, runc list affiche demo au statut created, avec un
PID (7625 dans le lab, il change à chaque lancement) et le bundle /srv/bundle. runc state rend un JSON où
ociVersion vaut 1.2.0, status vaut created et bundle vaut
/srv/bundle. Côté hôte, ps -o pid,ppid,stat,comm -p "$(cat /tmp/demo.pid)"
montre un processus nommé runc:[2:INIT] : c'est l'initialisation du
conteneur, qui a déjà créé les namespaces et attend l'ordre de démarrage.
runc start demorunc state demo | jq -r .statusrunc ps demorunc exec demo /bin/sh -c 'ps -o pid,comm; hostname'Après runc start, le statut est running et runc ps demo montre
sleep 300 (PID 7625 vu de l'hôte, le même que ci-dessus). runc exec lance un second processus
dans le conteneur existant : la sortie liste sleep (PID 1), sh (PID 6) et
ps (PID 11), puis runc pour le hostname. Le sh a donc rejoint les mêmes
namespaces, à la façon de nsenter dans la leçon sur le conteneur à la main.
Vu de l'hôte, le conteneur n'est qu'un processus avec des attributs :
lsns -p "$(cat /tmp/demo.pid)" -o NS,TYPE,PID,COMMANDcat /proc/$(cat /tmp/demo.pid)/cgroupls /run/runc/demolsns affiche six namespaces propres au processus : mnt, uts, ipc,
pid, net et cgroup. Les namespaces time et user restent ceux de
l'hôte (ceux de /sbin/init). Le fichier /proc/PID/cgroup donne
0::/system.slice/demo, et lire memory.max dans ce cgroup renvoie
67108864. L'état du conteneur vit dans /run/runc/demo/state.json, un
fichier que runc relit à chaque commande : c'est ce qui permet de piloter un
conteneur depuis des invocations séparées.
runc kill demo KILLrunc listrunc delete demorunc listAprès runc kill demo KILL, le statut devient stopped avec un PID à 0 :
le processus est mort mais l'entrée reste listée. Seul runc delete l'efface.
runc list est alors vide, et le cgroup /sys/fs/cgroup/system.slice/demo a
disparu (ls répond No such file or directory).
Crun exécute-t-il le même bundle que runc ?
Section intitulée « Crun exécute-t-il le même bundle que runc ? »Oui : crun lit le même config.json et le même rootfs, sans
conversion, car les deux programmes implémentent la même spécification,
ce qui est précisément le but de l'OCI. On change la commande du bundle puis on
lance crun run.
jq '.process.args=["/bin/sh","-c","echo crun: $(hostname); ps -o pid,comm"]' config.json > config.tmp && mv config.tmp config.jsoncrun run demo-crunLa sortie doit afficher crun: runc puis un ps où le seul processus
visible est lui-même, en PID 1. Le code retour est 0 et crun list est
vide ensuite.
Le bundle est donc portable entre runtimes. Le hostname runc n'est pas le
nom du programme : c'est la valeur par défaut que runc spec a écrite dans
le fichier.
Choisir entre les deux relève de l'intégration, pas de la compatibilité. Podman utilise crun ou runc selon sa configuration, comme le montre la page Podman concepts, et Docker s'appuie sur runc. Quel programme appelle quel runtime fait l'objet de la leçon Runtime, moteur et orchestrateur.
Comment passer d'une image OCI à un bundle sans tout copier à la main ?
Section intitulée « Comment passer d'une image OCI à un bundle sans tout copier à la main ? »umoci convertit une image d'un layout OCI en bundle en une commande :
umoci unpack extrait les couches dans rootfs et écrit un config.json à
partir de la configuration de l'image, là où runc spec écrit des valeurs
génériques. Il évite l'extraction manuelle de la première section.
umoci unpack --image /srv/oci:3.24.2 /srv/bundle-umocils /srv/bundle-umociLe dossier produit contient config.json, rootfs, un fichier .mtree
nommé sha256_d56c381f961d307a21b3ca004cf1e3910f106644aefb1f43e654c8a56c4fd395.mtree
(un inventaire de rootfs : pour chaque fichier, type, taille, propriétaire,
mode et empreinte SHA-256, qui permet de détecter une modification ultérieure)
et un fichier umoci.json de métadonnées (le manifeste d'origine et les
options d'extraction, par exemple "rootless":false). Le nom du fichier .mtree reprend le digest du
manifeste de l'image pour l'architecture amd64.
La différence se lit dans process. Le config.json de umoci reprend la
commande de l'image : args vaut ["/bin/sh"] (le Cmd de l'image
Alpine), et env ajoute HOME (/root) à PATH et TERM. Le répertoire
de travail reste /. Comme avant, on bascule terminal à false et on remplace
la commande pour un test, puis on lance :
cd /srv/bundle-umocijq '.process.terminal=false | .process.args=["/bin/sh","-c","echo depuis umoci; cat /etc/alpine-release"]' config.json > c.tmp && mv c.tmp config.jsonrunc run demo-umociLa sortie doit afficher depuis umoci puis 3.24.2, avec un code retour 0. Le
chemin complet est maintenant visible : le layout fournit les couches,
umoci produit le bundle, runc crée le processus.
Dépannage
Section intitulée « Dépannage »Les trois erreurs ci-dessous ont été rencontrées telles quelles pendant le lab. Elles viennent presque toujours d'un config.json incohérent avec ce que l'hôte ou le rootfs peut offrir : lisez le message en entier, il nomme le champ ou le fichier fautif.
| Symptôme | Cause | Solution |
|---|---|---|
runc create failed: container with given ID already exists | L'identifiant demo2 est déjà utilisé par un conteneur créé ou arrêté, dont l'état traîne dans /run/runc | runc list pour le voir, puis runc delete demo2 (ou runc delete -f demo2 s'il tourne) avant de réutiliser l'identifiant |
runc run failed: open /dev/tty: no such device or address | process.terminal vaut true mais le programme n'est pas lancé depuis un terminal (script, CI, redirection) | Mettre process.terminal à false pour un lancement non interactif, ou lancer la commande depuis un vrai terminal |
runc run failed: unable to start container process: exec: "/bin/bash": stat /bin/bash: no such file or directory | process.args désigne un binaire absent du rootfs : l'image Alpine ne contient pas bash | Utiliser un binaire présent (/bin/sh), vérifiable avec ls rootfs/bin |
Un quatrième réflexe évite beaucoup de pannes : après un conteneur arrêté
brutalement, vérifiez runc list avant de relancer. Un conteneur stopped
occupe toujours son identifiant tant que runc delete n'a pas été exécuté,
et son état reste dans /run/runc.
À retenir
Section intitulée « À retenir »- Un bundle OCI est un dossier avec un
rootfset unconfig.json, défini par la Runtime Specification. - Un runtime bas niveau (runc, crun) ne fait que créer le processus isolé décrit par le bundle : ni téléchargement d'image, ni réseau, ni stockage.
- Chaque champ de
config.jsonremplace un geste manuel : namespaces, cgroup, capabilities, racine en lecture seule, montages. runc specécrit unconfig.jsongénérique ;umoci unpacken écrit un à partir de la configuration de l'image.runc runenchaîne tout et supprime le conteneur à la fin ;create,start,killetdeleteséparent les étapes.- Un conteneur arrêté garde son identifiant jusqu'à
runc delete. - Le même bundle s'exécute à l'identique avec crun.
- Un conteneur vu de l'hôte n'est qu'un processus avec ses namespaces et son cgroup.
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Dix questions vérifient que le lien entre le bundle, les gestes manuels et le cycle de vie est acquis ; six sont tirées à chaque passage.
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 : Runtime, moteur, orchestrateur : qui fait quoi
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications