Aller au contenu
L'architecture de Docker, du client à runc

L'architecture de Docker, du client à runc

100 · Comprendre ⏱ 45 min dockercontainerd

À la fin, vous saurez

  • Décrire le rôle du client, de dockerd, de containerd, du shim et de runc
  • Interroger directement l'API du démon Docker avec curl
  • Suivre le cycle de vie d'un conteneur avec docker events
  • Utiliser un contexte Docker pour piloter un hôte distant de façon sûre
  • Expliquer pourquoi l'accès au socket Docker équivaut à un accès root

Prérequis

Testé avec containerd 2.3.5 docker 29.8.1 runc 1.5.1 ubuntu 24.04 , vérifié le 1 octobre 2026

Pourquoi

« Docker » n'est pas un programme, c'est une chaîne de programmes. Tant qu'on ne la connaît pas, certains comportements restent incompréhensibles : pourquoi docker ps répond-il « failed to connect to the docker API » alors que Docker est installé ? Pourquoi le redémarrage du service Docker arrête-t-il toutes les applications d'un serveur ? Pourquoi Kubernetes a-t-il pu « abandonner Docker » sans que personne ne doive reconstruire ses images ? Et pourquoi les audits de sécurité s'alarment-ils dès qu'un conteneur monte /var/run/docker.sock ?

Les réponses tiennent toutes dans l'architecture. Cette leçon suit une commande docker run depuis votre clavier jusqu'au noyau.

Les concepts

Une chaîne de cinq maillons

    flowchart LR
  CLI["docker<br/>(client)"] -- "HTTP sur<br/>/var/run/docker.sock" --> D["dockerd<br/>(démon Docker)"]
  D -- "gRPC sur<br/>containerd.sock" --> C["containerd"]
  C --> S["containerd-shim-runc-v2<br/>(un par conteneur)"]
  S --> R["runc<br/>(éphémère)"]
  R --> P["Processus du conteneur"]
  
  1. Le client docker est un simple programme en ligne de commande. Il ne crée aucun conteneur : il traduit vos commandes en requêtes HTTP vers l'API du démon. Il peut parler à un démon local ou distant.
  2. Le démon dockerd expose l'API Docker Engine et porte les fonctions « de haut niveau » : la gestion des réseaux (ponts, règles de pare-feu, DNS interne), des volumes, des journaux, la construction d'images avec BuildKit, l'authentification auprès des registres. Il tourne en root.
  3. containerd gère le cycle de vie des conteneurs et le stockage des images : téléchargement, décompression des couches, préparation des systèmes de fichiers (les snapshots), création et supervision des tâches. Né dans Docker, il a été confié en 2017 à la CNCF, dont il est un projet diplômé. Kubernetes l'utilise directement.
  4. Le shim containerd-shim-runc-v2 est un petit processus, un par conteneur, qui reste le parent du processus du conteneur. Il conserve ses entrées-sorties, récupère son code de sortie, et découple la vie du conteneur de celle de containerd.
  5. runc est l'implémentation de référence de la spécification d'exécution OCI. Il lit le fichier config.json du conteneur, effectue tout le travail vu à la leçon précédente (namespaces, cgroups, capabilities, seccomp, pivot_root), lance le processus et se termine. Il n'existe donc pas de processus runc permanent.

Pourquoi tant de maillons ?

Ce découpage est le fruit de l'histoire, mais il a de vrais avantages :

  • Remplaçabilité. runc peut être remplacé par un autre runtime OCI : crun (écrit en C, plus rapide et plus économe), youki (Rust), runsc de gVisor (noyau en espace utilisateur) ou Kata Containers (micro-VM). containerd ne voit pas la différence.
  • Réutilisation. Kubernetes n'a besoin ni des réseaux ni des volumes de Docker : il a les siens. Il parle directement à containerd via la Container Runtime Interface (CRI). C'est pourquoi le retrait de dockershim en 2022 n'a rien changé pour les images : elles sont exécutées par le même containerd et le même runc qu'avant.
  • Résilience. Grâce au shim, containerd peut redémarrer (pour une mise à jour, par exemple) sans tuer les conteneurs.

