❯ apps · Running

Ingérer des flux temps réel peu fiables sur un cluster ARM : tempête de reconnexion, verrous SQLite et mémoire Taming Unreliable Real-Time Feeds on an ARM Cluster: Reconnect Storms, SQLite Locks and Memory

Un agrégateur de flux publics auto-hébergé a transformé un certificat expiré chez un fournisseur en 12,9 millions de lignes de logs en une journée, une table conntrack saturée et un pod tué par l'OOM killer. Le bug de boucles qui doublent, le correctif en trois couches (ordonnanceur single-flight, backoff, disjoncteur), puis les pannes plus discrètes trouvées ensuite : cascade de verrous SQLite, processus zombies et flux morts. A self-hosted aggregator of public feeds turned one expired upstream certificate into 12.9 million log lines in a day, a saturated conntrack table and an OOM-killed pod. Here is the doubling-loop bug behind it, the three-layer fix (single-flight scheduler, backoff, circuit breaker), and the quieter failures found next: a SQLite lock cascade, zombie processes and dead feeds.

· 10 min de lecture 10 min read ·
k3s-clusterincidentdebuggingmonitoringvictorialogsapps
🇬🇧 Read this article in English

Contexte : la chaîne de logs (Vector → VictoriaLogs) et les alertes sont décrites dans Observabilité v2 ; le digest quotidien dont il est question plus bas, dans Un healthcheck quotidien sur Telegram.

Le symptôme : un digest « 10/10 OK » et 4,5 millions de lignes d’erreur

Le 20 juillet 2026 à midi, le digest quotidien du homelab arrive sur Telegram. Une ligne détonne : un backend a produit 4 539 734 lignes d’erreur en 24 heures, et un pod a été tué par l’OOM killer à 06:15. En bas du message, le score global affiche pourtant 10/10 OK. Le healthcheck a vu le problème, il l’a écrit, puis il a conclu que tout allait bien.

Le backend en question est un agrégateur de flux publics auto-hébergé : une API Python (FastAPI) qui ingère en continu des dizaines de sources temps réel, dont un flux WebSocket tiers relayé par un petit sous-processus Node.js. En régime normal, il vit autour de 4,8 GiB de mémoire et écrit environ 3 000 lignes de logs par heure. Rien de spectaculaire.

Le soir, à 21:10, la tempête est toujours en cours :

MesureAvant le 20 juilletLe 20 juillet, 21:10
Lignes de logs du pod~3 000 / h1,9 M sur l’heure 20:00–21:00
Entrées conntrack du nœudquelques milliers94 083 / 131 072 (72 %)
Sockets TCP du podquelques centaines28 988
Redémarrages du backend02 (OOM 8 GiB à 06:15, liveness à 17:03)

Pire : à 04:46, le nœud qui héberge le pod (un Pi 5) a perdu le contact avec l’API Kubernetes pendant une dizaine de minutes. Timeouts vers le control-plane, watches HTTP/2 perdus, renouvellement de lease en échec. À 04:47, le kubelet a redémarré six conteneurs sans rapport sur ce nœud, dont un plugin CSI de stockage. Un bug applicatif venait de déborder sur le réseau d’une machine entière.

L’investigation : remonter la tempête

Le déclencheur, externe et banal

Les toutes premières erreurs datent de minuit UTC pile : certificate has expired. Le certificat TLS du fournisseur du flux WebSocket a expiré à 02:00 heure locale. Personne ne contrôle ça côté homelab, et ce n’est pas un drame en soi : un flux tombe, on réessaie plus tard.

Le problème, c’est la manière dont on réessaie.

La cause racine : chaque échec programme deux reconnexions

Dans le sous-processus Node.js, la fonction connect() ouvre un socket et lui attache ses handlers. Sur l’erreur CERT_HAS_EXPIRED, un chemin de repli ouvre un second socket (validation par épinglage de clé publique) avec ses propres handlers. Mais le premier socket garde les siens. Quand les deux se ferment, chacun appelle setTimeout(connect, 5000).

