Prometheus Alertmanager à partir de notifications par email

Alertmanager est principalement conçu pour être déclenché par Prometheus. On peut également déclencher (et résorber) des alertes à partir d’un client HTTP via l’API.

Cela m’a donné l’idée d’adapter LocalSmtpRelay, un petit programme que j’ai réalisé il y a 3 ans, par lequel passent toutes les notifications par email de mon réseau local. Ces notifications viennent typiquement de mon NAS ou de mon pare-feu pfSense par exemple, et plus généralement de n’importe quel service déployé qui émet des emails (tel qu’Alertmanager lui-même). L’idée est de rediriger certaines notifications email vers l’API d’Alertmanager, et ainsi de bénéficier de sa fonction principale qu’est le throttling des alertes: la même « alerte » peut se déclencher des dizaines de fois, très peu d’emails seront envoyés. Dans la continuité de ce travail, j’ai dû intégrer un petit serveur LLM (basé sur Llma.cpp) afin de déduire une description courte et intelligible à partir du contenu des notifications parfois verbeux et technique.

Dans la grande majorité des cas, les notifications simples par email fonctionnent comme attendu. Cependant il arrive que quelques notifications soient agaçantes par moment. C’est là qu’Alertmanager a tout son rôle. J’ai par exemple plusieurs connexions de clients VPN sur pfSense, avec une notification lorsque la connexion est perdue ou restaurée. Lorsque la connexion est instable, je peux ainsi recevoir des notifications de manière répétée (cela peut être par dizaines, voire centaines pendant plusieurs heures). En règle générale, une perte de connexion sur un client VPN n’a rien de critique car le pare-feu dispose de plusieurs connexions et il faudrait que toutes soient instables en même temps, ce qui est très rare. Et quand bien même toutes les connexions étaient perdues, certes ma connexion internet serait impactée (puisque l’essentiel de mon trafic passe par ces connexions VPN, sauf exceptions), je ne souhaite pas pour autant recevoir d’innombrables alertes la nuit, ou même la journée si je ne suis pas chez moi.

Mon premier réflexe a été de trouver le moyen d’intégrer les métriques de pfSense à Prometheus, pour ensuite configurer des alertes basées sur ces métriques. Je n’ai cependant rien trouvé qui publie le statut des gateways. Il est bien possible de remonter quelques métriques par SNMP, mais, d’abord c’est super compliqué, et une fois qu’on réussi, on se rend compte que très peu de métriques sont publiées par ce canal. Il est plus simple d’installer le package Node Exporter pour pfSense, qui remonte davantage de données, mais pas le statut des gateways.

Ma seconde piste a été ce petit projet qui expose via un script PHP sans authentification qui retourne le statut des gateways dans un format JSON. Cependant la réponse n’est pas directement exploitable par Prometheus. Il aurait fallu que je l’adapte, et je ne suis pas du tout spécialiste de PHP, cela m’aurait donc sûrement pris un certain temps et peu de plaisir. J’aurai peut-être aussi pu utiliser Prometheus Blackbox Exporter avec la configuration adéquate (ce serait en théorie possible grâce aux expressions régulières pour valider la réponse HTTP, mais je n’ai pas creusé cette piste).

Au final, grâce à l’API d’Alertmanager très simple à utiliser, adapter LocalSmtpRelay m’a semblé être la solution la plus simple qui pourrait être réutilisée dans d’autres cas similaires, où seule l’alerte email m’intéresse, et pas l’historique des métriques.

Fonctionnement des Alertes

Voici à quoi ressemble un email envoyé par pfSense lors d’un incident sur une gateway (avec un sujet générique pour toutes les notifications):


Notifications in this message: 2
================================

22:55:21 MONITOR: VPN1_WAN has packet loss, omitting from routing group VPN_WAN_Group
1.0.0.1|10.24.162.167|VPN1_WAN|17.101ms|0.819ms|23%|down|highloss
22:55:21 MONITOR: VPN2_WAN has packet loss, omitting from routing group VPN_WAN_Group
1.0.0.3|10.34.98.68|VPN2_WAN|18.314ms|3.575ms|22%|down|highloss

Ou bien lorsque la connexion semble rétablie:


Notifications in this message: 1
================================

10:56:50 MONITOR: VPN2_WAN is available now, adding to routing group VPN_WAN_Group
1.0.0.3|10.34.114.242|VPN2_WAN|66.727ms|40.33ms|14%|online|loss

