Performance Docker : pourquoi vos conteneurs ralentissent sous charge (et ce n’est pas le CPU)

Cet article vous a aidé ?

Tout semble normal. Le CPU est à 30 %. La charge moyenne n’est pas inquiétante. Pourtant les requêtes ramollissent, le p99 explose et votre canal d’astreinte se remplit de messages « est-ce que c’est en panne ? » qui raccourcissent les vies.

C’est le piège de la performance Docker : vous regardez le CPU parce que c’est mesurable et rassurant, alors que le vrai problème se situe généralement ailleurs — stockage, réseau, pression mémoire, limites du noyau, ou une politique de cgroup discrète qui vous bride comme un bureaucrate avec un tampon.

Si ce n’est pas le CPU, qu’est-ce que c’est ?

Quand un conteneur « ralentit », cela signifie rarement que votre code est soudainement devenu stupide. Cela signifie que le travail du conteneur attend : il attend l’évacuation du disque, attend la pile réseau, attend le DNS, attend des défauts de page, attend un verrou dans le noyau, attend un contrôleur de cgroup, attend que le driver de logs arrête de bloquer les écritures.

Les graphiques CPU sont séduisants parce qu’ils sont propres. Ils sont aussi incomplets. Un système peut être lent alors que le CPU est inactif si des threads sont bloqués en sommeil ininterruptible (généralement I/O), s’ils sont bloqués dans la file d’exécution à cause d’un throttling, ou s’ils sont affamés par le reclaim mémoire. Dans les conteneurs, ces problèmes sont plus faciles à créer parce que vous avez introduit :

  • Des systèmes de fichiers en union/overlay (copy-up, churn des métadonnées).
  • Des couches réseau supplémentaires (paires veth, bridges, NAT, conntrack).
  • Des plans de contrôle supplémentaires (cgroups, namespaces, limites, quotas).
  • Des comportements « utiles » par défaut (driver de logs, config DNS, driver de stockage).

La plupart des incidents de performance que j’ai vus n’étaient « pas le CPU ». Le CPU n’était que le dernier témoin qui n’a rien signalé.

Une citation exactement, parce qu’elle reste vraie : « L’espoir n’est pas une stratégie. » — General Gordon R. Sullivan

Voici l’essentiel : si vous traitez la performance comme un seul bouton, vous continuerez à livrer de la latence. Si vous la traitez comme un jeu d’élimination — mesurer, isoler, changer une chose, mesurer à nouveau — vous arrêterez de deviner et commencerez à réparer.

Faits et contexte intéressants (édition « pourquoi on en est là »)

  • Les conteneurs n’ont pas commencé avec Docker. Linux disposait de namespaces et de cgroups des années plus tôt ; Docker a popularisé le packaging et le workflow, pas les primitives du noyau.
  • Les cgroups existent parce que le « nice » n’était pas suffisant. La priorité des processus Unix traditionnelle ne fournissait pas d’isolation fiable multi-tenant pour la mémoire et l’I/O ; les cgroups ont été conçus pour l’imposer.
  • Les systèmes de fichiers overlay/union ont été conçus pour la commodité. Ils échangent une certaine simplicité du système de fichiers contre des couches composables. Ce compromis se révèle sous des charges riches en métadonnées.
  • Les premières versions de Docker utilisaient beaucoup AUFS. AUFS était rapide dans certains cas et pénible dans d’autres ; l’écosystème a progressivement migré vers overlay2 au fur et à mesure que le noyau a évolué.
  • Conntrack est une table d’état, pas de la magie. Le NAT et le suivi des connexions utilisent mémoire et CPU. Lors de pics, la table se remplit et le noyau commence à abandonner ou expirer les flux.
  • Le DNS dans les conteneurs est souvent différent du DNS sur l’hôte. Le DNS embarqué de Docker et le comportement du resolver peuvent amplifier les timeouts quand le DNS en amont est lent.
  • Les « fsync storms » sont un vieux problème aux nouveaux costumes. Les bases de données et les applications journalisées qui appellent fsync fréquemment peuvent effondrer l’I/O quand le backend de stockage n’est pas conçu pour une latence consistante.
  • La journalisation a toujours été un tueur de débit. À l’époque de syslog, la journalisation synchrone pouvait bloquer les applis. Les drivers de logs des conteneurs peuvent recréer cette douleur si vous n’y prenez pas garde.
  • Le reclaim mémoire Linux est un événement de performance. Même sans OOM kill, le reclaim direct et le swapping peuvent transformer une latence « correcte » en une scie.

Playbook de diagnostic rapide (premier/deuxième/troisième)

Premier : prouver si vous attendez de l’I/O, de la mémoire ou du réseau

  • Vérifier les tâches bloquées et l’attente I/O : si des threads s’accumulent en état D ou si iowait augmente, votre graphique « CPU bas » ment par omission.
  • Vérifier la pression mémoire : si vous effectuez du reclaim, du swapping ou atteignez memory.high, vous pouvez subir de la latence sans crash.
  • Vérifier les symptômes réseau : retransmissions, pertes conntrack, et latence DNS créent des blocages applicatifs qui ressemblent à une « lenteur aléatoire ».

