Construir un VPS autorreparable con Prometheus y Ollama
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:7brecomendado 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 restartydocker image prunefuncionan mediante la pertenencia al grupo docker. Sin embargo,journalctl --vacuum-size,kill(para procesos de otros usuarios) yfallocate /swapfilerequieren privilegios root. Si necesitas estas acciones en modo live, añade reglas sudoers específicas en/etc/sudoers.d/sentinelpara 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:
- Los datos de la alerta (qué ha pasado)
- La lista de acciones válidas (qué puede recomendar)
- 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 | Sí |
| 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 | Sí |
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:
- Aplicación del esquema. El parámetro
formatde Ollama restringe el modelo al esquema JSON. No puede devolver comandos de texto libre. - Validación Pydantic. El modelo
Diagnosisvalida querecommended_actionexiste enALLOWED_ACTIONS. Las acciones inválidas lanzan unValueErrory la alerta cae al fallback basado en el hint. - 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). - Aprobación humana. Las acciones marcadas con
requires_approval: Trueenví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 with ✅ to approve or ❌ to 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:
- Edita
/etc/sentinel/envy estableceSENTINEL_DRY_RUN=false - Reinicia el servicio:
sudo systemctl restart sentinel.service - 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