Un échec produit donc deux futurs connect(). Au cycle suivant, chacun de ces deux en produit deux. Le nombre de boucles parallèles double toutes les 5 secondes, sans backoff, sans jitter, borné seulement par les ressources de la machine : ports éphémères (EADDRNOTAVAIL), table conntrack, mémoire.

Trois détails ont transformé un bug en incident de nœud :

  1. Le double logging. Chaque erreur était journalisée deux fois, dans le handler et dans connect(). Le ratio observé le confirme : 7,9 M de ETIMEDOUT pour 4,2 M de Reconnecting.
  2. Le relais stderr sans limite. Côté Python, chaque ligne du stderr du sous-processus devenait un logger.warning, donc une ligne dans Vector, donc une ligne dans VictoriaLogs. Total : 12,9 millions de lignes en 24 heures.
  3. Le watchdog aveugle. Le superviseur Python avait bien un backoff exponentiel… qui ne s’appliquait que si le processus Node.js mourait. Or il ne mourait jamais : il tournait en rond à l’intérieur. Compteur de respawns immobile, télémétrie muette.

Vers 05:00, l’amont est passé de « certificat expiré » à « injoignable » (connect ETIMEDOUT). Panne du serveur, ou adresse IP du homelab mise sur liste noire après quelques millions de tentatives : depuis la même IP publique, impossible de trancher. La seconde hypothèse n’aurait rien d’injuste.

La mitigation : couper sans toucher au code

À 21:30, l’urgence est d’arrêter l’hémorragie. Le flux fautif s’active seulement si sa clé d’API est présente. Une variable env explicite l’emporte sur un envFrom qui pointe vers un Secret : une ligne dans le Deployment suffit à le désactiver proprement.

# Mitigation temporaire : un env explicite prime sur envFrom.secretRef
- name: FEED_API_KEY
  value: ""

Commit, push, ArgoCD synchronise en trois minutes. Pas de kubectl edit : le selfHeal l’aurait annulé. L’effet est immédiat :

MesureAvantAprès
Entrées conntrack du nœud94 0834 464 (−98,5 %)
Sockets TCP28 988363
Lignes d’erreur du nouveau pod8,7 M / 24 h pour l’ancien28

Le rollback de la mitigation, c’est un git revert. C’est tout l’intérêt de passer par Git même en urgence.

Le correctif : trois couches indépendantes

Le même soir, vers 23:00, la vraie correction est déployée. Elle ne se contente pas de boucher le trou du certificat : elle part du principe que n’importe quel flux finira par mal se comporter, et que la protection doit tenir même si une couche rate.

1. Un ordonnanceur single-flight avec backoff

La logique de reconnexion sort de connect() pour vivre dans une petite classe dédiée. Sa règle centrale tient en une ligne : si une reconnexion est déjà programmée, toute nouvelle demande est ignorée.

schedule(connectFn) {
  if (this.timer !== null) return null;          // single-flight
  const expo = Math.min(this.baseDelayMs * 2 ** this.attempts, this.maxDelayMs);
  const delay = Math.round(expo + expo * this.jitterRatio * this.random());
  this.attempts += 1;
  this.timer = this.setTimeoutFn(() => { this.timer = null; connectFn(); }, delay);
  return delay;
}

Peu importe combien de handlers close ou error se déclenchent : ils retombent tous sur le même timer. Le délai part de 5 s, double à chaque échec, plafonne à 10 minutes, avec 20 % de jitter pour ne pas resynchroniser plusieurs clients sur la même seconde.

Le détail qui compte : reset() n’est appelé qu’après 60 secondes de connexion stable, pas à chaque ouverture de socket. Sinon, un amont qui accepte la connexion puis la coupe aussitôt remet le compteur à zéro à chaque cycle, et le backoff ne sert à rien.

