Back to sh0
sh0

Le bug de 16 Ko : comment un buffer de pipe a figé toute notre plateforme

Un buffer de pipe de 16 Ko causait le gel de Caddy toutes les 5 minutes. L'histoire du débogage d'un deadlock classique de pipe Unix qui nous a menés de la confusion à un correctif de 5 lignes.

Thales & Claude | March 30, 2026 10 min sh0
EN/ FR/ ES
debuggingcaddyunixpipe-bufferdeadlockrustwar-story

Toutes les cinq à sept minutes, comme une horloge, le reverse proxy de sh0 se figeait. L'API Admin de Caddy cessait de répondre. Le moniteur de santé détectait la défaillance, tuait le processus, le redémarrait, ré-appliquait toutes les routes, et tout fonctionnait à nouveau -- pendant exactement cinq à sept minutes. Puis il se figeait encore.

Les logs racontaient une histoire d'auto-guérison acharnée :

ERROR sh0_proxy::manager: Caddy process is alive but admin API is unresponsive -- killing and restarting
INFO  sh0_proxy::manager: Caddy restarted -- re-applying 12 routes
...
ERROR sh0_proxy::manager: Caddy process is alive but admin API is unresponsive -- killing and restarting
INFO  sh0_proxy::manager: Caddy restarted -- re-applying 12 routes

Le moniteur de santé que nous avions construit (Article 5) faisait admirablement son travail -- aucune interruption visible pour les utilisateurs. Mais le schéma était exaspérant. Caddy ne plantait pas. Le processus était vivant. Il était simplement... figé. À chaque fois.

Voici l'histoire d'un bug qui existe depuis les premiers jours d'Unix, caché en pleine vue dans notre base de code Rust moderne.


Le symptôme

La défaillance était parfaitement constante :

  • Processus Caddy vivant (PID présent, pas zombie)
  • API Admin ne répondant pas (timeout HTTP sur localhost:2019)
  • Trafic HTTPS vers toutes les applications hébergées figé (Caddy gère toute la terminaison TLS)
  • Intervalle entre les gels : 5 à 7 minutes, variant légèrement
  • Après kill et redémarrage : récupération immédiate, routes ré-appliquées en moins d'une seconde

L'intervalle variable était le premier indice que quelque chose se remplissait. Un intervalle fixe suggérerait un timer ou un déclencheur de type cron. Un intervalle lentement variable suggère un buffer ou une file atteignant sa capacité, où le taux de remplissage dépend de l'activité.


L'investigation

Nous avons commencé par les suspects habituels.

Mémoire ? Non. La RSS de Caddy était stable à environ 30 Mo. Pas de croissance entre les redémarrages.

Descripteurs de fichiers ? Non. lsof montrait un nombre normal de sockets et fichiers ouverts.

Bug de Caddy ? Peu probable. Caddy est un logiciel éprouvé au combat servant des millions de sites. Un bug qui fige le processus entier toutes les cinq minutes ne survivrait pas à un seul cycle de release.

Notre configuration ? Nous avons inspecté la configuration JSON que nous envoyions à Caddy. Valide. Propre. La même configuration fonctionnait parfaitement quand elle était chargée depuis un fichier avec caddy run --config caddy.json.

Cette dernière observation a été la percée. Le même binaire Caddy, avec la même configuration, fonctionnait bien lancé seul mais se figeait quand il tournait comme processus enfant de sh0. La différence résidait dans la façon dont nous le lancions.

Nous avons regardé le code de spawn dans process.rs :

rustlet child = Command::new(&self.caddy_path)
    .args(["run", "--config", "-"])
    .stdin(Stdio::null())
    .stdout(Stdio::null())
    .stderr(Stdio::piped())   // <-- ligne 53
    .spawn()?;

Trois flux d'entrée/sortie standard. Stdin : null (Caddy n'a pas besoin d'entrée interactive). Stdout : null (nous n'avons pas besoin de sa sortie standard). Stderr : piped.

Piped. Vers où ?


La cause racine

La réponse est : vers nulle part. Nous avions pipé le stderr de Caddy dans notre processus mais ne lisions jamais depuis le pipe. Nous avions écrit .stderr(Stdio::piped()) avec l'intention de capturer la sortie d'erreur, mais n'avions jamais lancé de tâche pour la consommer.

