Construir un VPS autorreparable con Prometheus y Ollama

14 min de lectura·Matthieu·monitoringself-healingaiopsdocker-composeollamaalertmanagerprometheus|

Conecta las alertas de Prometheus a un LLM local que diagnostica fallos y ejecuta acciones de remediación seguras en tu VPS. Código funcional completo con listas blancas, modo dry-run y controles de aprobación humana.

Tu servidor falla a las 3 de la mañana. Te despiertan, te conectas por SSH medio dormido, reinicias el servicio caído y vuelves a la cama. Después de varias veces, te preguntas: ¿podría el servidor repararse solo?

Puede. Este tutorial construye un bucle de autorreparación en un solo VPS. Prometheus y node_exporter recopilan métricas. Alertmanager se dispara cuando los umbrales se superan. Un receptor webhook en Python captura esas alertas y envía el contexto a Ollama. El LLM diagnostica el problema y recomienda una acción de remediación. Si la acción está en la lista blanca, se ejecuta automáticamente. Si es destructiva, un humano la aprueba primero a través de Discord.

El flujo completo se ve así:

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

Cada acción queda registrada. Cada recomendación del LLM se trata como entrada no confiable. Esta es la parte que la mayoría del contenido AIOps omite, y la que más importa.

¿Qué es un servidor autorreparable y por qué construir uno en un VPS?

Un servidor autorreparable detecta fallos usando métricas y reglas de alerta, luego activa la remediación sin intervención humana. Combinado con un LLM local como Ollama, el sistema diagnostica las causas raíz a partir del contexto de la alerta y ejecuta acciones permitidas: reiniciar un servicio caído, liberar espacio en disco o matar un proceso desbocado.

Los equipos de empresa usan PagerDuty, Rundeck o StackStorm para esto. Esas herramientas asumen un equipo, una flota de servidores y un presupuesto. En un solo VPS, necesitas algo más ligero. El «sentinel pattern» descrito aquí es un agente autónomo que vigila tu servidor y arregla problemas comunes automáticamente, con controles de seguridad que impiden que el LLM haga algo peligroso.

Requisitos previos

  • Un VPS con 4+ vCPU y 8 GB de RAM (el LLM necesita espacio junto a tus servicios)
  • Debian 12 o Ubuntu 24.04
  • Docker y Docker Compose instalados
  • Ollama instalado con al menos un modelo descargado (qwen2.5:7b recomendado para salida JSON estructurada)
  • Un usuario no-root con acceso sudo
  • Una URL de webhook de Discord (para notificaciones de aprobación humana)

¿Cómo configurar Prometheus, node_exporter y Alertmanager con Docker Compose?

Despliega el stack de monitorización como tres contenedores: Prometheus v3.10.0 recopila métricas, node_exporter v1.10.2 expone métricas del sistema, y Alertmanager v0.31.1 enruta alertas a tu receptor webhook. Todo el stack se inicia con un solo docker compose up -d.

Crea el directorio del proyecto:

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 y Alertmanager solo escuchan en 127.0.0.1. Exponer paneles de monitorización a internet es un error de configuración habitual que filtra métricas internas a cualquiera que escanee tu IP.

Configuración de 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"]

Configuración de 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

La URL del webhook apunta al receptor Python que se ejecuta en el host. host.docker.internal resuelve a la IP del host desde dentro de los contenedores Docker. La directiva extra_hosts en el archivo Compose anterior lo mapea a la pasarela del host en Linux.

Establece los permisos de los archivos antes de iniciar el stack:

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

¿Qué reglas de alerta configurar para fallos comunes de VPS?

Cuatro reglas de alerta cubren los fallos de VPS más comunes: disco llenándose, agotamiento de memoria, un servicio caído y uso elevado de CPU sostenido. Cada regla se dispara cuando un umbral se supera durante un tiempo definido, dando a Prometheus tiempo para filtrar picos transitorios.

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"
Alerta Umbral PromQL Duración Severidad Remediación por defecto
DiskSpaceLow FS raíz > 85 % lleno 5 min warning Limpiar imágenes Docker
MemoryHigh RAM disponible < 10 % 5 min critical Matar proceso con más consumo de memoria
ServiceDown Objetivo de scrape inalcanzable 1 min critical Reiniciar servicio
HighCPU CPU medio > 85 % 10 min warning Identificar proceso CPU

La anotación remediation_hint es un campo personalizado. Le indica al motor de remediación qué acción sugerir si el diagnóstico del LLM es ambiguo.

