❯ monitoring · Running

48 alertes pour 24 pods : quand un seul reboot sature le Telegram 48 Alerts for 24 Pods: Taming an Alerting Pipeline That Amplified a Single Reboot

Une migration rack planifiée redémarre tous les nœuds et le canal d'alertes explose : deux règles qui se chevauchent, un filtre de raison qui confond la perte d'un nœud avec un kill de probe, et un script d'investigation automatique qui raisonne par pod au lieu de par cause. Trois correctifs et un routage revu ramènent une tempête simulée de 48 alertes à 4 messages. A planned rack move rebooted every node and the alert channel exploded: two overlapping rules, a reason filter that mistook node loss for probe kills, and an auto-investigation script that worked per pod instead of per cause. Three fixes and a routing change brought a simulated 48-alert storm down to 4 messages.

· 10 min de lecture 10 min read ·
monitoringvmalertalertmanagerpromqltelegramincident
🇬🇧 Read this article in English

Contexte : le pipeline vmalert → Alertmanager → Telegram est décrit dans Observabilité v2, et le digest quotidien qui complète l’alerting temps réel dans Un digest de santé quotidien qui a arrêté de crier au loup. Ici, il est question de l’alerting temps réel lui-même — et de ce qu’il fait quand une seule cause touche tout le cluster.

Le symptôme : 48 alertes pour 24 pods

Le 20 septembre 2026, le cluster déménage dans un rack. Opération planifiée : tous les nœuds sont arrêtés, déplacés, rallumés. Les pods redémarrent avec eux. Rien que de très attendu.

Côté Telegram, c’est une autre histoire. Le lendemain, le canal d’alertes ressemble à un fil d’actualité. En comptant, le constat est simple et un peu vexant : 48 alertes pour 24 pods. Chaque pod redémarré a déclenché deux règles différentes. Et derrière les alertes, une seconde vague : les messages du script qui lance une investigation automatique sur chaque nouvelle alerte. Le 21 septembre, son budget de 8 investigations par jour est brûlé en 20 minutes, toutes sur la même règle, PodProbeFailingSustained, toutes pour la même cause — un reboot que j’avais moi-même planifié.

Le plus inconfortable arrive en relisant l’état du script : le plafond quotidien était atteint chaque jour depuis le 9 septembre. La migration n’a pas créé le problème, elle l’a rendu impossible à ignorer.

En creusant, trois causes indépendantes se cumulent, chacune multipliant la précédente.

Le contexte : deux règles pour des restarts « lents »

Le cluster a déjà une alerte PodCrashLooping pour les crash-loops francs. Deux règles plus fines la complètent, ajoutées pour ce qui passe sous son radar.

PodRestartingSlowly attrape le crash-loop au ralenti : un conteneur qui redémarre trois fois en 24 heures, assez espacé pour ne jamais franchir un seuil de taux sur 15 minutes.

PodProbeFailingSustained vise un cas plus vicieux : un conteneur tué à répétition par sa liveness probe, qui affiche Running et Ready=True entre chaque kill. Le kubelet de ce cluster n’expose pas prober_probe_total, la règle utilise donc un proxy : kube_pod_container_status_last_terminated_reason, la raison de la dernière terminaison du conteneur, combinée au même compteur de restarts.

Petit rappel sur cette métrique de kube-state-metrics : c’est un état, pas un événement. Elle vaut 1 pour la raison de la dernière terminaison et le reste tant que le conteneur n’est pas terminé à nouveau. Le digest quotidien s’y était déjà cassé les dents avec un « OOM fantôme » ; ici, le piège est différent, il porte sur quelle raison on accepte.

Voici les deux expressions telles qu’elles étaient avant le 21 septembre :

# PodRestartingSlowly — avant
increase(kube_pod_container_status_restarts_total[24h]) >= 3

# PodProbeFailingSustained — avant
(kube_pod_container_status_last_terminated_reason{reason!="OOMKilled",reason!="Completed"} == 1)
and on(namespace, pod, container)
(increase(kube_pod_container_status_restarts_total[24h]) >= 3)