Deuxième : localiser — nœud entier vs un conteneur vs un montage

  • Nœud entier : saturation du dispositif, contention du journal du système de fichiers, table conntrack pleine, pression mémoire du nœud.
  • Un conteneur : inondation de logs, quota CPU minuscule provoquant du throttling, ulimit trop bas, volume voisin bruyant.
  • Un montage/chemin : chemin copy-up overlay2, bind mount vers un stockage réseau lent, options de volume inadaptées à la charge.

Troisième : supprimer ou contourner temporairement des couches

  • Exécuter la même charge avec tmpfs pour les données temporaires afin de vérifier si le disque est le goulot.
  • Basculer temporairement la journalisation sur none en staging pour voir si la journalisation bloque.
  • Exécuter avec host networking pour un test (là où c’est sûr) afin d’isoler bridge/NAT/conntrack.
  • Tester la charge sur une single writable layer (volume) au lieu de overlay2 pour le chemin chaud.

La vitesse compte pendant les incidents. Vous n’avez pas besoin de la vérité parfaite ; vous avez besoin de la contrainte suivante.

Bouchons de stockage et système de fichiers (overlay2, fsync, et la taxe oubliée)

Le disque est le suspect habituel parce que c’est le moyen le plus simple de créer des files d’attente. Les CPU modernes peuvent dépasser le stockage de plusieurs ordres de grandeur. Les conteneurs ne changent pas la physique ; ils ajoutent juste quelques façons créatives de se prendre les pieds dedans.

Overlay2 : assez rapide jusqu’à ce que ça ne le soit plus

Overlay2 combine une pile de couches image en lecture seule avec une couche supérieure inscriptible. Les lectures touchent souvent le cache et paraissent excellentes. Les écritures peuvent déclencher un copy-up : la première fois que vous modifiez un fichier qui existe dans une couche inférieure, overlay doit le copier dans la couche inscriptible. Ce n’est pas juste une copie de données ; ce sont des opérations sur les métadonnées, des vérifications de permissions, et parfois une quantité surprenante de travail pour ce que vous pensiez être une « petite écriture ».

La douleur overlay2 se manifeste par :

  • Installations de paquets ou étapes de build lentes à l’intérieur des conteneurs.
  • Pics de latence quand une appli écrit dans des chemins incorporés dans l’image.
  • IOPS de métadonnées élevés (stat, open, rename, unlink) plutôt que gros débits séquentiels.

Journaling, barrières, et pourquoi fsync est un contrat de performance

Les applications qui appellent fsync (bases de données, queues, tout ce qui prétend être durable) demandent à la pile de stockage d’engager le travail sur un média stable. Sur un bon NVMe, cela passe. Sur un stockage en réseau, des SSD grand public avec firmware douteux, ou des volumes surchargés, cela devient une file d’attente. Le conteneur n’est pas lent ; le stockage est honnête.

Un détail de plus : même si votre appli n’appelle pas fsync, votre journal de système de fichiers peut le faire. Les charges riches en métadonnées peuvent provoquer des commits fréquents du journal, surtout si le dispositif sous-jacent a une latence élevée ou un comportement d’écriture incohérent.

Bind mounts et « je ne savais pas que c’était du NFS »

Les bind mounts sont excellents. Ils sont aussi une arme à feu quand le chemin monté repose sur un backend lent ou incohérent : NFS, SMB, couches FUSE, chiffrement, ou un disque cloud qui est silencieusement soumis à un rate-limit. Un conteneur peut être « local » et écrire « quelque part ailleurs ».

Volumes : mieux pour les chemins d’écriture chauds

Si votre appli écrit fréquemment, placez les répertoires à forte écriture sur un volume Docker ou un bind mount vers un système de fichiers dédié. Gardez la couche inscriptible du conteneur aussi « froide » que possible. Overlay2 est un bon défaut, pas un système de fichiers haute performance pour bases de données.

Blague #1 (courte, pertinente) : Les conteneurs, c’est comme emménager dans un petit appartement : on peut y vivre, mais n’essayez pas d’y installer une scierie dans la cuisine.

Pression mémoire et le problème « pas d’OOM mais ça meurt »

Les problèmes mémoire n’annoncent pas toujours leur arrivée par un OOM kill. En fait, les bugs de latence les plus désagréables surviennent avant l’OOM. C’est alors que le noyau s’efforce de vous garder en vie en reclaimant des pages, compactant la mémoire, et parfois en swapant quelque chose d’important.

À quoi ressemble la pression mémoire dans les conteneurs

  • Pics de latence pendant les rafales de trafic, puis récupération quand la charge diminue.
  • Les pauses GC s’aggravent (parce que les allocations déclenchent du reclaim et des défauts de page).
  • L’I/O disque augmente sans raison évidente (swap ou writeback).
  • Le CPU reste modéré, mais l’appli semble « coincée ».

