Maksim Totmin 7394b14b52 Initial commit: ses-monitor watchdog for SES
Универсальный watchdog-мониторинг Service Engine Server (SES):
- Liveness-проверки (API, лицензия, роботы)
- Диалоговые сценарии из YAML с агрегацией в 6 фиксированных Zabbix-метрик
- Доставка: zabbix_sender (active push) или UserParameter (пассивный опрос)
- Поддержка Python 3.7+, Zabbix 5.0
2026-06-19 20:26:26 +07:00

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Главный цикл watchdog-демона для мониторинга SES.
Архитектура:
- Бесконечный asyncio-цикл с тремя параллельными задачами:
1. liveness_task: быстрые проверки каждые 30 секунд.
2. scenarios_task: запуск сценариев по их индивидуальному расписанию.
3. archive_task: анализ архива каждые 10 минут.
- Hot-reload сценариев из каталога каждые 60 секунд.
- Все метрики отправляются в Zabbix единым батчем после проверок.
- Graceful shutdown по SIGTERM/SIGINT.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Set
from ses_monitor.config import (
AppConfig,
ScenarioConfig,
ZabbixMode,
load_app_config,
load_scenarios,
)
from ses_monitor.utils import setup_logging
from ses_monitor.session_client import SESClient
from ses_monitor.checker import LivenessChecker
from ses_monitor.scenario_runner import ScenarioRunner, ScenarioResult, aggregate_scenario_results
from ses_monitor.log_client import LogChecker
from ses_monitor.zabbix import ZabbixSender, UserParameterWriter
log = logging.getLogger("ses_monitor")
class SESMonitorApp:
"""Главный класс watchdog-приложения."""
def __init__(self, config: AppConfig):
self._config = config
# HTTP-клиент (один на всё приложение)
self._client = SESClient(config.ses)
# Компоненты — выбираем способ доставки в Zabbix по конфигу
self._zabbix = self._create_zabbix_sender(config)
self._liveness = LivenessChecker(config.ses)
self._log_checker = LogChecker()
# Сценарии загружаются динамически (hot-reload)
self._scenarios: List[ScenarioConfig] = []
self._scenario_runner: Optional[ScenarioRunner] = None
self._scenarios_dir = config.monitoring.scenarios_dir
# Отслеживание времени последнего запуска сценариев
self._last_scenario_run: Dict[str, float] = {}
# Флаг graceful shutdown — создаётся в start() внутри цикла (Python 3.7 фикс)
# Эталонный список роботов (заполняется при первом успешном запросе)
self._known_robots: Set[str] = set()
@staticmethod
def _create_zabbix_sender(config: AppConfig):
"""Фабрика: создаёт ZabbixSender или UserParameterWriter по конфигу."""
if config.zabbix.mode == ZabbixMode.USERPARAMETER:
log.info(
"Режим доставки: UserParameter (файл: %s)",
config.zabbix.metrics_file,
)
return UserParameterWriter(config.zabbix)
log.info(
"Режим доставки: zabbix_sender (сервер: %s:%d, хост: %s)",
config.zabbix.server,
config.zabbix.port,
config.zabbix.host,
)
return ZabbixSender(config.zabbix)
async def start(self) -> None:
"""Запускает watchdog-цикл."""
zbx_cfg = self._config.zabbix
if zbx_cfg.enabled:
if zbx_cfg.mode == ZabbixMode.USERPARAMETER:
log.info(
"SES Monitor v%s запускается (SES: %s, Zabbix UP: %s)",
__import__("ses_monitor").__version__,
self._config.ses.base_url,
zbx_cfg.metrics_file,
)
else:
log.info(
"SES Monitor v%s запускается (SES: %s, Zabbix: %s:%d, хост: %s)",
__import__("ses_monitor").__version__,
self._config.ses.base_url,
zbx_cfg.server, zbx_cfg.port, zbx_cfg.host,
)
else:
log.info("SES Monitor v%s запускается (SES: %s, Zabbix отключён)",
__import__("ses_monitor").__version__,
self._config.ses.base_url)
# Создаём Event УЖЕ внутри цикла (Python 3.7 фик: иначе self._loop
# привязывается к умолчательному циклу, а не к текущему)
self._shutdown_event = asyncio.Event()
# Привязываем child watcher к текущему циклу (Python 3.7-3.11)
loop = asyncio.get_running_loop()
if sys.platform != "win32" and sys.version_info < (3, 12):
watcher = asyncio.get_child_watcher()
if hasattr(watcher, "attach_loop"):
watcher.attach_loop(loop)
# Регистрируем обработчики сигналов
for sig in (signal.SIGTERM, signal.SIGINT):
try:
loop.add_signal_handler(sig, self._handle_shutdown)
except NotImplementedError:
# Сигналы не поддерживаются (например, Windows)
pass
try:
# Основной цикл
await self._main_loop()
except asyncio.CancelledError:
pass
finally:
await self._shutdown()
def _handle_shutdown(self) -> None:
"""Обработчик SIGTERM/SIGINT."""
log.info("Получен сигнал завершения, останавливаемся...")
self._shutdown_event.set()
async def _shutdown(self) -> None:
"""Graceful shutdown: закрываем клиент, сбрасываем метрики."""
log.info("Завершение работы...")
if self._zabbix.enabled:
# Отправляем последний батч метрик если есть
await self._zabbix.flush()
await self._client.close()
log.info("SES Monitor остановлен.")
async def _load_scenarios(self) -> None:
"""Загружает или перезагружает сценарии (hot-reload)."""
try:
scenarios = load_scenarios(self._scenarios_dir)
self._scenarios = scenarios
if self._scenario_runner is None:
self._scenario_runner = ScenarioRunner(
self._config.ses, scenarios
)
else:
self._scenario_runner.update_scenarios(scenarios)
log.debug("Загружено %d сценариев", len(scenarios))
except Exception as e:
log.error("Ошибка загрузки сценариев: %s", e)
async def _main_loop(self) -> None:
"""Основной цикл: liveness, сценарии, архив."""
# Первичная загрузка сценариев
await self._load_scenarios()
# Временные метки для планирования
last_hot_reload = time.monotonic()
while not self._shutdown_event.is_set():
now = time.monotonic()
# ---------- 1. Liveness-проверка ----------
await self._run_liveness()
# ---------- 2. Сценарии по расписанию ----------
await self._run_due_scenarios(now)
# ---------- 3. Hot-reload сценариев ----------
if now - last_hot_reload >= 60.0:
await self._load_scenarios()
last_hot_reload = now
# ---------- 4. Логи (если включено) ----------
if self._config.monitoring.log_enabled:
# Логи проверяем реже — раз в 30 минут
if int(now) % 1800 < self._config.monitoring.liveness_interval:
await self._run_log_check()
# ---------- 6. Отправка метрик в Zabbix ----------
await self._zabbix.flush()
# Ждём или до следующего цикла liveness, или до shutdown
sleep_time = self._config.monitoring.liveness_interval
try:
await asyncio.wait_for(
self._shutdown_event.wait(), timeout=sleep_time
)
except asyncio.TimeoutError:
pass # Нормально: просто прошёл интервал
async def _run_liveness(self) -> None:
"""Выполняет быстрые liveness-проверки."""
try:
result = await self._liveness.check(self._client)
if self._zabbix.enabled:
self._zabbix.add("ses.api.liveness", int(result.api_ok))
self._zabbix.add("ses.api.response_time", round(result.api_response_time_ms))
self._zabbix.add("ses.license.ok", int(result.license_ok))
self._zabbix.add("ses.license.days_remaining", result.license_days_remaining)
if result.robots_count > 0:
self._zabbix.add("ses.robots.count", result.robots_count)
if result.robots_count > 0 and not self._known_robots:
log.info(
"Liveness: %d сценариев, API=%s, license=%s, days_left=%d, время=%0.0fms",
result.robots_count,
"OK" if result.api_ok else "FAIL",
"OK" if result.license_ok else "FAIL",
result.license_days_remaining,
result.api_response_time_ms,
)
if not result.api_ok:
log.error("Liveness FAIL: %s", result.api_error)
if not result.license_ok:
log.error("License FAIL: %s", result.license_error)
except Exception as e:
log.exception("Liveness: неожиданная ошибка: %s", e)
async def _run_due_scenarios(self, now: float) -> None:
"""Запускает сценарии, у которых подошло время.
Все результаты собираются и агрегируются в фиксированный набор
Zabbix-метрик (ses.scenario.ok, .total, .failed, .avg_time, .max_time,
.error). Это делает шаблон Zabbix универсальным — не нужно добавлять
отдельный item под каждый сценарий.
"""
if not self._scenario_runner:
return
due_results: List[ScenarioResult] = []
for scenario in self._scenarios:
name = scenario.name or "unknown"
last_run = self._last_scenario_run.get(name, 0)
if now - last_run >= scenario.interval:
try:
result = await self._scenario_runner.run_one(
self._client, scenario
)
due_results.append(result)
self._last_scenario_run[name] = now
if not result.passed:
log.warning(
"Сценарий '%s': ПРОВАЛЕН за %dms (ошибок: %d шагов)",
name,
round(result.total_time_ms),
len(result.failed_steps),
)
except Exception as e:
log.exception(
"Сценарий '%s': неожиданное исключение: %s", name, e
)
# Агрегируем результаты и отправляем фиксированные ключи
if due_results and self._zabbix.enabled:
agg = aggregate_scenario_results(due_results)
self._zabbix.add("ses.scenario.ok", agg["ok"])
self._zabbix.add("ses.scenario.total", agg["total"])
self._zabbix.add("ses.scenario.failed", agg["failed"])
self._zabbix.add("ses.scenario.avg_time", agg["avg_time"])
self._zabbix.add("ses.scenario.max_time", agg["max_time"])
if agg["error"]:
self._zabbix.add("ses.scenario.error", agg["error"])
async def _run_log_check(self) -> None:
"""Выполняет проверку логов (если включено)."""
try:
result = await self._log_checker.check(self._client)
if self._zabbix.enabled:
for key, count in result.error_patterns.items():
self._zabbix.add(f"ses.log.{key}", count)
except Exception as e:
log.exception("Log check: неожиданная ошибка: %s", e)
def main() -> int:
"""Точка входа в приложение.
Returns:
0 при успешном завершении, 1 при ошибке.
"""
try:
# Загружаем конфигурацию
config = load_app_config()
except FileNotFoundError as e:
print(f"ОШИБКА: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"ОШИБКА загрузки конфигурации: {e}", file=sys.stderr)
return 1
# Настраиваем логирование
logger = setup_logging(config.logging)
try:
# Python 3.7: инициализируем child watcher ДО создания цикла
if sys.platform != "win32":
asyncio.get_child_watcher()
app = SESMonitorApp(config)
asyncio.run(app.start())
return 0
except KeyboardInterrupt:
logger.info("Остановлено пользователем (Ctrl+C)")
return 0
except Exception as e:
logger.exception("Критическая ошибка: %s", e)
return 1