Docker « too many open files » : augmenter correctement les limites (systemd + conteneur)

Cet article vous a aidé ?

C’est toujours la même ambiance : tout marche bien jusqu’au pic de trafic, puis votre appli commence à lancer des EMFILE, vos logs se remplissent de « too many open files » et le canal incident se transforme en séance de thérapie de groupe.

Quand cela arrive avec Docker, on « corrige » souvent en augmentant une limite quelque part au hasard. Parfois ça fonctionne. Le plus souvent ça ne tient pas — ou ça marche jusqu’au prochain déploiement, redémarrage ou rotation de nœud. Faisons-le proprement : trouvez la limite réelle que vous atteignez, augmentez-la au bon niveau (systemd, démon Docker, conteneur) et assurez-vous de ne pas simplement masquer une fuite.

Ce que signifie réellement « too many open files » dans les conteneurs

Sur Linux, « too many open files » correspond généralement à EMFILE (limite de descripteurs de fichiers par processus atteinte) ou ENFILE (épuisement de la table de fichiers système). La plupart des incidents en conteneur sont des EMFILE : un processus (ou quelques-uns) atteint son plafond de FDs et s’effondre de façon spectaculaire : incapacité à accepter des connexions, à ouvrir des fichiers de logs, à résoudre le DNS, à communiquer avec des upstreams, à créer des sockets. Autrement dit : incapacité à faire son travail.

Dans Docker, les descripteurs de fichiers (FD) ne sont pas une abstraction de conteneur. Ce sont des ressources du noyau. Les conteneurs partagent le même noyau et, en fin de compte, la même table de fichiers globale. Mais chaque processus a quand même une limite par-processus (RLIMIT_NOFILE) qui peut varier selon le service, le conteneur, le processus, selon qui l’a lancé et quelles limites ont été appliquées.

Donc quand un conteneur indique « too many open files », vous déboguez une pile de limites et de paramètres, incluant :

  • Table de fichiers globale du noyau et maximums par processus
  • Limites de session utilisateur (PAM, /etc/security/limits.conf) — parfois pertinentes, souvent mal comprises
  • Limites unit systemd pour dockerd (et parfois pour votre runtime de conteneur s’il est séparé)
  • Paramètres ulimit propres à Docker passés aux conteneurs
  • Le comportement de votre appli : pools de connexion, keep-alives, watchers de fichiers, fuites, schémas de logging

Et oui, vous pouvez « simplement augmenter la limite ». Mais si vous l’augmentez sans réfléchir, vous donnez à un processus fuyant un plus grand seau et appelez ça de la fiabilité. Ce n’est pas de l’ingénierie ; c’est une stratégie de report.

Une citation pour garder les pieds sur terre : « L’espoir n’est pas une stratégie. » — Général Gordon R. Sullivan

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

Quand le pager sonne et que vous devez arrêter l’hémorragie, vous ne commencez pas par éditer cinq configs et redémarrer l’hôte. Vous commencez par identifier quelle limite vous avez atteinte et où.

Premier : est-ce par-processus (EMFILE) ou système (ENFILE) ?

  • Si un seul service échoue et que l’hôte semble globalement stable, suspectez EMFILE.
  • Si de nombreux services non liés commencent à se plaindre d’impossibilité d’ouvrir sockets/fichiers en même temps, suspectez ENFILE ou un épuisement au niveau de l’hôte.

Deuxième : confirmez la limite à l’intérieur du conteneur en échec

  • Vérifiez ulimit -n à l’intérieur du conteneur (ou via /proc pour le processus exact).
  • Vérifiez combien de FDs le processus a réellement ouverts en ce moment (ls /proc/<pid>/fd | wc -l).

Troisième : vérifiez ce que systemd a accordé à Docker

  • Si dockerd est limité à une valeur faible, il peut contraindre ce que les conteneurs héritent.
  • Confirmez LimitNOFILE sur l’unité systemd Docker et ce que le processus Docker a actuellement.

Quatrième : écartez le cas « j’ai augmenté mais rien n’a changé »

  • Modifier /etc/security/limits.conf n’affecte pas les services gérés par systemd.
  • Modifier les fichiers d’unité systemd n’affecte pas les processus déjà en cours tant qu’on ne redémarre pas.
  • Modifier les valeurs par défaut du démon Docker ne change pas rétroactivement les conteneurs en cours d’exécution.

Cinquième : décidez si vous masquez une fuite

  • Une augmentation continue du nombre de FDs sur des heures/jours est un motif de fuite.
  • Un pic de FDs lié au trafic qui redescend est souvent une montée/descente normale, mais il faut quand même prévoir une marge.