Et ces notifications peuvent faire ping-pong pendant plusieurs heures.

Pour envoyer cela sous forme d’alerte à Alertmanager, ce serait quelque chose comme:


HTTP POST http://alertmanager:9093/api/v2/alerts
[
  {
    "labels": {
      "alertname": "pfsense-routing-gateway",
      "severity": "warning"
    },
    "annotations": {
      "summary": "Routing gateway has packet loss",
      "description": "Notifications in this message: 1
================================

10:56:50 MONITOR: VPN2_WAN is available now, adding to routing group VPN_WAN_Group
1.0.0.3|10.34.114.242|VPN2_WAN|66.727ms|40.33ms|14%|online|loss"
    }
  }
]

Le seul champ obligatoire d’une alerte est le label alertname, cependant les champs conventionnels sont le label severity et les annotations summary et description. Les labels sont utilisés comme tags (ils apparaitront dans le sujet de l’email envoyé par Alertmanager) tandis que les annotations sont traitées comme du texte rendu dans le corps de l’email. Le champ « summary » est une description courte de l’alerte, le champ « description » est une description éventuellement plus détaillée et verbeuse.

Le template par défaut de l’email envoyé par Alertmanager ressemble à cela:

1 alert for
alertname=pfsense-routing-gateway

View in Alertmanager
[1] Firing
Labels

alertname = pfsense-routing-gateway
forwarded-by = localsmtprelay
severity = warning

Annotations

description = Notifications in this message: 1
================================

21:44:10 MONITOR: VPN3_WAN is down, omitting from routing group VPN_WAN_Group
8.8.8.8|10.22.0.2|VPN3_WAN|31.771ms|0.225ms|0.0%|down|force_down


summary = Routing gateway has packet loss



Source

Déclencher et résorber une alerte

Alertmanager a les concepts de « Firing alert » et « Resolved alert ». Une alerte résorbée (ou résolue) est une alerte qui est terminée. Pour envoyer une alerte terminée, il suffit de définir le champ optionnel « endsAt » à une valeur passée.

Dans le cas de pfSense qui envoie une notification en cas d’incident et de résolution d’incident, l’idée est de reconnaître chaque cas à partir d’une expression régulière, et selon le cas d’envoyer une alerte avec un champs « endsAt » de plusieurs heures dans le futur (par exemple 4 heures), ou bien avec une valeur passée.

Evidemment, ce n’est pas parfait: une notification de pfSense peut contenir à la fois un incident (une connexion VPN temporairement exclue du routage WAN) et un incident résolu (une autre connexion VPN réintégrée au routage WAN). A l’usage, ça fonctionne cependant plutôt bien, il faut juste savoir que le mapping entre notification et alerte n’est pas absolument parfait.

Déduire une description à partir du texte de la notification grâce à un LLM

Le contenu du message de pfSense peut contenir plusieurs notifications et celles-ci ne sont pas spécialement très lisibles.

Je ne suis pas spécialement fan de la hype autour des LLM, mais c’est une tâche qui semble destinée à cet outil. Mon idée a donc été de déployer un serveur LLM dans mon réseau (je n’ai pas envie d’envoyer mes données, quelles qu’elles soient, sur Internet).

La solution open source Llma.cpp fait plutôt bien le job et est très facile à déployer via Docker ou Kubernetes.

Déploiement du serveur llama.cpp sur Kubernetes

En m’appuyant sur leur documentation pour Docker, j’en ai déduit ces manifestes pour Kubernetes:


apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: llama-pvc
spec:
  accessModes:
  - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llama-deployment
  labels:
    app: llama
spec:
  selector:
    matchLabels:
      app: llama
  template:
    metadata:
      labels:
        app: llama
    spec:
      securityContext: 
        fsGroup: 2000
      volumes:
      - name: llama-model
        persistentVolumeClaim:
          claimName: llama-pvc
      initContainers:
        - name: permission-fix
          image: busybox
          command: ['sh', '-c']
          args: ['chown -R root:2000 /models']
          volumeMounts:
            - name: llama-model
              mountPath: "/models"
        - name: init-curl-download-model
          image: quay.io/curl/curl
          imagePullPolicy: IfNotPresent
          securityContext:
            runAsUser: 1001
            runAsGroup: 2000
          args: ["-o", "/models/Llama-3.2-3B-Instruct-Q4_K_M.gguf", "-v", "--skip-existing", "-L", "https://huggingface.co/unsloth/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf?download=true"]
          volumeMounts:
            - name: llama-model
              mountPath: "/models"
      containers:
      - name: llama
        imagePullPolicy: IfNotPresent
        image: ghcr.io/ggerganov/llama.cpp:server
        securityContext:
          runAsUser: 1001
          runAsGroup: 2000
        resources:
          limits:
            cpu: "1"
            memory: "4Gi"
        # Cf https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md
        args: ["-m", "/models/Llama-3.2-3B-Instruct-Q4_K_M.gguf", "--no-warmup", "-c", "2048", "--metrics"]
        ports:
        - containerPort: 8080 
        volumeMounts:
        - name: llama-model
          mountPath: "/models"
