Costruire un VPS auto-riparante con Prometheus e Ollama

13 min di lettura·Matthieu·monitoringself-healingaiopsdocker-composeollamaalertmanagerprometheus|

Collega gli alert di Prometheus a un LLM locale che diagnostica i guasti ed esegue azioni di rimedio sicure sul tuo VPS. Codice funzionante completo con allowlist, modalità dry-run e controlli di approvazione umana.

Il tuo server va giù alle 3 di notte. Ti svegliano, ti colleghi via SSH mezzo addormentato, riavvii il servizio crashato e torni a letto. Dopo un po' di volte, ti chiedi: il server non potrebbe ripararsi da solo?

Può. Questo tutorial costruisce un ciclo di auto-riparazione su un singolo VPS. Prometheus e node_exporter raccolgono metriche. Alertmanager scatta quando le soglie vengono superate. Un ricevitore webhook in Python cattura questi alert e passa il contesto a Ollama. Il LLM diagnostica il problema e raccomanda un'azione di rimedio. Se l'azione è nella allowlist, viene eseguita automaticamente. Se è distruttiva, un umano la approva prima via Discord.

Il flusso completo è questo:

node_exporter -> Prometheus -> Alertmanager -> webhook receiver -> Ollama -> remediation engine -> action
                                                                                    |
                                                                              audit log + Discord

Ogni azione viene registrata. Ogni raccomandazione del LLM viene trattata come input non affidabile. Questa è la parte che la maggior parte dei contenuti AIOps salta, e la parte che conta di più.

Cos'è un server auto-riparante e perché costruirne uno su un VPS?

Un server auto-riparante rileva i guasti usando metriche e regole di alert, poi attiva il rimedio senza intervento umano. Combinato con un LLM locale come Ollama, il sistema diagnostica le cause alla radice dal contesto dell'alert ed esegue azioni consentite: riavviare un servizio crashato, liberare spazio su disco o terminare un processo fuori controllo.

I team enterprise usano PagerDuty, Rundeck o StackStorm per questo. Questi strumenti presuppongono un team, una flotta di server e un budget. Su un singolo VPS, serve qualcosa di più leggero. Il «sentinel pattern» descritto qui è un agente autonomo che sorveglia il tuo server e corregge automaticamente i problemi comuni, con controlli di sicurezza che impediscono al LLM di fare qualsiasi cosa pericolosa.