Blague #1 : Les descripteurs de fichiers, c’est comme les fourchettes dans une cuisine de bureau — tout le monde pense qu’il y en a assez jusqu’à l’heure du déjeuner.

Faits intéressants et contexte historique (court, concret)

  1. Les premiers UNIX avaient des limites de FD très faibles (souvent 20 ou 64 par processus) parce que la RAM coûtait cher et les charges étaient simples comparées à aujourd’hui.
  2. select(2) plafonnait historiquement les FDs à 1024 à cause de FD_SETSIZE, ce qui a influencé la conception des serveurs pendant des années — même après l’arrivée d’API meilleures.
  3. poll(2) et epoll(7) sont devenus la réponse pratique pour gérer beaucoup de sockets sans la limite de select.
  4. Linux compte « open files » de manière large : fichiers réguliers, sockets, pipes, eventfds, signalfds — beaucoup de choses qui ne ressemblent pas à des « fichiers » pour les développeurs d’applis.
  5. systemd a changé la donne en prenant en charge les limites des services ; éditer les fichiers de démarrage du shell ne touche pas un service lancé par PID 1.
  6. Les valeurs par défaut de Docker peuvent être conservatrices selon le packaging de la distribution et la config du démon ; supposer que « les conteneurs héritent de la limite de l’hôte » est souvent faux.
  7. La pression sur la table de fichiers du noyau se manifeste par des anomalies de performance avant l’échec brut : pics de latence, erreurs connect(), et échecs I/O « aléatoires ».
  8. Certaines runtimes consomment des FDs par commodité (par exemple des watchers de fichiers agressifs en mode dev) ; envoyer ça en prod, c’est la recette pour être appelé à 2 h du matin.

La pile des limites : noyau, utilisateur, systemd, Docker, conteneur

Vous ne réglez pas ce problème en apprenant une seule manette magique. Vous le résolvez en comprenant la chaîne de responsabilité pour RLIMIT_NOFILE et les limites globales du noyau.

1) Table de fichiers du noyau : fs.file-max

fs.file-max est un plafond système sur le nombre de handles de fichiers que le noyau allouera. Si vous l’atteignez, beaucoup de choses échouent en même temps. C’est la saveur ENFILE, et c’est typiquement catastrophique pour l’hôte.

2) Maximum par processus : fs.nr_open

fs.nr_open est le plafond imposé par le noyau pour les fichiers ouverts par processus. Même si vous mettez ulimit -n à un million, le noyau vous arrêtera à nr_open. C’est un piège courant du type « pourquoi ma limite ne s’applique pas ? ».

3) Limites de processus : RLIMIT_NOFILE et héritage

Chaque processus a une limite soft et une limite hard. La soft est celle utilisée ; la hard est le maximum qu’il peut s’augmenter sans privilège. Les processus enfants héritent des limites du parent sauf changement explicite. Ce détail compte parce que :

  • dockerd hérite de systemd
  • les conteneurs héritent du runtime et de la configuration Docker
  • votre processus applicatif hérite de l’init/entrypoint du conteneur

4) Limites des services systemd : l’endroit sérieux pour les définir

Pour des hôtes de production exécutant Docker comme service systemd, le moyen le plus fiable de définir les limites FD de Docker est un override systemd pour docker.service. Les modifications de /etc/security/limits.conf ne sont pas forcément fausses ; elles sont juste souvent sans effet pour les services gérés par systemd.

5) Ulimits des conteneurs Docker : explicite vaut mieux que « je crois qu’il hérite »

Vous pouvez définir des ulimits par conteneur avec les flags Docker run, Compose ou les defaults du démon. Position pratique : définissez des limites par service explicitement pour les charges critiques. Ça rend le comportement portable entre hôtes et réduit les drames du type « ça marchait sur ce nœud ».

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

Voici les vérifications que j’exécute réellement. Chacune inclut ce que signifie la sortie et la décision à prendre.

Task 1: Confirm the error type in logs (EMFILE vs ENFILE)

cr0x@server:~$ docker logs --tail=200 api-1 | egrep -i 'too many open files|emfile|enfile' || true
Error: EMFILE: too many open files, open '/app/logs/access.log'

Sens : EMFILE indique un épuisement de FDs par processus, pas un effondrement de la table de fichiers de l’hôte.

Décision : Concentrez-vous sur les ulimits du conteneur/de l’appli et le pattern d’utilisation des FDs avant de toucher aux réglages globaux du noyau.

