feat(ci): P2-3 Runner监控告警 - 离线/磁盘/内存/队列积压主动告警 (#537)
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 / Production Browser E2E (push) Failing after 1434h33m15s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1434h33m17s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1434h33m15s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 1434h33m51s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1434h33m17s
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1m55s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1435h5m20s
CI/CD Pipeline / Frontend Lint (push) Has been skipped

Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
This commit was merged in pull request #537.
This commit is contained in:
2026-07-18 20:55:43 +08:00
committed by auto-approve-bot
parent 0b60c43818
commit efc79b4457
5 changed files with 998 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Runner 状态巡检 - 调 Gitea API 查 runner 列表 + 状态 + 队列积压
功能:
- 获取所有 runner 的在线状态、忙闲状态
- 检测离线/禁用 runner
- 检测 CI 队列积压(pending 数量 + 持续时间)
- 生成 runner 状态快照
说明:
Gitea Actions API 直接返回的 runner 信息不包含心跳时间,
因此"离线超过N分钟"的判断通过以下方式近似:
1. status != "online" 的 runner 直接判定为离线
2. busy=true 且长时间无 job 完成的 runner 标记为疑似挂起(待增强)
3. 通过 pending job 数量和时长判断队列积压
用法:
python3 scripts/ci/runner_monitor/runner_status.py --check
python3 scripts/ci/runner_monitor/runner_status.py --snapshot
python3 scripts/ci/runner_monitor/runner_status.py --list
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
# 复用 chatops 的 GiteaClient
_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 chatops.gitea_client import GiteaClient # noqa: E402
class RunnerStatusChecker:
"""Runner 状态巡检器"""
def __init__(self, gitea_client=None):
self.gitea = gitea_client or GiteaClient()
# ── Runner 列表 ──────────────────────────────────
def get_runners(self):
"""获取仓库所有 runner 列表
Returns:
list[dict]: runner 列表
"""
# 直接调用 Gitea Actions runners API
data = self.gitea._request("actions/runners")
if not data:
return []
return data.get("runners", [])
def get_runner_summary(self):
"""获取 runner 汇总信息
Returns:
dict: {total, online, offline, busy, disabled, runners}
"""
runners = self.get_runners()
if not runners:
return {
"total": 0,
"online": 0,
"offline": 0,
"busy": 0,
"disabled": 0,
"runners": [],
}
online = sum(1 for r in runners if r.get("status") == "online" and not r.get("disabled"))
offline = sum(1 for r in runners if r.get("status") != "online" and not r.get("disabled"))
busy = sum(1 for r in runners if r.get("busy"))
disabled = sum(1 for r in runners if r.get("disabled"))
return {
"total": len(runners),
"online": online,
"offline": offline,
"busy": busy,
"disabled": disabled,
"runners": runners,
}
def get_offline_runners(self, offline_minutes=5):
"""获取离线的 runner 列表
由于 Gitea API 不返回心跳时间,status != online 即视为离线。
offline_minutes 参数保留用于后续 SSH 心跳检测增强。
Returns:
list[dict]: 离线 runner 列表
"""
runners = self.get_runners()
if not runners:
return []
offline = [r for r in runners if not r.get("disabled") and r.get("status") != "online"]
# 补充离线时长字段(暂时用 None,后续增强)
for r in offline:
r["offline_minutes"] = None
r["offline_reason"] = f"status={r.get('status', 'unknown')}"
return offline
# ── 队列积压检测 ──────────────────────────────────
def get_pending_runs(self):
"""获取 pending / queued 状态的 workflow runs
Returns:
list[dict]: pending run 列表
"""
# 尝试多种状态名(Gitea 可能用 queued / pending / waiting)
pending = []
for status in ["queued", "pending", "waiting"]:
runs, _ = self.gitea.list_runs(status=status, limit=50)
pending.extend(runs)
# 去重
seen = set()
unique = []
for r in pending:
rid = r.get("id")
if rid and rid not in seen:
seen.add(rid)
unique.append(r)
return unique
def get_queue_backlog(self, pending_threshold=10, duration_minutes=10):
"""检测队列积压
Args:
pending_threshold: pending 数量阈值
duration_minutes: 持续时间阈值(分钟)
Returns:
dict: {is_backlogged, pending_count, oldest_pending_minutes, pending_runs}
"""
pending = self.get_pending_runs()
if not pending:
return {
"is_backlogged": False,
"pending_count": 0,
"oldest_pending_minutes": 0,
"pending_runs": [],
}
now = datetime.now(timezone.utc)
oldest_minutes = 0
for r in pending:
created = r.get("created_at", "")
if not created:
continue
try:
t = datetime.fromisoformat(created.replace("Z", "+00:00"))
age = (now - t).total_seconds() / 60
oldest_minutes = max(oldest_minutes, age)
except Exception:
pass
is_backlogged = len(pending) >= pending_threshold and oldest_minutes >= duration_minutes
return {
"is_backlogged": is_backlogged,
"pending_count": len(pending),
"oldest_pending_minutes": round(oldest_minutes, 1),
"pending_runs": pending,
}
# ── 综合巡检 ──────────────────────────────────────
def run_full_check(self, offline_minutes=5, pending_threshold=10, pending_duration=10):
"""执行完整的 runner 巡检
Returns:
dict: 巡检结果
"""
summary = self.get_runner_summary()
offline_runners = self.get_offline_runners(offline_minutes=offline_minutes)
backlog = self.get_queue_backlog(
pending_threshold=pending_threshold,
duration_minutes=pending_duration,
)
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"runner_summary": {
"total": summary["total"],
"online": summary["online"],
"offline": summary["offline"],
"busy": summary["busy"],
"disabled": summary["disabled"],
},
"offline_runners": [
{
"id": r.get("id"),
"name": r.get("name"),
"status": r.get("status"),
"busy": r.get("busy"),
"labels": [label.get("name") for label in r.get("labels", [])],
"offline_minutes": r.get("offline_minutes"),
"offline_reason": r.get("offline_reason"),
}
for r in offline_runners
],
"queue_backlog": {
"is_backlogged": backlog["is_backlogged"],
"pending_count": backlog["pending_count"],
"oldest_pending_minutes": backlog["oldest_pending_minutes"],
},
"issues_found": len(offline_runners) > 0 or backlog["is_backlogged"],
}
# ── 快照输出 ──────────────────────────────────────
def save_snapshot(self, output_dir=None, data=None):
"""保存状态快照为 JSON 文件
Returns:
str: 快照文件路径
"""
if data is None:
data = self.run_full_check()
if output_dir is None:
from runner_monitor import config
output_dir = config.OUTPUT_DIR
os.makedirs(output_dir, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
filename = f"runner_snapshot_{ts}.json"
filepath = os.path.join(output_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# 清理旧快照(保留最近 24 个)
self._cleanup_old_snapshots(output_dir, keep=24)
return filepath
@staticmethod
def _cleanup_old_snapshots(directory, keep=24):
"""清理旧快照文件"""
try:
files = sorted(
[f for f in os.listdir(directory) if f.startswith("runner_snapshot_")],
reverse=True,
)
for old in files[keep:]:
os.remove(os.path.join(directory, old))
except OSError:
pass
# ── CLI 入口 ──────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Runner 状态巡检")
parser.add_argument("--list", action="store_true", help="列出所有 runner")
parser.add_argument("--check", action="store_true", help="执行完整巡检")
parser.add_argument("--snapshot", action="store_true", help="生成快照 JSON")
parser.add_argument("--pending", action="store_true", help="查看 pending 队列")
parser.add_argument("--output-dir", help="快照输出目录")
args = parser.parse_args()
checker = RunnerStatusChecker()
if args.list:
summary = checker.get_runner_summary()
print(
f"Runner 总览: {summary['online']}/{summary['total']} 在线, "
f"{summary['busy']} 忙碌, {summary['offline']} 离线, "
f"{summary['disabled']} 禁用"
)
print()
for r in summary["runners"]:
status_icon = "🟢" if r.get("status") == "online" else "🔴"
if r.get("disabled"):
status_icon = "⚪"
busy_icon = "⚡" if r.get("busy") else " "
labels = ", ".join(label.get("name") for label in r.get("labels", [])[:4])
print(f" {status_icon}{busy_icon} {r['name']:<30} {r.get('status', '?'):<10} labels: {labels}")
elif args.pending:
pending = checker.get_pending_runs()
print(f"Pending runs: {len(pending)}")
for r in pending[:10]:
print(
f" #{r.get('id')} {r.get('name', '?')} - {r.get('status', '?')} "
f"({r.get('head_branch', '?')}) created: {r.get('created_at', '?')[:16]}"
)
elif args.check:
result = checker.run_full_check()
print(json.dumps(result, indent=2, ensure_ascii=False))
elif args.snapshot:
path = checker.save_snapshot(output_dir=args.output_dir)
print(f"快照已保存: {path}")
else:
parser.print_help()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())