← Retour aux tutoriels

Déboguer des conteneurs Docker lents : throttling CPU, goulots d’étranglement I/O et limites mal configurées

dockerperformancedebuggingcgroupscpu-throttlingio-bottlenecklimitslinux

Déboguer des conteneurs Docker lents : throttling CPU, goulots d’étranglement I/O et limites mal configurées

Un conteneur « lent » n’est presque jamais un mystère : il est généralement bridé par le CPU (throttling CFS), bloqué en attente d’entrées/sorties (I/O disque ou réseau), confiné par une limite mémoire (avec swap, pression mémoire, OOM), ou victime d’une configuration de limites incohérente (CPU set, quotas trop bas, cgroups v1/v2, limites implicites). Ce tutoriel propose une méthode pratique et reproductible pour diagnostiquer et corriger ces situations, avec commandes réelles et interprétation des résultats.


1) Pré-requis et principes de diagnostic

Objectif

Répondre à trois questions :

  1. Le conteneur est-il réellement lent, ou est-ce un problème d’observabilité (latence réseau, DNS, timeouts, contention côté base de données, etc.) ?
  2. Quelle ressource est le goulot : CPU, mémoire, disque, réseau, verrous applicatifs ?
  3. Quelle limite ou contention provoque le goulot : quotas CPU, cpuset, throttling, I/O saturées, limites de fichiers, pression mémoire, etc.

Outils utiles (hôte Linux)

Installez (selon distro) :

sudo apt-get update
sudo apt-get install -y \
  sysstat iotop htop jq curl \
  linux-tools-common linux-tools-generic \
  strace lsof procps

Optionnel (très utile) :


2) Étape 0 : établir une baseline et isoler le problème

2.1 Identifier le conteneur et son PID « init »

docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'

Récupérer le PID du conteneur (processus 1 dans le namespace du conteneur) :

CID=<id_ou_nom>
docker inspect --format '{{.State.Pid}}' "$CID"

Gardez ce PID, il servira pour nsenter, strace, perf, etc.

2.2 Vérifier rapidement l’état global

Sur l’hôte :

uptime
free -h
vmstat 1 5
iostat -xz 1 5

Interprétation rapide :

2.3 Vérifier si la lenteur est interne au conteneur

Exécutez une commande simple dans le conteneur :

docker exec -it "$CID" sh -lc 'date; uname -a; ps aux | head'

Si même ps ou ls est lent, suspectez I/O ou pression mémoire. Si l’application seule est lente, suspectez CPU throttling, verrous, GC, latence réseau, etc.


3) Comprendre les limites CPU Docker (et pourquoi ça throttle)

Docker s’appuie sur les cgroups. Deux mécanismes principaux :

Exemple :

Le symptôme : l’application « veut » du CPU, mais le kernel la bloque car elle a consommé son quota.


4) Détecter le throttling CPU (preuve chiffrée)

4.1 docker stats (indicateur, pas preuve)

docker stats "$CID"

Si vous voyez un CPU à ~100% mais une latence élevée, ce n’est pas suffisant. Le throttling peut se produire même si le pourcentage affiché semble « plafonner » bas.

4.2 Lire les stats cgroup CPU (cgroups v1)

Sur beaucoup de systèmes, Docker utilise encore des chemins cgroup v1 (ou un mode hybride). Pour trouver le chemin exact :

PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
cat /proc/$PID/cgroup

Vous verrez des lignes du type cpu,cpuacct:/docker/<id>.

Ensuite, lisez cpu.stat :

CGPATH=$(awk -F: '$2 ~ /cpu/ {print $3}' /proc/$PID/cgroup | head -n1)
cat /sys/fs/cgroup/cpu"$CGPATH"/cpu.stat

Sortie typique :

Interprétation :

watch -n 1 "cat /sys/fs/cgroup/cpu$CGPATH/cpu.stat"

4.3 Lire les stats cgroup CPU (cgroups v2)

En v2, les fichiers diffèrent (cpu.stat, cpu.max). Même approche pour trouver le chemin, puis :

# Exemple : chemin unifié
CGPATH=$(awk -F: '$1 == 0 {print $3}' /proc/$PID/cgroup)
cat /sys/fs/cgroup"$CGPATH"/cpu.stat
cat /sys/fs/cgroup"$CGPATH"/cpu.max

4.4 Vérifier la configuration CPU du conteneur

docker inspect "$CID" | jq '.[0].HostConfig | {NanoCpus, CpuQuota, CpuPeriod, CpuShares, CpusetCpus, CpusetMems}'

