Самовосстанавливающийся VPS с Prometheus и Ollama

11 мин чтения·Matthieu·monitoringself-healingaiopsdocker-composeollamaalertmanagerprometheus|

Подключите алерты Prometheus к локальной LLM, которая диагностирует сбои и выполняет безопасные действия по восстановлению на вашем VPS. Полный рабочий код с белыми списками, режимом dry-run и контролем человеческого одобрения.

Твой сервер падает в 3 часа ночи. Тебя будит алерт, ты полусонный подключаешься по SSH, перезапускаешь упавший сервис и идёшь обратно спать. После нескольких таких случаев возникает вопрос: а может сервер сам себя починить?

Может. Этот туториал строит цикл самовосстановления на одном VPS. Prometheus и node_exporter собирают метрики. Alertmanager срабатывает при превышении пороговых значений. Python-вебхук-приёмник перехватывает эти алерты и передаёт контекст в Ollama. LLM диагностирует проблему и рекомендует действие по восстановлению. Если действие есть в белом списке, оно выполняется автоматически. Если оно деструктивное, сначала его одобряет человек через Discord.

Полный поток выглядит так:

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

Каждое действие логируется. Каждая рекомендация LLM обрабатывается как недоверенный ввод. Это та часть, которую большинство AIOps-контента пропускает, и та, которая важнее всего.

Что такое самовосстанавливающийся сервер и зачем строить его на VPS?

Самовосстанавливающийся сервер обнаруживает сбои с помощью метрик и правил алертов, а затем запускает восстановление без участия человека. В сочетании с локальной LLM вроде Ollama система диагностирует корневые причины из контекста алерта и выполняет разрешённые действия: перезапускает упавший сервис, освобождает место на диске или убивает зависший процесс.

Корпоративные команды используют для этого PagerDuty, Rundeck или StackStorm. Эти инструменты предполагают наличие команды, парка серверов и бюджета. На одном VPS нужно что-то полегче. «Sentinel pattern», описанный здесь, это автономный агент, который следит за сервером и автоматически исправляет типичные проблемы, с контролями безопасности, которые не дают LLM сделать что-то опасное.

Что понадобится

  • VPS с 4+ vCPU и 8 ГБ RAM (LLM нужно место рядом с твоими сервисами)
  • Debian 12 или Ubuntu 24.04
  • Установленные Docker и Docker Compose
  • Установленный Ollama с хотя бы одной загруженной моделью (рекомендуется qwen2.5:7b для структурированного JSON-вывода)
  • Пользователь без root-прав с доступом sudo
  • URL вебхука Discord (для уведомлений о необходимости одобрения)

Как настроить Prometheus, node_exporter и Alertmanager через Docker Compose?

Разворачиваем стек мониторинга из трёх контейнеров: Prometheus v3.10.0 собирает метрики, node_exporter v1.10.2 отдаёт метрики хоста, Alertmanager v0.31.1 направляет алерты на вебхук-приёмник. Весь стек запускается одной командой docker compose up -d.

Создай директорию проекта:

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

Стек Docker Compose

Создай 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 и Alertmanager слушают только на 127.0.0.1. Открывать дашборды мониторинга в интернет — типичная ошибка конфигурации, которая сливает внутренние метрики любому, кто просканирует твой IP.

Конфигурация Prometheus

Создай 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"]

Конфигурация Alertmanager

Создай 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

URL вебхука указывает на Python-приёмник, работающий на хосте. host.docker.internal резолвится в IP хоста изнутри Docker-контейнеров. Директива extra_hosts в Compose-файле выше маппит его на хостовый шлюз в Linux.

Выставь права на файлы перед запуском стека:

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

Какие правила алертов настроить для типичных сбоев VPS?

Четыре правила покрывают самые частые сбои VPS: заполнение диска, нехватка памяти, падение сервиса и длительная высокая нагрузка на CPU. Каждое правило срабатывает, когда порог превышен в течение заданного времени, давая Prometheus отфильтровать кратковременные всплески.

Создай 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"
Алерт Порог PromQL Длительность Критичность Действие по умолчанию
DiskSpaceLow Корневая ФС > 85% заполнена 5 мин warning Очистка Docker-образов
MemoryHigh Доступная RAM < 10% 5 мин critical Убить процесс с наибольшим потреблением памяти
ServiceDown Цель скрейпинга недоступна 1 мин critical Перезапуск сервиса
HighCPU Средний CPU > 85% 10 мин warning Определить процесс, нагружающий CPU