Les limites mémoire cgroup changent le comportement du noyau

Quand vous définissez des limites mémoire, vous ne faites pas que prévenir l’utilisation excessive de RAM par le conteneur. Vous changez quand et comment le reclaim se produit. Si le conteneur est proche de sa limite, il peut rencontrer plus souvent du direct reclaim, ce qui peut bloquer des threads applicatifs. Si le swap est activé, il peut swapper à l’intérieur du cgroup, ce qui ressemble souvent à une latence aléatoire.

Autre point : le cache de fichiers compte. Affamer un conteneur de mémoire peut réduire le cache effectif et déplacer la charge vers le disque. Le CPU reste inactif tandis que votre pile de stockage fait une danse interprétative.

Réseau : latence, conntrack et DNS qui semble innocent

La performance réseau dans les conteneurs est souvent « suffisante » jusqu’au jour où elle ne l’est plus. Le mode défaillance sous charge n’est généralement pas la bande passante. C’est la latence et les pertes : retransmissions, mise en file et timeouts.

Surcharge bridge/NAT/conntrack

La configuration bridge par défaut de Docker implique souvent du NAT et du suivi de connexion. Chaque connexion devient un état dans conntrack. Si vous avez un fort churn de connexions (appels HTTP de courte durée, service mesh, clients agressifs), la table conntrack peut se remplir. Quand elle est pleine, le noyau commence à abandonner les nouvelles connexions ou à les expirer. Votre appli remonte des « upstream timeout », et vous regardez le CPU parce qu’il est bas.

DNS : mort par mille timeouts de 5 secondes

Les problèmes DNS sont la source la plus sous-diagnostiquée de lenteur des conteneurs. Un conteneur qui ne peut parfois pas résoudre un nom se comportera comme s’il était « lent », pas « cassé ». Le resolver attend. Vos threads attendent. Le CPU reste là, poli.

Les mauvaises configurations incluent :

  • Le DNS en amont surchargé ou soumis à des limites de débit.
  • Trop de search domains provoquant des requêtes répétées.
  • Le comportement du resolver glibc créant des requêtes en série et un fallback lent.
  • Le DNS embarqué de Docker sous stress (surtout avec de nombreux conteneurs et des redémarrages fréquents).

Cgroups : throttling, quotas, et pourquoi les « limites » ne sont pas gratuites

Les cgroups sont la raison méconnue pour laquelle les conteneurs peuvent partager un hôte sans immédiatement commencer une bagarre. Ils sont aussi un moyen fiable de créer une « lenteur mystère ».

Throttling de quota CPU

Si vous définissez des limites CPU, le noyau les applique avec des quotas par période. Sous charge, un conteneur peut brûler son quota tôt et être ensuite throttlé jusqu’à la période suivante. Le résultat : le processus n’utilise pas le CPU parce qu’on ne lui en permet pas. Votre graphique CPU semble calme. Votre graphique de latence, non.

Contrôles I/O (blkio / io controller)

Selon la version de cgroup et la configuration, les conteneurs peuvent être pondérés ou limités pour l’I/O. Même sans limites I/O explicites, des voisins bruyants peuvent saturer un dispositif, rendant tout le monde lent. Avec des limites, vous pouvez accidentellement saboter votre base de données et ensuite blâmer le réseau.

Limites PID et file descriptor

Toutes les limites ne sont pas dans les cgroups. Les PIDs et ulimits peuvent silencieusement limiter la concurrence. Quand vous manquez de descripteurs de fichiers, les applis peuvent se bloquer, échouer à accepter de nouvelles connexions, ou boucler sur des retries. Ce n’est pas le CPU. C’est le noyau qui vous dit « non » à répétition.

Journalisation : la falaise de performance déguisée en observabilité

La journalisation, c’est de l’I/O. La journalisation est aussi souvent suffisamment synchrone pour vous nuire. Le json-file par défaut de Docker peut devenir un goulot quand un conteneur émet un grand volume de logs. Le daemon écrit les logs sur disque. Le disque s’encombre. Tout ce qui veut écrire sur disque attend maintenant. Parfois, le conteneur se bloque sur les écritures stdout/stderr si les buffers se remplissent.

C’est un de ces problèmes qui ressemble à de la sorcellerie : « Nous avons ajouté plus de logs debug et le service est devenu plus lent. » Oui. Vous avez transformé votre nœud de production en machine à écrire.

Blague #2 (courte, pertinente) : Le debug logging en production, c’est comme ajouter un second volant à votre voiture — techniquement plus de contrôle, pratiquement plus d’accidents.

Tâches pratiques : commandes, sorties et décisions (12+)

Ce ne sont pas des exercices académiques. Ce sont les commandes que vous lancez quand quelqu’un dit « Docker est lent » et que vous voulez des preuves avant de changer quoi que ce soit.

Task 1: Check if the kernel is screaming about blocked tasks