Prerequisiti

  • Un VPS con 4+ vCPU e 8 GB di RAM (il LLM ha bisogno di spazio accanto ai tuoi servizi)
  • Debian 12 o Ubuntu 24.04
  • Docker e Docker Compose installati
  • Ollama installato con almeno un modello scaricato (qwen2.5:7b raccomandato per l'output JSON strutturato)
  • Un utente non-root con accesso sudo
  • Un URL webhook Discord (per le notifiche di approvazione umana)

Come configurare Prometheus, node_exporter e Alertmanager con Docker Compose?

Distribuisci lo stack di monitoraggio come tre container: Prometheus v3.10.0 raccoglie metriche, node_exporter v1.10.2 espone le metriche dell'host, e Alertmanager v0.31.1 instrada gli alert al tuo ricevitore webhook. L'intero stack si avvia con un singolo docker compose up -d.

Crea la directory del progetto:

mkdir -p ~/sentinel/{prometheus,alertmanager}
cd ~/sentinel

Stack Docker Compose

Crea 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 e Alertmanager sono in ascolto solo su 127.0.0.1. Esporre le dashboard di monitoraggio su internet è un errore di configurazione comune che rende visibili le metriche interne a chiunque scansioni il tuo IP.

Configurazione di Prometheus

Crea 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"]

Configurazione di Alertmanager

Crea 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 del webhook punta al ricevitore Python in esecuzione sull'host. host.docker.internal risolve all'IP dell'host dall'interno dei container Docker. La direttiva extra_hosts nel file Compose sopra lo mappa al gateway dell'host su Linux.

Imposta i permessi dei file prima di avviare lo stack:

chmod 644 prometheus/prometheus.yml prometheus/alert_rules.yml alertmanager/alertmanager.yml

Quali regole di alert configurare per i guasti VPS comuni?

Quattro regole di alert coprono i guasti VPS più comuni: disco che si riempie, esaurimento memoria, un servizio che va giù e utilizzo CPU elevato prolungato. Ogni regola scatta quando una soglia viene superata per una durata definita, dando a Prometheus il tempo di filtrare i picchi transitori.

Crea 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"
Alert Soglia PromQL Durata Gravità Rimedio predefinito
DiskSpaceLow FS root > 85% pieno 5 min warning Pulizia immagini Docker
MemoryHigh RAM disponibile < 10% 5 min critical Terminare il processo con più consumo di memoria
ServiceDown Target di scrape irraggiungibile 1 min critical Riavviare il servizio
HighCPU CPU media > 85% 10 min warning Identificare il processo CPU

L'annotazione remediation_hint è un campo personalizzato. Indica al motore di rimedio quale azione suggerire se la diagnosi del LLM è ambigua.

Avvia lo stack:

cd ~/sentinel
docker compose up -d
[+] Running 4/4
 ✔ Network sentinel_sentinel  Created
 ✔ Container node-exporter     Started
 ✔ Container prometheus        Started
 ✔ Container alertmanager      Started

Verifica che Prometheus stia raccogliendo i target:

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",

Entrambi i target dovrebbero mostrare "health": "up".

Come cattura il ricevitore webhook gli alert di Alertmanager?

Il ricevitore webhook è un'applicazione Flask che ascolta le richieste POST da Alertmanager, estrae il contesto dell'alert, interroga Ollama per una diagnosi e passa il risultato al motore di rimedio. Gira sull'host, non in un container, perché ha bisogno di accedere a Docker e systemd per eseguire le azioni di rimedio.

Installa le dipendenze:

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

Crea ~/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)

Il ricevitore è in ascolto su 0.0.0.0:5001 perché Alertmanager possa raggiungerlo tramite il gateway dell'host Docker (host.docker.internal risolve all'IP del bridge Docker, non a 127.0.0.1). Senza una regola firewall, questa porta è esposta su internet. Blocca l'accesso esterno ora:

sudo ufw deny in on eth0 to any port 5001

Se usi nftables invece di ufw, aggiungi una regola equivalente per scartare il traffico in ingresso sulla porta 5001 dalle interfacce pubbliche.

Eseguire il ricevitore con systemd

Crea un'unità systemd perché il ricevitore si avvii al boot e si riavvii in caso di errore.

Crea /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 riga SupplementaryGroups=docker dà al processo sentinel accesso al socket Docker senza eseguire come root. È necessario per le azioni docker restart e docker image prune.

Crea un utente dedicato, aggiungilo al gruppo docker, configura la sua home directory e copia i file del ricevitore:

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 gira con l'utente sentinel, quindi il codice del ricevitore e il virtualenv devono trovarsi sotto la sua home directory.

Nota sulle azioni con privilegi elevati: Le azioni docker restart e docker image prune funzionano tramite l'appartenenza al gruppo docker. Tuttavia, journalctl --vacuum-size, kill (per processi di altri utenti) e fallocate /swapfile richiedono i privilegi root. Se hai bisogno di queste azioni in modalità live, aggiungi regole sudoers mirate in /etc/sudoers.d/sentinel per i comandi specifici. Non dare all'utente sentinel accesso sudo illimitato.

Crea /etc/sentinel/env con i tuoi segreti:

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

Blocca il file:

sudo chmod 600 /etc/sentinel/env
sudo chown sentinel:sentinel /etc/sentinel/env

Avvia il servizio:

sudo systemctl daemon-reload
sudo systemctl enable --now sentinel.service

enable lo fa sopravvivere ai riavvii. --now lo avvia immediatamente.

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)

Avvia in modalità dry-run (SENTINEL_DRY_RUN=true) finché non hai testato ogni percorso di alert. Passa a false solo quando ti fidi del comportamento.

Come diagnostica Ollama i problemi del server dal contesto dell'alert?

Ollama riceve un prompt strutturato contenente il nome dell'alert, la gravità, la descrizione, i valori metrici attuali e la lista delle azioni consentite. Restituisce un oggetto JSON con la diagnosi: valutazione della gravità, causa alla radice, azione raccomandata e ragionamento. Il parametro format di Ollama con uno schema JSON impone la struttura dell'output a livello di modello.

Il template del prompt nel codice sopra fornisce a Ollama esattamente ciò che serve:

  1. I dati dell'alert (cosa è successo)
  2. La lista delle azioni valide (cosa può raccomandare)
  3. Lo schema di output (come rispondere)

Impostare temperature: 0 rende l'output deterministico. Lo stesso alert produce sempre la stessa diagnosi.

Quali modelli funzionano meglio?

Non tutti i modelli gestiscono l'output JSON strutturato in modo affidabile. Ecco cosa abbiamo testato su un VPS 4 vCPU / 8 GB di RAM:

Modello Dimensione Tempo di inferenza Affidabilità JSON Note
qwen2.5:7b 4,7 GB 3-5 s Alta Miglior compromesso velocità-precisione
llama3.1:8b 4,7 GB 4-6 s Media A volte ignora i vincoli dello schema
mistral:7b 4,1 GB 3-4 s Media Veloce ma a volte allucina azioni
phi3:mini 2,3 GB 1-2 s Bassa Troppo piccolo per output strutturato affidabile
qwen2.5:14b 9,0 GB 8-12 s Alta Maggiore precisione, ma stretto su 8 GB di RAM

qwen2.5:7b è la scelta raccomandata di default. Sta comodamente in 8 GB accanto a Prometheus e ai tuoi servizi, e produce in modo affidabile JSON valido conforme allo schema Pydantic.

Scaricalo:

ollama pull qwen2.5:7b

Un esempio di diagnosi per un alert 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."
}

Il validatore Pydantic rifiuta qualsiasi risposta dove recommended_action non è in ALLOWED_ACTIONS. Se Ollama allucina un'azione come rm -rf /tmp/*, il validatore la intercetta e l'azione non viene mai eseguita.

Quali azioni di rimedio può eseguire il sistema in sicurezza?

Il motore di rimedio esegue solo comandi definiti in una allowlist rigorosa. Ogni azione ha un template di comando fisso, una lista opzionale di servizi consentiti e un flag che indica se è richiesta l'approvazione umana prima dell'esecuzione.

Azione Comando Rischio Approvazione richiesta
restart_service docker restart {service} Basso No (solo container nella allowlist)
prune_docker_images docker image prune -af --filter 'until=72h' Basso No
kill_top_memory_process ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15 Alto
identify_cpu_hog ps aux --sort=-%cpu | head -5 Nessuno (sola lettura) No
clear_journal_logs journalctl --vacuum-size=200M Basso No
add_swap fallocate + mkswap + swapon Medio

L'azione restart_service accetta una variabile template {service}, ma solo se il nome del container è in allowed_services. Se Ollama raccomanda di riavviare sshd, il motore lo blocca perché sshd non è nella lista. Il nome del servizio proviene dall'etichetta Prometheus job, quindi assicurati che i valori job_name in prometheus.yml corrispondano ai nomi dei container Docker. Se esegui servizi gestiti da systemd, sostituisci docker restart con systemctl restart.

I comandi usano SIGTERM (kill -15), non SIGKILL. Il processo ha la possibilità di fare pulizia. subprocess.run ha un timeout di 60 secondi per evitare rimedi bloccati.

Come implementare il livello di sicurezza con allowlist e approvazione umana?

