Files
xiaoxia efc79b4457
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m18s
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m14s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 16s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m19s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Waiting to run
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Waiting to run
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
feat(ci): P2-3 Runner监控告警 - 离线/磁盘/内存/队列积压主动告警 (#537)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 20:55:43 +08:00

493 lines
18 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""
告警调度器 - 阈值判断 + 去重 + 飞书通知
功能:
1. 从 runner_status 和 runner_metrics 获取数据
2. 根据阈值判断是否触发告警
3. 告警去重(同一问题 30 分钟内只报一次)
4. 飞书卡片通知(复用 chatops FeishuNotifier
5. 生成状态快照 JSON(供看板用)
告警规则:
P1(严重):
- Runner 离线超过 5 分钟
- 磁盘使用率 > 90%
P2(警告):
- 磁盘使用率 > 85%
- 内存使用率 > 90% 持续 5 分钟
- CI 队列积压 > 10 个 pending 超过 10 分钟
用法:
python3 scripts/ci/runner_monitor/alert_manager.py --check
python3 scripts/ci/runner_monitor/alert_manager.py --daemon # 持续运行
python3 scripts/ci/runner_monitor/alert_manager.py --snapshot
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
# 复用 chatops 的飞书通知
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
_CI_DIR = os.path.dirname(_SCRIPT_DIR)
if _CI_DIR not in sys.path:
sys.path.insert(0, _CI_DIR)
from runner_monitor import config # noqa: E402
from runner_monitor.runner_metrics import RunnerMetricsCollector # noqa: E402
from runner_monitor.runner_status import RunnerStatusChecker # noqa: E402
class Alert:
"""单条告警"""
def __init__(self, alert_id, level, title, description, details=None, source="runner_monitor"):
self.alert_id = alert_id # 唯一标识,用于去重
self.level = level # P1 / P2 / INFO
self.title = title
self.description = description
self.details = details or {}
self.source = source
self.timestamp = datetime.now(timezone.utc).isoformat()
def to_dict(self):
return {
"alert_id": self.alert_id,
"level": self.level,
"title": self.title,
"description": self.description,
"details": self.details,
"source": self.source,
"timestamp": self.timestamp,
}
class AlertManager:
"""告警调度器"""
def __init__(
self,
status_checker=None,
metrics_collector=None,
dedupe_window=None,
):
self.status_checker = status_checker or RunnerStatusChecker()
self.metrics = metrics_collector or RunnerMetricsCollector()
self.dedupe_window = dedupe_window or config.DEDUPE_WINDOW
# 告警历史: {alert_id: last_triggered_timestamp}
self._alert_history = {}
# 内存持续超阈值记录: {host: first_detected_timestamp}
self._mem_high_since = {}
# ── 告警检测 ──────────────────────────────────────
def detect_alerts(self):
"""执行所有检测规则,返回触发的告警列表
Returns:
list[Alert]: 新触发的告警(已去重)
"""
all_alerts = []
# 1. Runner 离线检测
all_alerts.extend(self._check_runner_offline())
# 2. 队列积压检测
all_alerts.extend(self._check_queue_backlog())
# 3. 系统指标检测(SSH,可能为空)
all_alerts.extend(self._check_system_metrics())
# 去重过滤
new_alerts = [a for a in all_alerts if self._should_alert(a)]
# 更新告警历史
for alert in new_alerts:
self._alert_history[alert.alert_id] = time.time()
return new_alerts
def _check_runner_offline(self):
"""检测离线 runner"""
offline = self.status_checker.get_offline_runners(offline_minutes=config.RUNNER_OFFLINE_MINUTES)
alerts = []
for runner in offline:
name = runner.get("name", "unknown")
runner_id = runner.get("id", "?")
alert_id = f"runner_offline_{runner_id}"
# Gitea API 没有心跳时间,status != online 就告警(P1
alerts.append(
Alert(
alert_id=alert_id,
level=config.P1,
title=f"Runner 离线: {name}",
description=(
f"Runner **{name}** (ID: {runner_id}) 状态为 "
f"{runner.get('status', 'unknown')},已离线\n"
f"标签: {', '.join(label.get('name') for label in runner.get('labels', [])[:5])}"
),
details={
"runner_id": runner_id,
"runner_name": name,
"status": runner.get("status"),
"labels": [label.get("name") for label in runner.get("labels", [])],
},
)
)
return alerts
def _check_queue_backlog(self):
"""检测队列积压"""
backlog = self.status_checker.get_queue_backlog(
pending_threshold=config.QUEUE_PENDING_COUNT,
duration_minutes=config.QUEUE_PENDING_MINUTES,
)
if not backlog["is_backlogged"]:
return []
count = backlog["pending_count"]
age = backlog["oldest_pending_minutes"]
alert_id = f"queue_backlog_{int(age // 30)}" # 每30分钟一个新告警id
return [
Alert(
alert_id=alert_id,
level=config.P2,
title="CI 队列积压",
description=(
f"当前有 **{count}** 个 pending run,最老的已等待 **{age:.0f} 分钟**\n"
f"阈值: >{config.QUEUE_PENDING_COUNT}个 且 超过{config.QUEUE_PENDING_MINUTES}分钟"
),
details={
"pending_count": count,
"oldest_pending_minutes": age,
},
)
]
def _check_system_metrics(self):
"""检测系统指标(磁盘/内存/CPU"""
metrics_list = self.metrics.collect_all()
if not metrics_list:
return []
alerts = []
now = time.time()
for m in metrics_list:
host = m.get("host", "unknown")
if m.get("status") != "ok":
continue
# 磁盘告警
disk_pct = m.get("disk_percent", 0)
if disk_pct and disk_pct >= config.DISK_CRIT_PERCENT:
alerts.append(
Alert(
alert_id=f"disk_crit_{host}",
level=config.P1,
title=f"磁盘使用率严重过高: {host}",
description=(
f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P1阈值: {config.DISK_CRIT_PERCENT}%)\n"
f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
),
details={"host": host, "disk_percent": disk_pct},
)
)
elif disk_pct and disk_pct >= config.DISK_WARN_PERCENT:
alerts.append(
Alert(
alert_id=f"disk_warn_{host}",
level=config.P2,
title=f"磁盘使用率过高: {host}",
description=(
f"服务器 **{host}** 磁盘使用率 **{disk_pct:.1f}%** (P2阈值: {config.DISK_WARN_PERCENT}%)\n"
f"已用: {m.get('disk_used_gb', '?')}G / {m.get('disk_total_gb', '?')}G"
),
details={"host": host, "disk_percent": disk_pct},
)
)
# 内存告警(持续 N 分钟)
mem_pct = m.get("mem_percent", 0)
mem_key = f"mem_high_{host}"
if mem_pct and mem_pct >= config.MEM_WARN_PERCENT:
if mem_key not in self._mem_high_since:
self._mem_high_since[mem_key] = now
else:
duration_min = (now - self._mem_high_since[mem_key]) / 60
if duration_min >= config.MEM_DURATION_MINUTES:
alerts.append(
Alert(
alert_id=f"mem_warn_{host}",
level=config.P2,
title=f"内存使用率持续过高: {host}",
description=(
f"服务器 **{host}** 内存使用率 **{mem_pct:.1f}%**"
f"已持续 **{duration_min:.0f} 分钟**\n"
f"阈值: {config.MEM_WARN_PERCENT}% 持续 {config.MEM_DURATION_MINUTES} 分钟"
),
details={"host": host, "mem_percent": mem_pct, "duration_min": duration_min},
)
)
else:
# 恢复了,清除记录
self._mem_high_since.pop(mem_key, None)
return alerts
# ── 去重 ──────────────────────────────────────────
def _should_alert(self, alert):
"""判断是否应该发送告警(去重 + 等级开关)"""
# 等级开关
if alert.level == config.P1 and not config.P1_ENABLED:
return False
if alert.level == config.P2 and not config.P2_ENABLED:
return False
# 去重窗口
last = self._alert_history.get(alert.alert_id, 0)
if time.time() - last < self.dedupe_window:
return False
return True
# ── 通知 ──────────────────────────────────────────
def send_alerts(self, alerts):
"""发送告警到飞书
复用 chatops 的 FeishuNotifier,这里直接构造卡片。
不依赖 FeishuNotifier 实例方法,因为告警卡片格式不同。
"""
if not alerts:
return 0
# 延迟导入
# 直接用 urllib 发,走同一个 webhook
import urllib.request
from chatops.feishu_notify import FeishuNotifier # noqa: F401
webhook_url = config.__dict__.get("FEISHU_WEBHOOK_URL", "")
if not webhook_url:
# 从 chatops config 拿
from chatops import config as chatops_config
webhook_url = chatops_config.FEISHU_WEBHOOK_URL
if not webhook_url:
print("[WARN] 未配置飞书 webhook,跳过告警通知")
return 0
sent = 0
for alert in alerts:
card = self._build_alert_card(alert)
payload = json.dumps({"msg_type": "interactive", "card": card}).encode("utf-8")
req = urllib.request.Request(
webhook_url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
body = resp.read().decode()
result = json.loads(body)
if result.get("code", 0) == 0:
sent += 1
print(f"[INFO] 告警已发送: [{alert.level}] {alert.title}")
else:
print(f"[WARN] 告警发送失败: {result.get('msg', body)}", file=sys.stderr)
except Exception as e:
print(f"[WARN] 告警发送异常: {e}", file=sys.stderr)
return sent
@staticmethod
def _build_alert_card(alert):
"""构建飞书告警卡片"""
color = config.LEVEL_COLOR.get(alert.level, "blue")
emoji = config.LEVEL_EMOJI.get(alert.level, "️")
fields = [
{
"is_short": True,
"text": {"tag": "lark_md", "content": f"**等级**\n{alert.level}"},
},
{
"is_short": True,
"text": {"tag": "lark_md", "content": f"**来源**\n{alert.source}"},
},
{
"is_short": False,
"text": {"tag": "lark_md", "content": f"**详情**\n{alert.description}"},
},
]
return {
"header": {
"title": {"tag": "plain_text", "content": f"{emoji} Runner监控告警: {alert.title}"},
"status": color,
},
"elements": [
{"tag": "div", "fields": fields},
{
"tag": "note",
"elements": [
{
"tag": "plain_text",
"content": f"告警ID: {alert.alert_id} | {alert.timestamp[:19].replace('T', ' ')}",
}
],
},
],
}
# ── 快照 ──────────────────────────────────────────
def generate_snapshot(self, alerts=None):
"""生成完整的监控快照
Returns:
dict: 快照数据
"""
status_result = self.status_checker.run_full_check()
metrics = self.metrics.collect_all()
if alerts is None:
alerts = self.detect_alerts()
snapshot = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"runner_summary": status_result["runner_summary"],
"offline_runners": status_result["offline_runners"],
"queue_backlog": status_result["queue_backlog"],
"system_metrics": metrics,
"active_alerts": [a.to_dict() for a in alerts],
"alert_history_count": len(self._alert_history),
}
return snapshot
def save_snapshot(self, output_dir=None):
"""保存快照到文件"""
from runner_monitor.runner_status import RunnerStatusChecker as RSC
snapshot = self.generate_snapshot()
if output_dir is None:
output_dir = config.OUTPUT_DIR
os.makedirs(output_dir, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
filepath = os.path.join(output_dir, f"monitor_snapshot_{ts}.json")
with open(filepath, "w", encoding="utf-8") as f:
json.dump(snapshot, f, indent=2, ensure_ascii=False)
# 清理旧快照
RSC._cleanup_old_snapshots(output_dir, keep=24)
return filepath
# ── 单次检查 ──────────────────────────────────────
def run_once(self):
"""执行一次完整检查 + 告警 + 快照
Returns:
dict: {alerts_count, sent_count, snapshot_path}
"""
alerts = self.detect_alerts()
sent = self.send_alerts(alerts)
snapshot_path = self.save_snapshot()
return {
"alerts_detected": len(alerts),
"alerts_sent": sent,
"snapshot_path": snapshot_path,
"alerts": [a.to_dict() for a in alerts],
}
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Runner 监控告警调度器")
parser.add_argument("--check", action="store_true", help="执行一次检查")
parser.add_argument("--snapshot", action="store_true", help="生成快照")
parser.add_argument("--daemon", action="store_true", help="持续运行模式")
parser.add_argument("--dry-run", action="store_true", help="只检测不发通知")
parser.add_argument("--interval", type=int, help="检测间隔(秒),覆盖环境变量")
args = parser.parse_args()
if args.interval:
config.CHECK_INTERVAL = args.interval
manager = AlertManager()
if args.daemon:
print(f"[INFO] Runner 监控告警服务启动,检测间隔 {config.CHECK_INTERVAL} 秒")
print(f"[INFO] P1告警: {'开启' if config.P1_ENABLED else '关闭'}")
print(f"[INFO] P2告警: {'开启' if config.P2_ENABLED else '关闭'}")
print(f"[INFO] 去重窗口: {config.DEDUPE_WINDOW} 秒")
while True:
try:
result = (
manager.run_once()
if not args.dry_run
else {
"alerts_detected": len(manager.detect_alerts()),
"alerts_sent": 0,
}
)
now = time.strftime("%Y-%m-%d %H:%M:%S")
print(
f"[{now}] 检测完成 - "
f"发现 {result['alerts_detected']} 个告警, "
f"发送 {result['alerts_sent']} 条通知"
)
except Exception as e:
print(f"[ERROR] 检测异常: {e}", file=sys.stderr)
time.sleep(config.CHECK_INTERVAL)
elif args.snapshot:
path = manager.save_snapshot()
print(f"快照已保存: {path}")
elif args.check or args.dry_run:
if args.dry_run:
alerts = manager.detect_alerts()
print(f"检测到 {len(alerts)} 个告警(dry-run,不发送):")
for a in alerts:
print(f" [{a.level}] {a.title}")
print(f" {a.description[:100]}")
else:
result = manager.run_once()
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
parser.print_help()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())