LGTM Agent : mon cluster me previent quand ca casse


J’avais un monitoring. Des dashboards Grafana, des alertes Prometheus, la totale. Mais quand une alerte fire a 3h du matin, tu recois un mail “PodCrashLooping - firing” et tu dois ouvrir ton laptop, te connecter au cluster, collecter les logs, regarder les metriques, comprendre la cause…

Et si le cluster faisait tout ca tout seul ?

L’idee : un agent incident autonome

Le concept est simple :

  1. AlertManager detecte un probleme et envoie un webhook
  2. Le backend cree un incident et lance une investigation automatique
  3. L’agent collecte tout le contexte (metriques, logs, pods, events K8s)
  4. Un LLM analyse le contexte et produit un diagnostic structure
  5. Le tout arrive sur Telegram et dans la console web en temps reel

Pas besoin d’ouvrir Grafana. Pas besoin de taper des commandes kubectl. Le diagnostic arrive directement dans ta poche.

Le pipeline d’investigation

Quand AlertManager fire une alerte critical, voici ce qui se passe dans les 30 secondes qui suivent :

AlertManager webhook
       |
       v
[processIncidentAlert]
  - Dedup par fingerprint
  - Dedup par alertname+namespace
  - Cree l'incident (status: open)
  - Notifie Telegram
       |
       v
[investigateIncident] (goroutine)
  - Status -> investigating
  - Collecte parallele :
    * Metriques Prometheus (PromQL)
    * Logs Loki (LogQL)
    * Pod statuses (K8s API)
    * Events K8s (API + Loki)
  - Status -> context_collected
       |
       v
[diagnoseIncident]
  - Envoie tout au LLM (OpenAI)
  - Parse le JSON structure
  - Status -> diagnosed
  - Notifie Telegram avec le diagnostic

Le tout est non-bloquant. L’investigation tourne en goroutine avec un semaphore (max 3 diagnostics concurrents) pour ne pas exploser les tokens LLM.

La collecte de contexte

C’est la partie la plus importante. Un LLM ne peut diagnostiquer que ce qu’il voit. Alors on lui donne tout.

Metriques Prometheus

On interroge Prometheus avec des queries PromQL adaptees a chaque type d’alerte :

func (s *Server) collectMetrics(ctx context.Context, inc *Incident) []MetricResult {
    queries := buildPromQLQueries(inc.AlertName, inc.Namespace, inc.Labels)

    var results []MetricResult
    for _, q := range queries {
        result, _ := s.prometheusQuery(ctx, q)
        results = append(results, result)
    }
    return results
}

Pour un PodCrashLooping, on recupere le taux de restart, l’utilisation CPU/memoire, le throttling. Pour un NodeDown, c’est le up metric, la charge systeme, la memoire disponible.

Logs Loki

On recupre les logs recents du namespace concerne via LogQL :

query := fmt.Sprintf(`{namespace="%s"} |= ""`, inc.Namespace)

On prend les 50 derniers logs pour donner du contexte au LLM sans exploser la fenetre de contexte.

Kubernetes API

On interroge directement l’API K8s pour les statuts de pods et les events :

  • Pod statuses : phase, ready, restarts, reason (OOMKilled, CrashLoopBackOff, etc.)
  • K8s events : les Warning et Normal events lies aux pods du namespace

Tout ca est collecte en parallele pour aller vite. La collecte complete prend generalement 2-5 secondes.

Le diagnostic IA

Une fois le contexte collecte, on l’envoie au LLM avec un system prompt precis :

Tu es un expert SRE senior. Analyse cet incident et produis un diagnostic
structure en JSON avec :
- summary : 1-2 lignes du probleme
- root_cause : la cause racine probable
- hypotheses : liste classee par probabilite (max 5)
- suggested_fixes : actions concretes avec commandes kubectl et niveau de risque
- confidence : score de 0.0 a 1.0

Le LLM retourne un JSON structure qu’on parse directement :