Le socket de repli marque aussi l’ancien comme « remplacé », pour que son handler close ne puisse plus rien programmer. L’horloge et l’aléa sont injectables, ce qui permet de tester le scénario exact du 20 juillet (CERT_HAS_EXPIRED puis double close) et de vérifier qu’une seule reconnexion sort.

2. Des logs par transition, pas par tentative

Une erreur identique répétée devient une ligne de résumé par minute. Le bandeau « TLS dégradé » ne s’écrit qu’au changement d’état. Le double logging disparaît. Une reconnexion qui échoue pendant une heure doit coûter une soixantaine de lignes, pas un million.

3. Un disjoncteur sur le symptôme, pas sur la mort du processus

Côté Python, le relais du stderr passe par une fenêtre glissante : 30 lignes par minute relayées telles quelles, le reste résumé en une ligne (« N lignes similaires supprimées dans les 60 dernières secondes »). La même fenêtre fournit un débit, et ce débit sert de signal : à 600 lignes par minute ou plus, le sous-processus est arrêté et son redémarrage suspendu 15 minutes. L’état du disjoncteur (circuit_open, secondes restantes) est exposé sur /api/health.

C’est la leçon la plus transférable de l’incident. L’ancien garde-fou surveillait la cause supposée d’un problème (le processus meurt) ; le nouveau surveille son symptôme observable (le processus hurle). Un processus vivant et fou passait sous le premier. Il ne passe plus sous le second.

Les garde-fous plateforme : ce qui aurait dû sonner

Pendant tout l’incident, vmalert n’a tiré aucune alerte. Trois règles ont été ajoutées le soir même :

  • ContainerOOMKilled ;
  • ContainerMemNearLimit : working set au-dessus de 85 % de la limite pendant 15 minutes (l’OOM d’un cgroup est invisible pour une alerte de pression mémoire du nœud) ;
  • ConntrackNearFull : table conntrack au-dessus de 80 % pendant 5 minutes.

La deuxième a flappé toute la première nuit sur VictoriaMetrics, qui vit volontairement entre 81 et 89 % de sa limite (son cache est dimensionné sur elle). Exclusion de ce pod et keep_firing_for: 1h, et le bruit a cessé. Une alerte neuve se calibre toujours sur la réalité, pas sur l’intuition.

Côté logs, Vector applique désormais un throttle de 1 000 lignes par minute et par pod, environ 15 fois le pod légitime le plus bavard. Ce n’est pas une alerte, c’est un plafond : VictoriaLogs ne peut plus être inondé par un seul conteneur. Et le healthcheck quotidien a appris à compter : un OOM récent ou 50 000 lignes d’erreur en 24 h coûtent un point, 500 000 en coûtent deux. Rejoué sur les données du 20 juillet, il rend CRITICAL 8/10 au lieu de « OK 10/10 ».

Les pannes discrètes : ce qu’on trouve en cherchant autre chose

Une semaine plus tard, le 27 juillet, le même backend entre dans une boucle de redémarrages. En l’examinant, trois défauts sans lien avec la tempête remontent. Aucun ne bloque l’event loop principal, mais tous saturent le petit pool de deux threads qui exécute les tâches lentes.

La cascade de verrous SQLite

Une partie des flux passe par 8 à 10 collecteurs HTTP planifiés, qui écrivent tous dans la même base SQLite. La base tournait dans le mode de journal par défaut (DELETE) avec un busy_timeout de 5 secondes. Quand les collecteurs finissaient leurs appels réseau ensemble, ils se battaient pour le verrou d’écriture exclusif : database is locked, des tâches qui traînaient 50 à 120 secondes jusqu’au timeout dur, et qui chevauchaient le cycle suivant.

Le pire était ailleurs : chaque collecteur ouvrait sa connexion avant son appel réseau de 10 à 60 secondes. La fenêtre de verrou couvrait donc l’attente réseau entière. Le correctif tient en quatre gestes :

_DB_CONNECT_TIMEOUT_S = 30