Voici ce qui se passe quand on pipe la sortie d'un processus enfant et qu'on ne la lit pas :

  1. Le processus enfant écrit sur stderr (Caddy enregistre chaque requête, chaque handshake TLS, chaque mise à jour de route)
  2. Les données vont dans un buffer de pipe du noyau
  3. Sur macOS, ce buffer fait environ 16 Ko (65 Ko sur Linux)
  4. Quand le buffer est plein, le prochain appel write() de l'enfant bloque
  5. L'écriture se produit sur le thread principal de Caddy (ou un thread qui détient un verrou critique)
  6. Caddy est maintenant figé -- il ne peut pas traiter les requêtes HTTP, ne peut pas répondre à l'API Admin, ne peut rien faire tant que le buffer de pipe n'a pas de place

Ce n'est pas un bug de Caddy. Ce n'est pas un bug de Rust. C'est une propriété fondamentale des pipes Unix qui existe depuis les années 1970. Un pipe est un buffer de taille fixe. Quand il est plein, l'écrivain bloque jusqu'à ce que le lecteur consomme des données. S'il n'y a pas de lecteur, l'écrivain bloque indéfiniment.

L'intervalle de 5 à 7 minutes correspond parfaitement au temps nécessaire pour que la sortie de log de Caddy remplisse 16 Ko. Avec un trafic modéré (une douzaine d'applications hébergées, des health checks périodiques, des renouvellements TLS), Caddy produit quelques centaines d'octets de sortie de log par seconde. À ce rythme, 16 384 octets se remplissent en environ 5 à 7 minutes.


Pourquoi pas Stdio::null() ?

La question naturelle : pourquoi avions-nous pipé stderr en premier lieu au lieu de l'envoyer vers null comme stdout ?

Parce que nous voulions la sortie d'erreur de Caddy pour le débogage. Quand Caddy ne parvient pas à lier un port, rejette une configuration ou rencontre une erreur TLS, cette information apparaît sur stderr. La rejeter avec Stdio::null() aurait rendu le débogage des problèmes de proxy quasi impossible.

L'erreur n'était pas de piper stderr. L'erreur était de le piper sans le lire.


Le correctif

Le correctif tient en cinq lignes de code, ajoutées immédiatement après le lancement du processus enfant :

rust// Drainer stderr dans une tâche d'arrière-plan pour empêcher le deadlock du buffer de pipe
if let Some(stderr) = child.stderr.take() {
    let reader = tokio::io::BufReader::new(stderr);
    let mut lines = reader.lines();
    tokio::spawn(async move {
        while let Ok(Some(line)) = lines.next_line().await {
            tracing::debug!(target: "caddy", "{}", line);
        }
    });
}

Une tâche tokio en arrière-plan lit stderr ligne par ligne et transmet chaque ligne au logger tracing au niveau debug. Le buffer de pipe ne se remplit jamais parce qu'il est continuellement drainé. La sortie de log de Caddy est préservée (visible en lançant avec RUST_LOG=caddy=debug) mais n'obstrue pas le pipe.

Nous avons aussi rétrogradé le message de redémarrage du moniteur de santé de error! à warn! :

rust// Avant
tracing::error!("Caddy process is alive but admin API is unresponsive -- killing and restarting");

// Après
tracing::warn!("Caddy process is alive but admin API is unresponsive -- killing and restarting");

Le redémarrage est un comportement d'auto-guérison, pas une défaillance critique. Le niveau warn est approprié : quelque chose d'inattendu s'est produit, mais le système l'a géré automatiquement.


Confirmer le correctif

Après avoir déployé le correctif, nous avons laissé le serveur tourner pendant plus de 15 minutes. Puis une heure. Puis toute la nuit. Le cycle de redémarrage avait disparu. Caddy tournait en continu, l'API Admin restait réactive, et le moniteur de santé ne rapportait que des vérifications propres.

La logique de redémarrage que nous avions construite dans l'Article 5 est restée en place comme filet de sécurité. Elle a simplement cessé de se déclencher. Un système qui redémarrait toutes les cinq minutes tournait maintenant indéfiniment sans intervention.


Un bug classique dans une base de code moderne

Ce bug est documenté dans tous les manuels de programmation Unix. La spécification POSIX pour pipe(2) stipule explicitement que les écritures dans un pipe plein bloqueront. La documentation Python avertit à ce sujet. La documentation Rust std::process le mentionne. Et pourtant il nous a piégés, deux bâtisseurs expérimentés (un humain, une IA), parce que le symptôme -- un serveur HTTP qui ne répond plus -- ne ressemblait en rien à sa cause -- un buffer de pipe plein de 16 Ko dans le noyau.

