Een zelfherstellende VPS bouwen met Prometheus en Ollama
Koppel Prometheus-alerts aan een lokaal LLM dat storingen diagnosticeert en veilige herstelacties uitvoert op je VPS. Volledig werkende code met allowlists, dry-run modus en menselijke goedkeuringscontroles.
Je server valt om 3 uur 's nachts uit. Je wordt wakker gebeld, SSH't half slapend in, herstart de gecrashte service en gaat weer naar bed. Na een paar keer vraag je je af: kan de server zichzelf niet gewoon repareren?
Dat kan. Deze tutorial bouwt een zelfherstellende feedbackloop op een enkele VPS. Prometheus en node_exporter verzamelen metrics. Alertmanager vuurt wanneer drempelwaarden worden overschreden. Een Python webhook-ontvanger vangt die alerts op en stuurt de context naar Ollama. Het LLM diagnosticeert het probleem en beveelt een herstelactie aan. Als de actie op de allowlist staat, wordt deze automatisch uitgevoerd. Als de actie destructief is, keurt een mens deze eerst goed via Discord.
De volledige flow ziet er zo uit:
node_exporter -> Prometheus -> Alertmanager -> webhook receiver -> Ollama -> remediation engine -> action
|
audit log + Discord
Elke actie wordt gelogd. Elke LLM-aanbeveling wordt behandeld als onbetrouwbare invoer. Dit is het deel dat de meeste AIOps-content overslaat, en het deel dat het meest telt.
Wat is een zelfherstellende server en waarom er een bouwen op een VPS?
Een zelfherstellende server detecteert storingen met metrics en alertregels, en activeert vervolgens herstel zonder menselijke tussenkomst. Gecombineerd met een lokaal LLM zoals Ollama diagnosticeert het systeem de onderliggende oorzaak uit de alertcontext en voert het toegestane acties uit: een gecrashte service herstarten, schijfruimte vrijmaken of een op hol geslagen proces beëindigen.
Enterprise-teams gebruiken hiervoor PagerDuty, Rundeck of StackStorm. Die tools gaan uit van een team, een vloot servers en een budget. Op een enkele VPS heb je iets lichters nodig. Het "sentinel pattern" dat hier wordt beschreven is een zelfstandige agent die je server bewaakt en veelvoorkomende problemen automatisch oplost, met beveiligingscontroles die voorkomen dat het LLM iets gevaarlijks doet.
Vereisten
- Een VPS met 4+ vCPU en 8 GB RAM (het LLM heeft ruimte nodig naast je services)
- Debian 12 of Ubuntu 24.04
- Docker en Docker Compose geïnstalleerd
- Ollama geïnstalleerd met minstens één gedownload model (
qwen2.5:7baanbevolen voor gestructureerde JSON-output) - Een niet-root gebruiker met sudo-toegang
- Een Discord webhook-URL (voor menselijke goedkeuringsmeldingen)
Hoe stel je Prometheus, node_exporter en Alertmanager in met Docker Compose?
Deploy de monitoringstack als drie containers: Prometheus v3.10.0 verzamelt metrics, node_exporter v1.10.2 stelt hostmetrics beschikbaar, en Alertmanager v0.31.1 routeert alerts naar je webhook-ontvanger. De hele stack start met één enkel docker compose up -d.
Maak de projectdirectory aan:
mkdir -p ~/sentinel/{prometheus,alertmanager}
cd ~/sentinel
Docker Compose stack
Maak docker-compose.yml aan:
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 en Alertmanager luisteren alleen op 127.0.0.1. Monitoringdashboards blootstellen aan het internet is een veelgemaakte configuratiefout die interne metrics zichtbaar maakt voor iedereen die je IP scant.
Prometheus-configuratie
Maak prometheus/prometheus.yml aan:
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"]
Alertmanager-configuratie
Maak alertmanager/alertmanager.yml aan:
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
De webhook-URL wijst naar de Python-ontvanger die op de host draait. host.docker.internal resolvet naar het host-IP vanuit Docker-containers. De extra_hosts-directive in het Compose-bestand hierboven mapt dit naar de hostgateway op Linux.
Stel bestandspermissies in voordat je de stack start:
chmod 644 prometheus/prometheus.yml prometheus/alert_rules.yml alertmanager/alertmanager.yml
Welke alertregels moet je configureren voor veelvoorkomende VPS-storingen?
Vier alertregels dekken de meest voorkomende VPS-storingen: schijf loopt vol, geheugenuitputting, een service die uitvalt en aanhoudend hoog CPU-gebruik. Elke regel vuurt wanneer een drempelwaarde voor een gedefinieerde duur wordt overschreden, waardoor Prometheus tijd heeft om tijdelijke pieken te filteren.
Maak prometheus/alert_rules.yml aan:
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 | PromQL-drempel | Duur | Ernst | Standaard herstelactie |
|---|---|---|---|---|
| DiskSpaceLow | Root FS > 85% vol | 5 min | warning | Docker-images opschonen |
| MemoryHigh | Beschikbaar RAM < 10% | 5 min | critical | Proces met meeste geheugengebruik beëindigen |
| ServiceDown | Scrape-doel onbereikbaar | 1 min | critical | Service herstarten |
| HighCPU | Gem. CPU > 85% | 10 min | warning | CPU-verbruiker identificeren |
De remediation_hint-annotatie is een aangepast veld. Het vertelt de remediation engine welke actie moet worden gesuggereerd als de LLM-diagnose dubbelzinnig is.
Start de stack:
cd ~/sentinel
docker compose up -d
[+] Running 4/4
✔ Network sentinel_sentinel Created
✔ Container node-exporter Started
✔ Container prometheus Started
✔ Container alertmanager Started
Controleer of Prometheus targets scrapet:
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",
Beide targets moeten "health": "up" tonen.
Hoe vangt de webhook-ontvanger Alertmanager-alerts op?
De webhook-ontvanger is een Flask-applicatie die luistert naar POST-verzoeken van Alertmanager, de alertcontext extraheert, Ollama ondervraagt voor een diagnose en het resultaat doorgeeft aan de remediation engine. Deze draait op de host, niet in een container, omdat het toegang nodig heeft tot Docker en systemd om herstelacties uit te voeren.
Installeer de afhankelijkheden:
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
Maak ~/sentinel/receiver/sentinel.py aan:
#!/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)
De ontvanger luistert op 0.0.0.0:5001 zodat Alertmanager deze kan bereiken via Dockers hostgateway (host.docker.internal resolvet naar het Docker bridge-IP, niet naar 127.0.0.1). Zonder firewallregel staat deze poort open voor het internet. Blokkeer externe toegang nu:
sudo ufw deny in on eth0 to any port 5001
Als je nftables gebruikt in plaats van ufw, voeg dan een gelijkwaardige regel toe om inkomend verkeer op poort 5001 van publieke interfaces te verwerpen.
De ontvanger draaien met systemd
Maak een systemd-unit aan zodat de ontvanger bij het opstarten start en herstart bij fouten.
Maak /etc/systemd/system/sentinel.service aan:
[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
De regel SupplementaryGroups=docker geeft het sentinel-proces toegang tot de Docker-socket zonder als root te draaien. Dit is vereist voor de acties docker restart en docker image prune.
Maak een dedicated gebruiker aan, voeg deze toe aan de docker-groep, stel de homedirectory in en kopieer de ontvangerbestanden:
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
De systemd-unit draait als de sentinel-gebruiker, dus de ontvangercode en de virtualenv moeten zich onder diens homedirectory bevinden.
Opmerking over acties met verhoogde rechten: De acties
docker restartendocker image prunewerken via docker-groepslidmaatschap. Echter,journalctl --vacuum-size,kill(voor processen van andere gebruikers) enfallocate /swapfilevereisen root-rechten. Als je deze acties in live-modus nodig hebt, voeg dan gerichte sudoers-regels toe in/etc/sudoers.d/sentinelvoor de specifieke commando's. Geef de sentinel-gebruiker geen onbeperkte sudo-toegang.
Maak /etc/sentinel/env aan met je geheimen:
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
Vergrendel het bestand:
sudo chmod 600 /etc/sentinel/env
sudo chown sentinel:sentinel /etc/sentinel/env
Start de service:
sudo systemctl daemon-reload
sudo systemctl enable --now sentinel.service
enable zorgt dat het reboots overleeft. --now start het direct.
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)
Start in dry-run modus (SENTINEL_DRY_RUN=true) totdat je elk alertpad hebt getest. Schakel pas naar false wanneer je het gedrag vertrouwt.
Hoe diagnosticeert Ollama serverproblemen vanuit de alertcontext?
Ollama ontvangt een gestructureerde prompt met de alertnaam, ernst, beschrijving, actuele metriekwaarden en de lijst van toegestane acties. Het retourneert een JSON-object met de diagnose: ernstbeoordeling, oorzaak, aanbevolen actie en redenering. Het gebruik van Ollama's format-parameter met een JSON-schema dwingt de outputstructuur af op modelniveau.
Het prompttemplate in de bovenstaande code geeft Ollama precies wat het nodig heeft:
- De alertdata (wat er is gebeurd)
- De lijst met geldige acties (wat het kan aanbevelen)
- Het outputschema (hoe het moet antwoorden)
temperature: 0 instellen maakt de output deterministisch. Dezelfde alert produceert altijd dezelfde diagnose.
Welke modellen werken het best?
Niet alle modellen verwerken gestructureerde JSON-output betrouwbaar. Dit is wat we hebben getest op een VPS met 4 vCPU / 8 GB RAM:
| Model | Grootte | Inferentietijd | JSON-betrouwbaarheid | Opmerkingen |
|---|---|---|---|---|
| qwen2.5:7b | 4,7 GB | 3-5 s | Hoog | Beste balans snelheid-nauwkeurigheid |
| llama3.1:8b | 4,7 GB | 4-6 s | Gemiddeld | Negeert soms schemabeperkingen |
| mistral:7b | 4,1 GB | 3-4 s | Gemiddeld | Snel maar hallucineert soms acties |
| phi3:mini | 2,3 GB | 1-2 s | Laag | Te klein voor betrouwbare gestructureerde output |
| qwen2.5:14b | 9,0 GB | 8-12 s | Hoog | Beste nauwkeurigheid, maar krap op 8 GB RAM |
qwen2.5:7b is de aanbevolen standaardkeuze. Het past comfortabel in 8 GB naast Prometheus en je services, en produceert betrouwbaar geldige JSON die overeenkomt met het Pydantic-schema.
Download het:
ollama pull qwen2.5:7b
Een voorbeelddiagnose van een DiskSpaceLow-alert:
{
"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."
}
De Pydantic-validator weigert elk antwoord waarbij recommended_action niet in ALLOWED_ACTIONS staat. Als Ollama een actie zoals rm -rf /tmp/* hallucineert, vangt de validator dit op en wordt de actie nooit uitgevoerd.
Welke herstelacties kan het systeem veilig uitvoeren?
De remediation engine voert alleen commando's uit die zijn gedefinieerd in een strikte allowlist. Elke actie heeft een vast commandotemplate, een optionele service-allowlist en een vlag die aangeeft of menselijke goedkeuring vereist is vóór uitvoering.
| Actie | Commando | Risico | Goedkeuring vereist |
|---|---|---|---|
| restart_service | docker restart {service} |
Laag | Nee (alleen containers op de allowlist) |
| prune_docker_images | docker image prune -af --filter 'until=72h' |
Laag | Nee |
| kill_top_memory_process | ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15 |
Hoog | Ja |
| identify_cpu_hog | ps aux --sort=-%cpu | head -5 |
Geen (alleen-lezen) | Nee |
| clear_journal_logs | journalctl --vacuum-size=200M |
Laag | Nee |
| add_swap | fallocate + mkswap + swapon |
Gemiddeld | Ja |
De actie restart_service accepteert een templatevariabele {service}, maar alleen als de containernaam in allowed_services staat. Als Ollama aanbeveelt om sshd te herstarten, blokkeert de engine dit omdat sshd niet op de lijst staat. De servicenaam komt van het Prometheus job-label, dus zorg dat je job_name-waarden in prometheus.yml overeenkomen met de Docker-containernamen. Als je systemd-beheerde services draait, vervang dan docker restart door systemctl restart.
Commando's gebruiken SIGTERM (kill -15), niet SIGKILL. Het proces krijgt de kans om op te ruimen. subprocess.run heeft een timeout van 60 seconden om hangende herstelacties te voorkomen.
Hoe implementeer je de beveiligingslaag met allowlists en menselijke goedkeuring?
De beveiligingslaag behandelt alle LLM-output als onbetrouwbaar. Drie mechanismen voorkomen gevaarlijke acties: de actie-allowlist weigert elk commando dat niet vooraf in de code is gedefinieerd, de Pydantic-validator onderschept onjuiste antwoorden voordat ze de engine bereiken, en de Discord-goedkeuringspoort blokkeert destructieve acties totdat een mens reageert.
Verdediging in diepte
Het systeem heeft vier beschermingslagen:
- Schema-afdwinging. Ollama's
format-parameter beperkt het model tot het JSON-schema. Het kan geen vrije-tekst commando's retourneren. - Pydantic-validatie. Het
Diagnosis-model valideert datrecommended_actionbestaat inALLOWED_ACTIONS. Ongeldige acties genereren eenValueErroren de alert valt terug op de hint-gebaseerde fallback. - Actie-allowlist. De remediation engine voert alleen commando's uit die zijn gedefinieerd in
ALLOWED_ACTIONS. Geen dynamische commandoconstructie. Geen stringinterpolatie buiten de servicenaam (die zelf ook wordt gevalideerd tegen een lijst). - Menselijke goedkeuring. Acties gemarkeerd met
requires_approval: Truesturen een Discord-melding en worden niet uitgevoerd. Een aparte goedkeuringshandler (buiten het bereik van deze tutorial) luistert naar Discord-reacties en activeert de uitvoering.
Dry-run modus
Stel SENTINEL_DRY_RUN=true in /etc/sentinel/env. De ontvanger logt wat het zou doen zonder iets uit te voeren:
{"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'"}
Lees de auditlog:
tail -f /var/log/sentinel/audit.jsonl | jq .
Draai minstens een week in dry-run modus voordat je live-herstel activeert. Bekijk elke gelogde actie. Zorg dat het LLM verstandige acties aanbeveelt voor je werkelijke alerts.
Discord-goedkeuringsflow
Wanneer een destructieve actie wordt geactiveerd, plaatst de ontvanger een embed in je Discord-kanaal:
🔒 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.
Zonder geconfigureerde Discord-webhook worden destructieve acties volledig geblokkeerd. Dit is het veilige standaardgedrag. Een ontbrekende webhook betekent dat geen destructieve acties worden uitgevoerd, niet dat ze zonder goedkeuring draaien.
Wat is het sentinel pattern en waarom heb je het nodig?
Het sentinel pattern is een zelfstandige op cron gebaseerde watchdog die de monitoringstack zelf bewaakt. Als Prometheus crasht, vuurt niemand een alert omdat het alertsysteem zelf plat ligt. Het sentinel-script controleert of Prometheus, Alertmanager en de webhook-ontvanger draaien, en herstart ze als dat niet het geval is. Het is het antwoord op de vraag "wie bewaakt de bewakers".
Maak ~/sentinel/watchdog.sh aan:
#!/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
Voeg een cronjob toe die elke 2 minuten draait. Vervang /root door je werkelijke homedirectory:
(sudo crontab -l 2>/dev/null; echo "*/2 * * * * /root/sentinel/watchdog.sh") | sudo crontab -
De watchdog heeft geen afhankelijkheden buiten bash en de Docker CLI. Het werkt zelfs als Python, Ollama of de gehele monitoringstack plat ligt. Daarom bestaat het: het is het enige component dat niet afhankelijk is van enig ander component.
Bekijk de watchdog-log:
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
Hoe test je de zelfherstellende loop end-to-end?
Simuleer echte storingen om te bevestigen dat de volledige loop werkt: alert vuurt, webhook ontvangt, Ollama diagnosticeert, herstel wordt uitgevoerd (of gelogd in dry-run modus). Start elke test met SENTINEL_DRY_RUN=true zodat er niets daadwerkelijk wordt uitgevoerd totdat je de diagnose hebt geverifieerd.
Test 1: service down
Start een dummy-container en stop hem:
docker run -d --name test-service --network sentinel_sentinel alpine sleep 3600
Voeg een scrape-doel toe in prometheus/prometheus.yml voor de test (of stop gewoon de node-exporter container):
docker stop node-exporter
Wacht 1-2 minuten tot de ServiceDown-alert vuurt. Bekijk de auditlog:
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"
}
Herstart node-exporter handmatig om de alert op te lossen:
docker start node-exporter
Test 2: schijfdruk
Maak een groot bestand aan om het schijfgebruik boven 85% te duwen:
fallocate -l 20G /tmp/disk-pressure-test
Wacht op de DiskSpaceLow-alert (drempel van 5 minuten). Het LLM moet het diagnosticeren en prune_docker_images aanbevelen. Na bevestiging van de dry-run log, ruim op:
rm /tmp/disk-pressure-test
Test 3: geheugendruk
Gebruik stress-ng om geheugen te verbruiken:
sudo apt install -y stress-ng
stress-ng --vm 1 --vm-bytes 6G --timeout 600s &
De MemoryHigh-alert vuurt. Het LLM moet kill_top_memory_process aanbevelen, wat Discord-goedkeuring vereist. Bevestig dat het goedkeuringsbericht verschijnt in Discord.
Stop de stresstest:
killall stress-ng
Overschakelen naar live-modus
Zodra elke dry-run test correcte diagnoses en passende actieaanbevelingen oplevert:
- Bewerk
/etc/sentinel/enven stelSENTINEL_DRY_RUN=falsein - Herstart de service:
sudo systemctl restart sentinel.service - Voer de service-down test opnieuw uit. De node-exporter container zou automatisch moeten herstarten via
docker restart.
Blijf /var/log/sentinel/audit.jsonl monitoren gedurende de eerste dagen. Elke actie wordt gelogd met volledige context voor post-incident review.
Probleemoplossing
Ollama reageert niet. Controleer of Ollama draait: systemctl status ollama. Controleer of het model is gedownload: ollama list. Als het model niet is geladen, duurt het eerste verzoek 10-20 seconden terwijl het in het RAM wordt geladen.
Alertmanager kan de webhook niet bereiken. Verifieer dat de ontvanger luistert: curl http://127.0.0.1:5001/health. Controleer de Alertmanager-logs: docker logs alertmanager. Zorg dat host.docker.internal resolvet vanuit de Alertmanager-container: docker exec alertmanager wget -q -O- http://host.docker.internal:5001/health.
Alerts vuren niet. Controleer of Prometheus de regels heeft geladen: curl http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool. Verifieer dat de scrape-doelen actief zijn. Een for: 5m-clausule betekent dat de conditie 5 aaneengesloten minuten waar moet zijn voordat de alert vuurt.
Pydantic-validatiefouten. Het model heeft output geproduceerd die niet overeenkomt met het schema. Controleer Ollama's ruwe antwoord in het journal: journalctl -u sentinel.service -f. Probeer een ander model. qwen2.5:7b is het meest betrouwbaar voor gestructureerde output op beperkte hardware.
Watchdog draait niet. Verifieer de cron-entry: sudo crontab -l. Controleer de cron-daemon: systemctl status cron. Controleer of de watchdog-log bestaat: ls -la /var/log/sentinel/watchdog.log.
Logs voor elk component:
# 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 .
Volgende stappen
Het sentinel pattern behandelt reactief herstel. Voor proactieve monitoring kun je je logs naar een LLM voeren voor patroondetectie voordat alerts vuren.
Als je de remediation engine uitbreidt om complexere scripts te draaien, sandbox ze dan. Laat nooit een LLM-gestuurd proces draaien met onbeperkte systeemtoegang.
De monitoringstack hier dekt een enkele VPS. Voor Docker-setups met meerdere services, voeg cAdvisor toe als scrape-doel om container-niveau CPU-, geheugen- en netwerkmetrics te monitoren.
Copyright 2026 Virtua.Cloud. Alle rechten voorbehouden. Deze inhoud is een origineel werk van het Virtua.Cloud-team. Reproductie, herpublicatie of herdistributie zonder schriftelijke toestemming is verboden.
Klaar om het zelf te proberen?
Deploy uw eigen server in seconden. Linux, Windows of FreeBSD.
Bekijk VPS-aanbod