Superviser des conteneurs Docker sur un seul VPS : boucles de redémarrage, fuites mémoire et dérive d'images
Tout ce que vous pouvez voir vous-même avec docker stats, docker events et un healthcheck dans votre fichier compose — plus le script cron qui signale en cinq minutes un conteneur qui redémarre toutes les 30 secondes. Avec le réglage de logging qui empêche les conteneurs de remplir votre disque.
Sommaire
Un VPS avec dix conteneurs tombe autrement qu'un VPS avec dix services. Le conteneur qui plante est redémarré automatiquement (restart: unless-stopped), donc personne ne le voit — jusqu'à ce que le client demande pourquoi la boutique disparaît trois secondes chaque minute. La fuite mémoire est dans un conteneur avec une limite, donc l'hôte reste sain pendant que l'application se fait tuer par l'OOM killer toutes les quelques heures. Et le tag latest que vous avez tiré en mars n'est pas le latest d'aujourd'hui. Cet article rend ces trois choses visibles avec Docker seul.
Étape 1 : les cinq commandes
# Qu'est-ce qui tourne, et depuis combien de temps ? "Up 2 minutes" sur un service censé tourner des semaines = boucle de redémarrage
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'
# Utilisation des ressources en direct, une seule fois (pas de TUI)
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}'
# Combien de fois chaque conteneur a-t-il redémarré depuis sa création ?
docker ps -q | xargs docker inspect --format '{{.Name}} restarts={{.RestartCount}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'
# Que s'est-il passé la dernière heure ? (die, oom, restart, health_status)
docker events --since 1h --until now --filter 'type=container' \
--format '{{.Time}} {{.Actor.Attributes.name}} {{.Action}}'
# Combien de disque consomment images, conteneurs, volumes et cache de build ?
docker system df -v | head -40
Deux erreurs d'interprétation que tout le monde fait une fois :
docker statsaffiche plus de 100 % de CPU sur les hôtes multi-cœurs : 250 % signifie 2,5 cœurs. Comparez avecnproc.- MemUsage inclut le cache de pages du conteneur. Un conteneur de base de données à « 95 % » de sa limite met souvent simplement en cache efficacement. Regardez
OOMKilledetdocker eventsavecoomavant d'augmenter la limite.
Étape 2 : des healthchecks qui veulent dire quelque chose
restart: unless-stopped redémarre un processus qui s'arrête. Il ne fait rien pour un processus qui pend. Pour cela, il faut un healthcheck, et il doit faire ce que fait le client :
# docker-compose.yml
services:
web:
image: ghcr.io/example/shop:2026.09.1 # pas de :latest, voir étape 5
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
deploy:
resources:
limits:
memory: 512m
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
Trois détails :
- L'endpoint
/healthzdoit toucher la base de données. Un healthcheck qui renvoie juste « 200 OK » alors que chaque vraie requête expire est pire qu'aucun healthcheck. - Docker ne redémarre pas un conteneur unhealthy. Le label passe à
unhealthyet c'est tout. Vous devez intervenir vous-même (étape 4) ou faire tourner un outil commeautoheal. - Le bloc
loggingn'est pas optionnel. Sansmax-size, un conteneur en boucle de crash écrit des centaines de Mo par heure dans/var/lib/docker/containers//-json.log. Mettez-le aussi par défaut dans/etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": { "max-size": "20m", "max-file": "5" }
}
Les conteneurs existants ne prennent le nouveau défaut qu'après docker compose up -d --force-recreate.
Étape 3 : attraper les fuites mémoire avant l'OOM killer
Une fuite ne se voit pas sur une mesure mais sur la tendance. Journalisez toutes les cinq minutes la mémoire par conteneur et regardez ce qui monte de façon monotone :
echo '*/5 * * * * root docker stats --no-stream --format "{{.Name}} {{.MemUsage}}" | sed "s/^/$(date +\%s) /" >> /var/log/docker-mem.log' \
| sudo tee /etc/cron.d/docker-mem
# Après un jour : par conteneur, la différence entre première et dernière mesure
awk '{
split($3,a,"MiB"); mb=a[1]; if ($3 ~ /GiB/) {split($3,g,"GiB"); mb=g[1]*1024}
if (!($2 in first)) first[$2]=mb; last[$2]=mb
} END { for (c in last) printf "%-30s %8.0f -> %8.0f MiB (%+.0f)\n", c, first[c], last[c], last[c]-first[c] }' /var/log/docker-mem.log | sort -k5 -rn
Un conteneur qui passe de 180 à 470 MiB en 24 heures sans que le trafic ait changé fuit. Mieux vaut le voir mardi que se le faire dire par l'OOM killer samedi soir.
Étape 4 : le script cron
Ce script tourne toutes les cinq minutes et ne signale via ntfy que ce qui dévie : boucles de redémarrage, OOM kills, conteneurs unhealthy et la vérification disque habituelle pour /var/lib/docker.
sudo tee /usr/local/sbin/docker-check.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
NTFY="https://ntfy.example.be/docker"
HOST=$(hostname -s)
STATE=/var/lib/docker-check.state # RestartCount précédent par conteneur
ALERTS=()
touch "$STATE"
for id in $(docker ps -aq); do
read -r name restarts oom health status < <(docker inspect --format \
'{{.Name}} {{.RestartCount}} {{.State.OOMKilled}} {{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}} {{.State.Status}}' "$id")
name=${name#/}
prev=$(grep "^$name " "$STATE" | awk '{print $2}'); prev=${prev:-0}
# ≥ 3 redémarrages depuis la vérification précédente (5 min) = boucle
[ $((restarts - prev)) -ge 3 ] && ALERTS+=("$name: $((restarts - prev)) restarts in 5 min")
[ "$oom" = "true" ] && ALERTS+=("$name: OOM-killed")
[ "$health" = "unhealthy" ] && ALERTS+=("$name: unhealthy")
[ "$status" = "exited" ] && ALERTS+=("$name: exited")
sed -i "/^$name /d" "$STATE"; echo "$name $restarts" >> "$STATE"
done
# Répertoire de données Docker > 85 %
pct=$(df --output=pcent /var/lib/docker | tail -1 | tr -dc '0-9')
[ "$pct" -gt 85 ] && ALERTS+=("/var/lib/docker at ${pct}%")
if [ ${#ALERTS[@]} -gt 0 ]; then
printf '%s\n' "${ALERTS[@]}" | curl -s -H "Title: docker@$HOST" -H "Priority: high" --data-binary @- "$NTFY" >/dev/null
fi
EOF
sudo chmod 0755 /usr/local/sbin/docker-check.sh
echo '*/5 * * * * root /usr/local/sbin/docker-check.sh' | sudo tee /etc/cron.d/docker-check
Test : docker run -d --name crashy --restart=always alpine sh -c 'sleep 5; exit 1' et attendez cinq minutes. Nettoyage avec docker rm -f crashy.
Étape 5 : dérive d'image — faites-vous tourner ce que vous croyez ?
image: nginx:latest signifie : la version qui était là lors de votre dernier pull. Deux serveurs avec le même fichier compose peuvent avoir des mois d'écart. Pour vérifier :
# Digest local vs. ce que le registre sert maintenant sous ce tag
for img in $(docker ps --format '{{.Image}}' | sort -u); do
local=$(docker image inspect --format '{{index .RepoDigests 0}}' "$img" 2>/dev/null | cut -d@ -f2)
remote=$(docker manifest inspect -v "$img" 2>/dev/null | jq -r '.[0].Descriptor.digest // .Descriptor.digest' 2>/dev/null)
[ "$local" != "$remote" ] && echo "DRIFT $img local=${local:0:19} remote=${remote:0:19}"
done
La solution structurelle n'est pas un script mais une habitude : épinglez sur un tag de version (nginx:1.27.2) ou sur un digest (nginx@sha256:…), et mettez à jour délibérément. Alors « quelle version tourne » est un grep dans git au lieu d'une question au registre.
Pièges
docker compose up -daprès une mise à jour d'image ne recrée que les conteneurs dont l'image a changé — mais pas si vous n'avez pas tiré le tag.docker compose pull && docker compose up -dvont ensemble.docker system prunedans cron. Il supprime aussi les conteneurs arrêtés et les volumes orphelins. Faites-le délibérément, avec--filter "until=168h", et jamais avec--volumesautomatisé.- Commandes de healthcheck absentes de l'image.
curlmanque dans beaucoup d'images slim/alpine. Utilisezwget -qO- --spiderou un endpoint intégré, et testez avecdocker inspect --format '{{json .State.Health}}'. - Limite mémoire sans limite de swap. Avec
memory: 512mmais sansmemswap_limit, un conteneur peut encore swapper et ralentir tout l'hôte. Sur un VPS sans swap, pas de problème ; avec swap, si. - Fuseau horaire dans les conteneurs. Des logs en UTC alors que l'hôte est en Europe/Brussels, c'est le classique « le crash était à 02:14 — non, à 04:14 ». Montez
/etc/localtime:roou définissezTZ=.
Ce qu'il vous manque encore
- L'historique par conteneur entre hôtes. Le
docker-mem.logd'aujourd'hui n'aide pas pour « depuis quel déploiement ça fuit ». - Ce qu'il y a dans cette image. Boucles de redémarrage et mémoire, c'est le côté opérationnel. Le côté sécurité — quelles CVE se trouvent dans les 340 paquets de votre
python:3.12-slim— aucune commandedockerne le montre. Pour cela, Trivy est la référence gratuite. - La corrélation. Le conteneur OOM-killed à 03:00 et la tâche de sauvegarde qui démarre à 02:55 sont dans deux logs différents.
Comment monsys fait
L'agent monsys lit la socket Docker et rapporte par conteneur l'utilisation des ressources, les événements de redémarrage, l'état de santé et le digest de l'image. Le hub compare les digests avec le registre (recherche image-latest), scanne chaque image avec Trivy et relie les CVE au conteneur qui les exécute. Boucles de redémarrage et OOM kills arrivent en alertes dédupliquées, avec les métriques de l'hôte du même instant à côté. Voir Applications dans la documentation.
FAQ
Docker redémarre-t-il un conteneur unhealthy ?
Non. La politique de redémarrage réagit à un processus qui s'arrête, pas à un healthcheck qui échoue. Un conteneur unhealthy continue de tourner avec le label unhealthy. Il faut un processus externe (un script cron, autoheal, ou un orchestrateur) pour intervenir.
Combien de mémoire dois-je donner à un conteneur ?
Mesurez d'abord une semaine sans limite avec docker stats et prenez le 95e percentile plus 30 %. Une limite trop basse provoque des OOM kills qui ressemblent à des crashs ; pas de limite signifie qu'un conteneur qui fuit emporte tout l'hôte.
:latest est-il toujours une erreur ?
Pour un environnement de test où vous voulez délibérément toujours le plus récent : non. Pour la production : oui, parce que vous ne pouvez jamais dire avec certitude quelle version tourne, et qu'un docker compose up sur un deuxième serveur peut donner une autre version que sur le premier.
Rédigé par l'équipe monsys — des sysadmins qui font cela tous les jours.
Fait à la main ? Laissez monsys s'en charger en continu.
Tout ce que contient ce guide tourne dans monsys comme contrôle permanent, avec historique, alertes et preuves d'audit. 5 serveurs gratuits, hébergés en Belgique, installés en 60 secondes.