type IncidentDiagnosis struct {
    Summary        string         `json:"summary"`
    RootCause      string         `json:"root_cause"`
    Hypotheses     []Hypothesis   `json:"hypotheses"`
    SuggestedFixes []SuggestedFix `json:"suggested_fixes"`
    Confidence     float64        `json:"confidence"`
}

type Hypothesis struct {
    Rank        int     `json:"rank"`
    Title       string  `json:"title"`
    Description string  `json:"description"`
    Probability float64 `json:"probability"`
    Evidence    string  `json:"evidence"`
}

type SuggestedFix struct {
    Title       string `json:"title"`
    Description string `json:"description"`
    Command     string `json:"command"`
    Risk        string `json:"risk"` // low, medium, high
}

Chaque hypothese a une probabilite et les preuves qui la supportent. Chaque fix a un niveau de risque. Ca permet de prendre des decisions eclairees sans deviner.

Les notifications Telegram

Le TelegramNotifier est volontairement minimaliste : du HTTP direct vers l’API Telegram, zero dependance externe.

type TelegramNotifier struct {
    botToken string
    chatID   string
    client   *http.Client
}

Il envoie 3 types de messages, tous en goroutine (fire-and-forget) :

Incident detecte :

🚨 Incident Detecte

Alerte: NodeHighCPU
Severite: 🔴 critical
Namespace: monitoring
Heure: 19 fev 14:32

Investigation en cours...

Diagnostic termine :

🔍 Diagnostic Termine

Alerte: NodeHighCPU
Confiance: 85%

Cause probable:
Le node master-01 a une charge CPU superieure a 90%...

Action recommandee:
kubectl top nodes

Incident resolu :

✅ Incident Resolu

Alerte: NodeHighCPU
Namespace: monitoring
Duree: 12 min

Si les variables d’environnement TELEGRAM_BOT_TOKEN et TELEGRAM_NOTIFICATION_CHAT_ID ne sont pas definies, le notifier est un no-op complet. Pas de crash, pas de log d’erreur.

Le filtrage par severite

Au debut, je recevais des notifications pour chaque PodNotReady sur des jobs Velero termines. Des warnings toutes les heures. Pas utile.

La solution est radicale : seules les alertes critical creent des incidents.

func (s *Server) processIncidentAlert(alert AlertManagerWebhookAlert, ...) {
    severity := alert.Labels["severity"]
    if severity != "critical" {
        return // Ignore completement les warnings
    }
    // ...
}

Les warnings restent visibles dans Grafana et AlertManager. Mais le pipeline d’investigation IA (et les tokens LLM qui vont avec) est reserve aux vrais problemes.

Le frontend : la page Incidents

La console web affiche les incidents en temps reel via SSE (Server-Sent Events) :

  • Liste filtrable par status : open, investigating, diagnosed, acknowledged, resolved, stale
  • Detail en accordeon : diagnostic complet, evidence, timeline
  • Actions : acquitter, resoudre, re-investiguer
  • Notification bell dans le header avec les incidents critiques individuels

Le workflow d’un incident :

open → investigating → diagnosed → acknowledged → resolved

                                          (+ stale si timeout)

La timeline montre chaque etape avec des icones lucide-react : alerte recue, collecte du contexte, diagnostic IA en cours, diagnostic termine.

Le panneau de diagnostic affiche :

  • La confiance du diagnostic (barre de progression)
  • La cause racine mise en avant
  • Les hypotheses classees avec leur probabilite
  • Les actions suggerees avec le niveau de risque et la commande kubectl
  • L’evidence collectee : pods, events K8s, metriques, logs

Le SSE temps reel

Les mises a jour sont pushees via Redis Pub/Sub → SSE :

// Canal global (liste des incidents)
s.incidentStore.PublishSSE(ctx, "incident_created", payload)

// Canal specifique (detail d'un incident)
s.incidentStore.PublishIncidentSSE(ctx, incidentID, "diagnosis_complete", payload)