def init_db():
    conn = _connect()                       # timeout partagé, une seule constante
    try:
        conn.execute("PRAGMA journal_mode=WAL")    # les lecteurs ne bloquent plus l'écrivain
        conn.execute("PRAGMA synchronous=NORMAL")  # pas de fsync à chaque commit
        ...
    finally:
        conn.close()

def ingest(self):
    rows = self.fetch_data()   # le réseau d'abord…
    conn = _connect()          # …le verrou ensuite, seulement pour l'écriture

Le mode WAL laisse les lecteurs travailler pendant qu’un écrivain écrit. synchronous=NORMAL, sûr en WAL, évite un fsync à chaque commit, ce qui compte double quand le fichier vit sur un volume Longhorn répliqué : chaque fsync traverse le réseau vers les répliques.

Le processus zombie

Un superviseur de sous-processus remplaçait son Popen après un crash sans jamais appeler wait() sur l’ancien. Résultat : un zombie dans la table des processus jusqu’à l’arrêt du serveur, observé en direct dans le pod. La correction : récolter le processus sorti, avec un timeout borné, à chaque respawn et à chaque détection de crash.

Les flux morts réessayés à chaque cycle

Deux sources renvoyaient un HTTP 404 permanent depuis des semaines, et le collecteur les réessayait à chaque passage. L’une rejoint la liste des URL mortes déjà gérée par le code, l’autre est commentée dans la planification, la classe conservée au cas où la source reviendrait. Un 404 permanent n’est pas une panne passagère : il mérite une décision, pas un retry éternel.

Et la mémoire ?

Le pod tournait déjà avec une limite de 8 GiB. Elle datait du 12 juin : à 4 GiB, la RAM saturait, l’event loop ralentissait, et les probes /api/health finissaient en timeout, ce qui faisait redémarrer le pod pour rien. Doubler la limite (et la request de 1 à 2 GiB) a réglé ce cas-là.

Avec le recul, c’est la correction la moins satisfaisante des trois. Elle achète de la marge, elle ne règle aucune cause. Le 20 juillet l’a montré : une boucle qui double toutes les 5 secondes remplit 8 GiB comme elle aurait rempli 4. Sur un Pi 5 à 16 GiB partagés avec d’autres charges, la mémoire n’est pas un levier qu’on peut tirer indéfiniment. Les vrais correctifs sont ceux qui bornent la croissance : une seule reconnexion, un délai qui grandit, un disjoncteur.

Leçons retenues

1. Tout flux externe finira par mal se comporter

Certificat expiré, 404 définitif, serveur qui accepte puis coupe : la question n’est pas si, mais quand. Chaque client de flux a besoin d’un ordonnanceur single-flight, d’un backoff plafonné avec jitter, et d’un reset conditionné à une connexion réellement stable.

2. Surveiller les symptômes, pas seulement les morts

Un superviseur qui ne réagit qu’à la mort du processus rate le cas le plus dangereux : le processus vivant qui tourne en rond. Le débit de logs, le nombre de sockets ou d’entrées conntrack sont des signaux bon marché et fiables.

3. Les logs sont une ressource partagée

Un pod bavard ne remplit pas que son propre volume : il charge l’agent de collecte, le stockage des logs et le réseau du nœud. Un plafond par pod côté collecte (ici 1 000 lignes par minute) protège tout le monde, y compris de bugs qu’on n’a pas encore écrits.

4. Ouvrir le verrou le plus tard possible

Pour SQLite comme pour n’importe quelle ressource exclusive : faire le travail lent d’abord (réseau, calcul), prendre le verrou ensuite, seulement pour écrire. Et passer en WAL dès qu’il y a plus d’un écrivain potentiel.

5. Une alerte qui ne sonne pas est un bug

Zéro alerte pendant 19 heures d’incident, et un digest « 10/10 OK » au milieu : c’est l’angle mort le plus coûteux de l’histoire. Après un incident, la première question n’est pas seulement « pourquoi c’est arrivé ? », mais aussi « pourquoi personne ne l’a vu ? ».

k3s-clusterincidentdebuggingmonitoringvictorialogsapps