Server monitoringgevorderd5 min lezen

Docker-containers monitoren op één VPS: restart-loops, geheugenlekken en image-drift

Alles wat je met docker stats, docker events en een healthcheck in je compose-file zelf kunt zien — en het cron-script dat een container die elke 30 seconden herstart binnen vijf minuten meldt. Inclusief de logging-instelling die voorkomt dat containers je schijf volschrijven.

Inhoud
  1. Stap 1: de vijf commando's
  2. Stap 2: healthchecks die iets betekenen
  3. Stap 3: geheugenlekken vangen vóór de OOM-killer
  4. Stap 4: het cron-script
  5. Stap 5: image-drift — draai je wat je denkt dat je draait?
  6. Valkuilen
  7. Wat je hiermee nog niet hebt
  8. Zo doet monsys het
  9. FAQ

Een VPS met tien containers faalt anders dan een VPS met tien services. De container die crasht wordt automatisch herstart (restart: unless-stopped), dus niemand ziet het — tot de klant vraagt waarom de webshop elke minuut drie seconden weg is. Het geheugenlek zit in een container met een limiet, dus de host blijft gezond terwijl de app elke paar uur door de OOM-killer wordt afgemaakt. En de latest-tag die je in maart pullde is niet de latest van vandaag. Dit artikel maakt die drie dingen zichtbaar met alleen Docker zelf.

Stap 1: de vijf commando's

# Wat draait er, en hoe lang al? "Up 2 minutes" bij een service die weken zou moeten draaien = restart-loop
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'

# Live resource-gebruik, eenmalig (geen TUI)
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}'

# Hoe vaak is elke container herstart sinds hij is aangemaakt?
docker ps -q | xargs docker inspect --format '{{.Name}} restarts={{.RestartCount}} oom={{.State.OOMKilled}} exit={{.State.ExitCode}}'

# Wat gebeurde er de laatste uur? (die, oom, restart, health_status)
docker events --since 1h --until now --filter 'type=container' \
  --format '{{.Time}} {{.Actor.Attributes.name}} {{.Action}}'

# Hoeveel schijf eten images, containers, volumes en build-cache?
docker system df -v | head -40

Twee interpretatiefouten die iedereen één keer maakt:

  • docker stats toont CPU boven 100 % op multi-core hosts: 250 % betekent 2,5 cores. Vergelijk met nproc.
  • MemUsage bevat de page cache van de container. Een database-container op "95 %" van zijn limiet is vaak gewoon efficiënt aan het cachen. Kijk naar OOMKilled en naar docker events met oom voordat je de limiet verhoogt.

Stap 2: healthchecks die iets betekenen

restart: unless-stopped herstart een proces dat stopt. Het doet niets voor een proces dat hangt. Daarvoor heb je een healthcheck, en die moet iets doen wat de klant ook doet:

# docker-compose.yml
services:
  web:
    image: ghcr.io/example/shop:2026.09.1     # géén :latest, zie stap 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"

Drie details:

  • Het /healthz-endpoint moet de database aanraken. Een healthcheck die alleen "200 OK" teruggeeft terwijl elke echte request time-out, is erger dan geen healthcheck.
  • Een unhealthy container wordt door Docker niet herstart. Het label staat op unhealthy en dat is alles. Je moet zelf ingrijpen (stap 4) of een tool als autoheal draaien.
  • De logging-blok is niet optioneel. Zonder max-size schrijft een container in een crash-loop honderden MB per uur naar /var/lib/docker/containers//-json.log. Zet het ook als default in /etc/docker/daemon.json:
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "20m", "max-file": "5" }
}

Bestaande containers krijgen de nieuwe default pas na een docker compose up -d --force-recreate.

Stap 3: geheugenlekken vangen vóór de OOM-killer

Een lek zie je niet aan één meting maar aan de richting. Log elke vijf minuten het geheugengebruik per container en kijk naar wat monotoon stijgt:

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

# Na een dag: per container het verschil tussen eerste en laatste meting
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

Een container die in 24 uur van 180 naar 470 MiB gaat zonder dat het verkeer veranderde, lekt. Beter dat je dat op dinsdag ziet dan dat de OOM-killer het je zaterdagnacht vertelt.