L'indirection est ce qui rend ce bug insidieux. La cause (un buffer plein de 16 Ko dans un pipe du noyau) et l'effet (l'API Admin de Caddy ne répondant pas aux requêtes HTTP) sont séparés par plusieurs couches d'abstraction. Il faut raisonner à travers la chaîne : pipe plein mène à une écriture bloquée, qui mène à un thread bloqué, qui mène à un processus en deadlock, qui mène à des endpoints HTTP qui ne répondent plus.

Plusieurs facteurs ont rendu ce bug particulièrement difficile à diagnostiquer :

Le processus n'était pas mort. Le monitoring traditionnel de processus (le PID est-il vivant ? est-ce un zombie ?) rapportait tout comme sain. Le processus était vivant, il était juste incapable de progresser.

L'intervalle était variable. S'il avait été exactement de 5 minutes à chaque fois, nous aurions peut-être cherché un timer. La variation de 5 à 7 minutes pointait vers un déclencheur dépendant de la capacité, mais nous avons d'abord regardé les caches internes de Caddy plutôt que le buffer de pipe au niveau du système d'exploitation.

Le contournement masquait la cause. Notre moniteur de santé (tuer, redémarrer, ré-appliquer les routes) maintenait la plateforme en fonctionnement. L'urgence de trouver la cause racine était moindre parce que les utilisateurs n'étaient pas affectés. C'est l'épée à double tranchant de l'infrastructure auto-guérisante : elle vous fait gagner du temps, mais elle vous permet aussi de vivre avec des bugs plus longtemps que vous ne le devriez.

macOS versus Linux. Sur Linux, le buffer de pipe par défaut est de 65 Ko, donc le même bug se manifesterait avec un intervalle plus long -- peut-être 20 à 30 minutes. Nous développions sur macOS où le buffer de 16 Ko rendait le cycle plus rapide et plus visible. Si nous avions été sur Linux, cela aurait pu être confondu avec un problème réseau intermittent.


Règles pour la gestion des processus enfants

Cette expérience a cristallisé trois règles que nous suivons maintenant pour chaque processus enfant dans sh0 :

Règle 1 : chaque flux pipé doit avoir un lecteur. Si vous pipez stdout ou stderr, lancez une tâche pour le consommer. Toujours. Même si vous pensez que le processus enfant ne produira pas beaucoup de sortie. « Pas beaucoup » finit par devenir « assez pour remplir le buffer ».

Règle 2 : préférer le drainage asynchrone aux lectures synchrones. Un read() bloquant dans un runtime tokio peut affamer l'exécuteur. Utilisez tokio::io::BufReader et lines() pour intégrer les I/O des processus enfants avec le runtime asynchrone.

Règle 3 : enregistrer la sortie des processus enfants, ne pas la rejeter. Envoyer stderr vers Stdio::null() empêche le deadlock mais détruit les informations de diagnostic. Drainer vers un logger au niveau debug vous donne les deux : pas de deadlock, et la possibilité de voir la sortie quand vous en avez besoin.


La leçon plus large

Le bug de 16 Ko est un rappel que la programmation système est pleine de contrats implicites. Un pipe Unix a un contrat : le lecteur doit suivre le rythme de l'écrivain, ou l'écrivain bloquera. Ce contrat est invisible dans l'API -- .stderr(Stdio::piped()) compile et s'exécute sans se plaindre. La violation ne se manifeste que sous charge, après des minutes de sortie accumulée, dans un symptôme qui semble complètement sans rapport avec la cause.

Chaque couche d'abstraction que nous utilisons -- runtimes asynchrones, serveurs HTTP, gestionnaires de processus, runtimes de conteneurs -- a des contrats comme ceux-ci. Les bugs les plus dangereux ne sont pas ceux qui font planter votre programme. Ce sont ceux qui l'empêchent de progresser tout en paraissant parfaitement sain vu de l'extérieur.


Ce qui vient ensuite

Avec le deadlock de pipe corrigé et Caddy tournant de manière stable, nous nous sommes tournés vers l'autre côté de l'équation du proxy : les certificats SSL. Le prochain article couvre comment sh0 gère le HTTPS automatique via ACME, supporte les uploads de certificats personnalisés pour les déploiements entreprise, et chiffre les clés privées au repos avec AES-256-GCM.

Ceci est la Partie 7 de la série « Comment nous avons construit sh0.dev ». sh0 est une plateforme PaaS construite entièrement par un CEO à Abidjan et un CTO IA, avec zéro ingénieur humain.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles