Supervision de serveursintermédiaire5 min de lecture

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
  1. Étape 1 : les cinq commandes
  2. Étape 2 : des healthchecks qui veulent dire quelque chose
  3. Étape 3 : attraper les fuites mémoire avant l'OOM killer
  4. Étape 4 : le script cron
  5. Étape 5 : dérive d'image — faites-vous tourner ce que vous croyez ?
  6. Pièges
  7. Ce qu'il vous manque encore
  8. Comment monsys fait
  9. FAQ

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 stats affiche plus de 100 % de CPU sur les hôtes multi-cœurs : 250 % signifie 2,5 cœurs. Comparez avec nproc.
  • 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 OOMKilled et docker events avec oom avant 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 /healthz doit 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 à unhealthy et c'est tout. Vous devez intervenir vous-même (étape 4) ou faire tourner un outil comme autoheal.
  • Le bloc logging n'est pas optionnel. Sans max-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 -d aprè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 -d vont ensemble.
  • docker system prune dans 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 --volumes automatisé.
  • Commandes de healthcheck absentes de l'image. curl manque dans beaucoup d'images slim/alpine. Utilisez wget -qO- --spider ou un endpoint intégré, et testez avec docker inspect --format '{{json .State.Health}}'.
  • Limite mémoire sans limite de swap. Avec memory: 512m mais sans memswap_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:ro ou définissez TZ=.

Ce qu'il vous manque encore

  • L'historique par conteneur entre hôtes. Le docker-mem.log d'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 commande docker ne 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.