Cote frontend, un EventSource ecoute le canal et rafraichit automatiquement la liste ou le detail. Pas de polling, pas de refresh manuel.

Pour l’authentification SSE (EventSource ne supporte pas les headers), on passe le token JWT en query param :

const accessToken = localStorage.getItem('accessToken')
const es = new EventSource(
    `/api/v1/incidents/stream?access_token=${accessToken}`
)

Le dedup

AlertManager envoie les alertes en batch. Un PodNotReady avec 20 pods concernes genere 20 webhooks d’un coup. Sans dedup, ca ferait 20 incidents identiques.

Deux niveaux de dedup :

  1. Par fingerprint : AlertManager assigne un fingerprint unique a chaque alerte. Si un incident existe deja pour ce fingerprint, on skip.
  2. Par alertname+namespace : Si un incident actif existe deja pour le meme couple alerte/namespace, on skip aussi (et on met a jour le mapping fingerprint).
// Dedup niveau 1 : fingerprint exact
existing, _ := s.incidentStore.GetByFingerprint(ctx, alert.Fingerprint)
if existing != nil && existing.Status != "resolved" {
    return
}

// Dedup niveau 2 : meme alerte + meme namespace
activeInc, _ := s.incidentStore.GetActiveByAlertKey(ctx, alertName, namespace)
if activeInc != nil {
    return
}

Les scenarios de test

Pour tester tout ca sans attendre une vraie panne, j’ai cree une page Alert Scenarios dans la console. Elle affiche les 9 regles d’alerte Prometheus du cluster en tant que boutons “Fire”.

Un clic envoie une fausse alerte a AlertManager via son API, qui la forward au webhook, ce qui cree un incident et lance le diagnostic. En 30 secondes, tu recois le diagnostic complet sur Telegram.

C’est comme un “fire drill” pour ton monitoring.

Architecture finale

Prometheus rules
       |
   AlertManager
       |
   POST /webhook ──→ processIncidentAlert()
                         |
                    [Dedup check]
                         |
                    Save to Redis
                         |
                    Telegram: "Incident detecte"
                         |
                    investigateIncident() (goroutine)
                         |
              ┌──────────┼──────────┐
              |          |          |
         Prometheus    Loki    K8s API
         (PromQL)    (LogQL)  (pods, events)
              |          |          |
              └──────────┼──────────┘
                         |
                    diagnoseIncident()
                         |
                      LLM (OpenAI)
                         |
                    Save diagnosis
                         |
                    Telegram: "Diagnostic termine"
                         |
                    SSE → Frontend

Ce que j’ai appris

1. Le contexte est roi. Un LLM sans evidence produit du bruit. Avec les bonnes metriques et logs, il produit des diagnostics etonnamment precis.

2. Le filtrage par severite est essentiel. Sans ca, tu te retrouves avec 50 incidents “PodNotReady” pour des jobs termines normalement. Les tokens LLM coutent cher, il faut les utiliser a bon escient.

3. Le fire-and-forget simplifie tout. Les notifications Telegram en goroutine ne bloquent jamais le pipeline d’investigation. Si Telegram est down, on perd la notif mais l’incident est quand meme traite.

4. Le SSE c’est genial pour le temps reel. Plus simple que les WebSockets, nativement supporte par les navigateurs, et ca marche parfaitement avec Redis Pub/Sub.

5. Le dedup a deux niveaux evite le bruit. Fingerprint + alertname/namespace, c’est suffisant pour filtrer les doublons sans perdre de vrais incidents.


Le LGTM Agent transforme mon homelab d’un truc que je dois surveiller activement en un truc qui me previent quand il a un probleme et me dit exactement quoi faire. C’est la difference entre un monitoring passif et un monitoring intelligent.

Prochaine etape : connecter l’agent a ArgoCD pour qu’il puisse non seulement diagnostiquer, mais aussi reparer automatiquement certains types de pannes. Le vrai self-healing.