Task 2: Check the container’s ulimit from inside

cr0x@server:~$ docker exec -it api-1 sh -lc 'ulimit -n; ulimit -Hn'
1024
1048576

Sens : La limite soft est 1024 (minuscule pour beaucoup de serveurs réseau). La limite hard est élevée, donc le processus pourrait augmenter sa soft s’il le fait (ou si l’entrypoint le fait).

Décision : Augmentez la limite soft via Docker/Compose ulimits afin que l’appli démarre avec une valeur sensée.

Task 3: Identify the PID of the real worker process

cr0x@server:~$ docker exec -it api-1 sh -lc 'ps -eo pid,comm,args | head'
PID COMMAND         COMMAND
1   tini            tini -- node server.js
7   node            node server.js

Sens : PID 7 est le vrai processus Node ; PID 1 est le wrapper init.

Décision : Inspectez les limites et les FDs ouverts pour le PID 7, pas seulement pour le PID 1.

Task 4: Read the process limit straight from /proc

cr0x@server:~$ docker exec -it api-1 sh -lc "grep -E 'Max open files' /proc/7/limits"
Max open files            1024                 1048576              files

Sens : Confirme la limite runtime réelle appliquée au processus.

Décision : Si c’est bas, corrigez la config ulimit du conteneur ; si c’est élevé mais que ça échoue, cherchez des fuites ou des problèmes fs.file-max/fs.nr_open.

Task 5: Count open FDs for the process right now

cr0x@server:~$ docker exec -it api-1 sh -lc 'ls /proc/7/fd | wc -l'
1018

Sens : Le processus est pratiquement au plafond 1024 ; l’échec est attendu.

Décision : Augmentez la limite et réduisez immédiatement la pression sur les FDs si possible (réduire la concurrence, pools de connexions, watchers).

Task 6: See what those FDs actually are

cr0x@server:~$ docker exec -it api-1 sh -lc 'ls -l /proc/7/fd | head -n 15'
total 0
lrwx------ 1 root root 64 Jan  3 10:11 0 -> /dev/null
lrwx------ 1 root root 64 Jan  3 10:11 1 -> /app/logs/stdout.log
lrwx------ 1 root root 64 Jan  3 10:11 2 -> /app/logs/stderr.log
lrwx------ 1 root root 64 Jan  3 10:11 3 -> socket:[390112]
lrwx------ 1 root root 64 Jan  3 10:11 4 -> socket:[390115]
lrwx------ 1 root root 64 Jan  3 10:11 5 -> anon_inode:[eventpoll]

Sens : Majoritairement des sockets et eventpoll — typique d’un serveur chargé. Si vous voyez des milliers du même chemin de fichier, suspectez une fuite dans la gestion des fichiers ou du logging.

Décision : Si ce sont surtout des sockets, vérifiez la réutilisation des connexions upstream et les keepalive clients ; si ce sont des fichiers, auditez l’ouverture/fermeture des fichiers.

Task 7: Check host-wide file table usage

cr0x@server:~$ cat /proc/sys/fs/file-nr
15872	0	9223372036854775807

Sens : Le premier nombre est le nombre de handles alloués. Le troisième est le max système (cet exemple est effectivement « très élevé »). Si les alloués approchent le max, vous êtes en territoire ENFILE.

Décision : Si vous êtes proches du max, augmentez fs.file-max et cherchez les gros consommateurs de FDs au niveau de l’hôte.

Task 8: Check kernel ceilings for per-process open files

cr0x@server:~$ sysctl fs.nr_open
fs.nr_open = 1048576

Sens : Aucun processus ne peut dépasser ce nombre de fichiers ouverts, quelle que soit la demande d’ulimit.

Décision : Si vous avez besoin de plus et comprenez le coût en RAM, augmentez fs.nr_open (rare ; généralement inutile).

Task 9: Inspect the Docker daemon’s current FD limit (systemd inheritance)

cr0x@server:~$ pidof dockerd
1240
cr0x@server:~$ cat /proc/1240/limits | grep -E 'Max open files'
Max open files            1048576              1048576              files

Sens : Le démon Docker a lui-même une limite élevée ; bien. Si elle était 1024 ou 4096, corrigez systemd d’abord.

Décision : Si la limite de dockerd est basse, appliquez un override systemd et redémarrez Docker. Ne discutez pas avec PID 1.

Task 10: Confirm systemd unit limits for Docker

cr0x@server:~$ systemctl show docker --property=LimitNOFILE
LimitNOFILE=1048576

