❯ monitoring · Running

Un digest de santé quotidien qui a arrêté de crier au loup A Daily Health Digest That Stopped Crying Wolf

L'alerting temps réel dit quand quelque chose brûle maintenant. Il ne dit pas « tout allait bien aujourd'hui ». Construire un digest Telegram quotidien — lecture seule, moindre privilège, corrélant métriques et logs — a d'abord révélé ses propres bugs : un OOM obsolète qui réapparaissait sans cesse, et un pod Pending de cinq minutes signalé comme un incident. Real-time alerting tells you when something is on fire right now. It doesn't tell you 'everything looked fine today.' Building a daily Telegram digest — read-only, least-privilege, correlating metrics and logs — surfaced its own bugs first: a stale OOM kept reappearing, and a five-minute-old Pending pod was flagged as an incident.

· 8 min de lecture 8 min read ·
monitoringtelegramrbacautomationvictorialogs
🇬🇧 Read this article in English

Le pipeline d’alerting mis en place dans Observabilité v2 répond bien à une question : quelque chose est-il en train de casser, là, maintenant ? vmalert évalue ses règles toutes les 30 secondes, Alertmanager route vers Telegram, et un pod qui crash-loop le sait avant moi. Mais il ne répond à aucune de ces questions :

  • Est-ce que tout allait bien aujourd’hui, ou juste rien n’a franchi de seuil ?
  • Ce pod qui a redémarré une fois hier, ce pic de RAM qui n’a duré que trois minutes — bruit ou signal faible qui s’accumule ?
  • Si le pipeline d’alerting lui-même est cassé (voir la leçon du « pending n’est pas firing » dans l’article précédent), qui le remarque ?

Un digest quotidien n’est pas une nouvelle source d’alertes — c’est une seconde paire d’yeux qui relit la journée et corrèle ce qu’aucune règle individuelle ne franchit seule.

Où l’exécuter : la contrainte d’accès

Premier obstacle, pas technique : l’agent qui orchestre ce genre d’automatisation tourne depuis un environnement sandbox distant, qui ne peut joindre que les endpoints publics du cluster — en pratique, seulement les métriques nœuds exposées publiquement, rien côté logs, historique ou API interne. Impossible d’y faire tourner un script qui a besoin d’un accès complet au cluster.

La solution la plus simple était déjà en place : le NAS gbnas, qui héberge déjà la stack de backup et le runner CI, a un accès réseau direct à tout — cluster compris, en LAN. Le healthcheck tourne donc là, pas dans le cluster lui-même : un script bash de plus dans ~/healthcheck/, à côté des autres automatisations du NAS.

Réutiliser plutôt que dupliquer

Le script ne réinvente aucune règle d’alerte. Pour la cohérence, il interroge la même série de métriques que le pipeline vmalert existant :

vmq 'ALERTS{alertstate="firing"}'

Au-dessus de ça, une couche PromQL propre au digest : CPU/RAM/disque par nœud, redémarrages de conteneurs sur 24h, OOMKilled récents, pics réseau. Une corrélation LogsQL sur VictoriaLogs remonte les pods les plus bruyants en erreurs et un échantillon de leurs lignes. Et un coup d’œil local côté Docker du NAS : conteneurs arrêtés, disques au-delà de 85%.

Deux sorties : un digest Telegram concis, envoyé via le bot déjà en place pour l’alerting — pas un nouveau canal, juste un message de plus dans le même chat — et un rapport Markdown complet archivé localement, un fichier par jour.

Moindre privilège : le RBAC dédié

Le script tourne sans supervision humaine directe, sur un cron, depuis une machine hors cluster. Lui donner le kubeconfig admin aurait été le chemin de moindre résistance et la pire idée. À la place : un ServiceAccount dédié, lecture seule, avec un ClusterRole scopé exactement à ce dont le script a besoin — pas plus :

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: healthcheck-readonly
rules:
- apiGroups: [""]
  resources: ["nodes","pods","services","namespaces","events","persistentvolumeclaims","persistentvolumes"]
  verbs: ["get","list","watch"]
- apiGroups: [""]
  resources: ["pods/portforward"]
  verbs: ["create"]
- apiGroups: ["longhorn.io"]
  resources: ["volumes","nodes","replicas","engines","backups"]
  verbs: ["get","list","watch"]
- apiGroups: ["argoproj.io"]
  resources: ["applications"]
  verbs: ["get","list","watch"]

Deux détails valent d’être soulignés. D’abord, pods/portforward en create mais rien en delete/update nulle part — juste assez pour ouvrir un tunnel vers VictoriaMetrics et VictoriaLogs le temps d’une requête, rien qui puisse modifier quoi que ce soit. Ensuite, l’accès au secret du bot Telegram n’est pas dans le ClusterRole global : il vit dans un Role namespacé séparé, scopé par resourceNames à ce seul secret précis :

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: healthcheck-telegram-secret
  namespace: monitoring
