Pendant longtemps, tout tenait sur un seul VPS : les sites, les bases, les outils de dev, empilés sur le même système, avec les mêmes droits, sur le même noyau. Le passage au homelab sous Proxmox a réglé ce soucis. Il en a créé un autre, plus discret : à chaque nouveau service, la même question revient, et je la tranchais toujours pareil, au réflexe. docker compose up, on verra plus tard. (Oui, y compris pour le conteneur dont le seul travail est de lancer un cron.)

L'autre jour, je me demandais ce que je devrais faire pour mon nouveau service de routage, je me suis demandais si je le mettais dans la meme vm que mes autres services et le faisais juste passer "devant" les autres en forçant toutes les entrée du firewall à passer par lui. Ou alors est ce que je devais le mettre directement dans une autre vm qui servirait de tête de réseaux mais dans ce cas la dimensionner au rabais.

Ce qui me gênait, en creusant, c'est que j'étais incapable de dire ce que je gagnais exactement en mettant un service en conteneur plutôt qu'en VM. J'avais les arguments de brochure (léger, rapide, portable) et rien en dessous. Or la question « conteneur ou VM ? » suppose qu'un conteneur soit une chose. Ce n'en est pas une.

Le noyau ne connaît pas les conteneurs

Il n'existe pas d'objet « conteneur » dans Linux. Pas de struct container, pas d'appel système create_container(). Ce qu'on appelle un conteneur est un processus ordinaire à qui on a menti sur le monde, via une poignée de mécanismes indépendants les uns des autres.

Le premier, ce sont les namespaces. La page de manuel namespaces(7) les décrit comme un enveloppement d'une ressource globale du système dans une abstraction qui la fait paraître privée au processus. Il y en a huit, chacun activable séparément :

Namespace Ce qu'il isole
mount les points de montage
pid les identifiants de processus
net interfaces, piles réseau, ports
ipc IPC System V, files de messages POSIX
uts nom d'hôte et domaine NIS
user identifiants d'utilisateurs et de groupes
cgroup la racine de la hiérarchie cgroup
time horloges boot et monotonic

On peut les regarder directement, sans outil de conteneur. Chaque processus expose ses namespaces sous forme de liens symboliques dans /proc :

readlink /proc/$$/ns/uts
lsns -p $$

Deux processus qui partagent un namespace pointent vers le même numéro d'inode. C'est tout. Un conteneur Docker, c'est un clone(2) avec les bons drapeaux, un cgroup pour les limites de ressources, un jeu réduit de capabilities, un filtre seccomp et un profil AppArmor ou SELinux par dessus.

La conséquence est plus intéressante que la définition : l'isolation n'est pas un interrupteur, c'est une dizaine de curseurs indépendants. docker run en positionne certains à des valeurs par défaut raisonnables, et laisse les autres ouverts. Dire « je conteneurise » ne dit donc rien sur le niveau d'isolation obtenu, exactement comme dire « j'ai mis un mot de passe » ne dit rien sur sa longueur.

Ce qui reste partagé, c'est l'interface d'appels système

Le curseur qu'aucun namespace ne déplace, c'est le noyau lui-même. Un conteneur et l'hôte parlent au même code kernel, par la même interface : les appels système. Un noyau récent en expose plus de 350. Le profil seccomp par défaut de Docker fonctionne en liste d'autorisation, avec une action par défaut SCMP_ACT_ERRNO, et bloque une quarantaine des plus dangereux : mount, kexec_load, init_module, ptrace, tout ce qui touche au keyring ou à l'heure système, qui ne sont pas namespacés.

Reste que plusieurs centaines d'appels autorisés, c'est plusieurs centaines de points d'entrée en C, atteignables depuis l'intérieur du conteneur. Chaque bug mémoire dans un de ces chemins est une évasion potentielle.

Et encore, ça suppose que l'attaque passe par le noyau. Ce n'est même pas toujours le cas. CVE-2024-21626 , la faille « Leaky Vessels », est un bel exemple : runc laissait fuiter un descripteur de fichier pointant vers le système de fichiers de l'hôte, et il suffisait qu'une image déclare WORKDIR /proc/self/fd/7 pour que le processus démarre du mauvais côté de la frontière. Aucun bug noyau là-dedans. Juste un ordre d'opérations dans le runtime, corrigé en 1.1.12.