Les deux maîtres de containerd

containerd range les objets de ses différents clients dans des namespaces containerd (un concept de rangement propre à containerd, sans rapport avec les namespaces du noyau). Docker utilise le namespace moby, Kubernetes le namespace k8s.io. Sur un même hôte, les deux ne se voient pas : un conteneur lancé par Kubernetes n'apparaît pas dans docker ps.

Le stockage des images depuis Docker 29

Pendant dix ans, dockerd a géré lui-même les images avec ses propres pilotes de stockage (graph drivers, dont overlay2). Depuis Docker 29, sur une installation neuve, c'est le magasin d'images de containerd qui s'en charge, avec ses snapshotters. Conséquences visibles : les couches se trouvent sous /var/lib/containerd et non plus seulement sous /var/lib/docker, et Docker sait désormais stocker nativement des images multi-architectures ou des artefacts OCI. Une installation mise à jour depuis une version antérieure conserve l'ancien stockage tant qu'on ne migre pas.

Docker Desktop

Sur macOS et Windows, il n'y a pas de noyau Linux. Docker Desktop démarre donc une petite machine virtuelle Linux et y fait tourner dockerd ; le client docker de votre poste lui parle à travers la frontière de la VM. Tout ce que décrit ce cours se passe dans cette VM. Deux conséquences pratiques : les montages de répertoires du poste vers les conteneurs traversent la frontière de la VM et sont plus lents qu'en natif, et Docker Desktop est un logiciel commercial. Il est gratuit pour l'usage personnel, l'enseignement, les projets libres non commerciaux et les entreprises de moins de 250 salariés et de moins de 10 millions de dollars de chiffre d'affaires annuel ; un abonnement est requis au-delà, ainsi que pour les administrations. Les collectivités clientes de Lyneko sont concernées : sur leurs postes, on privilégie Docker Engine dans WSL 2, ou des alternatives libres comme Podman Desktop, Rancher Desktop ou Colima.

En pratique

Le service et ses processus

Sur un hôte Linux, Docker Engine se compose de services systemd :

$ systemctl is-active docker containerd docker.socket
active
active
active
$ ps -o pid,ppid,user,cmd -C dockerd,containerd
    PID    PPID USER     CMD
   1718       1 root     /usr/bin/containerd
   2312       1 root     /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

Deux démons, tous deux root, tous deux enfants de systemd. L'option -H fd:// signifie que dockerd n'ouvre pas lui-même son socket d'écoute : il le reçoit de systemd, via l'unité docker.socket (activation par socket). L'option --containerd lui indique où joindre containerd.

$ systemctl cat docker.service | grep -E '^ExecStart|^Requires|^After|^Wants'
After=network-online.target nss-lookup.target docker.socket firewalld.service containerd.service time-set.target
Wants=network-online.target containerd.service
Requires=docker.socket
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

Le socket, porte d'entrée de l'API

$ ls -l /var/run/docker.sock
srw-rw---- 1 root docker 0 sept. 28 16:56 /var/run/docker.sock

Le s initial indique un socket Unix. Il appartient à root et au groupe docker, avec les droits de lecture et d'écriture pour les deux. Quiconque peut écrire dans ce fichier peut piloter le démon. Retenez-le pour la partie Sécurité.

Parler à l'API sans le client

Puisque le client n'est qu'un émetteur de requêtes HTTP, curl peut faire la même chose :