Il suffit de les poser l’une sous l’autre pour voir le premier problème.

Cause n° 1 — Le doublon : une règle incluse dans l’autre

PodProbeFailingSustained exige exactement la même condition que PodRestartingSlowly (au moins 3 restarts sur 24 heures), plus un filtre sur la raison. Ce n’est pas une règle voisine : c’est un sous-ensemble strict. Tout pod qui la déclenche déclenche aussi l’autre. Chaque incident « probe » produit donc deux alertes, deux notifications, et deux lignes à lire pour une seule information.

Tant que les restarts restaient rares, le doublon passait pour une coïncidence. Avec 24 pods redémarrés d’un coup, il devient un facteur 2 bien visible.

La correction tient en un opérateur. Les deux règles deviennent mutuellement exclusives : tout ce qui se termine en reason="Error" part chez PodProbeFailingSustained, tout le reste reste chez PodRestartingSlowly.

- alert: PodRestartingSlowly
  expr: |
    increase(kube_pod_container_status_restarts_total[24h]) >= 3
    unless on(namespace, pod, container)
    (kube_pod_container_status_last_terminated_reason{reason="Error"} == 1)
  for: 1m
  labels: { severity: warning }

unless est l’opérateur ensembliste de PromQL : il garde les séries de gauche qui n’ont pas de correspondance à droite sur les labels indiqués. Avec on(namespace, pod, container), la correspondance se fait conteneur par conteneur.

Une règle réécrite qui « devrait » marcher ne prouve rien. La mesure a été faite contre vmsingle, avec les deux expressions évaluées sur les données réelles :

Séries (les deux règles)Total
Avant24 + 2448
Après11 + 1324

Exactement les 24 pods concernés, chacun compté une seule fois. C’est le critère qui compte : pas « moins d’alertes », mais une alerte par conteneur réellement touché.

Cause n° 2 — reason="Unknown" : un reboot n’est pas un kill de probe

Le doublon explique le facteur 2. Il n’explique pas pourquoi une règle censée détecter des kills de liveness probe s’est allumée sur des pods qui n’avaient aucun problème de probe.

Le filtre de PodProbeFailingSustained était négatif : toute raison sauf OOMKilled et Completed. Le commentaire au-dessus de la règle disait pourtant clairement ce qu’elle cherchait : les kills liveness, exit 137, reason="Error". Entre les deux, une raison passait entre les mailles : Unknown.

RaisonCode de sortie typiqueCe que ça signifie
Error137Conteneur tué par SIGKILL, typiquement par le kubelet après des échecs de liveness probe
Error1Crash applicatif
OOMKilled137Tué par l’OOM killer
Completed0Sortie normale
Unknown255Le kubelet a perdu la trace du conteneur — parce que le nœud est tombé

Unknown, c’est la signature d’un nœud qui disparaît sous le conteneur : un reboot planifié, une coupure de courant, une migration de rack. Combiné aux restarts provoqués par ce même reboot, le filtre négatif armait la règle sur tous les pods du nœud. Une maintenance se présentait donc comme une épidémie de probes défaillantes.

Les chiffres en base ne laissent aucune ambiguïté : 87 séries Unknown contre 18 séries Error. La règle passait l’essentiel de son temps à surveiller des reboots.

Le correctif inverse la logique : au lieu d’exclure ce qu’on ne veut pas, on nomme ce qu’on veut.

- alert: PodProbeFailingSustained
  expr: |
    (kube_pod_container_status_last_terminated_reason{reason="Error"} == 1)
    and on(namespace, pod, container)
    (increase(kube_pod_container_status_restarts_total[24h]) >= 3)
  for: 5m

Ce filtre couvre exit 137 (kill liveness) et exit 1 (crash applicatif), ce qui reste cohérent avec l’intention : un conteneur qui meurt de lui-même ou qu’on tue, à répétition. Un pod redémarré trois fois parce que son nœud est tombé n’est pas réduit au silence pour autant : il retombe dans PodRestartingSlowly, la règle générique, en warning. C’est voulu — trois restarts en 24 heures restent une information, simplement pas celle-là.