Sens : C’est ce que systemd prévoit pour le service, pas ce que vous espérez que ce soit.

Décision : Si c’est bas, vous avez besoin d’un drop-in override ; si c’est élevé mais dockerd est bas, vous avez oublié de redémarrer.

Task 11: Check a running container’s effective ulimits from the outside

cr0x@server:~$ docker inspect api-1 --format '{{json .HostConfig.Ulimits}}'
[{"Name":"nofile","Soft":1024,"Hard":1048576}]

Sens : Docker définit explicitement la soft limit à 1024 pour ce conteneur.

Décision : Corrigez votre fichier Compose ou vos flags de lancement ; ne touchez pas aux sysctls du noyau pour un problème de soft limit par conteneur.

Task 12: Measure FD growth rate (leak vs load)

cr0x@server:~$ for i in 1 2 3 4 5; do docker exec api-1 sh -lc 'ls /proc/7/fd | wc -l'; sleep 5; done
812
845
880
914
950

Sens : Le nombre de FDs augmente régulièrement sur 25 secondes. Ce n’est pas une « variante normale ». Cela peut être une montée de trafic, mais ça sent la fuite ou une concurrence incontrôlée.

Décision : Augmentez les limites pour une stabilité immédiate, mais ouvrez un ticket et instrumentez l’usage des FDs. Calez aussi la concurrence jusqu’à comprendre la pente.

Task 13: Find the worst FD offenders on the host