Аннотация remediation_hint — пользовательское поле. Оно подсказывает движку восстановления, какое действие предложить, если диагноз LLM неоднозначен.

Запусти стек:

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

Проверь, что Prometheus скрейпит цели:

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

Обе цели должны показывать "health": "up".

Как вебхук-приёмник перехватывает алерты Alertmanager?

Вебхук-приёмник — это Flask-приложение, которое слушает POST-запросы от Alertmanager, извлекает контекст алерта, запрашивает у Ollama диагноз и передаёт результат в движок восстановления. Он работает на хосте, а не в контейнере, потому что ему нужен доступ к Docker и systemd для выполнения действий по восстановлению.

Установи зависимости:

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

Создай ~/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)

Приёмник слушает на 0.0.0.0:5001, чтобы Alertmanager мог достучаться до него через Docker host gateway (host.docker.internal резолвится в IP Docker-моста, а не в 127.0.0.1). Без правила файрвола этот порт торчит в интернет. Заблокируй внешний доступ:

sudo ufw deny in on eth0 to any port 5001

Если используешь nftables вместо ufw, добавь аналогичное правило для отброса входящего трафика на порт 5001 с публичных интерфейсов.

Запуск приёмника через systemd

Создай systemd-юнит, чтобы приёмник стартовал при загрузке и перезапускался при сбое.

Создай /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

Строка SupplementaryGroups=docker даёт процессу sentinel доступ к Docker-сокету без запуска от root. Это нужно для действий docker restart и docker image prune.

Создай выделенного пользователя, добавь его в группу docker, настрой домашнюю директорию и скопируй файлы приёмника:

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

Systemd-юнит работает от пользователя sentinel, поэтому код приёмника и virtualenv должны лежать в его домашней директории.

Про действия с повышенными привилегиями: Действия docker restart и docker image prune работают через членство в группе docker. Однако journalctl --vacuum-size, kill (для процессов других пользователей) и fallocate /swapfile требуют root-привилегий. Если тебе нужны эти действия в боевом режиме, добавь точечные sudoers-правила в /etc/sudoers.d/sentinel для конкретных команд. Не давай пользователю sentinel полный sudo-доступ.

Создай /etc/sentinel/env со своими секретами:

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

Заблокируй файл:

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

Запусти сервис:

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

enable обеспечивает запуск после ребута. --now запускает немедленно.

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)

Запускай в режиме dry-run (SENTINEL_DRY_RUN=true), пока не протестируешь каждый путь алерта. Переключай на false только когда доверяешь поведению.

Как Ollama диагностирует проблемы сервера из контекста алерта?

Ollama получает структурированный промпт с названием алерта, критичностью, описанием, текущими значениями метрик и списком разрешённых действий. Возвращает JSON-объект с диагнозом: оценка критичности, корневая причина, рекомендованное действие и обоснование. Параметр format Ollama с JSON-схемой обеспечивает структуру вывода на уровне модели.

Шаблон промпта в коде выше даёт Ollama всё необходимое:

  1. Данные алерта (что произошло)
  2. Список допустимых действий (что можно рекомендовать)
  3. Схему вывода (как отвечать)

Установка temperature: 0 делает вывод детерминированным. Один и тот же алерт всегда даёт один и тот же диагноз.

Какие модели работают лучше?

Не все модели надёжно обрабатывают структурированный JSON-вывод. Вот что мы протестировали на VPS с 4 vCPU / 8 ГБ RAM:

Модель Размер Время инференса Надёжность JSON Примечания
qwen2.5:7b 4,7 ГБ 3-5 с Высокая Лучший баланс скорости и точности
llama3.1:8b 4,7 ГБ 4-6 с Средняя Иногда игнорирует ограничения схемы
mistral:7b 4,1 ГБ 3-4 с Средняя Быстрая, но иногда галлюцинирует действия
phi3:mini 2,3 ГБ 1-2 с Низкая Слишком маленькая для надёжного структурированного вывода
qwen2.5:14b 9,0 ГБ 8-12 с Высокая Лучшая точность, но впритык по 8 ГБ RAM

qwen2.5:7b — рекомендованный выбор по умолчанию. Комфортно помещается в 8 ГБ рядом с Prometheus и твоими сервисами, и надёжно генерирует валидный JSON, соответствующий Pydantic-схеме.

Скачай модель:

ollama pull qwen2.5:7b

Пример диагноза для алерта 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."
}