Inicia el stack:

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

Comprueba que Prometheus está recopilando objetivos:

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

Ambos objetivos deben mostrar "health": "up".

¿Cómo captura el receptor webhook las alertas de Alertmanager?

El receptor webhook es una aplicación Flask que escucha peticiones POST de Alertmanager, extrae el contexto de la alerta, consulta a Ollama para un diagnóstico y pasa el resultado al motor de remediación. Se ejecuta en el host, no en un contenedor, porque necesita acceso a Docker y systemd para ejecutar las acciones de remediación.

Instala las dependencias:

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)

El receptor escucha en 0.0.0.0:5001 para que Alertmanager pueda alcanzarlo a través de la pasarela de host de Docker (host.docker.internal resuelve a la IP del puente Docker, no a 127.0.0.1). Sin una regla de firewall, este puerto queda expuesto a internet. Bloquea el acceso externo ahora:

sudo ufw deny in on eth0 to any port 5001

Si usas nftables en lugar de ufw, añade una regla equivalente para descartar el tráfico entrante en el puerto 5001 desde interfaces públicas.

Ejecutar el receptor con systemd

Crea una unidad systemd para que el receptor arranque con el sistema y se reinicie en caso de fallo.

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 línea SupplementaryGroups=docker da al proceso sentinel acceso al socket Docker sin ejecutarse como root. Es necesario para las acciones docker restart y docker image prune.

Crea un usuario dedicado, añádelo al grupo docker, configura su directorio home y copia los archivos del receptor:

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

La unidad systemd se ejecuta como el usuario sentinel, así que el código del receptor y el virtualenv deben estar bajo su directorio home.

Nota sobre acciones con privilegios elevados: Las acciones docker restart y docker image prune funcionan mediante la pertenencia al grupo docker. Sin embargo, journalctl --vacuum-size, kill (para procesos de otros usuarios) y fallocate /swapfile requieren privilegios root. Si necesitas estas acciones en modo live, añade reglas sudoers específicas en /etc/sudoers.d/sentinel para los comandos concretos. No des acceso sudo general al usuario sentinel.

Crea /etc/sentinel/env con tus secretos:

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

Bloquea el archivo:

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

Inicia el servicio:

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

enable hace que sobreviva a reinicios. --now lo inicia de inmediato.

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)

Empieza en modo dry-run (SENTINEL_DRY_RUN=true) hasta que hayas probado cada ruta de alerta. Solo cambia a false cuando confíes en el comportamiento.

¿Cómo diagnostica Ollama problemas del servidor a partir del contexto de alerta?

Ollama recibe un prompt estructurado que contiene el nombre de la alerta, la severidad, la descripción, los valores actuales de las métricas y la lista de acciones permitidas. Devuelve un objeto JSON con el diagnóstico: evaluación de severidad, causa raíz, acción recomendada y razonamiento. El parámetro format de Ollama con un esquema JSON fuerza la estructura de salida a nivel de modelo.

La plantilla del prompt en el código anterior proporciona a Ollama exactamente lo que necesita:

  1. Los datos de la alerta (qué ha pasado)
  2. La lista de acciones válidas (qué puede recomendar)
  3. El esquema de salida (cómo responder)

Fijar temperature: 0 hace la salida determinista. La misma alerta produce siempre el mismo diagnóstico.

¿Qué modelos funcionan mejor?

No todos los modelos manejan la salida JSON estructurada de forma fiable. Esto es lo que probamos en un VPS con 4 vCPU / 8 GB de RAM:

Modelo Tamaño Tiempo de inferencia Fiabilidad JSON Notas
qwen2.5:7b 4,7 GB 3-5 s Alta Mejor equilibrio velocidad-precisión
llama3.1:8b 4,7 GB 4-6 s Media A veces ignora las restricciones del esquema
mistral:7b 4,1 GB 3-4 s Media Rápido pero a veces alucina acciones
phi3:mini 2,3 GB 1-2 s Baja Demasiado pequeño para salida estructurada fiable
qwen2.5:14b 9,0 GB 8-12 s Alta Mayor precisión, pero justo con 8 GB de RAM

qwen2.5:7b es la opción recomendada por defecto. Cabe cómodamente en 8 GB junto con Prometheus y tus servicios, y produce JSON válido de forma fiable ajustándose al esquema Pydantic.

Descárgalo:

ollama pull qwen2.5:7b

Un diagnóstico de ejemplo para una alerta 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."
}