Points d’attention :


5) Corriger le throttling CPU (solutions concrètes)

5.1 Augmenter le quota CPU

En recréant le conteneur avec plus de CPU :

docker run --cpus="2.0" ...

Ou en mode explicite :

docker run --cpu-period=100000 --cpu-quota=200000 ...

Remarque : docker update permet parfois de modifier à chaud (selon options et version).

docker update --cpus="2.0" "$CID"

Vérifiez ensuite cpu.max/cpu.cfs_quota_us.

5.2 Éviter un cpuset trop restrictif

Si CpusetCpus="0" et que le CPU0 est déjà chargé, vous créez une contention inutile. Ajustez :

docker update --cpuset-cpus="2-5" "$CID"

5.3 Comprendre cpu-shares (priorité, pas plafond)

Si vous n’avez pas de quota mais des shares faibles, le conteneur peut être « affamé » quand d’autres consomment.

docker update --cpu-shares 1024 "$CID"

5.4 Vérifier le nombre de threads applicatifs

Un conteneur limité à 1 CPU mais une JVM/Node/Go configurée pour utiliser 8 threads peut empirer la latence (context switches). Ajustez la configuration applicative :


6) Diagnostiquer les goulots d’étranglement I/O (disque)

Une application peut être « CPU faible » mais lente car elle attend le disque. Symptômes :

6.1 Observer I/O au niveau hôte

iostat -xz 1

À surveiller :

6.2 Trouver quels processus font de l’I/O

sudo iotop -oPa

6.3 Relier au conteneur (PID namespace)

Récupérez le PID du conteneur et listez ses processus côté hôte :

PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
sudo ps -o pid,ppid,cmd --forest -g $(ps -o sid= -p "$PID")

Alternative simple : entrer dans le namespace du conteneur :

sudo nsenter -t "$PID" -p -m -u -i -n sh -lc 'ps aux'

6.4 OverlayFS et coûts cachés

Docker utilise souvent overlay2. Certaines opérations (beaucoup de petits fichiers, fsync fréquents, logs volumineux) peuvent devenir coûteuses.

Vérifiez le driver de stockage :

docker info | grep -i 'Storage Driver'
docker info | sed -n '/Storage Driver/,$p' | head -n 30

Si vous écrivez énormément, privilégiez des volumes plutôt que la couche writable du conteneur :

docker run -v /data/app:/var/lib/app ...

6.5 Identifier les fichiers chauds (logs, DB, temp)

Dans le conteneur :

docker exec -it "$CID" sh -lc 'ls -lah /var/log || true; du -sh /var/log/* 2>/dev/null | sort -h | tail'
docker exec -it "$CID" sh -lc 'lsof -nP | head'

Sur l’hôte (si vous connaissez le PID du process), vous pouvez inspecter ses fichiers ouverts :

sudo lsof -p <PID_DU_PROCESS> | head -n 50

6.6 Tracer les appels système I/O (preuve fine)

Sur un PID spécifique (dans le conteneur, mais PID hôte) :

sudo strace -f -tt -T -p <PID> -e trace=read,write,openat,fsync,fdatasync

Cherchez :


7) Limites I/O via cgroups (blkio / io) : quand Docker bride le disque

Docker peut limiter l’I/O avec :

7.1 Vérifier si une limite I/O est configurée

Inspectez la config :

docker inspect "$CID" | jq '.[0].HostConfig | {BlkioWeight, BlkioDeviceReadBps, BlkioDeviceWriteBps, BlkioDeviceReadIOps, BlkioDeviceWriteIOps}'

Si vous voyez des valeurs non nulles, c’est une piste forte.

7.2 Lire les fichiers cgroup I/O (selon v1/v2)

Toujours avec CGPATH :

cat /sys/fs/cgroup/blkio"$CGPATH"/blkio.throttle.io_service_bytes
cat /sys/fs/cgroup/blkio"$CGPATH"/blkio.throttle.io_serviced
cat /sys/fs/cgroup"$CGPATH"/io.stat
cat /sys/fs/cgroup"$CGPATH"/io.max

Si io.max impose des plafonds, vous verrez des limites par device (major:minor).


8) Mémoire : pression, swap, OOM et « lenteur » qui ressemble à du CPU

Une application peut être lente car :

8.1 Vérifier la mémoire du conteneur

docker stats "$CID"

Mais allez plus loin :

docker inspect "$CID" | jq '.[0].HostConfig | {Memory, MemorySwap, MemoryReservation, OomKillDisable}'