cr0x@server:~$ dmesg -T | tail -n 20
[Mon Feb  3 10:14:52 2026] INFO: task myservice:23144 blocked for more than 120 seconds.
[Mon Feb  3 10:14:52 2026]       Tainted: G        W  OE     5.15.0-97-generic #107-Ubuntu
[Mon Feb  3 10:14:52 2026] "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.

Ce que ça signifie : Un processus est bloqué, communément en sommeil ininterruptible (I/O). C’est le noyau qui agite un drapeau rouge.

Décision : Arrêtez de débattre du CPU. Passez immédiatement aux vérifications I/O et système de fichiers (iostat, pidstat, latence du backend de stockage).

Task 2: Identify containers with high restart churn or weird status

cr0x@server:~$ docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.RunningFor}}\t{{.Image}}'
NAMES        STATUS                     RUNNING FOR     IMAGE
api          Up 3 hours (healthy)       3 hours         myorg/api:4.2.1
worker       Up 3 hours                 3 hours         myorg/worker:4.2.1
sidecar      Restarting (1) 5s ago      2 minutes       myorg/sidecar:1.9.0

Ce que ça signifie : Les boucles de redémarrage peuvent créer de la charge (requêtes DNS, churn des couches d’image, spam de logs) et masquer le vrai goulot.

Décision : Stabilisez d’abord les conteneurs qui plantent. Optimiser la performance d’un processus instable, c’est du théâtre de performance.

Task 3: Check per-container CPU throttling

cr0x@server:~$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
NAME     CPU %     MEM USAGE / LIMIT     NET I/O        BLOCK I/O
api      35.12%    1.2GiB / 2GiB         1.1GB / 980MB  18GB / 9GB
worker   12.44%    800MiB / 1GiB         200MB / 210MB  3GB / 1GB

Ce que ça signifie : C’est un indice, pas une preuve. Un CPU% élevé ici n’affiche pas le throttling ; il affiche l’utilisation.

Décision : Si vous suspectez des limites, vérifiez les compteurs de throttling du cgroup (tâche suivante). N’assumez pas que « 35% » signifie qu’il y a de la marge.

Task 4: Read cgroup CPU throttling counters (cgroup v2)

cr0x@server:~$ CID=$(docker inspect -f '{{.Id}}' api)
cr0x@server:~$ CGP=$(find /sys/fs/cgroup -name "*$CID*" -type d 2>/dev/null | head -n 1)
cr0x@server:~$ cat "$CGP/cpu.stat"
usage_usec 987654321
user_usec 700000000
system_usec 287654321
nr_periods 123456
nr_throttled 45678
throttled_usec 912345678

Ce que ça signifie : nr_throttled et throttled_usec qui augmentent rapidement indiquent un throttling de quota. Votre appli est mise en pause par la politique.

Décision : Augmentez la limite CPU, supprimez le quota pour les services sensibles à la latence, ou ajustez la concurrence des workers. Si vous avez besoin d’équité, utilisez réservations/poids avec soin, pas un quota serré.

Task 5: Spot host-wide I/O saturation quickly

cr0x@server:~$ iostat -xz 1 3
Linux 5.15.0-97-generic (server)  02/04/2026  _x86_64_  (16 CPU)

avg-cpu:  %user %nice %system %iowait  %steal %idle
          12.10  0.00    4.20   28.50   0.00  55.20

Device            r/s     w/s   rkB/s   wkB/s  avgqu-sz await  svctm  %util
nvme0n1         120.0   900.0  8200.0 64000.0     35.2  28.4   0.9  98.0

Ce que ça signifie : %util proche de 100 % et un await élevé signifient que le dispositif est saturé et que les requêtes font la queue. iowait est aussi élevé.

Décision : Déplacez les chemins à forte écriture vers un stockage plus rapide, réduisez la fréquence des fsync (seulement si vous acceptez les compromis de durabilité), répartissez les charges sur plusieurs dispositifs, ou corrigez le problème d’« inondation de logs ».

Task 6: Identify which processes are waiting on disk

cr0x@server:~$ pidstat -d 1 5
Linux 5.15.0-97-generic (server)  02/04/2026  _x86_64_  (16 CPU)

12:10:01      UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  iodelay  Command
12:10:02        0     23144      10.00  52000.00      0.00     3200  myservice
12:10:02        0     14521       0.00   9000.00      0.00      600  dockerd

Ce que ça signifie : Le service et même dockerd écrivent beaucoup. iodelay indique le temps passé à attendre l’I/O.

Décision : Si dockerd est lourd, suspectez le driver de logs ou le churn des couches d’image. Si le service est lourd, inspectez ses chemins d’écriture et son comportement fsync.

Task 7: Prove overlay2 is in play and where it lives

cr0x@server:~$ docker info --format '{{.Driver}} {{.DockerRootDir}}'
overlay2 /var/lib/docker

Ce que ça signifie : Vous utilisez overlay2 sous /var/lib/docker. Si ce système de fichiers est lent, tout ce que Docker fait est lent.

Décision : Placez /var/lib/docker sur un SSD/NVMe local rapide pour les charges sérieuses. S’il est sur un stockage réseau, attendez-vous à des problèmes.

