Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Exécuter un bundle OCI avec runc et lire son config.json

Par mis à jour le

Améliorer cette page

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.

  • Fabriquer un bundle OCI avec runc spec et le comprendre.
  • Relier chaque champ de config.json au 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.
  • 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 depuis alpine: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.

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.

Chaîne de l'image au processus : une image OCI (layout /srv/oci) est transformée en bundle (dossier rootfs et fichier config.json) par umoci unpack, ou par une extraction manuelle suivie de runc spec ; runc run, ou crun run, lit ce bundle et crée le processus isolé avec ses namespaces, son cgroup et ses capabilities.Image OCIlayout /srv/ociumoci unpackou extraction manuelle + runc specBundlerootfs/ + config.jsonrunc run(ou crun run)Processus isolénamespaces, cgroup, capabilities
De l'image au processus : le bundle est le seul objet que le runtime comprend. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

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.

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.

  1. Extraire la couche dans rootfs.

    Fenêtre de terminal
    B=/srv/bundle
    mkdir -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"
  2. Générer le config.json par défaut.

    Fenêtre de terminal
    cd "$B"
    runc spec
    ls -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.

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.

Fenêtre de terminal
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.json

La 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.

Fenêtre de terminal
jq '[.mounts[] | {destination, type}]' config.json
jq '.linux | {namespaces, resources, maskedPaths: (.maskedPaths|length),
readonlyPaths: (.readonlyPaths|length)}' config.json

Les 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.jsonValeur par défaut de runc specGeste manuel équivalent
process.args, process.cwd, process.env["sh"], /, PATH et TERMLa commande passée après chroot ou unshare, avec son répertoire et son environnement (à la main)
process.useruid 0, gid 0Le compte sous lequel on lançait le shell : root
process.capabilities.bounding3 capabilitiessetpriv --bounding-set (limiter)
process.noNewPrivilegestrueAucun 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.readonlyrootfs, 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
hostnameruncLa commande hostname exécutée dans un namespace UTS (--uts) (à la main)
mounts7 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.namespacespid, network, ipc, uts, mount, cgroupLes options de unshare
linux.resourcespé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.readonlyPaths10 et 5 cheminsAucun : 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.

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.

Fenêtre de terminal
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.json
runc run demo

La 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.

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.

Cycle de vie d'un conteneur runc de gauche à droite : sans conteneur, runc create demo mène à created ; runc start demo mène à running ; runc exec demo sh laisse en running ; runc kill demo KILL mène à stopped ; runc delete demo mène à supprimé.runc create demorunc start demorunc kill demo KILLrunc delete demorunc exec demo shAucun conteneur(bundle seul sur le disque)createdprocessus runc:[2:INIT] en attenterunningle processus de config.json tournestoppedPID 0, état encore présentSuppriméétat et cgroup retirés
Les états observés et les commandes runc qui font passer de l'un à l'autre. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

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.

Fenêtre de terminal
jq '.process.args=["sleep","300"]' config.json > config.tmp && mv config.tmp config.json
runc create --pid-file /tmp/demo.pid demo
runc list
runc 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.

Fenêtre de terminal
runc start demo
runc state demo | jq -r .status
runc ps demo
runc 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 :

Fenêtre de terminal
lsns -p "$(cat /tmp/demo.pid)" -o NS,TYPE,PID,COMMAND
cat /proc/$(cat /tmp/demo.pid)/cgroup
ls /run/runc/demo

lsns 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.

Fenêtre de terminal
runc kill demo KILL
runc list
runc delete demo
runc list

Aprè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).

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.

Fenêtre de terminal
jq '.process.args=["/bin/sh","-c","echo crun: $(hostname); ps -o pid,comm"]' config.json > config.tmp && mv config.tmp config.json
crun run demo-crun

La 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.

Fenêtre de terminal
umoci unpack --image /srv/oci:3.24.2 /srv/bundle-umoci
ls /srv/bundle-umoci

Le 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 :

Fenêtre de terminal
cd /srv/bundle-umoci
jq '.process.terminal=false | .process.args=["/bin/sh","-c","echo depuis umoci; cat /etc/alpine-release"]' config.json > c.tmp && mv c.tmp config.json
runc run demo-umoci

La 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.

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ômeCauseSolution
runc create failed: container with given ID already existsL'identifiant demo2 est déjà utilisé par un conteneur créé ou arrêté, dont l'état traîne dans /run/runcrunc 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 addressprocess.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 directoryprocess.args désigne un binaire absent du rootfs : l'image Alpine ne contient pas bashUtiliser 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.

  • Un bundle OCI est un dossier avec un rootfs et un config.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.json remplace un geste manuel : namespaces, cgroup, capabilities, racine en lecture seule, montages.
  • runc spec écrit un config.json générique ; umoci unpack en écrit un à partir de la configuration de l'image.
  • runc run enchaîne tout et supprime le conteneur à la fin ; create, start, kill et delete sé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.

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

6 questions
6 min.
70% requis

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

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn