Construire un VPS auto-réparant avec Prometheus et Ollama
Connectez les alertes Prometheus à un LLM local qui diagnostique les pannes et exécute des actions de remédiation sécurisées sur votre VPS. Code fonctionnel complet avec listes blanches, mode dry-run et contrôles d'approbation humaine.
Votre serveur tombe à 3 h du matin. Vous êtes réveillé par une alerte, vous vous connectez en SSH à moitié endormi, vous redémarrez le service planté et vous retournez vous coucher. Au bout d'un moment, la question se pose : le serveur ne pourrait-il pas se réparer tout seul ?
Il peut. Ce tutoriel construit une boucle d'auto-réparation sur un seul VPS. Prometheus et node_exporter collectent les métriques. Alertmanager se déclenche quand les seuils sont dépassés. Un récepteur webhook en Python intercepte ces alertes et transmet le contexte à Ollama. Le LLM diagnostique le problème et recommande une action de remédiation. Si l'action figure dans la liste blanche, elle s'exécute automatiquement. Si elle est destructrice, un humain l'approuve d'abord via Discord.
Le flux complet ressemble à ceci :
node_exporter -> Prometheus -> Alertmanager -> webhook receiver -> Ollama -> remediation engine -> action
|
audit log + Discord
Chaque action est journalisée. Chaque recommandation du LLM est traitée comme une entrée non fiable. C'est la partie que la plupart des contenus AIOps passent sous silence, et la partie qui compte le plus.
Qu'est-ce qu'un serveur auto-réparant et pourquoi en construire un sur un VPS ?
Un serveur auto-réparant détecte les pannes à l'aide de métriques et de règles d'alerte, puis déclenche la remédiation sans intervention humaine. Combiné avec un LLM local comme Ollama, le système diagnostique les causes profondes à partir du contexte d'alerte et exécute des actions autorisées : redémarrer un service planté, libérer de l'espace disque ou tuer un processus emballé.
Les équipes en entreprise utilisent PagerDuty, Rundeck ou StackStorm pour cela. Ces outils supposent une équipe, un parc de serveurs et un budget. Sur un VPS seul, il faut quelque chose de plus léger. Le « sentinel pattern » décrit ici est un agent autonome qui surveille votre serveur et corrige automatiquement les problèmes courants, avec des contrôles de sécurité qui empêchent le LLM de faire quoi que ce soit de dangereux.
Prérequis
- Un VPS avec 4+ vCPU et 8 Go de RAM (le LLM a besoin de place à côté de vos services)
- Debian 12 ou Ubuntu 24.04
- Docker et Docker Compose installés
- Ollama installé avec au moins un modèle téléchargé (
qwen2.5:7brecommandé pour la sortie JSON structurée) - Un utilisateur non-root avec accès sudo
- Une URL de webhook Discord (pour les notifications d'approbation humaine)
Comment configurer Prometheus, node_exporter et Alertmanager avec Docker Compose ?
Déployez la pile de monitoring sous forme de trois conteneurs : Prometheus v3.10.0 collecte les métriques, node_exporter v1.10.2 expose les métriques système, et Alertmanager v0.31.1 route les alertes vers votre récepteur webhook. Toute la pile démarre avec un seul docker compose up -d.
Créez le répertoire du projet :
mkdir -p ~/sentinel/{prometheus,alertmanager}
cd ~/sentinel
Pile Docker Compose
Créez docker-compose.yml :
services:
prometheus:
image: prom/prometheus:v3.10.0
container_name: prometheus
restart: unless-stopped
user: "65534:65534"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=7d'
- '--web.enable-lifecycle'
ports:
- "127.0.0.1:9090:9090"
networks:
- sentinel
node-exporter:
image: prom/node-exporter:v1.10.2
container_name: node-exporter
restart: unless-stopped
pid: host
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--path.rootfs=/rootfs'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
networks:
- sentinel
alertmanager:
image: prom/alertmanager:v0.31.1
container_name: alertmanager
restart: unless-stopped
volumes:
- ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
ports:
- "127.0.0.1:9093:9093"
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- sentinel
volumes:
prometheus_data:
networks:
sentinel:
driver: bridge
Prometheus et Alertmanager n'écoutent que sur 127.0.0.1. Exposer les tableaux de bord de monitoring sur internet est une erreur de configuration courante qui rend vos métriques internes visibles par n'importe qui scannant votre IP.
Configuration de Prometheus
Créez prometheus/prometheus.yml :
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alert_rules.yml"
alerting:
alertmanagers:
- static_configs:
- targets:
- alertmanager:9093
scrape_configs:
- job_name: "node-exporter"
static_configs:
- targets: ["node-exporter:9100"]
- job_name: "prometheus"
static_configs:
- targets: ["localhost:9090"]
Configuration d'Alertmanager
Créez alertmanager/alertmanager.yml :
global:
resolve_timeout: 5m
route:
receiver: sentinel-webhook
group_by: ['alertname', 'instance']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: sentinel-webhook
webhook_configs:
- url: 'http://host.docker.internal:5001/alert'
send_resolved: true
max_alerts: 10
L'URL du webhook pointe vers le récepteur Python tournant sur l'hôte. host.docker.internal résout vers l'IP de l'hôte depuis l'intérieur des conteneurs Docker. La directive extra_hosts dans le fichier Compose ci-dessus mappe cette adresse vers la passerelle hôte sous Linux.
Définissez les permissions des fichiers avant de démarrer la pile :
chmod 644 prometheus/prometheus.yml prometheus/alert_rules.yml alertmanager/alertmanager.yml
Quelles règles d'alerte configurer pour les pannes VPS courantes ?
Quatre règles d'alerte couvrent les pannes VPS les plus fréquentes : disque qui se remplit, saturation mémoire, service tombé et utilisation CPU soutenue. Chaque règle se déclenche quand un seuil est franchi pendant une durée définie, laissant à Prometheus le temps de filtrer les pics transitoires.
Créez prometheus/alert_rules.yml :
groups:
- name: vps_health
rules:
- alert: DiskSpaceLow
expr: (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) * 100 > 85
for: 5m
labels:
severity: warning
annotations:
summary: "Disk usage above 85%"
description: "Root filesystem is {{ $value | printf \"%.1f\" }}% full."
remediation_hint: "prune_docker_images"
- alert: MemoryHigh
expr: (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 > 90
for: 5m
labels:
severity: critical
annotations:
summary: "Memory usage above 90%"
description: "Available memory is {{ $value | printf \"%.1f\" }}% used."
remediation_hint: "kill_top_memory_process"
- alert: ServiceDown
expr: up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Target {{ $labels.job }} is down"
description: "Scrape target {{ $labels.instance }} has been unreachable for over 1 minute."
remediation_hint: "restart_service"
- alert: HighCPU
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 10m
labels:
severity: warning
annotations:
summary: "CPU usage above 85% for 10 minutes"
description: "Average CPU usage is {{ $value | printf \"%.1f\" }}%."
remediation_hint: "identify_cpu_hog"
| Alerte | Seuil PromQL | Durée | Sévérité | Remédiation par défaut |
|---|---|---|---|---|
| DiskSpaceLow | FS racine > 85 % plein | 5 min | warning | Purge des images Docker |
| MemoryHigh | RAM disponible < 10 % | 5 min | critical | Tuer le processus le plus gourmand |
| ServiceDown | Cible de scrape inaccessible | 1 min | critical | Redémarrer le service |
| HighCPU | CPU moyen > 85 % | 10 min | warning | Identifier le processus CPU |
L'annotation remediation_hint est un champ personnalisé. Elle indique au moteur de remédiation quelle action suggérer si le diagnostic du LLM est ambigu.
Démarrez la pile :
cd ~/sentinel
docker compose up -d
[+] Running 4/4
✔ Network sentinel_sentinel Created
✔ Container node-exporter Started
✔ Container prometheus Started
✔ Container alertmanager Started
Vérifiez que Prometheus collecte les cibles :
curl -s http://127.0.0.1:9090/api/v1/targets | python3 -m json.tool | grep -A2 '"health"'
"health": "up",
"lastScrape": "2026-03-19T14:30:15.123Z",
Les deux cibles doivent afficher "health": "up".
Comment le récepteur webhook intercepte-t-il les alertes Alertmanager ?
Le récepteur webhook est une application Flask qui écoute les requêtes POST d'Alertmanager, extrait le contexte de l'alerte, interroge Ollama pour un diagnostic et transmet le résultat au moteur de remédiation. Il tourne sur l'hôte, pas dans un conteneur, car il a besoin d'accéder à Docker et systemd pour exécuter les actions de remédiation.
Installez les dépendances :
sudo apt install -y python3-pip python3-venv jq
mkdir -p ~/sentinel/receiver
cd ~/sentinel/receiver
python3 -m venv venv
source venv/bin/activate
pip install flask requests pydantic
Créez ~/sentinel/receiver/sentinel.py :
#!/usr/bin/env python3
"""Sentinel: self-healing webhook receiver for Alertmanager."""
import json
import logging
import os
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
import requests
from flask import Flask, request, jsonify
from pydantic import BaseModel, field_validator
app = Flask(__name__)
# --- Configuration ---
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
DISCORD_WEBHOOK = os.environ.get("DISCORD_WEBHOOK", "")
DRY_RUN = os.environ.get("SENTINEL_DRY_RUN", "false").lower() == "true"
AUDIT_LOG = Path(os.environ.get("SENTINEL_AUDIT_LOG", "/var/log/sentinel/audit.jsonl"))
AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s"
)
logger = logging.getLogger("sentinel")
# --- Safety: Action Allowlist ---
ALLOWED_ACTIONS = {
"restart_service": {
"command": "docker restart {service}",
"requires_approval": False,
"allowed_services": ["nginx", "docker", "prometheus", "node-exporter", "alertmanager"],
},
"prune_docker_images": {
"command": "docker image prune -af --filter 'until=72h'",
"requires_approval": False,
},
"kill_top_memory_process": {
"command": "ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15",
"requires_approval": True,
},
"identify_cpu_hog": {
"command": "ps aux --sort=-%cpu | head -5",
"requires_approval": False,
"read_only": True,
},
"clear_journal_logs": {
"command": "journalctl --vacuum-size=200M",
"requires_approval": False,
},
"add_swap": {
"command": "fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile",
"requires_approval": True,
},
}
class Diagnosis(BaseModel):
"""Structured LLM diagnosis output."""
severity: str
root_cause: str
recommended_action: str
reasoning: str
@field_validator("recommended_action")
@classmethod
def action_must_be_allowed(cls, v):
if v not in ALLOWED_ACTIONS:
raise ValueError(f"Action '{v}' is not in the allowlist")
return v
# --- Ollama Integration ---
def query_ollama(alert_data: dict) -> Diagnosis | None:
"""Send alert context to Ollama and parse structured diagnosis."""
prompt = f"""You are a server diagnostics agent. Analyze this alert and respond with a JSON diagnosis.
Alert: {alert_data.get('labels', {}).get('alertname', 'unknown')}
Status: {alert_data.get('status', 'unknown')}
Severity: {alert_data.get('labels', {}).get('severity', 'unknown')}
Summary: {alert_data.get('annotations', {}).get('summary', '')}
Description: {alert_data.get('annotations', {}).get('description', '')}
Remediation hint: {alert_data.get('annotations', {}).get('remediation_hint', 'none')}
Instance: {alert_data.get('labels', {}).get('instance', 'unknown')}
Started at: {alert_data.get('startsAt', 'unknown')}
Available actions: {', '.join(ALLOWED_ACTIONS.keys())}
Respond ONLY with a JSON object. Fields:
- severity: "low", "medium", "high", or "critical"
- root_cause: one-sentence explanation of the likely cause
- recommended_action: exactly one action from the available actions list
- reasoning: why you chose this action"""
schema = Diagnosis.model_json_schema()
try:
start = time.monotonic()
resp = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"format": schema,
"stream": False,
"options": {"temperature": 0},
},
timeout=120,
)
resp.raise_for_status()
elapsed = time.monotonic() - start
logger.info(f"Ollama responded in {elapsed:.1f}s")
content = resp.json()["message"]["content"]
diagnosis = Diagnosis.model_validate_json(content)
return diagnosis
except Exception as e:
logger.error(f"Ollama query failed: {e}")
return None
# --- Remediation Engine ---
def execute_action(action_name: str, context: dict) -> dict:
"""Execute an allowlisted remediation action."""
action = ALLOWED_ACTIONS.get(action_name)
if not action:
return {"status": "blocked", "reason": f"Action '{action_name}' not in allowlist"}
command = action["command"]
# Template the service name if needed
if "{service}" in command:
service = context.get("service", "")
if service not in action.get("allowed_services", []):
return {"status": "blocked", "reason": f"Service '{service}' not in allowed_services"}
command = command.format(service=service)
if DRY_RUN:
logger.info(f"DRY RUN: would execute: {command}")
return {"status": "dry_run", "command": command}
if action.get("requires_approval"):
approved = request_discord_approval(action_name, command, context)
if not approved:
return {"status": "awaiting_approval", "command": command}
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=60,
)
return {
"status": "executed",
"command": command,
"returncode": result.returncode,
"stdout": result.stdout[:500],
"stderr": result.stderr[:500],
}
except subprocess.TimeoutExpired:
return {"status": "timeout", "command": command}
# --- Discord Approval ---
def request_discord_approval(action_name: str, command: str, context: dict) -> bool:
"""Send a Discord message requesting human approval. Returns False (async approval)."""
if not DISCORD_WEBHOOK:
logger.warning("No Discord webhook configured. Blocking destructive action.")
return False
payload = {
"embeds": [{
"title": f"🔒 Approval Required: {action_name}",
"description": (
f"**Alert:** {context.get('alertname', 'unknown')}\n"
f"**Command:** `{command}`\n"
f"**Diagnosis:** {context.get('reasoning', 'N/A')}\n\n"
"React with ✅ to approve or ❌ to deny."
),
"color": 15158332,
}]
}
try:
requests.post(DISCORD_WEBHOOK, json=payload, timeout=10)
logger.info(f"Discord approval requested for {action_name}")
except Exception as e:
logger.error(f"Discord notification failed: {e}")
return False
# --- Audit Logging ---
def audit_log(entry: dict):
"""Append a structured JSON log entry."""
entry["timestamp"] = datetime.now(timezone.utc).isoformat()
with open(AUDIT_LOG, "a") as f:
f.write(json.dumps(entry) + "\n")
logger.info(f"Audit: {entry.get('event', 'unknown')} - {entry.get('action', 'none')}")
# --- Webhook Endpoint ---
@app.route("/alert", methods=["POST"])
def receive_alert():
"""Handle incoming Alertmanager webhook."""
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "invalid payload"}), 400
alerts = data.get("alerts", [])
results = []
for alert in alerts:
if alert.get("status") == "resolved":
audit_log({"event": "alert_resolved", "alert": alert.get("labels", {})})
continue
alertname = alert.get("labels", {}).get("alertname", "unknown")
logger.info(f"Processing alert: {alertname}")
# Step 1: Diagnose with Ollama
diagnosis = query_ollama(alert)
if diagnosis is None:
# Fallback to remediation_hint from alert annotations
hint = alert.get("annotations", {}).get("remediation_hint")
if hint and hint in ALLOWED_ACTIONS:
logger.warning(f"Ollama unavailable. Falling back to hint: {hint}")
diagnosis = Diagnosis(
severity="unknown",
root_cause="LLM unavailable, using alert hint",
recommended_action=hint,
reasoning="Fallback to annotation-defined remediation",
)
else:
audit_log({
"event": "diagnosis_failed",
"alert": alertname,
"action": "none",
})
results.append({"alert": alertname, "status": "diagnosis_failed"})
continue
audit_log({
"event": "diagnosis",
"alert": alertname,
"severity": diagnosis.severity,
"root_cause": diagnosis.root_cause,
"action": diagnosis.recommended_action,
"reasoning": diagnosis.reasoning,
})
# Step 2: Execute remediation
context = {
"alertname": alertname,
"service": alert.get("labels", {}).get("job", ""),
"reasoning": diagnosis.reasoning,
}
result = execute_action(diagnosis.recommended_action, context)
audit_log({
"event": "remediation",
"alert": alertname,
"action": diagnosis.recommended_action,
**result,
})
results.append({
"alert": alertname,
"diagnosis": diagnosis.model_dump(),
"remediation": result,
})
return jsonify({"processed": len(results), "results": results})
@app.route("/health", methods=["GET"])
def health():
"""Health check endpoint."""
return jsonify({"status": "ok", "dry_run": DRY_RUN})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)
Le récepteur écoute sur 0.0.0.0:5001 pour qu'Alertmanager puisse l'atteindre via la passerelle hôte de Docker (host.docker.internal résout vers l'IP du pont Docker, pas 127.0.0.1). Sans règle de pare-feu, ce port est exposé sur internet. Bloquez l'accès externe maintenant :
sudo ufw deny in on eth0 to any port 5001
Si vous utilisez nftables au lieu de ufw, ajoutez une règle équivalente pour rejeter le trafic entrant sur le port 5001 depuis les interfaces publiques.
Exécution du récepteur avec systemd
Créez une unité systemd pour que le récepteur démarre au boot et redémarre en cas d'échec.
Créez /etc/systemd/system/sentinel.service :
[Unit]
Description=Sentinel self-healing webhook receiver
After=network.target docker.service
Wants=docker.service
[Service]
Type=simple
User=sentinel
Group=sentinel
SupplementaryGroups=docker
WorkingDirectory=/home/sentinel/sentinel/receiver
ExecStart=/home/sentinel/sentinel/receiver/venv/bin/python sentinel.py
EnvironmentFile=/etc/sentinel/env
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/log/sentinel
[Install]
WantedBy=multi-user.target
La ligne SupplementaryGroups=docker donne au processus sentinel l'accès au socket Docker sans tourner en root. C'est nécessaire pour les actions docker restart et docker image prune.
Créez un utilisateur dédié, ajoutez-le au groupe docker, configurez son répertoire personnel et copiez les fichiers du récepteur :
sudo useradd -r -m -d /home/sentinel -s /bin/false sentinel
sudo usermod -aG docker sentinel
sudo mkdir -p /etc/sentinel /var/log/sentinel
sudo chown sentinel:sentinel /var/log/sentinel
sudo cp -r ~/sentinel/receiver /home/sentinel/sentinel/receiver
sudo chown -R sentinel:sentinel /home/sentinel/sentinel
L'unité systemd tourne sous l'utilisateur sentinel, donc le code du récepteur et le virtualenv doivent se trouver sous son répertoire personnel.
Note sur les actions à privilèges élevés : Les actions
docker restartetdocker image prunefonctionnent via l'appartenance au groupe docker. Cependant,journalctl --vacuum-size,kill(pour les processus d'autres utilisateurs) etfallocate /swapfilenécessitent les privilèges root. Si vous avez besoin de ces actions en mode live, ajoutez des règles sudoers ciblées dans/etc/sudoers.d/sentinelpour les commandes spécifiques. Ne donnez pas un accès sudo global à l'utilisateur sentinel.
Créez /etc/sentinel/env avec vos secrets :
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen2.5:7b
DISCORD_WEBHOOK=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
SENTINEL_DRY_RUN=true
SENTINEL_AUDIT_LOG=/var/log/sentinel/audit.jsonl
Verrouillez le fichier :
sudo chmod 600 /etc/sentinel/env
sudo chown sentinel:sentinel /etc/sentinel/env
Démarrez le service :
sudo systemctl daemon-reload
sudo systemctl enable --now sentinel.service
enable assure la persistance après redémarrage. --now lance le service immédiatement.
sudo systemctl status sentinel.service
● sentinel.service - Sentinel self-healing webhook receiver
Loaded: loaded (/etc/systemd/system/sentinel.service; enabled)
Active: active (running) since Wed 2026-03-19 14:35:00 UTC
Main PID: 12345 (python)
Démarrez en mode dry-run (SENTINEL_DRY_RUN=true) tant que vous n'avez pas testé chaque chemin d'alerte. Ne passez à false qu'une fois le comportement validé.
Comment Ollama diagnostique-t-il les problèmes serveur à partir du contexte d'alerte ?
Ollama reçoit un prompt structuré contenant le nom de l'alerte, la sévérité, la description, les valeurs métriques actuelles et la liste des actions autorisées. Il renvoie un objet JSON avec le diagnostic : évaluation de la sévérité, cause profonde, action recommandée et raisonnement. L'utilisation du paramètre format d'Ollama avec un schéma JSON force la structure de sortie au niveau du modèle.
Le template de prompt dans le code ci-dessus fournit à Ollama exactement ce dont il a besoin :
- Les données de l'alerte (ce qui s'est passé)
- La liste des actions valides (ce qu'il peut recommander)
- Le schéma de sortie (comment répondre)
Fixer temperature: 0 rend la sortie déterministe. La même alerte produit toujours le même diagnostic.
Quels modèles fonctionnent le mieux ?
Tous les modèles ne gèrent pas la sortie JSON structurée de manière fiable. Voici ce que nous avons testé sur un VPS 4 vCPU / 8 Go de RAM :
| Modèle | Taille | Temps d'inférence | Fiabilité JSON | Notes |
|---|---|---|---|---|
| qwen2.5:7b | 4,7 Go | 3-5 s | Élevée | Meilleur compromis vitesse-précision |
| llama3.1:8b | 4,7 Go | 4-6 s | Moyenne | Ignore parfois les contraintes du schéma |
| mistral:7b | 4,1 Go | 3-4 s | Moyenne | Rapide mais hallucine parfois des actions |
| phi3:mini | 2,3 Go | 1-2 s | Faible | Trop petit pour une sortie structurée fiable |
| qwen2.5:14b | 9,0 Go | 8-12 s | Élevée | Meilleure précision, mais serré sur 8 Go de RAM |
qwen2.5:7b est le choix recommandé par défaut. Il tient confortablement dans 8 Go aux côtés de Prometheus et de vos services, et produit de manière fiable du JSON valide correspondant au schéma Pydantic.
Téléchargez-le :
ollama pull qwen2.5:7b
Un exemple de diagnostic pour une alerte DiskSpaceLow :
{
"severity": "medium",
"root_cause": "Docker images and build cache accumulating over time, consuming disk space on the root filesystem",
"recommended_action": "prune_docker_images",
"reasoning": "Disk is at 87% usage. The most common cause on a Docker-based VPS is unused images. Pruning images older than 72 hours will reclaim space without affecting running containers."
}
Le validateur Pydantic rejette toute réponse où recommended_action ne figure pas dans ALLOWED_ACTIONS. Si Ollama hallucine une action comme rm -rf /tmp/*, le validateur la bloque et l'action ne s'exécute jamais.
Quelles actions de remédiation le système peut-il exécuter en toute sécurité ?
Le moteur de remédiation n'exécute que des commandes définies dans une liste blanche stricte. Chaque action possède un template de commande fixe, une liste de services autorisés optionnelle et un indicateur précisant si une approbation humaine est requise avant l'exécution.
| Action | Commande | Risque | Approbation requise |
|---|---|---|---|
| restart_service | docker restart {service} |
Faible | Non (uniquement les conteneurs autorisés) |
| prune_docker_images | docker image prune -af --filter 'until=72h' |
Faible | Non |
| kill_top_memory_process | ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15 |
Élevé | Oui |
| identify_cpu_hog | ps aux --sort=-%cpu | head -5 |
Aucun (lecture seule) | Non |
| clear_journal_logs | journalctl --vacuum-size=200M |
Faible | Non |
| add_swap | fallocate + mkswap + swapon |
Moyen | Oui |
L'action restart_service accepte une variable template {service}, mais uniquement si le nom du conteneur figure dans allowed_services. Si Ollama recommande de redémarrer sshd, le moteur le bloque car sshd n'est pas dans la liste. Le nom du service provient du label Prometheus job, veillez donc à ce que vos valeurs job_name dans prometheus.yml correspondent aux noms des conteneurs Docker. Si vous utilisez des services gérés par systemd, remplacez docker restart par systemctl restart.
Les commandes utilisent SIGTERM (kill -15), pas SIGKILL. Le processus a la possibilité de nettoyer ses ressources. subprocess.run a un timeout de 60 secondes pour éviter les remédiations bloquées.
Comment implémenter la couche de sécurité avec listes blanches et approbation humaine ?
La couche de sécurité traite toute sortie du LLM comme non fiable. Trois mécanismes empêchent les actions dangereuses : la liste blanche des actions rejette toute commande non prédéfinie dans le code, le validateur Pydantic intercepte les réponses malformées avant qu'elles n'atteignent le moteur, et la porte d'approbation Discord bloque les actions destructrices jusqu'à réaction d'un humain.
Défense en profondeur
Le système dispose de quatre couches de protection :
- Application du schéma. Le paramètre
formatd'Ollama contraint le modèle au schéma JSON. Il ne peut pas renvoyer de commandes en texte libre. - Validation Pydantic. Le modèle
Diagnosisvérifie querecommended_actionexiste dansALLOWED_ACTIONS. Les actions invalides lèvent uneValueErroret l'alerte bascule vers le fallback basé sur le hint. - Liste blanche des actions. Le moteur de remédiation n'exécute que les commandes définies dans
ALLOWED_ACTIONS. Pas de construction dynamique de commande. Pas d'interpolation de chaîne au-delà du nom de service (qui lui-même est validé contre une liste). - Approbation humaine. Les actions marquées
requires_approval: Trueenvoient une notification Discord et ne s'exécutent pas. Un gestionnaire d'approbation séparé (hors du périmètre de ce tutoriel) écoute les réactions Discord et déclenche l'exécution.
Mode dry-run
Définissez SENTINEL_DRY_RUN=true dans /etc/sentinel/env. Le récepteur journalise ce qu'il ferait sans rien exécuter :
{"timestamp": "2026-03-19T15:00:00Z", "event": "remediation", "alert": "DiskSpaceLow", "action": "prune_docker_images", "status": "dry_run", "command": "docker image prune -af --filter 'until=72h'"}
Consultez le journal d'audit :
tail -f /var/log/sentinel/audit.jsonl | jq .
Restez en mode dry-run pendant au moins une semaine avant d'activer la remédiation live. Relisez chaque action journalisée. Vérifiez que le LLM recommande des actions sensées pour vos alertes réelles.
Flux d'approbation Discord
Quand une action destructrice se déclenche, le récepteur publie un embed dans votre canal Discord :
🔒 Approval Required: kill_top_memory_process
Alert: MemoryHigh
Command: ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15
Diagnosis: Memory at 93%. The top process by RSS is consuming 4.2 GB.
React with ✅ to approve or ❌ to deny.
Sans webhook Discord configuré, les actions destructrices sont entièrement bloquées. C'est le comportement sûr par défaut. Un webhook manquant signifie qu'aucune action destructrice ne s'exécute, pas qu'elles s'exécutent sans approbation.
Qu'est-ce que le sentinel pattern et pourquoi en avez-vous besoin ?
Le sentinel pattern est un watchdog autonome basé sur cron qui surveille la pile de monitoring elle-même. Si Prometheus plante, personne ne déclenche d'alerte car le système d'alerte est en panne. Le script sentinel vérifie si Prometheus, Alertmanager et le récepteur webhook tournent, et les redémarre si ce n'est pas le cas. C'est la réponse à la question « qui surveille les surveillants ».
Créez ~/sentinel/watchdog.sh :
#!/bin/bash
# Sentinel watchdog: monitors the monitoring stack
# Runs via cron every 2 minutes
LOG="/var/log/sentinel/watchdog.log"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
check_and_restart() {
local name="$1"
local check_cmd="$2"
local restart_cmd="$3"
if ! eval "$check_cmd" > /dev/null 2>&1; then
echo "${TIMESTAMP} ALERT: ${name} is down. Restarting..." >> "$LOG"
eval "$restart_cmd"
sleep 5
if eval "$check_cmd" > /dev/null 2>&1; then
echo "${TIMESTAMP} OK: ${name} restarted successfully" >> "$LOG"
else
echo "${TIMESTAMP} CRITICAL: ${name} failed to restart" >> "$LOG"
fi
fi
}
# Check Docker containers
check_and_restart "prometheus" \
"docker inspect --format='{{.State.Running}}' prometheus 2>/dev/null | grep -q true" \
"cd $HOME/sentinel && docker compose up -d prometheus"
check_and_restart "alertmanager" \
"docker inspect --format='{{.State.Running}}' alertmanager 2>/dev/null | grep -q true" \
"cd $HOME/sentinel && docker compose up -d alertmanager"
check_and_restart "node-exporter" \
"docker inspect --format='{{.State.Running}}' node-exporter 2>/dev/null | grep -q true" \
"cd $HOME/sentinel && docker compose up -d node-exporter"
# Check the webhook receiver systemd service
check_and_restart "sentinel-receiver" \
"systemctl is-active --quiet sentinel.service" \
"systemctl restart sentinel.service"
chmod 700 ~/sentinel/watchdog.sh
Ajoutez une tâche cron qui s'exécute toutes les 2 minutes. Remplacez /root par votre répertoire personnel réel :
(sudo crontab -l 2>/dev/null; echo "*/2 * * * * /root/sentinel/watchdog.sh") | sudo crontab -
Le watchdog n'a aucune dépendance au-delà de bash et du CLI Docker. Il fonctionne même si Python, Ollama ou toute la pile de monitoring est en panne. C'est sa raison d'être : c'est le seul composant qui ne dépend d'aucun autre composant.
Consultez le journal du watchdog :
cat /var/log/sentinel/watchdog.log
2026-03-19T15:10:00Z ALERT: prometheus is down. Restarting...
2026-03-19T15:10:05Z OK: prometheus restarted successfully
Comment tester la boucle d'auto-réparation de bout en bout ?
Simulez de vraies pannes pour confirmer que la boucle complète fonctionne : l'alerte se déclenche, le webhook reçoit, Ollama diagnostique, la remédiation s'exécute (ou se journalise en mode dry-run). Lancez chaque test avec SENTINEL_DRY_RUN=true pour que rien ne s'exécute réellement tant que vous n'avez pas vérifié le diagnostic.
Test 1 : service en panne
Démarrez un conteneur factice et tuez-le :
docker run -d --name test-service --network sentinel_sentinel alpine sleep 3600
Ajoutez une cible de scrape dans prometheus/prometheus.yml pour le test (ou arrêtez simplement le conteneur node-exporter) :
docker stop node-exporter
Attendez 1 à 2 minutes que l'alerte ServiceDown se déclenche. Surveillez le journal d'audit :
tail -f /var/log/sentinel/audit.jsonl | jq .
{
"timestamp": "2026-03-19T15:12:30Z",
"event": "diagnosis",
"alert": "ServiceDown",
"severity": "critical",
"root_cause": "node-exporter scrape target is unreachable, container likely stopped or crashed",
"action": "restart_service",
"reasoning": "The node-exporter container is down. Restarting it will restore metric collection."
}
{
"timestamp": "2026-03-19T15:12:31Z",
"event": "remediation",
"alert": "ServiceDown",
"action": "restart_service",
"status": "dry_run",
"command": "docker restart node-exporter"
}
Redémarrez node-exporter manuellement pour résoudre l'alerte :
docker start node-exporter
Test 2 : pression disque
Créez un fichier volumineux pour pousser l'utilisation disque au-dessus de 85 % :
fallocate -l 20G /tmp/disk-pressure-test
Attendez l'alerte DiskSpaceLow (seuil de 5 minutes). Le LLM devrait diagnostiquer le problème et recommander prune_docker_images. Après avoir confirmé le journal dry-run, nettoyez :
rm /tmp/disk-pressure-test
Test 3 : pression mémoire
Utilisez stress-ng pour consommer de la mémoire :
sudo apt install -y stress-ng
stress-ng --vm 1 --vm-bytes 6G --timeout 600s &
L'alerte MemoryHigh se déclenche. Le LLM devrait recommander kill_top_memory_process, qui nécessite une approbation Discord. Confirmez que le message d'approbation apparaît dans Discord.
Arrêtez le test de stress :
killall stress-ng
Passage en mode live
Une fois que chaque test en dry-run produit des diagnostics corrects et des recommandations d'action appropriées :
- Éditez
/etc/sentinel/envet définissezSENTINEL_DRY_RUN=false - Redémarrez le service :
sudo systemctl restart sentinel.service - Relancez le test de service en panne. Le conteneur node-exporter devrait redémarrer automatiquement via
docker restart.
Continuez à surveiller /var/log/sentinel/audit.jsonl pendant les premiers jours. Chaque action est journalisée avec le contexte complet pour une revue post-incident.
Dépannage
Ollama ne répond pas. Vérifiez qu'Ollama tourne : systemctl status ollama. Vérifiez que le modèle est téléchargé : ollama list. Si le modèle n'est pas chargé, la première requête prend 10 à 20 secondes pendant le chargement en RAM.
Alertmanager ne peut pas atteindre le webhook. Vérifiez que le récepteur écoute : curl http://127.0.0.1:5001/health. Consultez les logs Alertmanager : docker logs alertmanager. Assurez-vous que host.docker.internal résout depuis l'intérieur du conteneur Alertmanager : docker exec alertmanager wget -q -O- http://host.docker.internal:5001/health.
Les alertes ne se déclenchent pas. Vérifiez que Prometheus a chargé les règles : curl http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool. Vérifiez que les cibles de scrape sont actives. Une clause for: 5m signifie que la condition doit être vraie pendant 5 minutes consécutives avant le déclenchement de l'alerte.
Erreurs de validation Pydantic. Le modèle a produit une sortie non conforme au schéma. Consultez la réponse brute d'Ollama dans le journal : journalctl -u sentinel.service -f. Essayez un autre modèle. qwen2.5:7b est le plus fiable pour la sortie structurée sur du matériel limité.
Le watchdog ne s'exécute pas. Vérifiez l'entrée cron : sudo crontab -l. Vérifiez le démon cron : systemctl status cron. Vérifiez que le journal du watchdog existe : ls -la /var/log/sentinel/watchdog.log.
Logs pour chaque composant :
# Prometheus
docker logs prometheus --tail 50
# Alertmanager
docker logs alertmanager --tail 50
# Sentinel receiver
journalctl -u sentinel.service -f
# Audit trail
tail -f /var/log/sentinel/audit.jsonl | jq .
Prochaines étapes
Le sentinel pattern gère la remédiation réactive. Pour un monitoring proactif, alimentez vos logs dans un LLM pour détecter des patterns avant que les alertes ne se déclenchent.
Si vous étendez le moteur de remédiation pour exécuter des scripts plus complexes, isolez-les dans un sandbox. Ne laissez jamais un processus piloté par un LLM tourner avec un accès système sans restriction.
La pile de monitoring décrite ici couvre un seul VPS. Pour les configurations Docker multi-services, ajoutez cAdvisor comme cible de scrape pour surveiller les métriques CPU, mémoire et réseau au niveau des conteneurs.
Copyright 2026 Virtua.Cloud. Tous droits réservés. Ce contenu est une création originale de l'équipe Virtua.Cloud. Toute reproduction, republication ou redistribution sans autorisation écrite est interdite.
Prêt à essayer ?
Déployez votre serveur en quelques secondes. Linux, Windows ou FreeBSD.
Voir les offres VPS