Il livello di sicurezza tratta tutto l'output del LLM come non affidabile. Tre meccanismi prevengono azioni pericolose: la allowlist delle azioni rifiuta qualsiasi comando non predefinito nel codice, il validatore Pydantic intercetta le risposte malformate prima che raggiungano il motore, e il gate di approvazione Discord blocca le azioni distruttive finché un umano non reagisce.

Difesa in profondità

Il sistema ha quattro livelli di protezione:

  1. Applicazione dello schema. Il parametro format di Ollama vincola il modello allo schema JSON. Non può restituire comandi in testo libero.
  2. Validazione Pydantic. Il modello Diagnosis verifica che recommended_action esista in ALLOWED_ACTIONS. Le azioni non valide generano un ValueError e l'alert ricade sul fallback basato sul hint.
  3. Allowlist delle azioni. Il motore di rimedio esegue solo comandi definiti in ALLOWED_ACTIONS. Nessuna costruzione dinamica di comandi. Nessuna interpolazione di stringhe oltre al nome del servizio (che viene esso stesso validato contro una lista).
  4. Approvazione umana. Le azioni marcate requires_approval: True inviano una notifica Discord e non vengono eseguite. Un gestore di approvazione separato (fuori dall'ambito di questo tutorial) ascolta le reazioni Discord e attiva l'esecuzione.

Modalità dry-run

Imposta SENTINEL_DRY_RUN=true in /etc/sentinel/env. Il ricevitore registra cosa farebbe senza eseguire nulla:

{"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'"}

Leggi il log di audit:

tail -f /var/log/sentinel/audit.jsonl | jq .

Resta in modalità dry-run per almeno una settimana prima di attivare il rimedio live. Rivedi ogni azione registrata. Assicurati che il LLM raccomandi azioni sensate per i tuoi alert reali.

Flusso di approvazione Discord

Quando si attiva un'azione distruttiva, il ricevitore pubblica un embed nel tuo canale 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 withto approve orto deny.

Senza un webhook Discord configurato, le azioni distruttive vengono bloccate completamente. Questo è il comportamento sicuro predefinito. Un webhook mancante significa che nessuna azione distruttiva viene eseguita, non che vengono eseguite senza approvazione.

Cos'è il sentinel pattern e perché ne hai bisogno?

Il sentinel pattern è un watchdog autonomo basato su cron che monitora lo stack di monitoraggio stesso. Se Prometheus crasha, nessuno lancia un alert perché il sistema di alert è giù. Lo script sentinel verifica se Prometheus, Alertmanager e il ricevitore webhook sono in esecuzione, e li riavvia in caso contrario. È la risposta alla domanda «chi sorveglia i sorveglianti».

Crea ~/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

Aggiungi un cron job che si esegue ogni 2 minuti. Sostituisci /root con la tua home directory reale:

(sudo crontab -l 2>/dev/null; echo "*/2 * * * * /root/sentinel/watchdog.sh") | sudo crontab -

Il watchdog non ha dipendenze oltre a bash e il CLI Docker. Funziona anche se Python, Ollama o l'intero stack di monitoraggio è giù. Ecco perché esiste: è l'unico componente che non dipende da nessun altro componente.

Controlla il log del 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

Come testare il ciclo di auto-riparazione end-to-end?

Simula guasti reali per confermare che il ciclo completo funziona: l'alert scatta, il webhook riceve, Ollama diagnostica, il rimedio viene eseguito (o registrato in modalità dry-run). Avvia ogni test con SENTINEL_DRY_RUN=true così nulla viene effettivamente eseguito finché non verifichi la diagnosi.

Test 1: servizio giù

Avvia un container di prova e fermalo:

docker run -d --name test-service --network sentinel_sentinel alpine sleep 3600

Aggiungi un target di scrape in prometheus/prometheus.yml per il test (o semplicemente ferma il container node-exporter):

docker stop node-exporter

Attendi 1-2 minuti che l'alert ServiceDown scatti. Osserva il log di 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"
}

