Files
xiaoxia-saas/scripts/ci/runner_monitor/alert_manager.py
T
xiaoxia 6eac0b2cf2
Worker Base Image Build / Build Worker Base Images (worker-base-builder-cache, infra/docker/worker-base-builder.Dockerfile, worker-base-builder, builder) (push) Failing after 1m54s
Worker Base Image Build / Build Worker Base Images (worker-base-runtime-cache, infra/docker/worker-base-runtime.Dockerfile, worker-base-runtime, runtime) (push) Failing after 1m36s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m29s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m4s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m2s
CI/CD Pipeline / Unit Tests (push) Successful in 3m38s
CI/CD Pipeline / Integration Tests (push) Successful in 2m0s
CI/CD Pipeline / Frontend Lint (push) Successful in 28s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 44s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Failing after 2m16s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 8m14s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 2m0s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
chore(ci): 同步main分支CI配置与scripts/ci脚本 - 与develop对齐
同步内容:
1. CI流水线配置(ci-pipeline.yml)与develop对齐
2. PR构建脚本docker_build_only.sh增加buildx→docker build回退
3. pre-build步骤worker基础镜像构建增加buildx回退
4. 单元测试脚本全量覆盖率改为仅报告不阻塞
5. diff-cover依赖加入requirements-dev.txt
6. worker base builder/runtime Dockerfile同步
7. test_config_oss.py clear=False→clear=True修复OSS污染
8. Frontend Lint增加prettier依赖
2026-07-24 10:36:29 +08:00

493 lines
18 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.
#!/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())