Certains liens de cette page sont des liens d'affiliation : si vous vous inscrivez via ces liens, ce site touche une commission, sans surcoût pour vous. Les prix et les recommandations ne changent pas.
Un billet intitulé “Docker is weird” est arrivé en page d’accueil de Hacker News la semaine dernière : 880 points, 369 commentaires. Son auteur écrit sa propre runtime de conteneurs et a découvert que les conteneurs ne sont pas une chose que le noyau connaît, que les images sont de simples dossiers et que runc a besoin d’un module C parce que Go n’a pas de fork utilisable. C’est une bonne lecture, et tout y est vrai.
Ce billet est l’autre moitié de l’histoire. Non pas pourquoi Docker est étrange à l’intérieur, mais où cette étrangeté remonte à la surface sur un petit serveur de production, à deux heures du matin. Sept comportements, chacun reproduit ci-dessous sur une machine vivante en Docker 28.3.1, avec la vraie sortie de commande et non de mémoire. Pour le matériel que demande ce genre de machine, nous avons comparé les options dans Le meilleur VPS pour l’auto-hébergement en 2026 ; la pile elle-même est décrite dans Caddy et Docker en production.
1. Une politique de redémarrage se fiche que votre conteneur soit unhealthy
C’est celui qui coûte le plus d’indisponibilité, parce qu’il donne l’impression d’une double sécurité : un healthcheck et restart: always.
docker run -d --name dw1 --restart always \
--health-cmd 'exit 1' --health-interval 2s --health-retries 1 \
alpine:3 sleep 600
sleep 12
docker inspect -f 'status={{ .State.Status }} health={{ .State.Health.Status }} failing={{ .State.Health.FailingStreak }} restarts={{ .RestartCount }}' dw1
status=running health=unhealthy failing=5 restarts=0
Cinq vérifications échouées de suite, le conteneur ouvertement marqué unhealthy, et zéro redémarrage. Une politique de redémarrage réagit à la fin du processus principal. Un healthcheck ne fait qu’écrire un statut. Rien dans Docker nu ne relie les deux.
Donc l’application qui a bloqué son propre pool de workers, qui garde le port ouvert et qui ne répond plus restera exactement là, avec une ligne docker ps d’apparence presque verte où Up 3 days (unhealthy) se cache dans une colonne que personne ne lit. Swarm et Kubernetes agissent sur la santé. Compose sur une seule machine, non.
Ce qu’il faut faire à la place : faire en sorte que le processus sorte quand il est cassé. Si l’application ne sait pas le faire, mettez le contrôle hors de Docker. Un timer systemd qui interroge l’endpoint et lance docker restart, c’est cinq lignes, et ça marche vraiment :
curl -fsS --max-time 5 http://127.0.0.1:3000/health >/dev/null || docker restart app
Servez-vous du healthcheck pour ce qu’il fait vraiment bien : depends_on: condition: service_healthy, pour qu’une base de données soit prête avant que l’application s’y connecte.
2. Un port publié passe tout droit devant ufw
Vous avez configuré ufw. ufw status dit que seuls 22, 80 et 443 sont autorisés. Puis vous publiez un port dans Compose pour jeter un coup d’oeil à un tableau de bord, et ce port est sur l’internet public.
Voici pourquoi, sur la même machine :
docker run -d --name pub -p 8099:80 nginx:alpine
iptables -t nat -S | grep 8099
iptables -S FORWARD | head -8
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8099 -j DNAT --to-destination 172.17.0.5:80
-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
...
Lisez l’ordre. Le trafic vers un port publié est réécrit par DNAT avant toute décision de filtrage que vous avez prise, puis remis à DOCKER-USER et DOCKER-FORWARD avant d’atteindre la première chaîne ufw. Docker accepte, le paquet est transmis, et les chaînes ufw plus bas ne le voient jamais. Sur cette machine, DOCKER-USER est ce qu’il est sur la plupart des machines :
-N DOCKER-USER
Vide. Cette chaîne existe précisément pour que vous puissiez poser vos règles là où Docker ne les écrasera pas, et presque personne ne s’en sert.
Trois correctifs, par ordre d’utilité :
- Lier à localhost.
-p 127.0.0.1:8099:80au lieu de-p 8099:80, puis y accéder par un tunnel SSH. C’est le bon réglage par défaut pour toute interface d’administration. - Ne rien publier du tout. Mettez le conteneur sur le réseau du reverse proxy et laissez le proxy l’atteindre par son nom sur le port interne. Rien dans la pile derrière notre Caddy ne publie de port.
- Écrire les règles dans DOCKER-USER si un port doit vraiment être public et restreint :
iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp --dport 8099 -j DROP.
Si vous venez de reprendre une machine, c’est le point numéro un du balayage, et le reste de ce balayage est dans l’audit d’un serveur hérité.
3. Les logs ne tournent pas avant que vous l’ajoutiez
Le pilote de journalisation par défaut est json-file, et la configuration par défaut est vide :
docker inspect -f '{{ json .HostConfig.LogConfig }}' dw2
{"Type":"json-file","Config":{}}
Pas de max-size, pas de max-file. Le fichier sous /var/lib/docker/containers/<id>/<id>-json.log grossit jusqu’à remplir le disque. À quelle vitesse ? Un conteneur qui imprime une courte ligne en boucle, laissé tourner vingt secondes :
docker run -d --name chat alpine:3 sh -c \
'i=0; while [ $i -lt 200000 ]; do echo "line $i some payload here"; i=$((i+1)); done; sleep 60'
# vingt secondes plus tard
du -sh /var/lib/docker/containers/9ee39f60*/
19.0M /var/lib/docker/containers/9ee39f60.../
19 Mo en vingt secondes pour un conteneur qui écrit une courte ligne par itération. Une application qui journalise chaque requête en niveau debug, laissée seule sur un long week-end, c’est un système de fichiers racine plein, et un système de fichiers racine plein sur une machine unique veut dire que la base refuse les écritures, que le proxy ne peut plus écrire son journal d’accès et que le renouvellement TLS échoue.
Réglez-le globalement, une fois, avant d’en avoir besoin :
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
Cela va dans /etc/docker/daemon.json, suivi de systemctl restart docker. Cela s’applique aux conteneurs créés après le redémarrage, pas aux existants : recréez-les. Par service dans Compose :
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Trente mégaoctets par conteneur, bornés, et docker logs fonctionne toujours.
4. Le PID 1 est un shell, et votre SIGTERM ne va nulle part
Deux conteneurs, le même sleep, arrêtés de la même façon :
docker run -d --name dw2 alpine:3 sh -c 'trap "echo caught; exit 0" TERM; sleep 600'
time docker stop dw2
docker run -d --name dw3 --init alpine:3 sleep 600
time docker stop dw3
dw2 10.11s
dw3 0.09s
Dix secondes contre neuf centièmes. Et remarquez que le lent a un trap TERM : les shells mettent le trap en file d’attente jusqu’à la fin de la commande au premier plan, donc le gestionnaire est inutile pendant que sleep tourne. docker stop envoie SIGTERM au PID 1, attend le délai de grâce, puis envoie SIGKILL.
Quatre-vingt-dix-neuf fois sur cent, ce n’est qu’une latence agaçante. La centième, c’est une perte de données, parce que SIGKILL est ce que reçoivent votre base, votre consommateur de file ou votre écrivain de fichiers à la place d’une séquence d’arrêt. Cela rend aussi chaque déploiement dix secondes plus lent par conteneur, ce qui se sent dans un pipeline rsync puis restart.
Trois sorties, toutes bon marché :
- La forme exec dans le Dockerfile :
CMD ["nginx", "-g", "daemon off;"], jamaisCMD nginx -g 'daemon off;'. execen dernière ligne d’un script d’entrypoint, pour que votre processus remplace le shell au lieu d’être son enfant.init: truedans Compose, qui place un init minuscule en PID 1 pour transmettre les signaux et récolter les zombies.
Puis vérifiez, car c’est typiquement le genre de chose que tout le monde croit avoir déjà fait : docker exec <nom> ps -o pid,comm et regardez ce qu’est vraiment le PID 1.
5. Le DNS des conteneurs n’existe que sur les réseaux que vous créez
Le même moteur, deux conteneurs, deux résolveurs différents. Sur le bridge par défaut :
nameserver 192.168.178.1
nameserver 8.8.8.8
Sur un réseau créé avec docker network create :
nameserver 127.0.0.11
127.0.0.11 est le serveur DNS intégré de Docker, et c’est lui qui fait marcher postgres://db:5432. Sur le bridge par défaut, il n’est pas là : le /etc/resolv.conf de l’hôte est recopié et les noms de conteneurs ne résolvent rien. Voilà pourquoi des exemples docker run tirés d’un billet de blog ne se trouvent pas entre eux alors que les mêmes images en Compose se trouvent : Compose vous crée un réseau de projet.
Cela veut dire aussi que vos noms de services ne sont pas une API stable. L’ancien --link a disparu, et le bridge par défaut ne vous donne que des adresses IP, ce qui amène au point suivant.
6. Compose vous donne un autre sous-réseau que celui que vous avez autorisé
docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.19.0.0/16
docker compose down
docker network create decoy1 && docker network create decoy2
docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.21.0.0/16
docker compose down supprime le réseau du projet. Le up suivant en crée un nouveau et prend le prochain bloc libre, qui n’est pas celui que vous aviez. Deux réseaux sans rapport créés entre-temps ont suffi à déplacer le projet de 172.19 vers 172.21.
Tout ce que vous avez épinglé sur l’ancien sous-réseau est désormais silencieusement faux : une ligne de pg_hba.conf, une liste d’autorisation applicative, une règle de pare-feu, une API qui compare X-Forwarded-For à un CIDR. La panne arrive sous la forme “connection refused from unknown host” après un redéploiement de routine, c’est-à-dire au pire moment pour aller lire pg_hba.conf.
Donc soit vous arrêtez d’épingler des sous-réseaux et vous utilisez les noms de conteneurs sur un réseau que vous avez créé, soit vous déclarez le sous-réseau pour qu’il ne puisse pas dériver :
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24
7. Un kill OOM ne ressemble pas à un crash
Donnez 32 Mo à un conteneur et laissez-le allouer :
docker run --rm -m 32m alpine:3 sh -c 'a=""; while true; do a="$a$(head -c 1000000 /dev/zero | tr "\0" x)"; done'
echo $?
137
Le code de sortie 137, c’est 128 + 9 : SIGKILL. Pas votre code, pas d’exception, pas de trace d’appels, rien dans le journal applicatif. Le cgroup a atteint sa limite et le noyau a tué le plus gros processus à l’intérieur.
Et c’est ici que le billet de Hacker News et celui-ci se rejoignent. Le noyau ne tue pas “un conteneur”, il tue un processus ordinaire qu’il voit depuis le début :
hp=$(docker inspect -f '{{ .State.Pid }}' dw4)
ps -o pid,cmd -p $hp
grep NSpid /proc/$hp/status
PID CMD
1085777 sleep 600
NSpid: 1085777 1
Un processus, deux numéros : 1085777 sur l’hôte, 1 à l’intérieur. “Les conteneurs ne sont pas réels” n’est pas une provocation, c’est exactement ce que vous regardez quand vous déboguez un kill OOM. Le PID 1 du conteneur est dans la table des processus de votre hôte, et c’est là que vous le trouvez quand docker logs n’a rien parce que le processus n’a jamais eu le temps d’écrire une ligne.
Donc quand un conteneur disparaît sans explication : lisez d’abord le code de sortie (docker inspect -f '{{ .State.ExitCode }} {{ .State.OOMKilled }}'), puis dmesg -T | grep -i oom, puis posez une limite mémoire sur chaque service pour que le noyau tue le conteneur fautif et non ce qui tournait par hasard sur votre hôte.
La checklist
Tout ce qui précède, sous forme de diff à appliquer à une pile que vous exploitez déjà :
services:
app:
image: app:1.4.2 # pas :latest
init: true # le PID 1 transmet les signaux
restart: unless-stopped
mem_limit: 512m # le noyau tue ça, pas votre base
ports:
- "127.0.0.1:3000:3000" # jamais un port hôte nu
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
interval: 30s
depends_on:
db:
condition: service_healthy
networks:
default:
ipam:
config:
- subnet: 172.28.0.0/24 # pour qu'un redéploiement ne le déplace pas
Plus deux choses qui vivent hors du fichier : max-size dans /etc/docker/daemon.json pour tout ce que vous lancez à la main, et un watchdog externe pour le statut de santé sur lequel Docker n’agira pas.
Rien de tout cela n’est exotique. Ce sont des comportements par défaut, documentés, reproductibles en vingt secondes chacun, et chaque point de la liste a mis à terre un petit serveur de production chez quelqu’un ce mois-ci. Si cela se répète, c’est parce que les valeurs par défaut sont calibrées pour un portable, où un fichier de log de 19 Mo et un arrêt de dix secondes ne coûtent rien.
Pour le reste de la pile à laquelle appartiennent ces réglages, la configuration complète de la machine qui sert cette page est dans Caddy et Docker en production, et le volet mesure dans l’analytics auto-hébergé avec Umami.