Cause n° 3 — L’amplification : une investigation par pod

Les deux premières causes produisent 48 alertes. La troisième les transforme en avalanche.

Le cluster est surveillé par un script, lancé par cron, qui lance une investigation automatique sur chaque nouvelle alerte : il collecte le contexte, rédige un rapport et envoie un résumé sur Telegram. Une investigation peut durer jusqu’à 15 minutes ; elles sont donc rationnées, avec un plafond global de 8 par jour et un debounce de 60 minutes par alerte.

Le problème tient en une ligne, celle qui calcule l’empreinte d’une alerte :

fp="$(jq -r '[.alertname, .namespace // "-", .pod // .instance // "-"] | join("_")' <<<"$alert")"

L’empreinte inclut le pod. Une cause commune qui touche 24 pods produit donc 24 empreintes distinctes, 24 alertes « nouvelles » aux yeux du debounce, 24 candidates à une investigation. Le plafond de 8 par jour ne protège rien : il limite les dégâts, mais il les limite à 8 investigations quasi identiques, sur la même cause, qui consomment tout le budget du jour avant qu’une alerte vraiment différente n’arrive. C’est exactement ce qui s’est passé le 21 septembre : 8 sur 8 en 20 minutes, toutes sur PodProbeFailingSustained.

Et un plafond atteint tous les jours depuis le 9 septembre n’est plus un garde-fou. C’est un symptôme.

Bonus : le log du script. Chaque alerte écartée par le plafond produisait sa propre ligne, à chaque cycle. Résultat : 2,2 Mo de log, dont 12 715 lignes pour la seule journée du 20 septembre.

Quatre changements dans le script :

  1. Un plafond par alertname : 2 investigations au maximum par règle et par jour, en plus du plafond global. Une cause unique ne peut plus monopoliser le budget.
  2. Un debounce porté de 60 minutes à 6 heures : une alerte qui dure une journée n’est pas une nouvelle alerte toutes les heures.
  3. Le nombre de cibles annoncé : avant d’investiguer, le script compte combien d’alertes du cycle partagent le même alertname, et le message Telegram l’annonce en tête — « ATTENTION : 24 cibles touchées par PodRestartingSlowly — cause probablement commune, ceci n’est qu’un échantillon. » L’humain qui lit sait immédiatement qu’il regarde une cause, pas vingt-quatre incidents.
  4. Un log borné : les alertes écartées sont comptées et résumées en une seule ligne de synthèse par cycle, et le log s’auto-tronque au-delà d’un seuil.

L’essentiel du nouveau garde-fou :

MAX_PER_ALERT_PER_DAY="${MAX_PER_ALERT_PER_DAY:-2}"

NCOUNT_FILE="$STATE/count-${TODAY}-${nsafe}"
NCOUNT="$(cat "$NCOUNT_FILE" 2>/dev/null || echo 0)"
if [ "$NCOUNT" -ge "$MAX_PER_ALERT_PER_DAY" ]; then
  SKIP_ALERT_CAP=$(( SKIP_ALERT_CAP + 1 )); continue
fi

L’ordre des contrôles compte : debounce d’abord, puis plafond par alerte, puis plafond global. Une alerte déjà investiguée récemment ne consomme rien ; une règle qui a déjà eu ses deux investigations ne mange pas le budget des autres.

Le routage : grouper par alerte, pas par machine

Reste Alertmanager. Deux réglages amplifiaient eux aussi le bruit.

Le premier : group_by: ['alertname', 'instance']. Grouper par instance signifie qu’un événement qui touche N machines produit N messages — sept hôtes presque pleins, sept notifications DiskAlmostFull séparées, là où un seul message listant les sept suffit. Le label instance sort du regroupement, remplacé par severity.