Riavvia node-exporter manualmente per risolvere l'alert:

docker start node-exporter

Test 2: pressione disco

Crea un file grande per portare l'utilizzo disco sopra l'85%:

fallocate -l 20G /tmp/disk-pressure-test

Attendi l'alert DiskSpaceLow (soglia di 5 minuti). Il LLM dovrebbe diagnosticare il problema e raccomandare prune_docker_images. Dopo aver confermato il log dry-run, pulisci:

rm /tmp/disk-pressure-test

Test 3: pressione memoria

Usa stress-ng per consumare memoria:

sudo apt install -y stress-ng
stress-ng --vm 1 --vm-bytes 6G --timeout 600s &

L'alert MemoryHigh scatta. Il LLM dovrebbe raccomandare kill_top_memory_process, che richiede approvazione Discord. Conferma che il messaggio di approvazione appare in Discord.

Ferma il test di stress:

killall stress-ng

Passaggio alla modalità live

Una volta che ogni test dry-run produce diagnosi corrette e raccomandazioni di azione appropriate:

  1. Modifica /etc/sentinel/env e imposta SENTINEL_DRY_RUN=false
  2. Riavvia il servizio: sudo systemctl restart sentinel.service
  3. Esegui di nuovo il test servizio giù. Il container node-exporter dovrebbe riavviarsi automaticamente tramite docker restart.

Continua a monitorare /var/log/sentinel/audit.jsonl per i primi giorni. Ogni azione è registrata con il contesto completo per la revisione post-incidente.

Risoluzione dei problemi

Ollama non risponde. Verifica che Ollama sia in esecuzione: systemctl status ollama. Verifica che il modello sia scaricato: ollama list. Se il modello non è caricato, la prima richiesta impiega 10-20 secondi per il caricamento in RAM.

Alertmanager non riesce a raggiungere il webhook. Verifica che il ricevitore sia in ascolto: curl http://127.0.0.1:5001/health. Controlla i log di Alertmanager: docker logs alertmanager. Assicurati che host.docker.internal risolva dall'interno del container Alertmanager: docker exec alertmanager wget -q -O- http://host.docker.internal:5001/health.

Gli alert non scattano. Verifica che Prometheus abbia caricato le regole: curl http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool. Controlla che i target di scrape siano attivi. Una clausola for: 5m significa che la condizione deve essere vera per 5 minuti continui prima che l'alert scatti.

Errori di validazione Pydantic. Il modello ha prodotto un output che non corrisponde allo schema. Controlla la risposta grezza di Ollama nel journal: journalctl -u sentinel.service -f. Prova un modello diverso. qwen2.5:7b è il più affidabile per l'output strutturato su hardware limitato.

Il watchdog non si esegue. Verifica la voce cron: sudo crontab -l. Controlla il demone cron: systemctl status cron. Verifica che il log del watchdog esista: ls -la /var/log/sentinel/watchdog.log.

Log per ogni componente:

# 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 .

Prossimi passi

Il sentinel pattern gestisce il rimedio reattivo. Per un monitoraggio proattivo, alimenta i tuoi log in un LLM per il rilevamento di pattern prima che gli alert scattino.

Se estendi il motore di rimedio per eseguire script più complessi, isolali in una sandbox. Non lasciare mai che un processo guidato da un LLM giri con accesso di sistema illimitato.

Lo stack di monitoraggio qui copre un singolo VPS. Per configurazioni Docker multi-servizio, aggiungi cAdvisor come target di scrape per monitorare le metriche CPU, memoria e rete a livello di container.


Copyright 2026 Virtua.Cloud. Tutti i diritti riservati. Questo contenuto è un'opera originale del team Virtua.Cloud. La riproduzione, ripubblicazione o redistribuzione senza autorizzazione scritta è vietata.

Pronto a provare?

Distribuisci il tuo server in pochi secondi. Linux, Windows o FreeBSD.

Vedi piani VPS
VPS auto-riparante con Prometheus e Ollama