cr0x@server:~$ for p in /proc/[0-9]*; do pid=${p#/proc/}; if [ -r "$p/fd" ]; then c=$(ls "$p/fd" 2>/dev/null | wc -l); echo "$c $pid"; fi; done | sort -nr | head
21012 3351
16840 2980
9021  1240

Sens : PID 3351 et 2980 détiennent des dizaines de milliers de FDs. PID 1240 est dockerd avec ~9k, ce qui peut être normal sur des hôtes chargés.

Décision : Mappez les PID en services. Si un processus non lié consomme les FDs, vous vous dirigez vers un problème systémique sur l’hôte.

Task 14: Map a PID to a systemd unit (host-side)

cr0x@server:~$ ps -p 3351 -o pid,comm,args
PID COMMAND  COMMAND
3351 nginx    nginx: worker process

Sens : C’est nginx. La question devient : pourquoi nginx tient-il 21k FDs ? Probablement trop de keepalives, trop de connexions upstream, ou une fuite via une mauvaise configuration.

Décision : Vous devrez peut-être tuner worker_connections de nginx ou le keepalive upstream, pas seulement augmenter ulimit.

Augmenter les limites correctement : systemd + Docker + conteneur

Il y a deux volets pour bien faire :

  1. Définir des valeurs par défaut sensées pour le service Docker (pour que votre plateforme ne soit pas fragile).
  2. Définir des ulimits explicites pour les conteneurs qui en ont besoin (pour que le comportement soit reproductible et auditable).

Step 1: Set Docker’s service-level LimitNOFILE with a systemd drop-in

Éditez via le mécanisme supporté de systemd. Ne dupliquez pas les fichiers unit fournis par le vendor sauf si vous aimez les conflits de diff lors des mises à jour de paquets.

cr0x@server:~$ sudo systemctl edit docker

Dans l’éditeur, ajoutez un drop-in comme ceci :

cr0x@server:~$ cat /etc/systemd/system/docker.service.d/override.conf
[Service]
LimitNOFILE=1048576

Puis rechargez systemd et redémarrez Docker (oui, redémarrez — les limites s’appliquent au moment de l’exec) :

cr0x@server:~$ sudo systemctl daemon-reload
cr0x@server:~$ sudo systemctl restart docker

Ce que vous protégez : une valeur par défaut de distro à 1024/4096, ou une future image de nœud qui réinitialise silencieusement les limites du démon. C’est une ceinture de sécurité au niveau plateforme.

Step 2: Ensure the kernel isn’t the real ceiling

La plupart du temps, fs.nr_open est déjà suffisamment élevé. Vérifiez-le quand même et intégrez-le dans votre baseline si vos charges sont gourmandes en FDs.

cr0x@server:~$ sysctl fs.nr_open
fs.nr_open = 1048576

Si vous devez l’augmenter (rare), faites-le explicitement et de façon persistante :

cr0x@server:~$ sudo tee /etc/sysctl.d/99-fd-limits.conf >/dev/null <<'EOF'
fs.nr_open = 1048576
fs.file-max = 2097152
EOF
cr0x@server:~$ sudo sysctl --system
* Applying /etc/sysctl.d/99-fd-limits.conf ...
fs.nr_open = 1048576
fs.file-max = 2097152

Conseil d’opinion : ne poussez pas fs.file-max jusqu’au ciel « au cas où ». Ce n’est pas gratuit. Décidez en fonction de la concurrence réelle et de l’usage des FDs, puis ajoutez une marge.

Step 3: Set container ulimits explicitly (Docker run)

Pour des tests ponctuels ou une mitigation d’urgence :

cr0x@server:~$ docker run --rm -it --ulimit nofile=65536:65536 alpine sh -lc 'ulimit -n; ulimit -Hn'
65536
65536

Sens : Le conteneur démarre avec une limite soft/hard supérieure.

Décision : Si cela résout l’erreur immédiate, mettez le réglage dans Compose/Kubernetes spec pour que ce ne soit pas une bidouille manuelle.

Step 4: Set container ulimits explicitly (Docker Compose)

Compose rend cela traçable. C’est une bonne gouvernance et une meilleure disponibilité.

cr0x@server:~$ cat docker-compose.yml
services:
  api:
    image: myorg/api:latest
    ulimits:
      nofile:
        soft: 65536
        hard: 65536

Recréez le conteneur (les ulimits ne changent pas à chaud) :

cr0x@server:~$ docker compose up -d --force-recreate api
[+] Running 1/1
 ✔ Container project-api-1  Started

Décision : Si le conteneur recréé affiche encore 1024, vous ne déployez pas ce que vous pensez (mauvais fichier, mauvais projet, ancienne version de Compose, ou un autre orchestrateur est en charge).

Step 5: Consider Docker daemon defaults (carefully)

Docker peut définir des ulimits par défaut pour tous les conteneurs via la config du démon. C’est tentant. C’est aussi un instrument grossier.

Utilisez-le si vous contrôlez toute la flotte et voulez des valeurs par défaut cohérentes, mais gardez des overrides par service pour les charges vraiment gourmandes en FDs.

cr0x@server:~$ cat /etc/docker/daemon.json
{
  "default-ulimits": {
    "nofile": {
      "Name": "nofile",
      "Hard": 65536,
      "Soft": 65536
    }
  }
}
cr0x@server:~$ sudo systemctl restart docker
cr0x@server:~$ docker run --rm alpine sh -lc 'ulimit -n'
65536

Attention d’opinion : les defaults au niveau du démon peuvent surprendre des charges qui étaient stables à 1024 mais mal se comporter avec une concurrence plus élevée (parce qu’elles le peuvent désormais). Augmenter les limites peut révéler de nouveaux goulots : max connections base de données, limites upstream, pression sur la table NAT, etc.

Step 6: Validate the change where it matters: the process

Vérifiez toujours sur le vrai PID qui gère le trafic.

cr0x@server:~$ docker exec -it api-1 sh -lc 'ps -eo pid,comm,args | head -n 5'
PID COMMAND         COMMAND
1   tini            tini -- node server.js
7   node            node server.js
cr0x@server:~$ docker exec -it api-1 sh -lc "grep -E 'Max open files' /proc/7/limits"
Max open files            65536                65536                files

Décision : Si ça montre la bonne limite, la configuration est correcte. Il vous reste à décider si le design de l’application nécessite des corrections.

Step 7: Don’t forget the app can set its own limits

Certaines runtimes et wrappers de service appellent setrlimit() au démarrage. Cela peut abaisser votre limite effective même si Docker/systemd sont généreux.

Si la limite du conteneur est élevée mais que celle du processus est basse, vérifiez :

  • Scripts d’entrypoint qui appellent ulimit -n avec une valeur faible
  • Paramètres du runtime langage (ex. JVM, modèles master/worker de nginx, process managers)
  • Valeurs par défaut de l’image de base

Blague #2 : « On l’a mis à trois endroits » est la version opérationnelle de « Je l’ai sauvegardé sur mon bureau ».

Trois mini-récits d’entreprise depuis le terrain

Mini-story 1: the incident caused by a wrong assumption

L’entreprise exécutait une API client-facing sur une flotte Docker. Le service était stable depuis des mois. Puis une nouvelle image de nœud a été déployée — même famille d’OS, version mineure plus récente. Deux jours plus tard, l’API a commencé à échouer sous charge avec des 500 intermittents. Les logs montraient EMFILE à l’intérieur des conteneurs. La réaction immédiate fut l’incrédulité : « On a déjà réglé nofile de l’hôte à 1,048,576. Ce n’est pas les limites. »

L’assumption erronée était subtile et commune : penser que modifier /etc/security/limits.conf sur l’hôte affecte les conteneurs Docker. Cela affecte les sessions login démarrées via PAM. Docker est démarré par systemd. systemd ne tient pas compte des limites PAM. Il a sa propre configuration de limites.

Pendant l’incident, quelqu’un a augmenté l’ulimit du conteneur dans Compose et redéployé. Ça a aidé — sur certains nœuds. Sur d’autres, non. Cette inconsistance était l’indice : la dérive d’image de nœud avait changé la valeur par défaut LimitNOFILE de l’unité systemd Docker, et seuls certains hôtes étaient encore sur l’ancienne valeur.

La correction fut ennuyeuse mais permanente : drop-in systemd pour docker.service définissant LimitNOFILE, plus des ulimits explicites par service dans Compose. Ils ont ajouté aussi un contrôle post-provision qui échoue le nœud si systemctl show docker -p LimitNOFILE n’est pas la valeur attendue.

Le résultat durable n’était pas « un nombre plus grand ». C’était une compréhension partagée : les conteneurs ne flottent pas au-dessus de l’hôte ; ils en héritent. Les suppositions coûtent peu. Les incidents, beaucoup.

Mini-story 2: the optimization that backfired

Une autre organisation exploitait un pipeline d’ingestion haut-débit : web tier → queue → processors → stockage d’objets. Ils ont tuning pour l’efficacité, toujours à la recherche du gain de latence suivant. Quelqu’un a remarqué un churn de connexions TCP entre processors et la queue et a décidé de garder les connexions plus longtemps et d’augmenter la concurrence des workers.

En staging cela semblait excellent : moins de handshakes, meilleur débit, CPU plus stable. Ils ont donc déployé. La semaine suivante, la production a connu des brownouts en rolling. Pas des pannes complètes — pire. Un pourcentage de requêtes échouait ; les retries amplifiaient la charge ; le backlog de la queue augmentait ; le client stockage d’objets commençait à timeout. Les logs étaient pleins de « too many open files ».

Ils ont augmenté les ulimits. Ça a réduit le taux d’erreur, mais le système est resté instable. C’est le retour de bâton : des limites FD plus hautes ont permis au service de garder encore plus de connexions inactives ou semi-bloquées ouvertes, ce qui a augmenté l’utilisation mémoire et la pression sur les limites downstream (connections DB et suivi load balancer). L’incident n’était pas « on a atteint 1024 ». C’était « on a conçu une forme de concurrence fragile ».

La correction fut multi-couches : une augmentation modérée des ulimits à un baseline sensé, un plafonnement de la concurrence, des keepalive plus courts pour certains upstreams, et un meilleur comportement de backpressure. Après cela, le compte FD montait encore sous pic, mais restait borné et prévisible.

La leçon : augmenter les limites FD n’est pas une optimisation. C’est de la capacité. Si vous ne gérez pas la concurrence et le backpressure, vous échouerez plus fort et plus tard.

Mini-story 3: the boring but correct practice that saved the day

Une autre équipe exploitait une flotte mixte : bare metal, virtuel, beaucoup de Docker, beaucoup de transferts entre équipes. Ils avaient été brûlés par les problèmes « marche sur mon nœud », alors ils ont intégré une petite check-list dans le bootstrap des nœuds : vérifier les sysctls du noyau, vérifier les limites systemd, vérifier les defaults du démon Docker. Rien de fancy. Pas de dashboards tape-à-l’œil. Juste des garde-fous.

Un après-midi, une nouvelle release d’application a commencé à déclencher des EMFILE dans une région. Les ingénieurs ont suspecté une régression de code. Mais l’on-call a suivi la check-list : à l’intérieur du conteneur, ulimit -n était 1024. C’était déjà suspect. Sur l’hôte, systemctl show docker -p LimitNOFILE indiquait une valeur basse sur un sous-ensemble de nœuds.

Il s’est avéré que ces nœuds avaient été provisionnés par une pipeline parallèle créée pour un burst de capacité temporaire. Elle avait oublié une étape de bootstrap. L’application n’était pas soudainement cassée ; elle atterrissait sur des hôtes avec des limites différentes.

Parce que l’équipe avait une baseline connue et des vérifications rapides, la réponse à l’incident fut propre : cordon/évacuer des nœuds défectueux, patcher le bootstrap, puis réintroduire la capacité graduellement. Pas de chasse aux sorcières, pas d’éditions de configs frénétiques en production, pas d’« je l’ai réparé en tapant vite ».

Les pratiques ennuyeuses ne font pas vendre des slides. Elles gardent les clients en ligne.

Erreurs courantes : symptôme → cause racine → correction

1) Symptom: “I increased /etc/security/limits.conf but containers still show 1024”