La surface d'attaque d'un conteneur, ce n'est donc pas seulement le noyau : c'est le noyau, plus le runtime, plus la configuration, plus la provenance de l'image. La documentation de Proxmox le pose sans détour :

Les conteneurs utilisent le noyau de l'hôte, ce qui expose une surface d'attaque. De manière générale, les machines virtuelles offrent une meilleure isolation.

wiki Proxmox VE, Linux Container

Ce n'est pas une raison de tout mettre en VM. C'est une raison de savoir ce qu'on achète.

Root dans le conteneur, root sur l'hôte

Le curseur le plus mal compris (par moi le premier) est le user namespace. Test rapide, sur une installation Docker standard :

docker run --rm alpine cat /proc/self/uid_map

La sortie est 0 0 4294967295. Traduction : l'UID 0 dans le conteneur est l'UID 0 sur l'hôte, sur toute la plage. Le user namespace n'est pas utilisé. Le processus est bien confiné par les namespaces, les capabilities et seccomp, mais s'il en sort, il en sort en root. Docker sait faire autrement (--userns-remap côté daemon, ou le mode rootless), ce n'est simplement pas le défaut.

Proxmox, lui, a fait le choix inverse : depuis la 4.4, un conteneur LXC créé depuis l'interface est non privilégié par défaut, et son root est remappé vers un UID quelconque de l'hôte, typiquement 100000. L'argument est plus fort que le simple « c'est mieux » :

L'équipe LXC considère les conteneurs privilégiés comme non sûrs, et ne traitera pas les nouvelles évasions comme des failles méritant un CVE.

wiki Proxmox VE, Linux Container

Une contrainte de ce type tranche une décision d'architecture à elle seule. Si l'éditeur annonce qu'il ne corrigera pas en urgence les évasions d'un mode donné, ce mode n'est plus un choix technique, c'est un pari. Un conteneur privilégié se justifie dans un environnement de confiance, et nulle part ailleurs.

Je m'en suis rendu compte en montant un partage de l'hôte dans un LXC non privilégié. Sur l'hôte, les fichiers appartenaient à mon utilisateur en UID 1000 ; vus de l'intérieur du conteneur, ils appartenaient tous à nobody:nogroup. Un chown depuis le conteneur ne réglait rien, forcément : l'UID 1000 du conteneur, c'est 101000 côté hôte, et 1000 côté hôte ne correspond à rien dans la plage remappée. Le nobody n'était pas un bug, c'était le remapping qui faisait exactement son travail.

Le retournement honnête, maintenant : le user namespace n'est pas gratuit non plus. Il permet à un utilisateur non privilégié de manipuler des mécanismes noyau qui lui étaient fermés, donc il augmente la surface d'attaque du noyau. Debian et Ubuntu ont ajouté des sysctl pour le désactiver, et Ubuntu 23.10 est allé plus loin en restreignant les user namespaces non privilégiés via AppArmor , application par application. Docker, de son côté, bloque unshare(CLONE_NEWUSER) dans son profil seccomp par défaut. Autrement dit : le mécanisme qui protège l'hôte contre le conteneur est aussi celui que les distributions cherchent à verrouiller. Les deux positions sont défendables, ce qui devrait suffire à calmer quiconque présente les user namespaces comme une frontière de sécurité franche.

Entre le conteneur et la VM, il y a des étages

C'est le point que les premières versions de cet article (je l'ai réécrit pas mal de fois avant d'en être à peu près satisfait) ratait complètement : j'opposais deux options alors qu'il en existe une demi-douzaine, et le vrai travail consiste à savoir laquelle achète quoi.

gVisor prend le problème par l'autre bout. Plutôt que de filtrer les appels système, il les réimplémente : un noyau applicatif en espace utilisateur, écrit en Go, appelé le Sentry, qui répond lui-même aux appels du conteneur.

gVisor ne transmet jamais aucun appel système à l'hôte.

documentation gVisor

