Maksim Totmin bc437cbf8c feat: добавил систему мониторинга Airflow DAG-ов с интеграцией в Zabbix
- автообнаружение Docker Airflow через docker inspect и .env
- мониторинг failed и long-running DAG-запусков с автоматическим retry
- экспорт данных в файлы для Zabbix Agent через UserParameter
- офлайн-сборка ZIP-архива для закрытых контуров
- Zabbix шаблоны для 5.x (XML) и 6.x+ (YAML)
- systemd сервис с graceful shutdown и lock file
2026-04-08 17:23:03 +07:00

245 lines
8.7 KiB
Python

"""Action handlers: retry DAG runs and export data for Zabbix agent."""
import json
import logging
import os
import pathlib
import tempfile
import time
from datetime import datetime, timezone
from .analyzer import DagIssue
from .client import AirflowClient
from .config import MonitorConfig, ZabbixConfig
from .state import StateManager
logger = logging.getLogger(__name__)
class DataExporter:
"""Exports monitoring data to files for Zabbix agent UserParameter.
Files written:
- problems.json — JSON array of problematic DAG runs (or [])
- heartbeat — epoch timestamp (updated every cycle)
- status.json — overall monitor status (cycle count, DAG count, etc.)
Zabbix agent reads these via UserParameter defined in
/etc/zabbix/zabbix_agentd.conf.d/airflow-monitor.conf
"""
def __init__(self, config: ZabbixConfig):
self._config = config
self._data_dir = pathlib.Path(config.data_dir)
self._problems_path = self._data_dir / config.problems_file
self._heartbeat_path = self._data_dir / config.heartbeat_file
self._status_path = self._data_dir / config.status_file
logger.debug(
"DataExporter initialized: enabled=%s, data_dir=%s, "
"problems=%s, heartbeat=%s, status=%s",
config.enabled, self._data_dir,
self._problems_path, self._heartbeat_path, self._status_path,
)
if config.enabled:
self._data_dir.mkdir(parents=True, exist_ok=True)
def _atomic_write(self, path: pathlib.Path, content: str):
"""Write content to file atomically (write tmp → rename)."""
try:
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp",
)
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(content)
os.replace(tmp_path, str(path))
logger.debug(
"File written: %s (%d bytes)",
path, len(content),
)
except OSError as e:
logger.error("Failed to write %s: %s", path, e)
try:
os.unlink(tmp_path)
except OSError:
pass
def export_problems(self, problems: list[dict]) -> bool:
"""Write problem list as JSON file for Zabbix agent.
Writes '[]' if no problems (Zabbix trigger auto-clears).
"""
if not self._config.enabled:
logger.debug("DataExporter disabled, skipping export")
return True
payload = json.dumps(problems, indent=2, ensure_ascii=False)
logger.debug(
"Exporting problems: count=%d, size=%d bytes → %s",
len(problems), len(payload), self._problems_path,
)
self._atomic_write(self._problems_path, payload)
return True
def export_heartbeat(self):
"""Write current epoch timestamp for Zabbix agent heartbeat check."""
if not self._config.enabled:
return
epoch = str(int(time.time()))
logger.debug("Exporting heartbeat: %s%s", epoch, self._heartbeat_path)
self._atomic_write(self._heartbeat_path, epoch)
def export_status(self, cycle_count: int, dag_count: int,
issue_count: int, cycle_time: float):
"""Write overall monitor status for Zabbix agent."""
if not self._config.enabled:
return
status = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"cycle_count": cycle_count,
"dag_count": dag_count,
"issue_count": issue_count,
"cycle_time_seconds": round(cycle_time, 2),
}
payload = json.dumps(status, indent=2, ensure_ascii=False)
logger.debug(
"Exporting status: cycle=#%d, dags=%d, issues=%d, time=%.1fs → %s",
cycle_count, dag_count, issue_count, cycle_time, self._status_path,
)
self._atomic_write(self._status_path, payload)
class ActionHandler:
"""Handles issue resolution: retry or escalate via data export."""
def __init__(
self,
client: AirflowClient,
state: StateManager,
exporter: DataExporter,
config: MonitorConfig,
):
self._client = client
self._state = state
self._exporter = exporter
self._config = config
logger.debug(
"ActionHandler initialized: max_retries=%d, retry_wait=%ds",
config.max_retries, config.retry_wait,
)
def handle_issue(self, issue: DagIssue) -> str:
"""Process a single issue.
Returns:
"already_handled" - previously alerted, skip
"retried" - retry initiated, needs re-check
"needs_alert" - retry exhausted, alert needed
"""
dag_id = issue.dag_id
run_id = issue.dag_run_id
# Ensure entry exists in state
self._state.ensure_entry(dag_id, run_id)
retry_count = self._state.get_retry_count(dag_id, run_id)
is_alerted = self._state.is_alerted(dag_id, run_id)
logger.debug(
"Handling issue: %s/%s type=%s state=%s duration=%.0fs "
"retry_count=%d/%d alerted=%s",
dag_id, run_id, issue.issue_type, issue.state,
issue.duration_seconds, retry_count, self._config.max_retries,
is_alerted,
)
# Already alerted? Skip.
if is_alerted:
logger.debug(" → already_handled: alert was sent previously")
return "already_handled"
# Can we retry?
if retry_count < self._config.max_retries:
logger.debug(
" → attempting retry %d/%d via clear_dag_run",
retry_count + 1, self._config.max_retries,
)
success = self._client.clear_dag_run(dag_id, run_id)
if success:
self._state.increment_retry(dag_id, run_id)
logger.info(
"Retry %d/%d initiated for %s/%s (%s, duration=%.0fs)",
retry_count + 1, self._config.max_retries,
dag_id, run_id, issue.issue_type, issue.duration_seconds,
)
return "retried"
else:
logger.warning(
"Retry failed for %s/%s (API error), escalating to alert",
dag_id, run_id,
)
return "needs_alert"
# Retries exhausted
logger.debug(
" → needs_alert: retries exhausted (%d/%d)",
retry_count, self._config.max_retries,
)
return "needs_alert"
def collect_and_alert(self, issues: list[DagIssue]):
"""Export alerts for issues that need alerting.
Filters to only unalerted issues, builds JSON payload,
writes to file for Zabbix agent, and marks as alerted in state.
"""
to_alert = []
for issue in issues:
if not self._state.is_alerted(issue.dag_id, issue.dag_run_id):
entry = self._state.ensure_entry(issue.dag_id, issue.dag_run_id)
alert_data = {
"dag_id": issue.dag_id,
"dag_run_id": issue.dag_run_id,
"issue_type": issue.issue_type,
"status": issue.state,
"duration_seconds": issue.duration_seconds,
"error_info": issue.error_info,
"retry_count": entry["retry_count"],
}
to_alert.append(alert_data)
logger.debug(
"Issue queued for alert: %s/%s type=%s retries=%d",
issue.dag_id, issue.dag_run_id,
issue.issue_type, entry["retry_count"],
)
if not to_alert:
# Write empty list to clear Zabbix trigger
self._exporter.export_problems([])
logger.info("No issues to alert, exported empty problem list")
return
logger.warning(
"Exporting %d problematic DAG runs for Zabbix: %s",
len(to_alert),
", ".join(
f"{p['dag_id']}/{p['dag_run_id']}({p['issue_type']})"
for p in to_alert
),
)
# Log full alert payload at debug level
logger.debug("Alert payload:\n%s", json.dumps(to_alert, indent=2, ensure_ascii=False))
self._exporter.export_problems(to_alert)
for issue in issues:
if not self._state.is_alerted(issue.dag_id, issue.dag_run_id):
self._state.mark_alerted(issue.dag_id, issue.dag_run_id)
logger.debug("Marked as alerted: %s/%s", issue.dag_id, issue.dag_run_id)
logger.info("Exported %d issues to problems file for Zabbix agent", len(to_alert))