Task 8: Check filesystem type and mount options for the Docker root

cr0x@server:~$ findmnt -no SOURCE,FSTYPE,OPTIONS /var/lib/docker
/dev/nvme0n1p2 ext4 rw,relatime,errors=remount-ro

Ce que ça signifie : ext4 avec des options typiques. Si vous voyez NFS/FUSE ou des options sync bizarres, c’est un signal d’alerte.

Décision : Si les options de montage incluent sync ou si le backend est distant, arrêtez-vous et redesign. Les conteneurs ne corrigent pas un stockage lent.

Task 9: Check Docker log file growth (json-file driver)

cr0x@server:~$ ls -lh /var/lib/docker/containers/*/*-json.log | sort -k5 -h | tail -n 5
-rw-r----- 1 root root  6.2G Feb  4 12:09 /var/lib/docker/containers/7c.../7c...-json.log
-rw-r----- 1 root root  7.8G Feb  4 12:09 /var/lib/docker/containers/aa.../aa...-json.log

Ce que ça signifie : Des logs énormes signifient d’énormes écritures, plus des problèmes de rotation si mal configurée.

Décision : Activez la rotation des logs, réduisez la verbosité, et envisagez un driver de logs qui n’attache pas tout au disque chaud du nœud.

Task 10: Measure DNS latency from inside the container

cr0x@server:~$ docker exec -it api bash -lc 'time getent hosts db.internal >/dev/null'
real	0m2.013s
user	0m0.000s
sys	0m0.004s

Ce que ça signifie : Deux secondes pour résoudre un nom, c’est un incident de performance en attente.

Décision : Inspectez la config du resolver, la performance du DNS en amont et les search domains. Corrigez le DNS avant d’ajuster les threads applicatifs.

Task 11: Inspect resolver configuration inside the container

cr0x@server:~$ docker exec -it api cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:5
search corp.example internal.example svc.cluster.local

Ce que ça signifie : DNS embarqué de Docker (127.0.0.11) et ndots:5 avec plusieurs search domains peuvent multiplier les recherches.

Décision : Réduisez les search domains, ajustez ndots quand approprié, et assurez-vous que le DNS en amont est sain. Dans certains environnements, contournez le DNS embarqué avec des resolvers explicites.

Task 12: Check conntrack table usage

cr0x@server:~$ sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_count = 248932
net.netfilter.nf_conntrack_max = 262144

Ce que ça signifie : Vous êtes proche du plafond. De nouvelles connexions commenceront à échouer lors des rafales.

Décision : Augmentez conntrack max (en tenant compte de la mémoire), réduisez le churn de connexions (keepalive, pooling), ou réduisez la dépendance au NAT (host networking, routage direct).

Task 13: Look for retransmits and general TCP misery

cr0x@server:~$ ss -s
Total: 2138
TCP:   1821 (estab 932, closed 756, orphaned 3, timewait 650)

Transport Total     IP        IPv6
RAW	  0         0         0
UDP	  45        40        5
TCP	  1065      980       85
INET	  1110      1020      90
FRAG	  0         0         0

Ce que ça signifie : Beaucoup de TIMEWAIT peut indiquer un fort churn de connexions. Pas forcément mauvais, mais suspect sous charge.

Décision : Si vous voyez du churn, ajoutez des keepalives, réutilisez les connexions, vérifiez le comportement des clients. Puis validez la capacité conntrack.

Task 14: Verify open file descriptor pressure for a containerized process

cr0x@server:~$ PID=$(pgrep -f myservice | head -n 1)
cr0x@server:~$ ls /proc/$PID/fd | wc -l
9823

Ce que ça signifie : Près de 10k FDs. Si vos limites sont basses, vous êtes proche de l’échec ; si vous échouez déjà, vous verrez « too many open files ».

Décision : Augmentez l’ulimit pour le service, auditez les fuites de connexions, et vérifiez fs.file-max au niveau hôte et les limites par processus.

Task 15: Check container’s ulimit setting

cr0x@server:~$ docker exec -it api bash -lc 'ulimit -n'
1024

Ce que ça signifie : 1024 est minuscule pour des services réseau modernes sous charge.

Décision : Définissez un --ulimit nofile=... plus élevé ou configurez-le dans votre orchestrateur. Puis vérifiez que l’application utilise réellement du pooling de connexions.

Task 16: Detect memory pressure on the host (swap/reclaim hints)

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 3  2  524288  81234  10240 901234   5    8   120  1800  900 1400 12  4 55 29  0

Ce que ça signifie : Des si/so non nuls indiquent du swapping. b pour processus bloqués plus un wa élevé signalent de l’attente.

Décision : Réduisez la pression mémoire : augmentez les limites là où c’est sûr, corrigez les fuites, ajoutez de la RAM, ou rééquilibrez les charges. Le swapping pour un service sensible à la latence est un choix ; choisissez autrement.

Trois mini-récits d’entreprise depuis les tranchées de la performance

Mini-récit 1 : L’incident causé par une mauvaise hypothèse

Ils avaient une API à fort trafic et un plan de migration propre : la déplacer dans des conteneurs sur un nouveau pool de nœuds. Les mêmes binaires, même config, « juste Docker ». L’équipe a fait la chose raisonnable et a surveillé le CPU. Il est resté bas. Ils ont déclaré la victoire.

Puis lundi est arrivé. Le p99 a triplé, mais la latence moyenne semblait seulement un peu pire. L’autoscaler a ajouté plus de conteneurs, ce qui a empiré le problème d’une façon presque personnelle. Tout le monde a discuté des pools de threads et du garbage collection parce que ce sont des sujets sur lesquels les ingénieurs peuvent débattre sans quitter leur chaise.

La mauvaise hypothèse était simple : ils supposaient que le système de fichiers du conteneur se comportait comme celui de l’hôte. En réalité, l’appli écrivait des fichiers temporaires dans un chemin qui vivait dans la couche image, pas dans un volume. Sous charge, le copy-up d’overlay2 a provoqué un churn et les opérations sur métadonnées ont explosé. Le disque n’était pas lent en débit ; il était surchargé en petites écritures aléatoires et commits du journal.

Ils l’ont prouvé en déplaçant uniquement le répertoire temp sur un volume sur stockage rapide. Rien d’autre n’a changé. La latence est revenue à la normale, le CPU est resté bas, et le chat d’incident s’est tu. La leçon n’était pas « overlay2 est mauvais. » La leçon était « les chemins d’écriture sont une décision d’architecture. »

Mini-récit 2 : L’optimisation qui s’est retournée contre eux

Une autre entreprise avait une facture de logging qu’elle n’aimait pas. Quelqu’un a proposé une optimisation simple : augmenter les logs debug seulement pendant les heures de pointe, pour attraper des cas d’edge. Ils ont livré un toggle config. Ça a fonctionné en staging. Ça a même fonctionné en production… pendant un jour.

Le deuxième jour, le trafic de pointe est arrivé avec le logging debug activé. L’utilisation du disque a atteint le plafond. Dockerd passait du temps réel à écrire des logs json. L’application a commencé à bloquer sur stdout parce que les buffers se remplissaient pendant les rafales. La latence a grimpé. Les retries ont augmenté. Plus de logs. Plus d’écritures disque. Une boucle de rétroaction s’est formée, comme un tutoriel pour construire votre propre outage.

L’« optimisation » reposait sur un mythe : que la journalisation est gratuite si le CPU est disponible. La journalisation, c’est de l’I/O, et l’I/O en contention, c’est de la latence. Ils ont revert le debug logging, activé la rotation des logs (ce qu’ils auraient dû avoir de toute façon), et déplacé les logs à fort volume vers une pipeline asynchrone avec backpressure.

Après le postmortem, l’équipe a cessé de décrire le logging comme « observabilité » et a commencé à le traiter comme « une charge de production qui concurrence le service ». Ce changement de vocabulaire a évité plus d’incidents que n’importe quelle tweak de config.

Mini-récit 3 : La pratique ennuyeuse mais correcte qui a sauvé la mise

Celui-ci est moins glamour. Une équipe exécutait des services stateful en conteneurs avec un contrôle strict des changements. Ils avaient l’habitude — impopulaire, presque embarrassante — de faire un baseline de performance pour chaque pool de nœuds : latence disque, RTT réseau, headroom conntrack, et comportement de reclaim mémoire. Ils stockaient les résultats sous forme d’un rapport simple et le relançaient après les upgrades du noyau.

Un après-midi, un nouveau lot de nœuds est arrivé en ligne. Tout « fonctionnait », mais le p99 était légèrement pire. Pas d’alarmes. Juste une régression silencieuse. Parce qu’ils avaient des baselines, ils ont remarqué immédiatement que les percentiles de latence du stockage étaient décalés et qu’une option de montage différait pour le Docker root filesystem.

Il s’est avéré que la pipeline de provisioning avait appliqué une config de système de fichiers différente sur les nouveaux nœuds. Pas catastrophique, mais suffisant pour augmenter la latence d’écriture sous charges sync-heavy. Ils ont drainé les nœuds, corrigé la config, et sont passés à autre chose. Pas d’outage. Pas d’héroïsme. Juste de la compétence ennuyeuse.

Si vous voulez de la fiabilité, vous devez accepter d’être ennuyeux à l’avance. Ce n’est pas un slogan ; c’est une ligne budgétaire.

Erreurs courantes : symptôme → cause racine → correction

1) Symptom: CPU low, latency high, threads “stuck”

Cause racine : Saturation I/O ou tâches bloquées (état D), souvent due à l’amplification d’écriture overlay2 ou à un stockage sous-jacent lent.

Correction : Placez les chemins à écriture chaude sur des volumes ; déplacez le Docker root vers un stockage local rapide ; réduisez les écritures synchrones ; confirmez avec iostat/pidstat.

2) Symptom: Random 1–5s stalls, especially on new connections

Cause racine : Timeouts DNS amplifiés par les réglages du resolver (ndots/search domains) ou DNS en amont surchargé.

Correction : Mesurez le temps de résolution dans le conteneur ; simplifiez resolv.conf ; corrigez le DNS en amont ; envisagez des résolveurs cache proches des workloads.

3) Symptom: p99 spikes during bursts, no OOM kills

Cause racine : Pression mémoire provoquant reclaim/compaction ; limites mémoire du conteneur trop serrées ; activité de swap.

Correction : Augmentez la marge mémoire ; définissez des limites mémoire appropriées ; réduisez les allocations par requête ; évitez le swap pour les services sensibles à la latence.

4) Symptom: Service scales out but gets slower

Cause racine : Goulot partagé : disque unique saturé, egress réseau partagé, pression table conntrack, ou goulot centralisé de journalisation.

Correction : Identifiez la ressource partagée. Monter le compute ne scale pas le disque. Séparez les dispositifs, ajoutez des nœuds avec I/O indépendantes, réduisez les écritures de logs.

5) Symptom: Periodic “stutter” every 100ms or 1s under load

Cause racine : Throttling de quota CPU (périodes cgroup). Le conteneur atteint son quota, se pause, reprend.

Correction : Assouplissez les limites CPU ou utilisez les shares/requests ; gardez les quotas pour les jobs batch, pas pour les services exigeants sur la latence.

6) Symptom: Connection errors, timeouts, SYN retries during peak

Cause racine : Table conntrack pleine ou overhead NAT ; churn élevé de connexions créant des TIMEWAIT storms.

Correction : Augmentez les limites conntrack, réduisez le churn avec keepalive, et envisagez des modes réseau qui réduisent la dépendance NAT/conntrack.

7) Symptom: “Too many open files” or weird partial failures

Cause racine : ulimit bas dans le conteneur ou sur l’hôte ; fuite de FD dans l’application.

Correction : Augmentez nofile ; auditez l’usage des FDs ; utilisez du pooling de connexions ; alertez sur le compteur de FDs.

8) Symptom: Node disk fills unexpectedly, then everything degrades

Cause racine : Fichiers de logs conteneur non bornés ou données temporaires incontrôlées sur le Docker root.

Correction : Configurez la rotation des logs ; plafonnez le volume de logs ; stockez les données temporaires sur des volumes dimensionnés ; alertez sur l’usage de /var/lib/docker.

Checklists / plan pas-à-pas (ennuyeux, répétable, efficace)

Checklist d’incident : « les conteneurs sont lents maintenant »

  1. Confirmer l’étendue : un service, un nœud, ou tout le cluster ?
  2. Vérifier la saturation I/O : lancez iostat -xz ; si %util est bloqué et await élevé, traitez le stockage en priorité.
  3. Vérifier les tâches bloquées : scannez dmesg pour les hung tasks ; confirmez avec les états de processus.
  4. Vérifier la pression mémoire : vmstat, activité swap, événements mémoire cgroup si disponibles.
  5. Vérifier le throttling : lisez cpu.stat pour les compteurs de throttling ; comparez au pattern de latence des requêtes.
  6. Vérifier le DNS : timez une résolution dans le conteneur ; regardez /etc/resolv.conf.
  7. Vérifier conntrack : comparez le count vs max ; cherchez des drops dans les logs du noyau si présent.
  8. Vérifier les logs : taille des json logs ; usage disque sous /var/lib/docker.
  9. Isoler en contournant des couches (en staging ou avec précaution) : tmpfs pour les temporaires, volume pour les écritures chaudes, test host networking.
  10. Faire un seul changement : choisissez le goulot le plus probable et changez une seule chose.
  11. Mesurer à nouveau : le p99 s’est-il amélioré ? iostat/conntrack/temps DNS se sont-ils améliorés ?

Checklist préventive : concevoir des conteneurs qui ne ralentissent pas

  1. Placer les chemins à forte écriture sur des volumes : bases de données, queues, caches persistants, et répertoires temp.
  2. Garder la couche image froide : n’écrivez pas dans des chemins cuits dans l’image au runtime.
  3. Définir des ulimits sensés : surtout nofile et les limites de processus pour les services à haute concurrence.
  4. Éviter les quotas CPU serrés pour les services sensibles : préférez poids et réservations ; les quotas sont pour l’équité batch.
  5. Configurer la rotation des logs : ne vous fiez pas aux valeurs par défaut ; plafonnez taille et nombre.
  6. Baseliner le nœud : latence disque, RTT réseau, temps de résolution DNS, headroom conntrack.
  7. Alerter sur les vrais goulots : disk await, activité swap, compte conntrack, croissance des logs, compteurs de throttling.
  8. Tester avec des patterns I/O proches de la production : surtout le comportement fsync et les charges riches en métadonnées.

Plan de changement : améliorer la performance sans tuning cargo-cult

  1. Choisir un service avec une douleur de latence mesurable et un trafic stable.
  2. Capturer une baseline : latences p50/p95/p99, disk await, temps DNS, compteurs de throttling, et taux d’erreurs.
  3. Identifier la contrainte la plus forte : saturation disque, reclaim mémoire, DNS, conntrack, ou throttling.
  4. Appliquer le plus petit correctif viable : déplacer un répertoire vers un volume, changer une limite, activer la rotation des logs, ou ajuster le DNS.
  5. Rejouer la même charge et comparer à la baseline ; ne garder le changement que s’il fait bouger les métriques.
  6. Automatiser la vérif : convertir les commandes manuelles en dashboards/alertes et en runbook.

FAQ

Pourquoi mon conteneur ralentit quand le CPU est bas ?

Parce que le travail attend, pas qu’il calcule. Les coupables habituels sont la latence disque, le reclaim mémoire, les timeouts DNS, les pertes/retransmissions réseau, ou le throttling de quota CPU.

Overlay2 est-il toujours plus lent que le système de fichiers hôte ?

Non. Il est souvent suffisant pour des charges à lecture intensive et des écritures modérées. Ça devient moche quand vous faites beaucoup de petites écritures, du churn de métadonnées, ou modifiez des fichiers présents dans des couches d’image inférieures (coût du copy-up).

Dois-je mettre des bases de données dans Docker ?

Vous le pouvez, mais vous devez traiter le stockage comme un choix d’architecture de première importance : volumes dédiés, stockage à latence prédictible, et tests honnêtes de fsync. Si vous faites tourner une base sur un disque partagé saturé et que vous blâmez Docker, le disque rira en silence.

Les limites CPU rendent-elles la performance plus prévisible ?

Elles rendent l’utilisation CPU plus prévisible. La latence devient souvent moins prévisible parce que le throttling introduit des pauses périodiques. Pour les services sensibles au tail-latency, les quotas serrés sont généralement un mauvais outil.

Comment savoir si la journalisation nuit à la performance ?

Regardez les gros fichiers json log, l’activité d’écriture de dockerd, et la saturation disque. Réduire temporairement le volume de logs dans un test contrôlé est un moyen simple de valider la causalité.

Pourquoi les problèmes DNS ressemblent-ils à une lenteur applicative ?

Parce que les échecs DNS sont souvent des échecs lents (timeouts) plutôt que des erreurs rapides. Les appels bloquent pendant la résolution. Sous charge, ces threads bloqués se propagent en files d’attente et timeouts ailleurs.

Quelle est la manière la plus rapide de confirmer que le disque est le goulot ?

iostat -xz 1 sur l’hôte pour la saturation et pidstat -d pour identifier qui écrit. Un await élevé plus une forte utilisation est un pistolet fumant.

Pourquoi scaler parfois aggrave-t-il la situation ?

Parce que vous scalez la mauvaise ressource. Plus de conteneurs peuvent augmenter la contention sur un disque partagé, le NAT/conntrack partagé, le DNS partagé, ou une pipeline de logs centralisée.

Ces problèmes sont « spécifiques à Docker » ou « spécifiques à Linux » ?

Principalement spécifiques à Linux. Docker facilite leur déclenchement parce qu’il ajoute des couches et des valeurs par défaut. Le noyau fait toujours le vrai travail, et c’est dans le noyau que résident les goulots.

Devrais-je passer au host networking pour la performance ?

Parfois cela aide en réduisant l’overhead NAT/conntrack, mais cela change l’isolation et la gestion des ports. Utilisez-le d’abord comme outil de diagnostic, puis décidez en fonction du risque et des gains mesurables.

Conclusion : prochaines étapes que vous pouvez réellement faire cette semaine

Si vos conteneurs ralentissent sous charge et que le CPU est bas, arrêtez de fixer le CPU comme s’il vous devait de l’argent. Traitez la performance comme une enquête : trouvez ce qui attend, pas ce qui est occupé.

  1. Instrumentez les goulots que vous ratez sans cesse : disk await/utilization, activité swap, compteurs de throttling cgroup, temps de résolution DNS, usage conntrack, et croissance des logs Docker.
  2. Déplacez les chemins d’écriture chauds hors de la couche inscriptible du conteneur : utilisez des volumes pour temp, caches, et tout ce qui ressemble à une base de données.
  3. Corrigez les « tueurs silencieux » : search domains/ndots DNS, plafonds conntrack, ulimits, et rotation des logs.
  4. Réévaluez les limites : surtout les quotas CPU. Si vous voulez de l’équité, n’achetez pas accidentellement de la latence.
  5. Faites des baselines des nœuds et conservez les rapports : c’est ennuyeux, et ça prévient des outages dont vous ne recevrez jamais le mérite.

Les conteneurs ne sont pas lents. Ce sont les contraintes non mesurées qui sont lentes. Docker rend juste plus facile de prétendre que les contraintes n’existent pas — jusqu’à ce que votre graphique p99 commence à hurler.

← Précédent
DNSSEC : l’erreur de rollover qui fait tomber le web et les e-mails en même temps
Suivant →
Windows 11 Dev Drive : l’accélération des builds (parlons franchement)

Laisser un commentaire