Le Sentry implémente un peu plus de deux cents appels système sur les ~350 du noyau, ce qui est aussi sa limite : si une fonctionnalité n'est pas réimplémentée, elle n'existe pas pour la charge de travail. On échange de la compatibilitée et du coût par appel système contre une frontière beaucoup plus étroite.

Kata Containers fait le choix opposé et assumé : chaque conteneur (ou chaque pod) démarre dans une micro-VM avec son propre noyau invité, pilotée par QEMU, Cloud Hypervisor ou Firecracker, tout en restant compatible avec la spécification de runtime OCI. Vu de l'orchestrateur, c'est un conteneur normal. Vu de l'attaquant, il y a un hyperviseur sur le chemin.

Ce qui donne un continuum, pas un choix binaire :

Approche Frontière réelle Ce que ça coûte
runc par défaut namespaces + seccomp + capabilities, root non remappé rien, mais l'évasion se fait en root
Rootless (Podman, Docker rootless) idem + user namespace ports < 1024 interdits sans sysctl, stockage parfois en fuse-overlayfs
LXC non privilégié idem + AppArmor + remapping systématique frictions de permissions sur les montages
gVisor noyau applicatif en espace utilisateur compatibilité partielle, surcoût par appel système
Kata Containers hyperviseur + noyau invité par sandbox démarrage plus lent, mémoire par sandbox
VM complète hyperviseur un OS complet à administrer et à patcher

Les limites du mode rootless valent la peine d'être lues avant de s'y engager : la page de manuel podman-rootless(7) liste tout ce qui ne marche pas, dont l'impossibilité de se lier aux ports privilégiés sans toucher à net.ipv4.ip_unprivileged_port_start, et une plage de 65536 UID qui suffit rarement à faire tourner n'importe quelle image trouvée sur un registre.

Comment je découpe, du coup

La règle que je retiens tient en une phrase : la VM est ma frontière de confiance, le conteneur est mon unité d'empaquetage. Ce sont deux problèmes différents, et je les mélangeais.

Concrètement, dans mon lab : Nextcloud, Grafana et VaultWarden tournent en conteneurs, dans une VM Debian. Keycloak aussi, derrière le proxy. Mes bases critiques (PostgreSQL, MariaDB) restent sur une VM avec du stockage dédié. Portainer, Nginx Proxy Manager et CrowdSec sont regroupés dans une même VM.

Ce découpage n'est pas « containers pour ce qui est simple, VM pour ce qui est sérieux ». C'est : les services qui se font mutuellement confiance partagent une VM et un noyau, ceux qui ne se font pas confiance ne partagent rien. Un conteneur compromis dans le groupe applicatif n'atteint pas le noyau qui fait tourner les bases, parce que ce n'est pas le même noyau. À l'intérieur d'un groupe, le conteneur ne me sert pas à me protéger, il me sert à déployer, versionner et détruire proprement.

Deux frictions concrètes de ce modèle, qu'il vaut mieux connaître avant :

  • Faire tourner Docker dans un LXC non privilégié demande d'activer la fonction keyctl, et la doc Proxmox est claire sur l'arbitrage : dans les faits, on choisit entre systemd-networkd et Docker. C'est le genre de détail qui transforme une bonne idée d'architecture en après-midi perdu.
  • Les conteneurs Proxmox ne se migrent pas à chaud. La migration se fait par redémarrage, ce qui reste court vu leur légèreté, mais ça seras toujours une coupure. Une VM, elle, se déplace en marche. Et le coût que je paie sans le dire : chaque VM est un noyau de plus à mettre à jour. Mon découpage « hybride » est aussi une multiplication du travail d'administration, et je détaillerais volontiers ma stratégie de mises à jour si j'en avais une digne de ce nom.

Ce qui compte n'est pas où tourne le service

La question de départ (« peut-on tout mettre en conteneur ? ») était mal posée, et pas seulement parce que la réponse est « oui, techniquement ». Elle laisse croire que l'isolation est une propriété du contenant, alors que c'est une propriété de ce qu'on a accepté de partager : un noyau, un runtime, un jeu d'appels système, un registre d'images.

Ce qui veut dire que la frontière la plus solide de mon lab n'est pas celle que j'ai le mieux choisie. C'est celle que j'ai redémarrée le plus récemment.