rules:
- apiGroups: [""]
  resources: ["secrets"]
  resourceNames: ["alertmanager-telegram"]
  verbs: ["get"]

Une éventuelle compromission de ce token ne donne donc pas accès à tous les secrets du namespace, juste à celui-là. Le token du ServiceAccount lui-même est long-vécu, volontairement : un cron sans interaction humaine ne peut pas gérer un refresh OAuth. Le compromis — un token qui ne tourne pas — est documenté, pas caché.

La contrainte d’hébergement qui change l’implémentation

Détail d’infra qui a eu une vraie conséquence de conception : sur le NAS, l’utilisateur d’automatisation n’a pas de sudo généralisé — seule une commande matérielle précise (liée au monitoring SMART) est autorisée en NOPASSWD. Pas de sudo, pas d’unité systemd système possible. La planification passe donc par un crontab utilisateur classique, pas par un .service/.timer :

0 12 * * * ~/healthcheck/healthcheck.sh >> ~/healthcheck/cron.log 2>&1

Rien d’exotique, mais c’est le genre de contrainte qu’on ne découvre qu’en essayant d’installer la version « propre » et en se cognant au mur du sudo manquant.

Les faux positifs qu’un script « qui marche » cachait

Le script a tourné dès le premier jour sans planter. Il a fallu plusieurs exécutions pour voir qu’il « marchait » au mauvais sens du terme : il produisait un digest chaque jour, mais une partie de son contenu était du bruit déguisé en signal.

OOM fantôme. kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} est un état, pas un événement — il reste à 1 tant que le pod vit, même si l’OOM remonte à trois semaines et que le pod tourne parfaitement depuis. Premier digest : un pod signalé OOMKilled. Deuxième digest, identique. Le correctif est un and sur les vrais redémarrages récents :

sum by(pod)(
  kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}
  and on(namespace,pod,container)
  (increase(kube_pod_container_status_restarts_total[24h]) > 0)
)

Sans le second terme, ce pod serait resté « incident » indéfiniment — jusqu’à sa prochaine bascule de nœud, des mois plus tard.

Pending n’a pas d’âge. Un pod passe par l’état Pending chaque fois qu’il est planifié — normalement quelques secondes. Un CronJob qui se déclenche toutes les cinq minutes générait donc un « incident » par exécution, juste parce que le script scannait l’état au mauvais instant. Fix : ne remonter un Pending que s’il a plus de 5 minutes d’ancienneté, calculé depuis creationTimestamp, pas juste depuis l’état courant.

Le score qui contredisait le texte. Le pire des trois : un digest pouvait annoncer un score de 10/10 avec, deux lignes plus bas, la liste des warnings actifs. Le score et le statut étaient calculés indépendamment — l’un ignorait l’autre. Un lecteur pressé qui ne regarde que le chiffre passe à côté du problème. Correctif, deux lignes de garde-fou après le calcul du score brut :

# Cohérence score/statut : jamais 10/10 avec des warnings, ni >8 avec un critique
(( WARN_N > 0 && SCORE > 9 )) && SCORE=9
(( CRIT_N > 0 && SCORE > 8 )) && SCORE=8

Pourquoi c’est la partie la plus intéressante

Un monitoring qui alerte correctement sur de faux problèmes est pire qu’un monitoring absent : il entraîne l’humain à ignorer les notifications, exactement le réflexe qu’on ne peut pas se permettre le jour où l’incident est réel. Les trois bugs ci-dessus n’ont rien de spectaculaire pris isolément — mais additionnés, ils auraient produit un digest qui « crie au loup » presque tous les jours, jusqu’à devenir un bruit de fond qu’on scrolle sans lire.

La validation qui a suivi n’a rien eu de sophistiqué non plus : rejouer le script contre les trois cas de figure précis (pod avec vieux OOM mais stable, pod fraîchement planifié, situation avec warnings actifs) avant de considérer le digest fiable. Pas de suite de tests formelle — juste s’assurer que chacun des trois pièges identifiés ne pouvait plus se reproduire avant de le laisser tourner sans supervision.

Résultat

Le digest tient dans un seul message Telegram (découpé automatiquement au-delà de ~3800 caractères, au cas où) : statut global, score, incidents, spikes, corrélation logs, un coup d’œil NAS. Le rapport Markdown complet part en parallèle dans les archives locales, un fichier par jour, avec un lien vers le rapport de la veille.

Depuis le correctif des trois faux positifs, le cron tourne chaque midi, silencieux quand tout va bien — et le jour où il ne l’est pas, c’est qu’il y a vraiment quelque chose à lire.

monitoringtelegramrbacautomationvictorialogs