Stap 4: het cron-script

Dit script draait elke vijf minuten en meldt via ntfy alleen wat afwijkt: restart-loops, OOM-kills, unhealthy containers en de gebruikelijke schijf-check voor /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   # vorige RestartCount per container
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 restarts sinds vorige check (5 min) = loop
  [ $((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

# Docker-datadir > 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' en wacht vijf minuten. Opruimen met docker rm -f crashy.

Stap 5: image-drift — draai je wat je denkt dat je draait?

image: nginx:latest betekent: de versie die er stond toen je voor het laatst pullde. Twee servers met dezelfde compose-file kunnen maanden uit elkaar liggen. Controleren:

# Lokale digest vs. wat het register nu onder die tag serveert
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

De structurele oplossing is geen script maar een gewoonte: pin op een versietag (nginx:1.27.2) of op een digest (nginx@sha256:…), en update bewust. Dan is "welke versie draait er" een grep in git in plaats van een vraag aan het register.

Valkuilen

  • docker compose up -d na een image-update herstart alleen containers waarvan de image veranderde — maar niet als je de tag niet hebt gepulld. docker compose pull && docker compose up -d is het paar.
  • docker system prune in cron. Het verwijdert ook stopped containers en dangling volumes. Doe het bewust, met --filter "until=168h", en nooit met --volumes geautomatiseerd.
  • Healthcheck-commando's die niet in de image zitten. curl ontbreekt in veel slim/alpine-images. Gebruik wget -qO- --spider of een ingebouwd endpoint, en test met docker inspect --format '{{json .State.Health}}'.
  • Geheugenlimiet zonder swap-limiet. Met memory: 512m maar zonder memswap_limit kan een container nog swappen en de hele host traag maken. Op een VPS zonder swap is dat geen probleem; met swap wel.
  • Tijdzone in containers. Logs in UTC terwijl de host in Europe/Brussels staat, is het klassieke "de crash was om 02:14 — nee, om 04:14"-misverstand. Mount /etc/localtime:ro of zet TZ=.

Wat je hiermee nog niet hebt

  • Historie per container over hosts heen. De docker-mem.log van vandaag helpt niet bij de vraag "sinds welke deploy lekt dit".
  • Wat zit er in die image? Restart-loops en geheugen zijn de operationele kant. De security-kant — welke CVE's zitten in de 340 packages van je python:3.12-slim — zie je met geen enkel docker-commando. Daarvoor is Trivy de gratis referentie.
  • Correlatie. De container die om 03:00 OOM-killed wordt en de backup-job die om 02:55 begint, staan in twee verschillende logs.

Zo doet monsys het

De monsys-agent leest de Docker-socket en rapporteert per container resource-gebruik, restart-events, health-status en de image-digest. De hub vergelijkt digests met het register (image-latest lookup), scant elke image met Trivy en koppelt CVE's aan de container die ze draait. Restart-loops en OOM-kills komen als gededupliceerde alerts binnen met de host-metrics van hetzelfde moment ernaast. Zie Applications in de docs.

FAQ

Herstart Docker een container die unhealthy is?

Nee. De restart-policy reageert op een proces dat stopt, niet op een healthcheck die faalt. Een unhealthy container blijft draaien met het label unhealthy. Je hebt een extern proces nodig (een cron-script, autoheal, of een orchestrator) om in te grijpen.

Hoeveel geheugen moet ik een container geven?

Meet eerst een week zonder limiet met docker stats en neem het 95e percentiel plus 30 %. Een te lage limiet veroorzaakt OOM-kills die eruitzien als crashes; geen limiet betekent dat één lekkende container de hele host meeneemt.

Is :latest altijd fout?

Voor een testomgeving waar je bewust altijd de nieuwste wilt: nee. Voor productie: ja, omdat je nooit met zekerheid kunt zeggen welke versie er draait, en een docker compose up op een tweede server een andere versie kan opleveren dan op de eerste.

Geschreven door het monsys-team — sysadmins die dit dagelijks doen.

Zelf gedaan? Laat monsys het bijhouden.

Alles uit deze how-to draait in monsys als doorlopende check, met historie, alerts en audit-bewijs. 5 servers gratis, EU-gehost in België, geïnstalleerd in 60 seconden.