$ curl -s --unix-socket /var/run/docker.sock http://localhost/version | python3 -m json.tool
{
    "Platform": {
        "Name": "Docker Engine - Community"
    },
    "Version": "29.8.1",
    "ApiVersion": "1.56",
    "MinAPIVersion": "1.40",
    "Os": "linux",
    "Arch": "amd64",
    "Components": [
        {
            "Name": "Engine",
            "Version": "29.8.1",
            ...
        },
        {
            "Name": "containerd",
            "Version": "v2.3.5",
            ...
        },
        {
            "Name": "runc",
            "Version": "1.5.1",
            ...

(Sortie abrégée.) --unix-socket dit à curl de se connecter au socket au lieu d'ouvrir une connexion TCP ; le nom d'hôte localhost de l'URL est alors ignoré, mais obligatoire pour former une URL valide. On retrouve les trois maillons et leurs versions. ApiVersion est la version de l'API parlée par le démon ; le client négocie automatiquement la plus haute version commune, ce qui permet à un client récent de piloter un démon plus ancien, dans les limites de MinAPIVersion.

Allons plus loin et faisons tout le cycle de vie d'un conteneur à la main. Créer :

$ curl -s --unix-socket /var/run/docker.sock -X POST \
    -H 'Content-Type: application/json' \
    -d '{"Image":"alpine:3.22","Cmd":["echo","bonjour depuis l API"]}' \
    'http://localhost/v1.56/containers/create?name=via-api'
{"Id":"be9aa621638b1237ddbf0fd1317cda16347ca4b2ed5558ba3e5af4267ff98023","Warnings":[]}

Démarrer, puis attendre la fin :

$ curl -s -o /dev/null -w '%{http_code}\n' --unix-socket /var/run/docker.sock \
    -X POST http://localhost/v1.56/containers/via-api/start
204
$ curl -s --unix-socket /var/run/docker.sock -X POST http://localhost/v1.56/containers/via-api/wait
{"StatusCode":0}

204 No Content signale un succès sans corps de réponse. Lire la sortie :

$ curl -s --unix-socket /var/run/docker.sock \
    'http://localhost/v1.56/containers/via-api/logs?stdout=1' | cat -v
^A^@^@^@^@^@^@^Ubonjour depuis l API

Les huit octets bizarres avant le texte sont un en-tête de multiplexage : l'API fait passer la sortie standard et la sortie d'erreur dans le même flux, et préfixe chaque bloc par son origine (^A, soit l'octet 1, pour la sortie standard ; 2 pour la sortie d'erreur), trois octets nuls, puis sa longueur sur quatre octets (^U, soit 21, la longueur de « bonjour depuis l API » suivi d'un saut de ligne). Le client docker logs décode cet en-tête pour vous. Supprimer :

$ curl -s -o /dev/null -w '%{http_code}\n' --unix-socket /var/run/docker.sock \
    -X DELETE http://localhost/v1.56/containers/via-api
204

Vous venez de faire ce que fait docker run --name via-api alpine:3.22 echo ... : docker run n'est qu'un raccourci du client pour create, attach, start et wait. Tous les outils qui pilotent Docker (Compose, les extensions des IDE, Portainer, les bibliothèques Python ou Go, Testcontainers) passent par cette même API.

Suivre les événements du démon

docker events diffuse en continu tout ce qui se passe dans le démon. Lancez-le dans un terminal :

$ docker events --filter container=evenements --format '{{.Type}} {{.Action}} {{.Actor.Attributes.name}}'

Et dans un second terminal :

$ docker run --rm --name evenements alpine:3.22 true

Le premier terminal affiche :

container create evenements
container attach evenements
container start evenements
container die evenements
container destroy evenements

On voit les étapes décomposées par le client : création, rattachement aux flux, démarrage, fin du processus (die), puis suppression (destroy) demandée par --rm. Sans le filtre, vous verriez aussi les événements réseau (le conteneur branché puis débranché du réseau bridge) et, sur une machine active, ceux de tous les autres conteneurs. docker events est un excellent outil de diagnostic quand un conteneur redémarre en boucle ou disparaît sans explication.

Le processus 1 et docker-init

Le processus lancé par runc devient le PID 1 du conteneur. Or le PID 1 a des responsabilités particulières sous Linux : il doit récupérer les processus orphelins devenus zombies, et le noyau ne lui applique pas le comportement par défaut des signaux (nous y reviendrons à la leçon 6, avec ses conséquences sur docker stop). L'option --init intercale un init minimal fourni avec Docker :

$ docker run --rm --init alpine:3.22 ps
PID   USER     TIME  COMMAND
    1 root      0:00 /sbin/docker-init -- ps
    7 root      0:00 ps

docker-init est en fait tini, un init de quelques centaines de lignes de C, monté dans le conteneur par Docker. Il relaie les signaux à votre programme et ramasse les zombies.

Piloter un hôte distant avec un contexte

Le client peut parler à un démon distant. La méthode recommandée passe par SSH, sans rien exposer de plus sur le serveur :

$ docker context create distant --description "Hôte de recette" --docker host=ssh://deploy@recette.exemple.fr
distant
Successfully created context "distant"
$ docker context ls
NAME        DESCRIPTION                               DOCKER ENDPOINT                   ERROR
default *   Current DOCKER_HOST based configuration   unix:///var/run/docker.sock
distant     Hôte de recette                           ssh://deploy@recette.exemple.fr

docker context use distant bascule toutes les commandes suivantes vers ce serveur ; docker --context distant ps le fait pour une seule commande. Le client ouvre une connexion SSH et y fait passer l'API, comme nous l'avons fait avec curl. L'utilisateur deploy doit pouvoir accéder au socket Docker distant. Supprimez le contexte de démonstration avec docker context rm distant.

Sous le capot

Voici ce qui se passe, maillon par maillon, pour docker run -d -p 8080:80 nginx:1.29 :

  1. Client. Il envoie POST /containers/create avec la configuration (image, ports, variables, limites). Si le démon répond que l'image est absente, le client demande POST /images/create (le pull) et affiche la progression, puis recommence. Il envoie ensuite POST /containers/<id>/start.
  2. dockerd. Il valide la configuration, réserve le nom, crée la description du conteneur et la configuration réseau : il prépare une interface sur le pont docker0, choisit une adresse IP, écrit les règles de pare-feu qui publient le port 8080 et lance, si nécessaire, un petit relais docker-proxy. Il traduit le tout en spécification OCI et appelle containerd par gRPC.
  3. containerd. Il prépare un snapshot : une vue overlay des couches de l'image plus une couche inscriptible. Il écrit le bundle OCI (config.json et la racine) sous /run/containerd/io.containerd.runtime.v2.task/moby/<id>/, puis lance un shim.
  4. Shim et runc. Le shim appelle runc create puis runc start. runc crée les namespaces et le cgroup, configure la racine, se retire.
  5. Retour à dockerd. Le démon branche l'interface réseau dans le namespace du conteneur, branche la collecte des journaux et rend la main au client, qui affiche l'identifiant.

Et quand dockerd s'arrête ? Par défaut, il arrête tous les conteneurs avant de s'éteindre. L'option live-restore ("live-restore": true dans /etc/docker/daemon.json) change ce comportement : les conteneurs continuent de tourner, portés par leurs shims, et dockerd les retrouve à son redémarrage. Sur l'hôte de test, elle est désactivée, comme sur toute installation par défaut :

$ docker info --format '{{.LiveRestoreEnabled}}'
false

Pièges courants

« failed to connect to the docker API ». Quand le client ne parvient pas à joindre le démon, Docker 29 affiche un message de ce type (les versions antérieures disaient Cannot connect to the Docker daemon) :

$ DOCKER_HOST=unix:///var/run/absent.sock docker ps
failed to connect to the docker API at unix:///var/run/absent.sock; check if the path is correct and if the daemon is running: dial unix /var/run/absent.sock: connect: no such file or directory

La fin du message donne la cause réelle. no such file or directory : le socket n'existe pas, soit parce que le démon et docker.socket sont arrêtés (systemctl status docker docker.socket), soit parce que DOCKER_HOST ou le contexte actif pointe ailleurs (docker context ls, l'astérisque marque le contexte actif). connection refused : le socket existe mais personne n'écoute derrière. Et quand l'utilisateur n'a pas le droit d'ouvrir le socket, le message change :

permission denied while trying to connect to the docker API at unix:///var/run/docker.sock

C'est le cas typique juste après l'installation, avant d'avoir réglé la question des droits (leçon suivante).

Redémarrer Docker en production sans le savoir. Une mise à jour du paquet docker-ce redémarre dockerd, ce qui, sans live-restore, arrête et relance tous les conteneurs. Planifiez ces mises à jour comme des interruptions de service, ou activez live-restore (qui ne couvre que les mises à jour correctives, d'une version patch à la suivante).

Chercher les conteneurs Kubernetes avec docker ps. Sur un nœud Kubernetes qui a aussi Docker, docker ps ne montre rien des pods : ils sont dans le namespace containerd k8s.io. Les outils sont crictl ps ou ctr -n k8s.io containers list.

Croire qu'une image ancienne a disparu après la mise à jour vers Docker 29. Après une mise à jour (et non une installation neuve), Docker garde l'ancien stockage. Si vous activez le magasin d'images containerd sur un hôte existant, les images et conteneurs de l'ancien stockage deviennent invisibles (sans être supprimés) jusqu'à ce que vous reveniez en arrière ou les reconstruisiez.

Sécurité

Le socket Docker est un accès root. C'est la conséquence la plus importante de cette leçon. dockerd tourne en root et exécute ce qu'on lui demande : créer un conteneur privilégié, monter / de l'hôte dans un conteneur, partager le namespace PID de l'hôte. Donc :

  • être membre du groupe docker, c'est être root sur la machine (la leçon suivante le démontre) ;
  • monter /var/run/docker.sock dans un conteneur (pratique courante pour les outils de supervision, Traefik, Portainer ou certains agents de CI), c'est donner à ce conteneur le contrôle de l'hôte. Si ce conteneur est compromis, l'hôte l'est aussi. Quand c'est indispensable, on intercale un proxy de socket qui ne laisse passer que les appels en lecture nécessaires.

N'exposez jamais l'API en TCP sans TLS mutuel. dockerd -H tcp://0.0.0.0:2375 ouvre un accès root à quiconque atteint ce port, sans mot de passe. Des campagnes automatisées balaient Internet à la recherche de ce port pour y lancer des mineurs de cryptomonnaie ou des portes dérobées. Pour un accès distant, utilisez un contexte SSH ; si une API réseau est indispensable, elle se configure sur le port 2376 avec authentification mutuelle par certificats (--tlsverify), et les clés client se protègent comme des mots de passe root.

Moins de démons, moins de surface. Une partie de l'intérêt de Podman (sans démon) ou du mode rootless de Docker vient de là : il n'y a plus de service root à l'écoute qui exécute des ordres.

En production

  • Sur Kubernetes, Docker Engine n'est plus là. Les nœuds Kapsule de Scaleway, comme ceux de la plupart des offres managées, exécutent containerd directement. Vos compétences sur les images, l'isolation et le diagnostic restent valables ; les commandes changent (crictl au lieu de docker).
  • Sur un serveur Docker « simple », activez live-restore, planifiez les mises à jour du moteur, et supervisez les deux démons (dockerd et containerd) : si containerd tombe, plus aucun conteneur ne peut démarrer.
  • Choisissez le runtime selon le risque. Pour du code non fiable (outils d'analyse de fichiers déposés par des usagers, environnements d'exécution de code pour des étudiants), un runtime à isolation renforcée comme gVisor ou Kata Containers se branche à la place de runc sans changer les images.
  • Pour l'automatisation, préférez l'API (via une bibliothèque officielle) au scraping de la sortie texte de docker. Le format JSON de l'API est stable et versionné ; la mise en page des commandes ne l'est pas.

Exercices

1. Lister sans client. Avec curl et le socket, obtenez la liste des conteneurs en cours d'exécution (point d'entrée GET /containers/json), puis la liste des images (GET /images/json). Comparez avec docker ps et docker images.

Solution
$ curl -s --unix-socket /var/run/docker.sock http://localhost/containers/json | python3 -m json.tool | grep -E '"Names"|"Image"'
$ curl -s --unix-socket /var/run/docker.sock http://localhost/images/json | python3 -m json.tool | grep RepoTags -A1

Sans numéro de version dans l'URL, le démon utilise sa version d'API courante. GET /containers/json?all=1 inclut les conteneurs arrêtés, comme docker ps -a. Les informations sont les mêmes que celles du client, qui n'en est qu'une mise en forme.

2. Observer un redémarrage en boucle. Lancez docker events dans un terminal, puis dans un autre : docker run -d --name boucle --restart on-failure:3 alpine:3.22 sh -c 'sleep 1; exit 1'. Décrivez la séquence d'événements. Supprimez ensuite le conteneur.

Solution

On observe create, start, puis trois fois de suite die suivi de start (Docker redémarre le conteneur selon la politique on-failure:3), avec des délais croissants entre les tentatives, puis un die final. L'attribut exitCode=1 apparaît dans les événements die si l'on retire --format. Nettoyez avec docker rm -f boucle. Cette méthode permet de comprendre en quelques secondes un conteneur qui « clignote ».

3. Le bon accès distant. Un prestataire demande, pour son outil de déploiement, que le démon Docker d'un serveur de production écoute sur tcp://0.0.0.0:2375. Rédigez en quelques lignes votre réponse et une alternative.

Solution

Refus : le port 2375 expose l'API sans authentification ni chiffrement, ce qui donne un accès root au serveur à toute personne qui peut l'atteindre. Alternatives, de la plus simple à la plus robuste : un contexte Docker par SSH avec un compte dédié et une clé restreinte ; une API sur le port 2376 avec TLS mutuel et des certificats clients émis pour ce seul usage, restreinte au réseau du prestataire par le pare-feu ; ou mieux, un déploiement « tiré » par le serveur lui-même (approche GitOps, voir le chapitre Livrer), qui supprime tout accès entrant.

4. Docker Desktop dans une collectivité (niveau 200). Une mairie de 400 agents veut équiper ses trois développeurs de Docker Desktop. Quel est le problème, et que proposez-vous ?

Solution

Docker Desktop exige un abonnement payant pour les administrations, quelle que soit leur taille, et pour les organisations de plus de 250 salariés ou de plus de 10 millions de dollars de chiffre d'affaires. Soit la mairie achète trois licences (le choix le plus simple si elle veut le support), soit elle s'équipe d'une alternative libre : Docker Engine installé dans WSL 2 sous Windows, Podman Desktop, Rancher Desktop (qui embarque containerd ou dockerd) ou Colima sous macOS. Le format d'image étant standard (OCI), les images produites sont les mêmes.

Récapitulatif

  • docker (client) parle HTTP à dockerd par le socket /var/run/docker.sock ; dockerd délègue à containerd, qui lance un shim par conteneur, qui appelle runc, qui crée le conteneur et se termine.
  • Toute la fonctionnalité est accessible par l'API Docker Engine ; docker run n'est qu'une combinaison de create, attach, start et wait.
  • Kubernetes parle directement à containerd via la CRI : c'est pourquoi il a pu se passer de Docker Engine sans toucher aux images.
  • Depuis Docker 29, les images sont stockées par défaut dans le magasin d'images de containerd.
  • Accès au socket Docker = accès root. On ne l'expose jamais en TCP sans TLS mutuel ; on préfère SSH pour l'accès distant.

Pour aller plus loin

  • La référence de l'API Docker Engine : utile dès que vous automatisez.
  • La documentation de containerd, en particulier ses notions de snapshotter, de content store et de namespaces.
  • Le dépôt de runc et son fichier libcontainer/SPEC.md, qui décrit précisément ce que fait runc pour créer un conteneur.
  • Leçon suivante : installer Docker Engine proprement, et comprendre ce qu'implique le groupe docker.

Sources