Cause racine : Docker est démarré par systemd ; les limites PAM ne s’appliquent pas aux services systemd.

Correction : Définissez LimitNOFILE dans un drop-in systemd pour docker.service, redémarrez Docker, puis recréez les conteneurs.

2) Symptom: container ulimit -n is high, but the app still throws EMFILE

Cause racine : Vous avez vérifié le shell, pas le processus. Le vrai worker a une limite plus basse, ou l’appli l’a abaissée à l’exécution.

Correction : Inspectez /proc/<pid>/limits pour le processus worker. Auditez les scripts d’entrypoint et les gestionnaires de processus.

3) Symptom: raising ulimit didn’t help; multiple services fail to open files/sockets

Cause racine : Épuisement de la table de fichiers au niveau hôte (ENFILE) ou une autre limite noyau (ports éphémères, conntrack, mémoire).

Correction : Vérifiez /proc/sys/fs/file-nr, identifiez les plus gros consommateurs de FDs, et augmentez fs.file-max seulement avec des preuves. Inspectez aussi les tables réseau si les symptômes incluent des échecs connect().

4) Symptom: ulimit applies after redeploy, then resets after reboot or node rotation

Cause racine : Vous l’avez corrigé manuellement sur un nœud mais vous ne l’avez pas persisté dans la gestion de configuration, le drop-in systemd, ou les manifests Compose/Kubernetes.