Le second : un repeat_interval unique de 4 heures. Or les règles de restarts travaillent sur une fenêtre de 24 heures : une fois armées, elles mettent mécaniquement jusqu’à 24 heures à s’effacer. Avec un rappel toutes les 4 heures, un même warning résonnait six fois par jour. Le rappel passe donc par sévérité :

route:
  receiver: telegram
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 12h
  routes:
  - matchers: [ 'severity="critical"' ]
    receiver: telegram
    repeat_interval: 4h
  - matchers: [ 'severity="warning"' ]
    receiver: telegram
    repeat_interval: 24h

Un critical garde un rappel serré, toutes les 4 heures. Un warning sonne une fois par jour. Si un warning mérite plus d’attention que ça, c’est probablement qu’il devrait être critical.

La vérification : 48 alertes simulées, 4 messages

Avant de pousser, trois contrôles :

  • YAML valide : 8 groupes, 31 règles.
  • Syntaxe bash du script vérifiée.
  • Un harnais de test qui injecte 48 alertes simulées — le scénario du 20 septembre — et compte ce qui sort côté Telegram : 4 messages, au lieu des 8 qui partaient chaque jour jusque-là.

Honnêteté oblige : c’est un harnais, pas un vrai reboot. Seule une vraie maintenance planifiée confirmera le résultat grandeur nature. Et le correctif ne rend pas un reboot silencieux : les pods qui redémarrent trois fois en 24 heures restent signalés, en warning, dans un message groupé, une fois par jour. C’est le comportement souhaité — savoir que quelque chose a redémarré, sans le lire quarante-huit fois.

Leçons retenues

1. Deux règles qui se chevauchent doivent être rendues exclusives

Quand une règle est un raffinement d’une autre, l’une des deux doit exclure l’autre. unless on(...) le fait proprement en PromQL. Et la preuve n’est pas la relecture de l’expression : c’est une requête contre la base qui montre que chaque cible n’apparaît qu’une fois (24 + 24 avant, 11 + 13 après).

2. Sur une énumération de raisons, filtrer en positif

reason!="OOMKilled",reason!="Completed" paraît précis, mais c’est une liste noire : toute raison oubliée passe. reason="Error" est une liste blanche qui dit exactement ce que la règle cherche. Si le commentaire d’une règle décrit un signal positif (« exit 137, reason=Error ») et que l’expression filtre en négatif, l’écart entre les deux est un bug qui attend son heure.

3. Un reboot planifié est un test d’alerting gratuit

Une maintenance qui redémarre tous les nœuds est le meilleur scénario de test pour l’alerting : une cause unique, connue, datée. Ce que le canal reçoit ce jour-là donne directement le facteur d’amplification du pipeline. Ici, il était de 2 au niveau des règles, et pire derrière.

4. Une automatisation déclenchée par alerte doit raisonner par cause

Tout ce qui réagit aux alertes — investigation, ticket, remédiation — hérite de leur granularité. Si l’empreinte inclut le pod, une cause commune devient N actions. Plafonner par alertname, et annoncer le nombre de cibles, redonne à l’humain la bonne lecture : un incident, N symptômes.

5. Un plafond qui sature chaque jour est une alerte en soi

Le budget de 8 investigations était atteint quotidiennement depuis douze jours. Un garde-fou qui se déclenche tous les jours ne protège plus rien, il masque. Surveiller le taux de saturation d’un plafond est aussi important que le plafond lui-même.

6. Les logs d’un script cron doivent être bornés

Une ligne par alerte écartée, à chaque cycle, c’est 12 715 lignes en une journée agitée. Une ligne de synthèse par cycle et une auto-troncature coûtent une dizaine de lignes de bash, et évitent de découvrir un log de plusieurs mégaoctets le jour où on en a besoin.

Le canal Telegram est redevenu ce qu’il doit être : un endroit où chaque message mérite d’être lu. Ce qui, après deux jours de fil d’actualité, demande un petit temps de réadaptation.

monitoringvmalertalertmanagerpromqltelegramincident