Le docker build qui a rempli le disque et fait tomber la production
La production est tombée sans changement de code. La cause : un docker compose build qui orphelinait une nouvelle image <none> à chaque déploiement jusqu'à remplir le disque. Ce qui l'a vraiment vidé, et le vrai correctif.
La production est tombée, et je n’avais livré aucun code. Pas de pic de trafic, pas de migration ratée, rien dans les logs applicatifs. L’API a juste arrêté de répondre, et tous les conteneurs de la machine avec elle.
La cause était banale et entièrement auto-infligée : le disque était plein. Et ce qui l’a rempli, c’est mon propre processus de déploiement, qui construit les images Docker sur la machine même qui sert le trafic des clients.
Voici la partie 1 d’une série sur le durcissement d’un SaaS construit en solo en production. Le setup et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle du premier point faible qui m’a mordu : builder là où l’on sert.
Ce que “déployer” voulait dire ce jour-là
Il n’y a pas de pipeline. Un déploiement, c’est moi, en SSH, qui lance make deploy. En version simplifiée, la cible fait ceci :
# Makefile, en gros
docker compose build api # build SUR la machine de prod
docker compose build --no-cache dashboard # et celui-ci sans cache
docker compose up -d --force-recreate --no-build
docker image prune -f # (ajouté plus tard, voir plus bas)
Relisez ça avec un œil neuf et le problème est là. Chaque déploiement construit des images sur l’hôte de production. Le build tire des couches de base, compile l’app, et écrit de nouvelles couches d’image, le tout sur le même disque où vivent Postgres, les uploads MinIO et les logs des conteneurs. Le build est un gros travail par lots, en rafale, qui partage un disque avec un service sensible à la latence qui ne doit jamais s’arrêter.
Mais ce n’est pas la concurrence sur les ressources qui a fait tomber la prod ce jour-là. Quelque chose de plus discret l’a fait.
Pourquoi le disque s’est rempli, et ce qui l’a vidé
Voici le mécanisme auquel je n’avais pas pensé. docker compose build ne met pas une image à jour en place. Chaque build produit une image toute neuve et déplace le tag du service dessus. L’image précédente perd son tag et devient une image dangling, toujours sur le disque, prenant toujours de la place, taguée <none> et référencée par rien.
Déploiement après déploiement, ces images <none> orphelines se sont empilées. Le dashboard, construit avec --no-cache, en écrivait un jeu complet neuf à chaque fois. Rien ne les nettoyait, donc elles ont grossi jusqu’à ce que le disque atteigne 100 pour cent et que le noyau refuse les écritures. Postgres ne peut plus écrire, donc l’API renvoie des erreurs, donc tout s’effondre.
Ce qui m’a sauvé, c’est exactement la commande que les gens sous-estiment :
docker image prune -f # supprime les images dangling <none>
docker image prune -f supprime les images dangling, et les images dangling <none> étaient précisément ce qui avait rempli le disque. Ça a récupéré l’espace et ramené la production en quelques minutes. C’est toute la raison pour laquelle cette ligne est dans mon déploiement aujourd’hui : j’ai ajouté le docker image prune -f à la fin de make deploy après cet incident, pour que les images que le build orpheline soient nettoyées à chaque déploiement au lieu de s’empiler.
Le diagnostic : deux commandes
Quand une machine Linux déraille d’une façon que les logs applicatifs n’expliquent pas, vérifiez le disque avant tout le reste :
df -h # le système de fichiers est-il plein ? presque toujours la réponse
Si Docker est le coupable, voici la commande qui montre où l’espace est parti :
docker system df # résumé : images, conteneurs, volumes, cache de build
docker system df -v # détail par élément, marque l'espace récupérable
La vue -v est celle qui compte. Elle liste les images une par une et sépare “actif” de “récupérable”. Les images récupérables, les <none> dangling, étaient l’espace que mes déploiements fuyaient. Voir cette pile d’images sans tag, c’est ce qui m’a dit que c’était le déploiement, pas les données, qui avait rempli le disque.
Un réflexe dangereux à éviter
Si le cache de build a lui aussi grossi, vous pouvez récupérer plus, mais soyez précis, parce que les commandes agressives peuvent supprimer plus que prévu :
docker image prune -af # toutes les images inutilisées, taguées ou non
docker builder prune -f # le cache de build
Ce que je n’ai pas lancé, c’est docker system prune --volumes. Ce flag supprime aussi les volumes. Sur ce setup les données vivent dans des bind mounts plutôt que des volumes nommés, donc elles n’auraient pas été touchées, mais le réflexe est dangereux : sur une machine qui utilise des volumes nommés, ce seul flag supprime votre base de données. Ne sortez jamais --volumes sur une machine de production que vous n’avez pas auditée. (Que les données soient dans des bind mounts sans backup est un problème à part, avec son propre article plus loin dans cette série.)
La seule bonne habitude déjà en place
Il faut le reconnaître : le côté logs était déjà géré. Chaque service du fichier compose plafonne ses logs :
logging:
driver: json-file
options: { 'max-size': '10m', 'max-file': '3' }
Sans ça, les logs json-file par défaut de Docker grossissent sans limite et sont une façon classique de remplir un disque à eux seuls. Donc les logs n’étaient pas le problème. Les images orphelines l’étaient. Ça vaut la peine de le dire, parce que “le disque est plein” a plusieurs causes possibles, et plafonner les logs en élimine une proprement. Si votre fichier compose ne le fait pas, ajoutez-le maintenant.
Le vrai correctif : arrêter de builder là où l’on sert
Pruner automatiquement après chaque déploiement empêche le disque de se remplir à nouveau, et c’est le bon correctif immédiat. Mais ça rustine un symptôme d’un problème plus profond : je construis toujours les images sur la machine qui sert les clients. Ça a des coûts que le prune ne touche pas. Le build entre en concurrence avec le trafic en direct pour le CPU, la RAM et le disque pendant qu’il tourne. Un déploiement qui échoue avant d’atteindre la ligne de prune laisse quand même ses déchets. Et --force-recreate veut toujours dire quelques secondes d’indisponibilité à chaque déploiement.
La réponse propre, c’est de sortir le build de la machine entièrement : construire l’image en CI, la pousser vers un registry, et faire en sorte que le serveur ne fasse plus qu’un pull d’une image finie. La machine de prod revient à un seul métier, exécuter des conteneurs, et ne voit plus jamais un build. C’est un changement plus large (il vient avec l’interdiction de pousser directement sur main et un vrai pipeline de déploiement), donc il a son propre article plus loin dans cette série.
À retenir
La panne, c’était mon processus de déploiement qui laissait des images <none> orphelines sur la machine sur laquelle il déployait, une par build, jusqu’à ce que le disque soit plein. docker image prune -f supprime exactement celles-là, et le câbler dans chaque déploiement est le correctif de cinq minutes que toute personne qui build avec Compose devrait avoir. Mais la leçon plus profonde, c’est qu’une machine de production ne devrait pas construire d’images du tout.
Vérifiez docker system df -v tout de suite. Si vous voyez une pile d’images dangling <none>, vos déploiements fuient du disque, et un prune (idéalement automatique) est à une commande. La version propre du correctif, builder en CI et puller sur le serveur, arrive dans cette série. La vue d’ensemble de la série suit tout ça.