8.2 Lire les stats mémoire cgroup

cat /sys/fs/cgroup/memory"$CGPATH"/memory.stat | egrep 'rss|cache|swap|pgfault|pgmajfault' || true
cat /sys/fs/cgroup/memory"$CGPATH"/memory.failcnt
cat /sys/fs/cgroup/memory"$CGPATH"/memory.limit_in_bytes
cat /sys/fs/cgroup"$CGPATH"/memory.current
cat /sys/fs/cgroup"$CGPATH"/memory.max
cat /sys/fs/cgroup"$CGPATH"/memory.stat | egrep 'anon|file|swap|pgfault|pgmajfault' || true

Indicateurs :

8.3 Détecter l’OOM killer

Sur l’hôte :

dmesg -T | egrep -i 'killed process|oom|out of memory' | tail -n 50
journalctl -k --since "1 hour ago" | egrep -i 'oom|killed process' | tail -n 50

Dans Docker :

docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}} {{.State.FinishedAt}}' "$CID"

8.4 Corriger


9) Réseau : latence, DNS, saturation et files d’attente

La lenteur peut venir d’appels externes (API, DB, S3), DNS, ou congestion.

9.1 Vérifier rapidement depuis le conteneur

docker exec -it "$CID" sh -lc 'ip a; ip r; cat /etc/resolv.conf'
docker exec -it "$CID" sh -lc 'getent hosts example.com'
docker exec -it "$CID" sh -lc 'time curl -sS -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.com'

Si time_namelookup est élevé : DNS. Si time_connect élevé : réseau/pare-feu/route. Si ttfb élevé : serveur distant lent.

9.2 Observer les sockets et connexions

Dans le conteneur :

docker exec -it "$CID" sh -lc 'ss -tanp | head -n 50'

Sur l’hôte, en entrant dans le namespace réseau :

PID=$(docker inspect --format '{{.State.Pid}}' "$CID")
sudo nsenter -t "$PID" -n ss -s
sudo nsenter -t "$PID" -n ss -tan state established '( sport = :http or dport = :http )' | head

9.3 Capturer un pcap (si nécessaire)

Sur l’hôte, dans le namespace réseau du conteneur :

sudo nsenter -t "$PID" -n tcpdump -i any -w /tmp/container.pcap port 443

Puis analysez avec Wireshark.


10) Limites mal configurées : les pièges fréquents

10.1 Confondre « CPU % » et « CPU disponible »

Un conteneur limité à 0.5 CPU peut afficher ~50% sur un système à 1 CPU, ou un chiffre trompeur sur un hôte multi-cœurs. La preuve du throttling reste cpu.stat.

10.2 ulimit trop bas (fichiers, processus)

Dans le conteneur :

docker exec -it "$CID" sh -lc 'ulimit -a'

Problèmes typiques :

Configurer au lancement :

docker run --ulimit nofile=1048576:1048576 ...

10.3 Logs Docker (json-file) qui saturent le disque

Si vous loggez énormément sur stdout/stderr, le driver json-file peut créer une I/O énorme.

Vérifiez la taille des logs :

docker inspect --format '{{.LogPath}}' "$CID"
sudo ls -lh "$(docker inspect --format '{{.LogPath}}' "$CID")"

Solutions :

docker run --log-opt max-size=10m --log-opt max-file=3 ...

10.4 CPU pinning et NUMA

Si CpusetMems contraint à une zone mémoire NUMA défavorable, ou si vous épinglez des CPU non optimaux, vous pouvez créer de la latence. Vérifiez :

docker inspect "$CID" | jq '.[0].HostConfig | {CpusetCpus, CpusetMems}'

10.5 Cgroups v1/v2 et métriques incohérentes

Selon la distribution, Docker peut être en v2 (unified) ou v1. Les chemins et fichiers changent. Toujours partir de :

cat /proc/$(docker inspect --format '{{.State.Pid}}' "$CID")/cgroup
mount | grep cgroup

11) Méthode pas-à-pas (checklist opérationnelle)

Étape A — Confirmer le symptôme

  1. Mesurez la latence applicative (endpoint, job, requête).
  2. Vérifiez si la lenteur est constante ou en pics (corrélation avec charge).

Étape B — CPU

  1. docker stats
  2. Lire cpu.stat et vérifier nr_throttled / throttled_time
  3. Vérifier CpuQuota/CpuPeriod/NanoCpus et CpusetCpus
  4. Corriger : augmenter --cpus, ajuster cpuset, réduire threads

Commandes clés :

