Un conteneur à la main : namespaces, cgroups, capabilities
Pourquoi
La leçon précédente a posé une affirmation : un conteneur n'est qu'un processus Linux ordinaire, isolé par des namespaces et limité par des cgroups. Une affirmation ne suffit pas. Tant que Docker reste une boîte noire, chaque incident ressemble à de la magie : pourquoi mon conteneur a-t-il été tué sans message ? Pourquoi ping fonctionne-t-il mais pas mount alors que je suis root ? Pourquoi le processus que je vois dans le conteneur porte-t-il un autre numéro sur l'hôte ?
Dans cette leçon, nous allons fabriquer un conteneur sans Docker, pièce par pièce, avec des outils présents sur toute distribution Linux. Ensuite, nous retrouverons chacune de ces pièces dans un vrai conteneur Docker. Une fois ce démontage fait, vous ne regarderez plus jamais un docker run de la même façon.
Les concepts
Les namespaces : ce qu'un processus voit
Un namespace (espace de noms) enveloppe une ressource globale du système de sorte que les processus qui sont dedans en aient leur propre instance, isolée. Linux en propose huit types :
| Namespace | Option unshare | Ce qu'il isole | Ce que voit le processus |
|---|---|---|---|
mnt | --mount | Les points de montage | Son propre arbre de systèmes de fichiers |
uts | --uts | Nom d'hôte et nom de domaine NIS | Son propre hostname |
ipc | --ipc | Files de messages et mémoire partagée System V | Ses propres objets IPC |
pid | --pid | Les numéros de processus | Un arbre où il peut être le PID 1 |
net | --net | Interfaces, routes, pare-feu, ports | Sa propre pile réseau, vide au départ |
user | --user | Les UID et GID | Un root qui n'est root que dans son namespace |
cgroup | --cgroup | La vue de la hiérarchie des cgroups | Son cgroup comme racine |
time | --time | Les horloges CLOCK_MONOTONIC et CLOCK_BOOTTIME | Un temps depuis le démarrage décalé |
Chaque processus appartient à exactement un namespace de chaque type. Au démarrage, tous les processus partagent les namespaces initiaux. Trois appels système permettent d'en sortir : clone() crée un processus dans de nouveaux namespaces, unshare() fait quitter au processus courant certains de ses namespaces, setns() rejoint un namespace existant (c'est ce que fait docker exec).
Les cgroups : ce qu'un processus consomme
Un cgroup (control group, groupe de contrôle) regroupe des processus pour mesurer et limiter leur consommation de ressources. Depuis la version 2 (la seule activée par défaut sur les distributions récentes, la version 1 étant dépréciée par Docker 29), les cgroups forment une seule arborescence, exposée comme un système de fichiers sous /sys/fs/cgroup. On les manipule avec mkdir, echo et cat :
- créer un cgroup, c'est créer un répertoire ;
- y placer un processus, c'est écrire son PID dans le fichier
cgroup.procs; - fixer une limite, c'est écrire dans un fichier de contrôleur :
memory.max,cpu.max,pids.max,io.max.
Les limites sont hiérarchiques : un cgroup ne peut jamais dépasser les limites de son parent.
Changer de racine : chroot et pivot_root
Pour qu'un processus voie le système de fichiers d'Alpine plutôt que celui de l'hôte, il faut changer sa racine. chroot le fait depuis 1979, mais il ne fait que changer le point de départ de la résolution des chemins : un processus root dans un chroot peut en sortir avec quelques manipulations connues. Les moteurs de conteneurs utilisent plutôt pivot_root, qui remplace réellement la racine du namespace de montage puis permet de démonter l'ancienne. Pour notre démonstration, chroot suffit.
Restreindre root : capabilities, seccomp, LSM
Historiquement, Unix ne connaît que deux catégories : root, qui peut tout, et les autres. Les capabilities découpent les pouvoirs de root en une quarantaine de privilèges distincts : CAP_NET_BIND_SERVICE (écouter sur un port inférieur à 1024), CAP_SYS_ADMIN (monter des systèmes de fichiers, et beaucoup d'autres choses), CAP_SYS_TIME (changer l'heure), etc. Un processus peut être root (UID 0) et n'avoir qu'une partie de ces privilèges.
Deux autres barrières complètent le dispositif :
- seccomp filtre les appels système qu'un processus a le droit de faire ;
- un LSM (Linux Security Module) comme AppArmor (Ubuntu, Debian) ou SELinux (Red Hat, Fedora) applique une politique de contrôle d'accès obligatoire sur les fichiers et les opérations.
En pratique
Un environnement de laboratoire
Les manipulations qui suivent créent des namespaces et des cgroups, ce qui demande les droits root. Faites-les dans une machine virtuelle jetable ou une machine de test, jamais sur un serveur partagé. Les commandes précédées de $ s'exécutent avec votre compte ; celles précédées de # dans un shell root (sudo -i), depuis le répertoire labo (cd /home/<vous>/labo, puisque sudo -i change de répertoire personnel).
Note
Les sorties de cette partie ont été capturées en root dans un environnement de laboratoire Ubuntu 24.04 jetable dont le nom d'hôte est labo : faute de VM sous la main, un conteneur Docker privilégié (option --privileged, présentée plus bas), qui a sa propre interface eth0 et sa propre sous-arborescence de cgroups. Sur votre machine, les noms, numéros et identifiants seront différents ; la forme sera la même.
Il nous faut d'abord un système de fichiers racine. Plutôt que de le construire, empruntons celui d'Alpine en exportant le contenu d'un conteneur créé (sans le démarrer) :
$ mkdir -p ~/labo/alpine-rootfs && cd ~/labo
$ docker export $(docker create alpine:3.22) | tar -x -C alpine-rootfs
$ ls alpine-rootfs
bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var
$ du -sh alpine-rootfs
8,6M alpine-rootfs
docker create crée un conteneur sans le lancer et affiche son identifiant ; docker export écrit son système de fichiers sous forme d'archive tar. Huit mégaoctets et demi : c'est tout l'espace utilisateur d'une distribution Linux minimale. (Si vous n'avez pas encore Docker, l'archive minirootfs téléchargeable sur alpinelinux.org donne le même résultat.)
Observer ses propres namespaces
Chaque processus expose ses namespaces sous /proc/<pid>/ns. Chaque lien pointe vers un identifiant d'inode : deux processus qui affichent le même numéro partagent le même namespace. Sur l'hôte, pour le shell courant :
$ ls -l /proc/$$/ns | awk '{print $9,$10,$11}'
cgroup -> cgroup:[4026531835]
ipc -> ipc:[4026531839]
mnt -> mnt:[4026531832]
net -> net:[4026531833]
pid -> pid:[4026531836]
pid_for_children -> pid:[4026531836]
time -> time:[4026531834]
time_for_children -> time:[4026531834]
user -> user:[4026531837]
uts -> uts:[4026531838]
Puis dans un conteneur Docker, qui lit ses propres liens via /proc/self :
$ docker run --rm alpine:3.22 ls -l /proc/self/ns | awk '{print $9,$10,$11}'
cgroup -> cgroup:[4026532994]
ipc -> ipc:[4026532991]
mnt -> mnt:[4026532798]
net -> net:[4026532996]
pid -> pid:[4026532993]
pid_for_children -> pid:[4026532993]
time -> time:[4026533609]
time_for_children -> time:[4026533609]
user -> user:[4026531837]
uts -> uts:[4026532986]
Sept namespaces sur huit sont différents. Un seul est partagé avec l'hôte : user, avec le même numéro 4026531837. Retenez ce détail, il est au cœur de la partie Sécurité : par défaut, l'UID 0 dans un conteneur Docker est le même UID 0 que sur l'hôte.
Le namespace time privé est récent : Docker l'active par défaut depuis la version 29.5 sur les noyaux qui le permettent.
Étape 1 : un nom d'hôte à soi
# hostname
labo
# unshare --uts sh -c 'hostname mon-conteneur; hostname'
mon-conteneur
# hostname
labo
unshare --uts lance sh dans un nouveau namespace UTS, copie de l'original. Le changement de nom d'hôte n'affecte que ce namespace ; à la sortie de sh, le namespace disparaît avec son dernier processus, et l'hôte n'a rien vu.
Étape 2 : un arbre de processus à soi
# unshare --pid --fork --mount-proc ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 09:43 ? 00:00:00 ps -ef
Trois options travaillent ensemble :
--pidcrée un nouveau namespace de PID. Particularité : ce n'est pas le processus appelant qui y entre, mais ses enfants.--forkfait donc lancer la commande comme enfant deunshare, pour qu'elle devienne le PID 1 du nouveau namespace.--mount-proccrée aussi un namespace de montage et y remonte/proc. Sans cela,pslirait le/procde l'hôte et afficherait tous ses processus : l'outilpsne fait que lire/proc.
ps se voit comme le processus 1, seul au monde.
Étape 3 : une pile réseau à soi
# ip -br link
lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
eth0@if173 UP 76:a1:43:f5:8a:47 <BROADCAST,MULTICAST,UP,LOWER_UP>
# unshare --net ip -br link
lo DOWN 00:00:00:00:00:00 <LOOPBACK>
Un namespace réseau neuf ne contient qu'une interface de boucle locale, éteinte. Pas de carte réseau, pas de route, pas de règle de pare-feu. Pour qu'un conteneur communique, le moteur doit créer une paire d'interfaces virtuelles (veth), en placer une extrémité dans le namespace et l'autre sur un pont de l'hôte. Ce sera l'objet de la leçon 10.
Étape 4 : tout assembler avec une racine Alpine
Combinons tous les namespaces et changeons de racine, depuis le répertoire labo :
# unshare --mount --uts --ipc --net --pid --fork \
--mount-proc=$PWD/alpine-rootfs/proc \
chroot alpine-rootfs /bin/sh -c \
"hostname conteneur-maison; hostname; head -1 /etc/os-release; ps; ip link; id"
conteneur-maison
NAME="Alpine Linux"
PID USER TIME COMMAND
1 root 0:00 /bin/sh -c hostname conteneur-maison; hostname; head -1 /etc/os-release; ps; ip link; id
5 root 0:00 ps
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
uid=0(root) gid=0(root) groups=0(root)
--mount-proc= reçoit ici le chemin du /proc dans la nouvelle racine, puisque c'est celui que ps lira après le chroot. Le résultat a toutes les apparences d'un conteneur : un autre système (Alpine), son propre nom d'hôte, son propre arbre de processus dont il est le PID 1, aucun réseau. Les commandes ps, ip et id sont celles de BusyBox, fournies par Alpine, et non celles de l'hôte Ubuntu.
Il manque deux choses pour égaler Docker : des limites de ressources et des restrictions de privilèges. Notre sh est root avec tous les pouvoirs.
Étape 5 : limiter la mémoire avec un cgroup
En cgroups v2, un cgroup qui distribue des ressources à des sous-groupes ne peut pas contenir lui-même de processus (règle dite no internal processes), à l'exception de la vraie racine de la machine. Sur une machine réelle, systemd gère déjà l'arborescence et on crée son groupe sous une tranche existante. Dans notre laboratoire, la « racine » visible est en réalité un cgroup délégué au conteneur de laboratoire, soumis à la règle : on déplace d'abord le shell courant dans un groupe feuille, puis on active les contrôleurs voulus pour les enfants :
# cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma misc dmem
# mkdir /sys/fs/cgroup/init && echo $$ > /sys/fs/cgroup/init/cgroup.procs
# echo "+memory +pids +cpu" > /sys/fs/cgroup/cgroup.subtree_control
Créons maintenant le groupe demo, limité à 50 Mio de mémoire et sans droit au swap :
# mkdir /sys/fs/cgroup/demo
# echo 50M > /sys/fs/cgroup/demo/memory.max
# echo 0 > /sys/fs/cgroup/demo/memory.swap.max
# cat /sys/fs/cgroup/demo/memory.max
52428800
Le noyau a converti 50M en octets : 50 × 1024 × 1024. Lançons dans ce groupe un processus gourmand. tail /dev/zero cherche la dernière ligne d'un flux infini d'octets nuls qui ne contient aucun saut de ligne : il accumule tout en mémoire, sans fin.
# sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs; tail /dev/zero'; echo "code de sortie : $?"
Killed
code de sortie : 137
# cat /sys/fs/cgroup/demo/memory.events
low 0
high 0
max 40
oom 1
oom_kill 1
oom_group_kill 0
sock_throttled 0
Le sous-shell écrit son propre PID ($$) dans cgroup.procs, ce qui le place dans le groupe ; tail, son enfant, en hérite. Dès que la consommation du groupe atteint 50 Mio, le noyau déclenche l'OOM killer (out of memory) à l'intérieur du groupe et tue le processus fautif. Le reste de la machine n'a rien senti. Le code de sortie 137 se lit comme 128 + 9 : le processus a été tué par le signal 9, SIGKILL. Le fichier memory.events garde la trace : 40 fois la limite atteinte, un OOM, un processus tué.
Étape 6 : limiter le nombre de processus
Le contrôleur pids protège contre les fork bombs et les fuites de processus :
# echo 5 > /sys/fs/cgroup/demo/pids.max
# sh -c 'echo $$ > /sys/fs/cgroup/demo/cgroup.procs; for i in 1 2 3 4 5 6; do sleep 30 & done; wait'
sh: 0: Cannot fork
# cat /sys/fs/cgroup/demo/pids.events
max 1
Le shell plus quatre sleep atteignent la limite de cinq ; le cinquième sleep est refusé par le noyau.
Retrouver tout cela dans Docker
Faisons maintenant la même expérience avec Docker, sans sudo, depuis l'hôte :
$ docker run --name gourmand --memory 50m --memory-swap 50m alpine:3.22 tail /dev/zero
$ echo "code de sortie : $?"
code de sortie : 137
$ docker inspect gourmand --format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}}'
OOMKilled=true ExitCode=137
$ docker rm gourmand
Même limite, même mort silencieuse, même code 137. --memory-swap fixe la limite mémoire plus swap : en la rendant égale à --memory, on interdit le swap, comme notre memory.swap.max à zéro. Docker, lui, a pris soin de noter OOMKilled=true dans l'état du conteneur, ce qui fait gagner beaucoup de temps de diagnostic.
Où est le cgroup d'un conteneur ? Il suffit de demander au noyau :
$ docker run -d --name limite --memory 50m --cpus 0.5 --pids-limit 100 alpine:3.22 sleep 600
$ PID=$(docker inspect limite --format '{{.State.Pid}}')
$ cat /proc/$PID/cgroup
0::/system.slice/docker-ebf0ab214a0f4ed5b019bad414596df49e6fe9e0531f5cccbd797bfa3fafcbf5.scope
$ D=/sys/fs/cgroup/system.slice/docker-$(docker inspect limite --format '{{.Id}}').scope
$ for f in memory.max cpu.max pids.max; do echo "$f: $(cat $D/$f)"; done
memory.max: 52428800
cpu.max: 50000 100000
pids.max: 100
$ docker rm -f limite
Les options de docker run ne sont qu'une manière commode d'écrire dans ces fichiers :
--memory 50mdevientmemory.max = 52428800;--cpus 0.5devientcpu.max = 50000 100000: 50 000 microsecondes de temps processeur par période de 100 000, soit la moitié d'un cœur ;--pids-limit 100devientpids.max = 100.
Le cgroup s'appelle docker-<id>.scope et vit sous system.slice parce que Docker délègue la gestion des cgroups à systemd (Cgroup Driver: systemd dans docker info).
Root, mais pas tout à fait
Dans un conteneur Docker, le processus est root, mais essayez de changer le nom d'hôte ou de monter un système de fichiers :
$ docker run --rm alpine:3.22 hostname pirate
hostname: sethostname: Operation not permitted
$ docker run --rm alpine:3.22 mount -t tmpfs none /mnt
mount: permission denied (are you root?)
Refusé, alors que nous sommes root, et que dans notre conteneur fait main (créé par un root disposant de tous ses privilèges) hostname fonctionnait. La différence tient aux capabilities. Regardons celles d'un processus du conteneur :
$ docker run --rm alpine:3.22 grep Cap /proc/self/status
CapInh: 0000000000000000
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
$ capsh --decode=00000000a80425fb
0x00000000a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap
CapEff (effectives) est un masque de bits ; capsh --decode le traduit. Docker ne laisse au root du conteneur que 14 capabilities, sur la quarantaine qui existent (le masque limitant d'un processus de l'hôte, CapBnd: 000001ffffffffff, en compte 41 sur ce noyau). Ni CAP_SYS_ADMIN (montages, et le nom d'hôte relève aussi de lui), ni CAP_NET_ADMIN (configuration réseau), ni CAP_SYS_MODULE (chargement de modules noyau), ni CAP_SYS_TIME.
Les deux autres barrières sont visibles aussi :
$ docker run --rm alpine:3.22 grep Seccomp /proc/self/status
Seccomp: 2
Seccomp_filters: 1
$ docker run --rm alpine:3.22 cat /proc/self/attr/current
docker-default (enforce)
Seccomp: 2 signifie « mode filtre » : le profil seccomp par défaut de Docker bloque plusieurs dizaines d'appels système rarement utiles et souvent dangereux (kexec_load, open_by_handle_at, bpf dans la plupart des cas, etc.). Et le profil AppArmor docker-default est appliqué en mode bloquant.
Sous le capot
Docker ne fait pas autre chose que ce que nous venons de faire, mais il le fait proprement, via runc. runc reçoit un bundle OCI : un répertoire contenant la racine du conteneur et un fichier config.json qui décrit, entre autres, les namespaces à créer, les limites de cgroup, les capabilities à conserver, le profil seccomp, les points de montage. Voici, résumé, le travail de runc pour un docker run :
- créer les namespaces (appels
clone()etunshare()avec les drapeauxCLONE_NEWNS,CLONE_NEWPID,CLONE_NEWNET, etc., en plusieurs étapes internes) ; - placer le processus dans son cgroup ;
- préparer les montages : la racine overlay,
/proc,/dev(un tmpfs avec seulement les périphériques autorisés),/sysen lecture seule ; - masquer des chemins sensibles : regardez
/proc/self/mountinfodans un conteneur et vous verrez des tmpfs vides montés en lecture seule par-dessus/proc/acpi,/proc/scsiou/sys/firmware; pivot_rootvers la nouvelle racine et démonter l'ancienne ;- abandonner les capabilities non autorisées, appliquer le profil AppArmor, installer le filtre seccomp ;
- exécuter le programme demandé avec
execve(), qui devient le PID 1 du conteneur.
La racine du conteneur se voit aussi depuis l'intérieur :
$ docker run --rm alpine:3.22 sh -c 'mount | head -1' | cut -c1-120
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/106
C'est un système de fichiers overlay qui empile les couches de l'image (lowerdir) et une couche inscriptible propre au conteneur. Depuis Docker 29, sur une installation neuve, ces couches sont gérées par containerd, d'où le chemin /var/lib/containerd. La leçon 7 explique ce mécanisme.
Pièges courants
unshare refusé sans sudo sur Ubuntu. Depuis Ubuntu 23.10, et par défaut sur 24.04, AppArmor interdit aux programmes non autorisés de créer des user namespaces sans privilège. La commande unshare --user --map-root-user, qui fonctionne sur Debian ou Fedora, échoue alors ainsi :
$ unshare --user --map-root-user id
unshare: échec d'écriture /proc/self/uid_map: Opération non permise
$ sysctl kernel.apparmor_restrict_unprivileged_userns
kernel.apparmor_restrict_unprivileged_userns = 1
C'est une mesure de sécurité délibérée : les user namespaces non privilégiés ont été à l'origine de nombreuses élévations de privilèges, parce qu'ils exposent à un utilisateur ordinaire du code du noyau autrefois réservé à root. Ne la désactivez pas globalement pour un exercice ; travaillez en root dans une VM de test.
Oublier --fork avec --pid. Sans --fork, unshare --pid sh lance sh dans l'ancien namespace (seuls les enfants entrent dans le nouveau), et la première commande lancée par ce shell devient PID 1 puis se termine, ce qui rend le namespace inutilisable. Les commandes suivantes échouent avec un message trompeur :
# unshare --pid bash -c "/bin/true; /bin/ls / >/dev/null; echo fin"
bash: fork: Cannot allocate memory
/bin/true est devenu le PID 1 du nouveau namespace puis s'est terminé ; le noyau refuse alors toute nouvelle création de processus dans ce namespace, et bash traduit ce refus par une erreur de mémoire qui n'en est pas une.
Oublier --mount-proc. ps affiche alors tous les processus de l'hôte et on croit que l'isolation PID ne fonctionne pas, alors que c'est seulement /proc qui n'a pas été remonté.
Croire qu'un conteneur tué est un plantage applicatif. Un code de sortie 137 sans aucun message d'erreur dans les journaux de l'application est presque toujours un OOM du cgroup. Le diagnostic se lit dans docker inspect (OOMKilled) ou, au niveau du noyau, dans memory.events et dmesg.
Sécurité
Cette leçon éclaire les limites de l'isolation présentées en leçon 1 :
- Le namespace
usern'est pas utilisé par défaut. Un processus root dans le conteneur est l'UID 0 de l'hôte. Si une faille ou une mauvaise configuration lui permet d'atteindre un fichier de l'hôte (un répertoire monté, le socket Docker), il y agit en root. Docker propose un mode userns-remap et un mode rootless qui changent cela ; la leçon 12 les présente. - La défense est en profondeur. Namespaces, capabilities réduites, seccomp et AppArmor sont des barrières indépendantes. Chacune peut avoir une faille ; il faut les franchir toutes. Les options qui les retirent (
--privileged,--cap-add SYS_ADMIN,--security-opt seccomp=unconfined) suppriment ces couches.--privilegeden particulier rend toutes les capabilities, désactive seccomp et AppArmor et donne accès aux périphériques de l'hôte : un conteneur privilégié n'est plus isolé de façon significative. - Les limites de ressources sont aussi une mesure de sécurité. Sans
--memoryni--pids-limit, un conteneur compromis ou défaillant peut épuiser la mémoire ou la table des processus de l'hôte et faire tomber ses voisins. C'est un déni de service interne, facile à prévenir.
Caution
Le laboratoire de cette leçon a été mené en root dans un environnement jetable. N'écrivez jamais dans /sys/fs/cgroup à la racine d'une machine de production : vous perturberiez la gestion des ressources de systemd.
En production
Personne ne crée de conteneurs avec unshare en production, mais ce démontage a des conséquences concrètes :
- Toujours fixer des limites. Un conteneur sans limite mémoire est une bombe à retardement sur un hôte partagé. Dans Kubernetes, les champs
resources.limitsdes pods se traduisent exactement enmemory.maxetcpu.max, par le même chemin (kubelet, containerd, runc). - Comprendre
cpu.max. Une limite de 0,5 CPU ne réserve pas un demi-cœur : elle autorise 50 ms de calcul toutes les 100 ms. Une application multi-thread peut consommer son quota en 10 ms puis rester bridée (throttled) les 90 ms suivantes, ce qui produit des latences en dents de scie. Le fichiercpu.stat(champsnr_throttled,throttled_usec) permet de le mesurer. Le cours sur la performance y reviendra. - Diagnostiquer avec les outils du noyau. Quand l'outillage Docker ne suffit plus,
nsenter --target <pid> --netpermet d'entrer dans le namespace réseau d'un conteneur avec les outils de l'hôte (ss,tcpdump), même si l'image n'en contient aucun.
Exercices
1. Compter les namespaces. Démarrez un conteneur avec docker run -d --name ns alpine:3.22 sleep 300. En comparant /proc/self/ns sur l'hôte et docker exec ns ls -l /proc/self/ns, listez les namespaces partagés avec l'hôte. Recommencez avec docker run -d --name ns2 --network host --pid host alpine:3.22 sleep 300. Qu'est-ce qui change ?
Solution
Avec la configuration par défaut, seul le namespace user est partagé. Avec --network host --pid host, les namespaces net et pid deviennent identiques à ceux de l'hôte : le conteneur voit toutes les interfaces réseau et tous les processus de la machine (essayez docker exec ns2 ps). Ces options suppriment volontairement une partie de l'isolation ; elles servent à des outils de supervision, rarement à des applications. Nettoyez avec docker rm -f ns ns2.
2. Lire les limites d'un conteneur. Lancez un conteneur avec --memory 256m --cpus 1.5. Sans regarder docker inspect, retrouvez depuis l'hôte la valeur de memory.max et de cpu.max, et expliquez-les.
Solution
Trouvez le PID avec docker inspect --format '{{.State.Pid}}' <nom> (c'est le seul passage par Docker), puis cat /proc/<pid>/cgroup donne le chemin du cgroup sous /sys/fs/cgroup. On y lit memory.max = 268435456 (256 × 1024 × 1024) et cpu.max = 150000 100000 : 150 ms de temps processeur par période de 100 ms, soit l'équivalent d'un cœur et demi, réparti sur autant de cœurs que l'application en utilise.
3. Le code de sortie. Un collègue vous signale qu'un conteneur de traitement de fichiers « s'arrête au hasard » avec le code 137, sans message dans ses journaux. Donnez deux causes possibles et la commande qui les distingue.
Solution
137 = 128 + 9, le processus a reçu SIGKILL. Deux causes typiques : un OOM du cgroup (limite mémoire atteinte), ou un arrêt forcé (un docker kill, ou un docker stop qui a dépassé son délai de grâce, voir leçon 6). docker inspect <nom> --format '{{.State.OOMKilled}}' les distingue : true désigne l'OOM. On confirmera avec docker stats pendant l'exécution pour voir la consommation monter.
4. Défense en profondeur (niveau 200). Pourquoi la commande docker run --rm --cap-add SYS_ADMIN alpine:3.22 mount -t tmpfs none /mnt échoue-t-elle encore ? Essayez-la, puis ajoutez --security-opt apparmor=unconfined. Que concluez-vous ?
Solution
Avec seulement --cap-add SYS_ADMIN, le montage reste refusé : le profil AppArmor docker-default interdit les opérations mount, indépendamment des capabilities. En retirant aussi AppArmor, le montage réussit. Chaque barrière bloque l'opération indépendamment des autres : c'est la défense en profondeur. Conclusion pratique : quand une opération est refusée dans un conteneur, cherchez quelle barrière l'interdit plutôt que d'ajouter --privileged, qui les retire toutes.
Récapitulatif
- Les namespaces isolent ce qu'un processus voit : processus, réseau, montages, nom d'hôte, IPC, utilisateurs, cgroups, horloges. Ils se lisent dans
/proc/<pid>/ns. - Les cgroups v2 limitent ce qu'il consomme, au moyen de fichiers sous
/sys/fs/cgroup:memory.max,cpu.max,pids.max. Les options--memory,--cpus,--pids-limitde Docker y écrivent. - Un code de sortie 137 signifie
SIGKILL, très souvent un OOM du cgroup. - Root dans un conteneur Docker est l'UID 0 de l'hôte, réduit à 14 capabilities, filtré par seccomp et confiné par AppArmor ou SELinux.
- runc assemble ces briques à partir d'un fichier
config.jsonau format OCI ; Docker et Kubernetes ne font que le préparer.
Pour aller plus loin
- La série Namespaces in operation de Michael Kerrisk sur LWN.net, en sept parties, avec des programmes C commentés.
- La documentation du noyau sur les cgroups v2 : longue, mais c'est la référence pour chaque fichier de contrôleur.
- La présentation Containers From Scratch de Liz Rice, qui écrit un mini-moteur de conteneurs en Go en une trentaine de lignes : le même démontage, côté programmation.
- Leçon suivante : l'architecture de Docker, du client jusqu'à runc.
Sources
- namespaces(7), Linux manual page
- cgroups(7), Linux manual page
- Control Group v2, documentation du noyau Linux
- capabilities(7), Linux manual page
- unshare(1), Linux manual page
- Michael Kerrisk, Namespaces in operation, LWN.net (2013)
- OCI Runtime Specification, Linux container configuration
- Liz Rice, Containers From Scratch, GOTO 2018
- Ubuntu, Restricted unprivileged user namespaces (23.10 et 24.04)
- Docker Engine 29 release notes (espace de noms time par défaut depuis 29.5)