---
apiVersion: v1
kind: Service
metadata:
  name: llama-service
  labels:
    app: llama
spec:
  type: ClusterIP
  selector:
    app: llama
  ports:
  - port: 8080
    protocol: TCP
    targetPort: 8080
    name: http

Ce déploiement va télécharger le modèle Llama 3.2 3B Instruct, en version 4-bit, adapté à un petit serveur (requiert 4 Go de RAM). Ce n’est pas le plus impressionnant mais il suffit pour mon usage.

L’un des paramètres les plus importants je pense dans le manifeste est la limitation des ressources en CPU et RAM (j’ai plafonné à 1 coeur CPU et 4Go de RAM), car une mauvaise configuration (par exemple avec un modèle plus évolué et donc plus consommateur) peut mettre à genoux un petit cluster Kubernetes jusqu’à rendre la machine quasiment indisponible.

La ligne de commande inclut l’API de métriques compatible Prometheus, ce qui permet de suivre l’usage réel en créant son dashboard dans Grafana. Les métriques sont assez basiques, en gros le temps de calcul consommé, notamment sur l’interprétation du prompt et la génération de la réponse (et le débit en tokens par seconde).

Le serveur offre même une UI basique pour le tester directement.

L’API du serveur a une compatibilité avec l’API d’OpenAi, on peut donc l’utiliser en théorie à partir d’un client qui supporte OpenAi pour autant que l’URL soit configurable.

Premiers résultats

Le prompt est configurable. Par défaut, LocalSmtpRelay va utiliser celui-ci:


System: 
You respond directly to user's instructions. 
Your responses are always made of a single short 
sentence of less than 250 characters, without 
introductory text, without any text formatting. 
Do not add any note at the end of your response.

User: 
### Instruction:

Identify up to 2 main sentences in following content 
and summarize them in a single sentence:

Notifications in this message: 2
================================

22:55:21 MONITOR: VPN1_WAN has packet loss, omitting from routing group VPN_WAN_Group
1.0.0.1|10.24.162.167|VPN1_WAN|17.101ms|0.819ms|23%|down|highloss
22:55:21 MONITOR: VPN2_WAN has packet loss, omitting from routing group VPN_WAN_Group
1.0.0.3|10.34.98.68|VPN2_WAN|18.314ms|3.575ms|22%|down|highloss

### Response:

Le résultat, après un peu de patience, est:


Two VPN connections are experiencing packet loss and are 
omitting from routing group VPN_WAN_Group.

Sur mon petit serveur, le débit est de 8 tokens par seconde pour le prompt et 2 tokens prédits par seconde pour le résultat. Selon l’état du cache, ce type de demande prend entre 11 et 45 secondes! Sur mon ordinateur de bureau, la même demande prend autour de 5 secondes grâce au GPU de ma carte graphique (testé grâce à gpt4all avec le même modèle et des paramètres similaires).

J’ai également testé le modèle plus petit Llama 3.2 1B Instruct, le résultat est plus décevant. Je pense que le modèle 3B est actuellement le minimum viable pour un usage généraliste et qui respecte à peu près les instructions qu’on lui donne.

Le résultat final de l’email envoyé par Alertmanager, avec description via le LLM, ressemble à:

email Alertmanager

Au final, j’ai même ajouté une configuration à LocalSmtpRelay pour modifier le sujet de certains emails avec le résultat du LLM (sans la partie Alertmanager). C’est utile pour certaines notifications de mon NAS: comme l’envoi par email est un mécanisme générique quel que soit le service sous-jacent, toutes les notifications ont le même sujet générique. LLM permet d’appliquer un sujet plus spécifique qui permet de savoir de quoi il s’agit sans avoir à lire le contenu du message.

Le projet LocalSmtpRelay est disponible sur GitHub, toute la configuration y est documentée.