Pydantic-валидатор отклоняет любой ответ, где recommended_action отсутствует в ALLOWED_ACTIONS. Если Ollama нагаллюцинирует действие вроде rm -rf /tmp/*, валидатор перехватит его, и действие никогда не выполнится.

Какие действия по восстановлению система может выполнять безопасно?

Движок восстановления выполняет только команды, определённые в строгом белом списке. Каждое действие имеет фиксированный шаблон команды, опциональный список разрешённых сервисов и флаг, требуется ли одобрение человека перед выполнением.

Действие Команда Риск Одобрение
restart_service docker restart {service} Низкий Нет (только контейнеры из списка)
prune_docker_images docker image prune -af --filter 'until=72h' Низкий Нет
kill_top_memory_process ps aux --sort=-%mem | awk 'NR==2{print $2}' | xargs kill -15 Высокий Да
identify_cpu_hog ps aux --sort=-%cpu | head -5 Нулевой (только чтение) Нет
clear_journal_logs journalctl --vacuum-size=200M Низкий Нет
add_swap fallocate + mkswap + swapon Средний Да

Действие restart_service принимает шаблонную переменную {service}, но только если имя контейнера есть в allowed_services. Если Ollama рекомендует перезапустить sshd, движок заблокирует это, потому что sshd нет в списке. Имя сервиса берётся из Prometheus-лейбла job, поэтому убедись, что значения job_name в prometheus.yml совпадают с именами Docker-контейнеров. Если ты используешь сервисы под управлением systemd, замени docker restart на systemctl restart.

Команды используют SIGTERM (kill -15), а не SIGKILL. Процесс получает возможность корректно завершиться. subprocess.run имеет таймаут 60 секунд, чтобы предотвратить зависание восстановления.

Как реализовать уровень безопасности с белыми списками и одобрением человека?

Уровень безопасности обрабатывает весь вывод LLM как недоверенный. Три механизма предотвращают опасные действия: белый список действий отклоняет любую команду, не определённую заранее в коде, Pydantic-валидатор перехватывает некорректные ответы до того, как они попадут в движок, а шлюз одобрения Discord блокирует деструктивные действия до реакции человека.

Эшелонированная защита

У системы четыре уровня защиты:

  1. Контроль схемы. Параметр format Ollama ограничивает модель JSON-схемой. Модель не может вернуть произвольные команды.
  2. Pydantic-валидация. Модель Diagnosis проверяет, что recommended_action существует в ALLOWED_ACTIONS. Невалидные действия вызывают ValueError, и алерт переходит на запасной вариант на основе hint.
  3. Белый список действий. Движок восстановления выполняет только команды из ALLOWED_ACTIONS. Никакого динамического построения команд. Никакой строковой интерполяции, кроме имени сервиса (которое само проверяется по списку).
  4. Одобрение человека. Действия с requires_approval: True отправляют уведомление в Discord и не выполняются. Отдельный обработчик одобрений (за рамками этого туториала) слушает реакции в Discord и запускает выполнение.

Режим dry-run

Установи SENTINEL_DRY_RUN=true в /etc/sentinel/env. Приёмник логирует, что бы он сделал, ничего не выполняя:

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

Читай аудит-лог:

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

Работай в режиме dry-run минимум неделю перед включением боевого восстановления. Просматривай каждое залогированное действие. Убедись, что LLM рекомендует адекватные действия для твоих реальных алертов.

Процесс одобрения через Discord

Когда срабатывает деструктивное действие, приёмник постит embed в твой 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.

Без настроенного Discord-вебхука деструктивные действия полностью блокируются. Это безопасное поведение по умолчанию. Отсутствие вебхука означает, что деструктивные действия не выполняются, а не что они выполняются без одобрения.

Что такое sentinel pattern и зачем он нужен?

Sentinel pattern — это автономный cron-сторожевик, который мониторит сам стек мониторинга. Если Prometheus упал, никто не сработает алерт, потому что система алертов лежит. Sentinel-скрипт проверяет, работают ли Prometheus, Alertmanager и вебхук-приёмник, и перезапускает их, если нет. Это ответ на вопрос «кто сторожит сторожей».

Создай ~/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

Добавь cron-задачу, которая запускается каждые 2 минуты. Замени /root на свою реальную домашнюю директорию:

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

У сторожевика нет зависимостей кроме bash и Docker CLI. Он работает, даже если Python, Ollama или весь стек мониторинга лежит. Для этого он и существует: это единственный компонент, который не зависит ни от одного другого компонента.

Проверь лог сторожевика:

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

Как протестировать цикл самовосстановления от начала до конца?

Симулируй реальные сбои, чтобы убедиться, что полный цикл работает: алерт срабатывает, вебхук получает, Ollama диагностирует, восстановление выполняется (или логируется в режиме dry-run). Каждый тест запускай с SENTINEL_DRY_RUN=true, чтобы ничего реально не выполнялось, пока не проверишь диагноз.

Тест 1: сервис упал

Запусти тестовый контейнер и останови его:

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

Добавь scrape-цель в prometheus/prometheus.yml для теста (или просто останови контейнер node-exporter):

docker stop node-exporter

Подожди 1-2 минуты, пока сработает алерт ServiceDown. Следи за аудит-логом:

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

Перезапусти node-exporter вручную для разрешения алерта:

docker start node-exporter

Тест 2: давление на диск

Создай большой файл, чтобы загрузка диска превысила 85%:

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

Подожди алерт DiskSpaceLow (порог 5 минут). LLM должна диагностировать проблему и рекомендовать prune_docker_images. Убедившись по dry-run логу, удали файл:

rm /tmp/disk-pressure-test

Тест 3: давление на память

Используй stress-ng для потребления памяти:

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

Сработает алерт MemoryHigh. LLM должна рекомендовать kill_top_memory_process, которое требует одобрения через Discord. Убедись, что сообщение с запросом одобрения появилось в Discord.

Останови стресс-тест:

killall stress-ng

Переключение в боевой режим

Когда каждый dry-run тест выдаёт правильные диагнозы и подходящие рекомендации:

  1. Отредактируй /etc/sentinel/env и установи SENTINEL_DRY_RUN=false
  2. Перезапусти сервис: sudo systemctl restart sentinel.service
  3. Снова проведи тест с падением сервиса. Контейнер node-exporter должен автоматически перезапуститься через docker restart.

Продолжай мониторить /var/log/sentinel/audit.jsonl в первые дни. Каждое действие логируется с полным контекстом для разбора инцидентов.

Устранение проблем

Ollama не отвечает. Проверь, что Ollama запущена: systemctl status ollama. Проверь, что модель скачана: ollama list. Если модель ещё не загружена, первый запрос занимает 10-20 секунд на загрузку в RAM.

Alertmanager не может достучаться до вебхука. Убедись, что приёмник слушает: curl http://127.0.0.1:5001/health. Проверь логи Alertmanager: docker logs alertmanager. Убедись, что host.docker.internal резолвится изнутри контейнера Alertmanager: docker exec alertmanager wget -q -O- http://host.docker.internal:5001/health.

Алерты не срабатывают. Проверь, что Prometheus загрузил правила: curl http://127.0.0.1:9090/api/v1/rules | python3 -m json.tool. Убедись, что scrape-цели активны. Условие for: 5m означает, что условие должно быть истинным 5 непрерывных минут до срабатывания алерта.

Ошибки Pydantic-валидации. Модель выдала вывод, не соответствующий схеме. Проверь сырой ответ Ollama в журнале: journalctl -u sentinel.service -f. Попробуй другую модель. qwen2.5:7b — самая надёжная для структурированного вывода на ограниченном железе.

Сторожевик не работает. Проверь cron-запись: sudo crontab -l. Проверь cron-демон: systemctl status cron. Проверь, существует ли лог сторожевика: ls -la /var/log/sentinel/watchdog.log.

Логи для каждого компонента:

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

Дальнейшие шаги

Sentinel pattern обрабатывает реактивное восстановление. Для проактивного мониторинга передавай логи в LLM для обнаружения паттернов до срабатывания алертов.

Если расширяешь движок восстановления для запуска более сложных скриптов, изолируй их в песочнице. Никогда не позволяй процессу под управлением LLM работать с неограниченным доступом к системе.

Стек мониторинга здесь покрывает один VPS. Для Docker-сетапов с несколькими сервисами добавь cAdvisor как scrape-цель для мониторинга метрик CPU, памяти и сети на уровне контейнеров.


Авторское право 2026 Virtua.Cloud. Все права защищены. Данный контент является оригинальным произведением команды Virtua.Cloud. Воспроизведение, повторная публикация или распространение без письменного разрешения запрещены.

Готовы попробовать?

Разверните свой сервер за секунды. Linux, Windows или FreeBSD.

Смотреть тарифы VPS