Le 31 juillet à 15h44, mon serveur a écrit cette ligne :
PANIC: could not write to file "pg_logical/replorigin_checkpoint.tmp":
No space left on device
Le 1ᵉʳ août, mon portfolio affichait fièrement : 0 apps livrées · 0 expériences · Aucun commit pour ce filtre.
Entre les deux, neuf jours. Neuf jours pendant lesquels toute personne arrivant sur mon site professionnel voyait une page vide. Aucune alerte, aucun mail, aucune page d'erreur. Juste un site parfaitement fonctionnel qui ne disait plus rien de moi.
La panne silencieuse est la pire
Un 500 est brutal mais honnête. Tu le vois, tu le corriges.
Mon code faisait ceci :
const { data, error } = await supabase.from("projects").select("*");
if (error) {
console.error("listProjects", error);
return { projects: [] }; // ← le crime est ici
}
En apparence, c'est de la bonne pratique : on gère l'erreur, on ne fait pas planter la page. En réalité, on a transformé une panne d'infrastructure en résultat métier valide. Le composant reçoit un tableau vide et fait consciencieusement son travail : il affiche « aucun projet ».
Le console.error part dans les journaux d'un conteneur que personne ne lit. La page renvoie 200. Le monitoring de disponibilité — s'il existait — serait vert.
C'est le piège du repli silencieux : un tableau vide est indistinguable d'un tableau vide légitime. Rien dans le type ne dit « ceci est un échec ».
L'enquête
Les symptômes vus de l'extérieur formaient un tableau incohérent :
/auth/v1/health → 200 GoTrue en pleine forme
/storage/v1/status → 200 stockage OK
/rest/v1/** → 503 PGRST002
Deux services sur trois répondaient. Seul PostgREST échouait, avec un message inhabituel : « Could not query the database for the schema cache. Retrying. »
Les journaux du conteneur ont donné la réponse en une ligne. Disque plein. Postgres n'arrive plus à écrire son checkpoint, il panique, il redémarre en mode récupération, il repanique. Boucle infinie.
Le plus vicieux : PostgREST, lui, tournait. Il acceptait les connexions, il répondait poliment, il expliquait même qu'il réessayait. Un service qui répond correctement qu'il est cassé est plus difficile à diagnostiquer qu'un service mort.
Le coupable était dans ma CI depuis le début
Le disque faisait 58 Go. docker system df a livré le verdict en une seconde :
Images 41 total, 19 actives 38.43 GB ← 20.49 GB récupérables
Build Cache 28 entrées, 0 active 8.93 GB
47 Go d'images Docker sur 57 utilisés.
D'où venaient-elles ? De mon propre pipeline de déploiement. Chaque build tague l'image avec le SHA du commit :
docker build -t "$IMAGE_NAME:$CI_COMMIT_SHA" -t "$IMAGE_NAME:latest" .
Et à la fin, le nettoyage :
docker image prune -f
Sans -a. Cette option, absente, change tout : prune -f ne supprime que les images « dangling », celles qui n'ont plus aucun tag. Or les miennes en ont un — le SHA du commit. Elles ne sont donc jamais supprimées.
Une image par commit. Quatre projets déployés sur le même serveur. Trois mois. J'avais huit versions de la même application à 1,45 Go chacune.
Le mécanisme est presque élégant dans sa perversité : plus je travaillais, plus je remplissais le disque. Un pipeline sain aurait été inoffensif ; c'est mon rythme de livraison qui a armé la bombe.
Sept jours d'accumulation, et un carton rouge
En football, tu ne prends pas un carton rouge d'un coup. Tu prends un jaune à la 20ᵉ, un autre à la 70ᵉ, et c'est le second qui te sort. Aucun des deux n'était grave. C'est le cumul.
Mon disque a fonctionné pareil. Chaque déploiement ajoutait quelques centaines de mégaoctets — invisible, indolore. Puis un jour le pourcentage passe de 99 % à 100 % et tout s'arrête d'un coup.
Les systèmes qui dégradent linéairement puis cassent brutalement sont les plus dangereux, parce que l'expérience quotidienne ne prépare à rien. Il n'y a pas eu de « ça devient lent » avant le « ça ne marche plus ».
Ce que j'ai changé
1. Le contenu ne dépend plus de la base
C'est le changement de fond. Le parcours et les études de cas de ce site vivent maintenant dans le dépôt, en TypeScript, versionnés avec le code.
Les lectures interrogent toujours la base d'abord, et retombent sur ce contenu si elle ne répond pas :
try {
const { data, error } = await supabase.from("projects").select(...);
if (error) throw new Error(error.message);
if (data?.length) return { projects: data };
} catch (err) {
console.error("[listProjects] base indisponible, repli local:", err);
}
return { projects: localProjectRows(lang) };
Deux différences avec l'ancien code, toutes deux essentielles :
- On
throwsur erreur au lieu de retourner un tableau vide. L'échec reste un échec. - On teste
data?.length: une réponse vide est traitée comme une non-réponse, pas comme une vérité.
Le résultat, testé contre la base réellement en panne : le site affiche l'intégralité de son contenu. La panne devient invisible pour le visiteur et bruyante dans les journaux. C'est le bon sens de la relation.
2. Le contenu critique n'a rien à faire dans une base
Voilà la leçon inconfortable. Mon parcours professionnel change trois fois par an. Mes études de cas, deux fois. Pourquoi diable étaient-ils dans une base de données interrogée à chaque requête ?
Parce qu'un tableau de bord d'administration, c'est satisfaisant à construire. Parce que « les données vont en base », c'est le réflexe. Personne ne s'est demandé si le contenu quasi statique d'un site vitrine méritait une dépendance réseau à chaque affichage.
Le critère que j'applique maintenant : si le contenu change moins souvent qu'on ne déploie, il appartient au dépôt. Un commit est un mécanisme de publication parfaitement valable — avec revue de code, historique et retour arrière gratuits.
3. Le pipeline nettoie vraiment
docker image prune -af --filter "until=168h"
docker builder prune -af --filter "until=168h"
Le -a fait le travail. Le filtre garde une semaine d'historique pour pouvoir revenir en arrière rapidement.
Et le plafond sur les journaux, qui n'existait pas non plus :
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
Par défaut, Docker laisse les journaux d'un conteneur grossir sans aucune limite. Mes conteneurs Supabase en avaient accumulé près d'un gigaoctet, dont une bonne partie écrite pendant la panne — Postgres qui journalise en boucle son propre échec, ce qui remplit le disque, ce qui aggrave l'échec.
Les valeurs par défaut ne sont pas neutres
C'est le vrai sujet, et il dépasse mon incident.
Docker choisit des journaux illimités. prune sans -a choisit de tout garder. Supabase choisit d'être toujours joignable. Ces défauts sont raisonnables pris isolément — et catastrophiques une fois combinés sur un VPS de 58 Go.
Nous héritons de dizaines de décisions prises par d'autres, pour des contextes qui ne sont pas le nôtre. Le développeur qui déploie sur son propre serveur assume, souvent sans le savoir, un rôle d'exploitant.
Ce que je fais désormais sur toute infrastructure que je gère :
- Une alerte à 80 % de disque, pas à 95 %. À 95 %, tu répares dans l'urgence ; à 80 %, tu planifies.
- Une supervision qui vérifie le contenu, pas le code HTTP. « La page d'accueil contient-elle au moins une fiche projet ? » aurait détecté ma panne en cinq minutes. Un simple contrôle de disponibilité l'a manquée pendant neuf jours.
- Une sauvegarde avant toute intervention. J'ai pris un dump complet avant de toucher quoi que ce soit. Il faisait 1,2 Mo. Mon site était à l'arrêt à cause de 47 Go d'images Docker, alors que la totalité de mes données tenait dans un fichier plus léger qu'une photo.
Ce dernier chiffre m'a fait réfléchir plus que tout le reste.
Le repli décrit ici est en place sur ce site — le détail est sur sa fiche projet. Si tu veux un audit de ce qui casserait silencieusement chez toi, écris-moi.