Correction : Committez le changement dans l’infrastructure-as-code et les manifests de déploiement applicatif ; ajoutez une vérification de bootstrap.

5) Symptom: after raising limits, memory usage climbs and latency worsens

Cause racine : Plus de FDs autorisés = plus de concurrence autorisée ; votre appli garde maintenant plus de sockets et buffers ouverts, augmentant mémoire et charge downstream.

Correction : Tondez la concurrence, les pools, les keepalive et le backpressure. Fixez des limites basées sur l’état mesuré + marge, pas sur le maximum théorique.

6) Symptom: can’t raise above a specific number even as root

Cause racine : Vous avez atteint fs.nr_open ou le runtime du conteneur refuse des valeurs plus larges.

Correction : Vérifiez sysctl fs.nr_open. Augmentez-le si justifié. Validez avec /proc/<pid>/limits.

7) Symptom: only one container hits EMFILE, but host has plenty of headroom

Cause racine : Limite spécifique au conteneur faible, souvent héritée des defaults du démon ou explicitement réglée à 1024.

Correction : Définissez des ulimits par service (Compose ulimits ou --ulimit) et recréez le conteneur.

Listes de vérification / plan étape par étape

Checklist A: stop the incident (15–30 minutes)

  1. Confirmer le type d’erreur dans les logs : EMFILE vs ENFILE.
  2. Vérifier la limite du processus via /proc/<pid>/limits à l’intérieur du conteneur.
  3. Compter les FDs ouverts pour le processus worker. Si proche de la limite, vous avez trouvé la contrainte immédiate.
  4. Augmenter l’ulimit du conteneur (flags Compose/run) à une valeur raisonnable (souvent 32k–128k selon la charge).
  5. Recréer le conteneur pour que les nouvelles limites s’appliquent.
  6. Vérifier à nouveau sur le PID worker.
  7. Appliquer un throttling temporaire (réduire le nombre de workers, la concurrence, keepalive) si la montée des FDs est incontrôlée.

Checklist B: make it stick (same day)

  1. Définir systemd LimitNOFILE pour docker.service via drop-in.
  2. Vérifier la limite de dockerd depuis /proc/<dockerd-pid>/limits.
  3. Définir des ulimits par service dans Compose pour rendre le comportement portable.
  4. Enregistrer des métriques de base : compte FD typique en charge stable et en pic.
  5. Ajouter un health check (même un script simple) pour alerter quand l’usage des FDs atteint 70–80 % de la limite.

Checklist C: prevent recurrence (next sprint)

  1. Investigation des fuites : le compte de FDs augmente-t-il continûment sous charge constante ?
  2. Auditer les pools de connexion : clients DB, keepalive HTTP, consommateurs de queues.
  3. Revoir le logging : éviter d’ouvrir des fichiers par requête ; privilégier stdout/stderr et l’agrégation en conteneur.
  4. Test de charge avec visibilité FD : suivre le compte FD comme signal de première classe, pas en afterthought.
  5. Codifier la baseline nœud : sysctls + limites systemd dans votre pipeline de provisioning.

FAQ

1) Why does my container show ulimit -n as 1024?

Parce que 1024 est une soft limit par défaut courante. Docker peut le passer explicitement, ou votre image/entrypoint le fixe. Vérifiez avec docker inspect ...HostConfig.Ulimits et /proc/<pid>/limits.

2) If I set LimitNOFILE for Docker, do I still need container ulimits?

Oui, pour les services importants. La limite du démon est une baseline plateforme ; les ulimits conteneur sont le comportement applicatif. Des limites explicites par service évitent la dérive entre hôtes et images de nœud futures.

3) Does raising FD limits harm the host?

Pas directement, mais cela permet aux processus de conserver plus d’objets noyau et de mémoire. Si l’application utilise cette capacité, vous verrez augmenter la RAM et la charge downstream. La capacité est un outil, pas une vertu en soi.

4) What’s a “reasonable” nofile limit for production containers?

Ça dépend. Beaucoup de services web sont à l’aise entre 32k et 128k. Des proxies à fort fan-out, brokers de messages, ou nginx très chargé peuvent nécessiter davantage. Mesurez l’état stable et le pic, puis ajoutez une marge.

5) I raised limits but still get “too many open files” in DNS lookups or TLS handshakes. Why?

Ces opérations ouvrent des sockets et parfois des fichiers temporaires. Si votre worker est à sa limite de FDs, tout ce qui nécessite un nouveau FD échoue de façon étrange. Confirmez le compte FD et la limite du worker ; ne poursuivez pas le symptôme ailleurs.

6) Do Kubernetes pods behave differently?

Les principes sont les mêmes : ressources noyau + limites par processus. Les knobs de configuration diffèrent (runtime, réglages kubelet, pod security context, et la façon dont le runtime applique les rlimits). Toujours vérifier dans /proc pour le processus réel.

7) Why did my change not apply until I restarted things?

Parce que les limites s’appliquent au démarrage d’un processus (exec time). Les changements systemd nécessitent de redémarrer le service. Les changements d’ulimit de conteneur exigent de recréer le conteneur. Les processus en cours gardent leurs limites actuelles.

8) How do I tell if it’s a leak vs just traffic?

Échantillonnez le compte FD dans le temps sous une charge relativement constante. Une fuite monte en tendance sans redescendre. L’usage lié au trafic monte et redescend avec la concurrence. La boucle du Task 12 est un test rapide ; un monitoring dédié est préférable.

9) Can I just set everything to 1,048,576 and move on?

Vous pouvez. Vous faciliterez aussi la vie à un bug qui consommera beaucoup plus de ressources avant d’échouer, et vous déplacerez les goulots vers des endroits moins visibles. Choisissez une limite qui colle à votre architecture, puis surveillez.

10) Is “too many open files” ever caused by storage issues?

Indirectement. Des disques lents et de l’I/O bloquée peuvent faire que des descripteurs restent ouverts plus longtemps, augmentant l’usage concurrent de FDs. Mais l’erreur reste une question de limite/capacité — résolvez la limite et le comportement I/O.

Conclusion : prochaines étapes qui tiennent

Quand Docker lance « too many open files », ne le traitez pas comme une bizarrerie Docker. C’est Linux qui fait exactement ce que vous lui avez dit : appliquer des limites de ressources. Votre travail est de déterminer quelle limite, à quel niveau, et si la charge mérite plus de capacité ou moins de chaos.

Faites ceci ensuite, dans cet ordre :

  1. Vérifiez la vraie limite du processus worker dans /proc/<pid>/limits et son usage actuel de FDs.
  2. Définissez des ulimits explicites pour le service (Compose/flags de run), puis recréez le conteneur.
  3. Définissez un drop-in systemd pour docker.service LimitNOFILE pour que la plateforme soit cohérente au reboot et aux rotations de nœud.
  4. Mesurez le comportement des FDs sous charge, et décidez si vous résolvez de la capacité ou si vous masquez une fuite.

Si vous faites ces quatre choses, ceci cesse d’être un incident récurrent et devient un détail opérationnel résolu. Ce que vous voulez : moins de surprises, moins d’actions héroïques, et un on-call plus tranquille.

← Précédent
Corriger « IOMMU non activé » sur Proxmox pour le passthrough PCI (VT-d/AMD‑Vi) en toute sécurité
Suivant →
Priorité de resilver ZFS : reconstruire rapidement sans écraser les E/S de production

Laisser un commentaire