Le 11 juin, en analysant les métriques du cluster sur 6 heures, je tombe sur un pic de load à 7.18 sur worker-1 (×16 sa moyenne) à 17h20. Aucun pod créé dans la fenêtre. Rien dans les logs de k3s-agent. Aucun moyen de savoir quel workload avait mangé le CPU — parce que vmagent ne scrapait pas cAdvisor, donc aucun historique par pod.
Ce pic inattribuable a servi de révélateur. Mon monitoring v1 (VictoriaMetrics + Grafana) savait dire « le nœud souffre », mais pas « voilà le coupable ». L’audit a listé quatre angles morts :
- Pas de métriques par pod/container — cAdvisor non scrapé, impossible d’attribuer un pic a posteriori
- Pas de logs centralisés — corrélation = SSH manuel sur chaque nœud, un par un
- Pas d’alerting — la readiness probe d’un backend échouait en silence depuis des jours
- Pas d’annotations — les graphes Grafana ne montraient ni les events K8s ni les déploiements ArgoCD
Le lendemain soir, les quatre étaient comblés. Voici comment, et surtout ce que j’ai appris en route.
① cAdvisor — le quick win (30 minutes)
cAdvisor est déjà dans le kubelet, sur chaque nœud, port 10250. Il suffit de le scraper :
# monitoring/vm/vmagent-values.yaml — job ajouté
- job_name: cadvisor
scheme: https
tls_config: { insecure_skip_verify: true }
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
kubernetes_sd_configs: [{ role: node }]
relabel_configs:
- source_labels: [__address__]
regex: '(.*):10250'
target_label: __metrics_path__
replacement: /metrics/cadvisor
Le ClusterRole existant de vmagent avait déjà nodes/metrics — un helm upgrade plus tard : 6/6 cibles up, 426 séries container_cpu_usage_seconds_total. Le dashboard K8s (15760) s’enrichit tout seul. Le prochain pic aura un nom et un namespace.
② Logs centralisés — VictoriaLogs + Vector, pas Loki
Loki était sur ma roadmap depuis des mois. Je ne l’ai jamais déployé, et avec le recul c’était une bonne procrastination : VictoriaLogs est plus léger, ARM64-natif, et cohérent avec ma stack VictoriaMetrics existante (même éditeur, même philosophie single-binary).
L’architecture tient en deux pièces :
- VictoriaLogs single-node (
v1.50.0) — PVC Longhorn 10 Gi, rétention 7 jours - Vector en DaemonSet (
0.56.0) sur les 6 nœuds — lit/var/log/pods, enrichit par namespace/pod, pousse via l’API Elasticsearch bulk
# victorialogs/vector.yaml — le cœur du pipeline
[sources.k8s]
type = "kubernetes_logs"
[sinks.vlogs]
type = "elasticsearch"
endpoints = ["http://victorialogs:9428/insert/elasticsearch/"]
api_version = "v8"
[sinks.vlogs.query]
_msg_field = "message"
_time_field = "timestamp"
_stream_fields = "kubernetes.pod_namespace,kubernetes.pod_name"
Vérification après déploiement : ~360 000 lignes ingérées en 2 minutes, requêtables dans Grafana via le plugin datasource victoriametrics-logs-datasource. Le gain concret : métriques et logs dans le même time range Grafana. Un pic sur un graphe → un clic → les logs de la même fenêtre. Fini le journalctl en SSH sur six machines.
Un détail vécu : les premiers pods Vector ont crash-loopé pendant le rollout (5-6 restarts le temps que VictoriaLogs et son PVC soient prêts). C’est exactement le genre d’événement que la suite de cet article rend visible.
③ Alerting — vmalert → Alertmanager → Telegram
Le morceau principal. Deux déploiements et un SealedSecret :
- vmalert (
v1.140.0) évalue les règles toutes les 30 s contre vmsingle, et réécrit l’étatALERTSdans VictoriaMetrics (pratique : l’historique pending/firing devient requêtable) - Alertmanager (
v0.32.2) groupe et route vers Telegram — le bot token vit dans un SealedSecret monté enbot_token_file, jamais en clair dans Git
Six règles pour commencer, volontairement simples :
- alert: NodeDown # up{job="kubernetes-nodes"} == 0, for: 3m
- alert: NodeHighLoad # node_load1 > 8, for: 10m
- alert: DiskAlmostFull # >90%, for: 15m
- alert: NodeMemoryPressure # >92%, for: 10m
- alert: PodCrashLooping # restarts > 3 en 15m, for: 5m
- alert: PodNotReady # not-ready en phase Running, for: 15m
Premier faux positif dès le premier soir : PodNotReady se déclenchait pour les pods de Jobs terminés (Completed = ready false à vie — les snapshots Longhorn, les CronJobs de métriques…). Le fix, un join sur la phase :
- alert: PodNotReady
expr: |
kube_pod_status_ready{condition="true"} == 0
and on(namespace, pod) kube_pod_status_phase{phase="Running"} == 1
for: 15m
④ Annotations — les events K8s sur les graphes
Dernière pièce : un CronJob grafana-annotator (*/5 min) qui pousse vers l’API annotations de Grafana :
- les events K8s
Warning(tagk8s-warning) — probe failures, OOMKilled, FailedScheduling… - les syncs ArgoCD (tag
argocd) — chaque déploiement devient une ligne verticale sur les graphes
C’est l’outil le moins spectaculaire et le plus rentable de la journée. Le lendemain matin, les dashboards racontaient une histoire : chaque pic de load avait sa ligne pointillée orange à côté, avec l’event qui l’expliquait.
La leçon du lendemain : pending n’est pas firing
Ironie immédiate : le jour suivant, les nouvelles annotations montraient des probe failures récurrentes sur un backend (timeouts /api/health sous charge) — et Telegram restait muet. Le pipeline était-il cassé ?
Non. Il fonctionnait exactement comme configuré :
for: → Alertmanager → Telegramvmalert → for: gate → Alertmanager → Telegram pending ≠ firing for:for: gate pending → firing for: doit se remplir sans interruption (15 min pour PodNotReady).
Une probe qui flappe par épisodes de 2 min remet la jauge à zéro à chaque récupération :
l'alerte vit en pending éternel — et Telegram reste muet, by design.
The for: gauge must fill without interruption (15 min for PodNotReady).
A probe that flaps in 2 min episodes resets the gauge to zero on every recovery:
the alert lives in eternal pending — and Telegram stays silent, by design.
L’enquête, chiffres à l’appui (l’état ALERTS réécrit dans VictoriaMetrics rend ça trivial) :
PodNotReady{pod="...backend..."}: 3 échantillonspendingen 24 h, zérofiring— chaque épisode not-ready durait ~2 min, jamais les 15 min dufor:NodeHighLoad{instance="worker-2"}: 6×pending— le load dépassait 8 en pics, jamais 10 min en continu- Pendant ce temps, côté Alertmanager :
alertmanager_notifications_total{integration="telegram"} = 8, zéro échec — les alertes qui avaient vraiment atteint firing (les crash-loops Vector, justement) étaient bien parties
Trois enseignements :
1. Le for: est une porte, pas un délai. La condition doit rester vraie sans interruption. Un workload qui flappe par épisodes courts vit en pending éternel — invisible si on ne regarde que les notifications.
2. Vérifier le pipeline de bout en bout, métriques à l’appui. alertmanager_notifications_failed_total par intégration dit en une requête si le problème est « rien n’a fire » ou « l’envoi échoue ». Dans mon cas : rien n’avait fire pour ce pod, et les 8 envois réels étaient partis vers le chat configuré — pas celui que je regardais. Vérifiez votre chat_id et ouvrez le bon chat.
3. Le flapping mérite sa propre règle. Ni PodNotReady ni NodeHighLoad ne peuvent l’attraper, par construction. La règle qui manquait :
- alert: PodReadyFlapping
expr: changes(kube_pod_status_ready{condition="true"}[30m]) > 4
for: 0m
labels: { severity: warning }
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} flappe ({{ $value }} transitions ready en 30m)"
changes() compte les transitions — c’est la signature du flapping, insensible à la durée des épisodes.
Bilan
Quatre angles morts comblés en une journée, en GitOps (deux apps ArgoCD : victorialogs, alerting, plus le CronJob annotator), sur des Raspberry Pi qui encaissent sans broncher — VictoriaLogs et Vector ajoutent ~150 Mi de RAM au total.
La vraie victoire n’est pas la stack, c’est la boucle : l’instrumentation déployée un soir a rendu visible dès le lendemain un problème qui durait depuis des semaines — et a fourni les métriques pour comprendre pourquoi il était structurellement inalertable. L’observabilité, c’est ça : pas des dashboards, des questions auxquelles on peut enfin répondre.