Une version précédente avait ajouté un plafond sur les logs de build. Dix mille lignes, huit mégaoctets, on jette les plus anciennes, on laisse un marqueur indiquant la quantité écartée. Livraison au vert. Sous un test de charge qui avait autrefois provoqué un OOM du processus à 7,3 Go, la mémoire résidente culminait désormais à 201 Mo. Dossier clos.
Puis un testeur a déployé une application dont le Dockerfile exécutait RUN seq 1 2000000, et a regardé la base ensuite. La colonne build_log contenait 13,95 Mo répartis sur 1 882 590 lignes. Aucun marqueur. Le plafond qui avait passé tous les tests et borné la RSS dans le test de charge n'avait, sur le chemin qui persiste réellement les logs, strictement rien fait.
Ceci est une histoire sur l'endroit où vit une valeur, et sur la raison pour laquelle « le test passe » et « le correctif fonctionne » sont deux affirmations différentes.
Deux consommateurs, un seul stream
Quand sh0 construit une image Docker, il lit la réponse du démon frame par frame. Chaque ligne de log part à deux endroits :
rustif let Some(stream) = output.stream {
let trimmed = stream.trim().to_string();
if !trimmed.is_empty() {
if let Some(tx) = log_tx {
let _ = tx.try_send(trimmed.clone()); // (1) vue en direct
}
logs.push(trimmed); // (2) accumulateur plafonné
}
}Le chemin (2) est un CappedLogs — une deque bornée qui écarte la ligne la plus ancienne dès qu'elle dépasse 10 000 lignes ou 8 Mo, en comptant les rejets. C'est le plafond ajouté par la version précédente, et il est correct. build_image le renvoie sous la forme BuildResult.logs.
Le chemin (1) est un canal mpsc alimentant une tâche de fond — le « flusher » — qui écrit en base toutes les 500 ms pour que le tableau de bord puisse suivre le build en direct.
Voici tout le bug, dans le flusher :
rustupdate_deployment_field(&pool, &dep_id, move |dep| {
let existing = dep.build_log.clone().unwrap_or_default();
dep.build_log = Some(format!("{existing}{batch}\n"));
}).await.ok();À chaque tick : lire toute la colonne, la cloner, y ajouter le nouveau lot, la réécrire. Aucun plafond nulle part sur ce chemin. Le CappedLogs du chemin (2) — celui qui porte la borne et le marqueur — est renvoyé à l'appelant en cas de succès et, pour le log persisté, ignoré. La base est alimentée par le canal, qui n'est pas plafonné.
Le plafond existait donc bien. Il était bien écrit et bien testé. Il protégeait simplement une valeur que le chemin de succès jetait à la poubelle.
Pourquoi chaque contrôle est passé à côté
- Les tests unitaires exerçaient
CappedLogsdirectement. Ils prouvaient que la deque se bornait elle-même. Ils ne pouvaient pas voir que le pipeline persiste une chaîne différente. - Le test de charge mesurait la RSS, et la RSS était bornée — parce qu'à 14 Mo de log accumulé, le transitoire du clone par tick vaut environ 28 Mo, largement à l'intérieur de « quelques centaines de Mo ». La mémoire avait l'air correcte précisément parce que la ligne n'était pas encore catastrophiquement grosse ; la croissance est linéaire et non bornée, mais un build de quelques secondes n'atteint jamais la falaise. La métrique qui avait attrapé l'incident d'origine était la mauvaise métrique pour ce défaut.
- L'assertion sur le marqueur — un point de checklist qui cherchait
[sh0: N earlier lines dropped]dans le log persisté — ne pouvait jamais passer sur un build réussi, car cette chaîne n'existe que dans le Vec abandonné. Elle échouait silencieusement depuis le jour où elle avait été écrite.
Trois signaux au vert, une ligne de 14 Mo. Aucun des signaux ne mentait ; ils répondaient tous à une question voisine de celle qui comptait.
Le correctif : plafonner ce que l'on garde
Le bon geste consiste à faire du flusher lui-même l'accumulateur borné. Les trois points d'appel du streaming (git, Dockerfile, upload) avaient dérivé vers trois copies du flusher identiques à l'octet près, elles ont donc fusionné en un seul helper, et l'ajout est devenu un re-plafonnement :
rustasync fn flush_build_log_batch(pool: &Arc<DbPool>, dep_id: &str, pending: &mut Vec<String>) {
if pending.is_empty() { return; }
let batch = redact_secrets(&std::mem::take(pending).join("\n"));
update_deployment_field(pool, dep_id, move |dep| {
let existing = dep.build_log.take().unwrap_or_default();
dep.build_log = Some(cap_build_log(format!("{existing}{batch}\n")));
}).await.ok();
}cap_build_log est une fonction pure : dans le budget, elle renvoie la chaîne intacte (les builds normaux ne sont pas affectés, les lignes de préfixe [STEP] sont préservées) ; au-delà du budget, elle écarte les lignes les plus anciennes et préfixe un unique marqueur, en repliant le compteur d'un éventuel marqueur antérieur pour que le re-plafonnement à chaque tick accumule au lieu d'empiler. Pure signifie qu'elle est trivialement testable pour la propriété qui compte vraiment — la chaîne persistée est bornée — au lieu d'une propriété voisine. Les tests portent désormais sur la sortie de la fonction que le pipeline appelle réellement : ≤ 10 001 lignes, < 8 Mo, marqueur présent, compteur qui s'accumule.
Le résidu assumé
Il reste une seconde perte que le correctif ne comptabilise pas entièrement. Quand le canal est plein, try_send écarte la ligne silencieusement — sous le déluge de 2 M de lignes, environ 117 000 lignes se sont volatilisées avant d'atteindre le flusher. Les attribuer exactement supposerait de faire passer un compteur atomique à travers les signatures de fonctions de trois crates.
Nous ne l'avons pas fait. Le marqueur du plafond annonce déjà « 1,9 M de lignes écartées pour borner la mémoire », donc rien ne se présente comme silencieusement complet, et le problème de sévérité haute — la ligne non bornée et sa re-sérialisation à chaque appel d'API — est clos. Compter les rejets de backpressure à la ligne près, c'est du vrai travail pour un nombre dont aucune décision utilisateur ne dépend. Ce compromis est écrit noir sur blanc dans le ticket et les notes de version, pas laissé à redécouvrir par quelqu'un d'autre. Un correctif qui va trop loin constitue sa propre forme de dette.
Ce qu'il faut en retenir
Un plafond ne vaut que par sa position dans le flux de données. « La valeur est-elle bornée ? » n'est pas la même question que « la valeur que nous persistons est-elle bornée ? » — et lorsqu'un stream se ramifie vers deux consommateurs, une garde sur une branche ne dit rien de l'autre. Quand vous ajoutez une borne, écrivez le test contre la valeur exacte qui sort du système, pas contre le proxy le plus commode. Le proxy passera. Le système continuera de grossir.