El validador Pydantic rechaza cualquier respuesta donde recommended_action no esté en ALLOWED_ACTIONS. Si Ollama alucina una acción como rm -rf /tmp/*, el validador la captura y la acción nunca se ejecuta.

¿Qué acciones de remediación puede ejecutar el sistema de forma segura?

El motor de remediación solo ejecuta comandos definidos en una lista blanca estricta. Cada acción tiene una plantilla de comando fija, una lista opcional de servicios permitidos y un indicador de si se requiere aprobación humana antes de la ejecución.

Acción Comando Riesgo Aprobación requerida
restart_service docker restart {service} Bajo No (solo contenedores en la lista)
prune_docker_images docker image prune -af --filter 'until=72h' Bajo 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 Ninguno (solo lectura) No
clear_journal_logs journalctl --vacuum-size=200M Bajo No
add_swap fallocate + mkswap + swapon Medio

La acción restart_service acepta una variable de plantilla {service}, pero solo si el nombre del contenedor está en allowed_services. Si Ollama recomienda reiniciar sshd, el motor lo bloquea porque sshd no está en la lista. El nombre del servicio viene de la etiqueta Prometheus job, así que asegúrate de que tus valores job_name en prometheus.yml coincidan con los nombres de los contenedores Docker. Si ejecutas servicios gestionados por systemd, cambia docker restart por systemctl restart.

Los comandos usan SIGTERM (kill -15), no SIGKILL. El proceso tiene oportunidad de limpiar. subprocess.run tiene un timeout de 60 segundos para evitar remediaciones bloqueadas.

¿Cómo implementar la capa de seguridad con listas blancas y aprobación humana?

La capa de seguridad trata toda salida del LLM como no confiable. Tres mecanismos previenen acciones peligrosas: la lista blanca de acciones rechaza cualquier comando no predefinido en el código, el validador Pydantic captura respuestas malformadas antes de que lleguen al motor, y la puerta de aprobación Discord bloquea acciones destructivas hasta que un humano reaccione.

Defensa en profundidad

El sistema tiene cuatro capas de protección:

  1. Aplicación del esquema. El parámetro format de Ollama restringe el modelo al esquema JSON. No puede devolver comandos de texto libre.
  2. Validación Pydantic. El modelo Diagnosis valida que recommended_action existe en ALLOWED_ACTIONS. Las acciones inválidas lanzan un ValueError y la alerta cae al fallback basado en el hint.
  3. Lista blanca de acciones. El motor de remediación solo ejecuta comandos definidos en ALLOWED_ACTIONS. Sin construcción dinámica de comandos. Sin interpolación de cadenas más allá del nombre del servicio (que a su vez se valida contra una lista).
  4. Aprobación humana. Las acciones marcadas con requires_approval: True envían una notificación a Discord y no se ejecutan. Un manejador de aprobación separado (fuera del alcance de este tutorial) escucha las reacciones de Discord y activa la ejecución.

Modo dry-run

Establece SENTINEL_DRY_RUN=true en /etc/sentinel/env. El receptor registra lo que haría sin ejecutar nada:

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

Lee el registro de auditoría:

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

Ejecuta en modo dry-run durante al menos una semana antes de activar la remediación live. Revisa cada acción registrada. Asegúrate de que el LLM recomienda acciones sensatas para tus alertas reales.

Flujo de aprobación de Discord

Cuando se activa una acción destructiva, el receptor publica un embed en tu canal de 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.

Sin un webhook de Discord configurado, las acciones destructivas se bloquean por completo. Este es el comportamiento seguro por defecto. Un webhook ausente significa que no se ejecutan acciones destructivas, no que se ejecutan sin aprobación.

¿Qué es el sentinel pattern y por qué lo necesitas?

El sentinel pattern es un watchdog autónomo basado en cron que monitoriza el propio stack de monitorización. Si Prometheus se cae, nadie dispara una alerta porque el sistema de alertas está caído. El script sentinel comprueba si Prometheus, Alertmanager y el receptor webhook están funcionando, y los reinicia si no es así. Es la respuesta a la pregunta «¿quién vigila a los vigilantes?».

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

Añade una tarea cron que se ejecute cada 2 minutos. Sustituye /root por tu directorio home real:

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

El watchdog no tiene dependencias más allá de bash y el CLI de Docker. Funciona aunque Python, Ollama o todo el stack de monitorización esté caído. Por eso existe: es el único componente que no depende de ningún otro componente.

Consulta el 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

¿Cómo probar el bucle de autorreparación de extremo a extremo?

Simula fallos reales para confirmar que el bucle completo funciona: la alerta se dispara, el webhook recibe, Ollama diagnostica, la remediación se ejecuta (o se registra en modo dry-run). Inicia cada prueba con SENTINEL_DRY_RUN=true para que nada se ejecute realmente hasta que verifiques el diagnóstico.

Prueba 1: servicio caído

Inicia un contenedor de prueba y detenlo:

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

Añade un objetivo de scrape en prometheus/prometheus.yml para la prueba (o simplemente detén el contenedor node-exporter):

docker stop node-exporter

Espera 1-2 minutos a que se dispare la alerta ServiceDown. Observa el registro de auditoría:

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

Reinicia node-exporter manualmente para resolver la alerta:

docker start node-exporter

Prueba 2: presión de disco

Crea un archivo grande para llevar el uso de disco por encima del 85 %:

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

Espera a la alerta DiskSpaceLow (umbral de 5 minutos). El LLM debería diagnosticarlo y recomendar prune_docker_images. Tras confirmar el registro dry-run, limpia:

rm /tmp/disk-pressure-test

Prueba 3: presión de memoria

Usa stress-ng para consumir memoria:

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

La alerta MemoryHigh se dispara. El LLM debería recomendar kill_top_memory_process, que requiere aprobación de Discord. Confirma que el mensaje de aprobación aparece en Discord.

Detén la prueba de estrés:

killall stress-ng

Cambiar a modo live

Una vez que cada prueba dry-run produce diagnósticos correctos y recomendaciones de acción apropiadas:

  1. Edita /etc/sentinel/env y establece SENTINEL_DRY_RUN=false
  2. Reinicia el servicio: sudo systemctl restart sentinel.service
  3. Ejecuta de nuevo la prueba de servicio caído. El contenedor node-exporter debería reiniciarse automáticamente mediante docker restart.

Sigue monitorizando /var/log/sentinel/audit.jsonl durante los primeros días. Cada acción queda registrada con el contexto completo para revisión post-incidente.

Solución de problemas

Ollama no responde. Comprueba que Ollama está ejecutándose: systemctl status ollama. Comprueba que el modelo está descargado: ollama list. Si el modelo no está cargado, la primera petición tarda 10-20 segundos mientras se carga en RAM.

Alertmanager no puede alcanzar el webhook. Verifica que el receptor está escuchando: curl http://127.0.0.1:5001/health. Revisa los logs de Alertmanager: docker logs alertmanager. Asegúrate de que host.docker.internal resuelve desde dentro del contenedor Alertmanager: docker exec alertmanager wget -q -O- http://host.docker.internal:5001/health.

Las alertas no se disparan. Comprueba que Prometheus cargó las reglas: curl http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool. Verifica que los objetivos de scrape están activos. Una cláusula for: 5m significa que la condición debe ser verdadera durante 5 minutos continuos antes de que la alerta se dispare.

Errores de validación Pydantic. El modelo produjo una salida que no coincide con el esquema. Revisa la respuesta cruda de Ollama en el journal: journalctl -u sentinel.service -f. Prueba un modelo diferente. qwen2.5:7b es el más fiable para salida estructurada en hardware limitado.

El watchdog no se ejecuta. Verifica la entrada cron: sudo crontab -l. Comprueba el demonio cron: systemctl status cron. Comprueba que el log del watchdog existe: ls -la /var/log/sentinel/watchdog.log.

Logs de cada 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 .

Próximos pasos

El sentinel pattern gestiona la remediación reactiva. Para monitorización proactiva, alimenta tus logs a un LLM para detectar patrones antes de que las alertas se disparen.

Si extiendes el motor de remediación para ejecutar scripts más complejos, aíslalos en un sandbox. Nunca dejes que un proceso controlado por un LLM se ejecute con acceso irrestricto al sistema.

El stack de monitorización aquí cubre un solo VPS. Para configuraciones Docker con múltiples servicios, añade cAdvisor como objetivo de scrape para monitorizar métricas de CPU, memoria y red a nivel de contenedor.


Copyright 2026 Virtua.Cloud. Todos los derechos reservados. Este contenido es una obra original del equipo de Virtua.Cloud. La reproducción, republicación o redistribución sin permiso escrito está prohibida.

¿Listo para probarlo?

Despliega tu propio servidor en segundos. Linux, Windows o FreeBSD.

Ver planes VPS
VPS autorreparable con Prometheus y Ollama