docker inspect "$CID" | jq '.[0].HostConfig | {NanoCpus, CpuQuota, CpuPeriod, CpuShares, CpusetCpus}'
watch -n 1 "cat /sys/fs/cgroup/cpu$CGPATH/cpu.stat 2>/dev/null || cat /sys/fs/cgroup$CGPATH/cpu.stat"

Étape C — I/O disque

  1. iostat -xz 1
  2. iotop -oPa
  3. strace sur le PID suspect
  4. Vérifier limites blkio/io
  5. Corriger : volumes, réduire fsync/logs, augmenter IOPS, changer storage, enlever throttling

Étape D — Mémoire

  1. free -h, vmstat 1
  2. cgroup memory stats (pgmajfault, swap)
  3. dmesg OOM
  4. Corriger : augmenter mémoire, ajuster heap, limiter swap, optimiser caches

Étape E — Réseau

  1. curl -w avec timings
  2. ss -s, ss -tanp
  3. tcpdump si besoin
  4. Corriger : DNS, MTU, timeouts, pool connexions, latence externe

12) Cas pratiques (symptômes → diagnostic → correction)

Cas 1 : API lente sous charge, CPU à 30% seulement

Symptôme : latence p95 explose, docker stats montre CPU ~30% alors que l’hôte a plein de CPU libre.

Diagnostic :

Commandes :

docker inspect "$CID" | jq '.[0].HostConfig | {NanoCpus, CpuQuota, CpuPeriod}'
cat /sys/fs/cgroup/cpu"$CGPATH"/cpu.stat 2>/dev/null || cat /sys/fs/cgroup"$CGPATH"/cpu.stat

Correction :


Cas 2 : Job de traitement « bloqué », CPU faible, wa élevé

Symptôme : CPU conteneur faible, mais le job prend 10× plus longtemps, l’hôte montre wa > 30%.

Diagnostic :

Correction :


Cas 3 : Lenteur aléatoire + pics de latence, mémoire « presque pleine »

Symptôme : parfois tout va bien, parfois latence énorme. Pas d’OOM, mais « ça rame ».

Diagnostic :

Correction :


13) Conseils de prévention (éviter de re-déboguer demain)

  1. Exposez les métriques cgroup (Prometheus node exporter + cAdvisor ou métriques orchestrateur) :
    • throttling CPU (throttled_time)
    • memory.current, oom_kills
    • I/O (io.stat, latences)
  2. Définissez des limites réalistes :
    • CPU : évitez des quotas trop bas « pour économiser » si la latence est critique.
    • Mémoire : dimensionnez avec marge, sinon swap/GC.
  3. Soignez la stratégie de logs :
    • rotation, driver adapté
  4. Préférez les volumes pour données et écritures intensives.
  5. Testez sous charge avec les mêmes limites qu’en prod.

14) Commandes récapitulatives (copier-coller)

# Identifier conteneur
docker ps
CID=<id_ou_nom>
PID=$(docker inspect --format '{{.State.Pid}}' "$CID")

# Voir cgroups
cat /proc/$PID/cgroup
mount | grep cgroup

# CPU throttling (v1 ou v2)
CGPATH=$(awk -F: '$1 == 0 {print $3}' /proc/$PID/cgroup)
cat /sys/fs/cgroup"$CGPATH"/cpu.stat 2>/dev/null || true
cat /sys/fs/cgroup"$CGPATH"/cpu.max 2>/dev/null || true

# Inspect limites Docker
docker inspect "$CID" | jq '.[0].HostConfig | {NanoCpus, CpuQuota, CpuPeriod, CpuShares, CpusetCpus, Memory, MemorySwap, BlkioWeight}'

# Vue globale
vmstat 1 5
iostat -xz 1 5
free -h

# I/O
sudo iotop -oPa
docker inspect --format '{{.LogPath}}' "$CID"

# Réseau
docker exec -it "$CID" sh -lc 'time curl -sS -o /dev/null -w "dns=%{time_namelookup} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" https://example.com'
docker exec -it "$CID" sh -lc 'ss -tanp | head'

# OOM
dmesg -T | egrep -i 'oom|killed process' | tail -n 50
docker inspect --format '{{.State.OOMKilled}}' "$CID"

Conclusion

Déboguer un conteneur Docker lent revient à prouver quel mécanisme le freine :

Si vous me donnez votre sortie (anonymisée) de docker inspect ... HostConfig, cpu.stat et iostat -xz 1, je peux vous aider à interpréter précisément où se situe le goulot et quelle correction appliquer.