"""诊断规则实现(15 条)。 规则按类型分四组: 页面必跑(轻量): R001 / R002 / R003 / R004 / R005 DB 聚合: R006 / R007 / R008 日志匹配: R009-R014(需要 docker.sock) 容器状态: R015(需要 docker.sock) """ from __future__ import annotations import os import subprocess import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from sqlalchemy import func from app.config import get_settings from app.diagnostic.config import get_diag_config from app.diagnostic.engine import DiagnosticEngine, RuleResult # ====================================================================== # 模块级状态缓存(用于检测重启事件) # key: container_name # value: {"restart_count": int, "started_at": str, "checked_at": float} # ====================================================================== _CONTAINER_LAST_STATE: Dict[str, Dict[str, Any]] = {} def _ok(rule_id: str, message: str, hint: str = "", evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult: return RuleResult( id=rule_id, severity="ok", message=message, hint=hint, evidence=evidence or [], run_by=run_by, ) def _warn(rule_id: str, message: str, hint: str = "", evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult: return RuleResult( id=rule_id, severity="medium", message=message, hint=hint, evidence=evidence or [], run_by=run_by, ) def _crit(rule_id: str, message: str, hint: str = "", evidence: Optional[List[str]] = None, run_by: str = "") -> RuleResult: return RuleResult( id=rule_id, severity="high", message=message, hint=hint, evidence=evidence or [], run_by=run_by, ) def _skip(rule_id: str, reason: str, run_by: str = "") -> RuleResult: return RuleResult( id=rule_id, severity="info", message=f"规则未运行: {reason}", skipped=True, skip_reason=reason, run_by=run_by, ) # ====================================================================== # 工具函数 # ====================================================================== def _fetch_recent_verdict_counts(engine: DiagnosticEngine, limit: int = 100) -> Dict[str, int]: """查最近 N 次扫描的 verdict 分布。 用子查询先 limit 再 group_by,兼容 SQLAlchemy 2.0。 """ from sqlalchemy import select from app.models import ScanTask # 先子查询拿到最近 N 条 verdict sub_stmt = ( select(ScanTask.verdict) .order_by(ScanTask.created_at.desc()) .limit(limit) ).subquery() # 再在外层 group_by 统计 rows = engine.db.execute( select(sub_stmt.c.verdict, func.count(sub_stmt.c.verdict)) .group_by(sub_stmt.c.verdict) ).all() return {v or "unknown": int(c) for v, c in rows} def _count_24h(engine: DiagnosticEngine) -> int: from app.models import ScanTask cutoff = datetime.now(timezone.utc) - timedelta(hours=24) return ( engine.db.query(func.count(ScanTask.id)) .filter(ScanTask.created_at >= cutoff) .scalar() or 0 ) def _fetch_recent_tasks(engine: DiagnosticEngine, limit: int = 500) -> List[Any]: from app.models import ScanTask return ( engine.db.query(ScanTask) .order_by(ScanTask.created_at.desc()) .limit(limit) .all() ) def _docker_logs(container: str, tail: int = 500) -> str: """用 docker SDK 取容器日志。失败返回空字符串。""" try: from app.diagnostic.docker_probe import get_client client = get_client() if not client: return "" c = client.containers.get(container) raw = c.logs(tail=tail) return raw.decode("utf-8", errors="replace") if isinstance(raw, (bytes, bytearray)) else str(raw) except Exception: # noqa: BLE001 return "" def _docker_inspect(container: str) -> Optional[Dict[str, Any]]: """用 docker SDK 取容器状态元数据。失败返回 None。""" try: from app.diagnostic.docker_probe import get_client client = get_client() if not client: return None c = client.containers.get(container) attrs = c.attrs or {} state = attrs.get("State", {}) return { "status": state.get("Status", "unknown"), "restart_count": attrs.get("RestartCount", 0), "started_at": state.get("StartedAt", ""), "image": attrs.get("Config", {}).get("Image", ""), } except Exception: # noqa: BLE001 return None def _grep_patterns(text: str, patterns: List[str], max_lines: int = 20) -> List[str]: """从日志文本中抓取命中 pattern 的行。""" hits: List[str] = [] for line in text.splitlines(): low = line.lower() for p in patterns: if p.lower() in low: hits.append(line.strip()) break if len(hits) >= max_lines: break return hits # ====================================================================== # R001: error 比例 # ====================================================================== def check_r001_error_ratio(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: cfg = get_diag_config() counts = _fetch_recent_verdict_counts(engine, 100) total = sum(counts.values()) if total == 0: return _ok("R001", "暂无扫描记录", run_by=run_by) error_count = counts.get("error", 0) error_ratio = error_count / total threshold = cfg.error_ratio_threshold if error_ratio > threshold: return _crit( "R001", f"错误比例偏高({error_ratio:.1%} > 阈值 {threshold:.0%})", f"近 100 次扫描 {error_count} 次 error", hint="建议检查 ClamAV / Redis / Postgres 引擎健康", evidence=[f"近 100 次 verdict 分布: {counts}"], run_by=run_by, ) return _ok( "R001", f"错误比例正常({error_ratio:.1%},阈值 {threshold:.0%})", run_by=run_by, ) check_r001_error_ratio._rule_id = "R001" # ====================================================================== # R002: ClamAV 病毒库日期 # ====================================================================== def check_r002_clamav_db(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: cfg = get_diag_config() try: clamav_info = engine.scanner.health().get("clamav", {}) except Exception as e: # noqa: BLE001 return _skip("R002", f"ClamAV 健康检查异常: {e}", run_by=run_by) sig_date_str = clamav_info.get("signature_date") if not sig_date_str: available = clamav_info.get("available", clamav_info.get("ok", False)) if not available: return _crit( "R002", "ClamAV 引擎不可用", hint="检查 clamd 容器是否在运行", run_by=run_by, ) return _warn( "R002", "ClamAV 病毒库日期未知", hint="在线更新失败?U 盘离线导入", run_by=run_by, ) # signature_date 可能是字符串如 "2026-10-14" try: sig_date = datetime.strptime(str(sig_date_str)[:10], "%Y-%m-%d").date() except Exception: # noqa: BLE001 return _skip("R002", f"无法解析病毒库日期: {sig_date_str}", run_by=run_by) days_old = (datetime.now().date() - sig_date).days stale = cfg.clamav_db_stale_days if days_old > stale: return _warn( "R002", f"ClamAV 病毒库已 {days_old} 天未更新", hint="在线更新不可用 → U 盘离线导入 main.cvd / daily.cvd", evidence=[f"signature_date = {sig_date_str}"], run_by=run_by, ) return _ok( "R002", f"ClamAV 病毒库新鲜({sig_date_str},{days_old} 天)", run_by=run_by, ) check_r002_clamav_db._rule_id = "R002" # ====================================================================== # R003: YARA 规则 mtime # ====================================================================== def check_r003_yara_mtime(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: settings = get_settings() rules_dir = settings.rules_dir if not os.path.isdir(rules_dir): return _skip("R003", f"规则目录不存在: {rules_dir}", run_by=run_by) yamls = [f for f in os.listdir(rules_dir) if f.endswith((".yar", ".yara", ".rules"))] if not yamls: return _warn( "R003", "YARA 规则目录为空", hint="检查 rules_dir 配置是否正确", run_by=run_by, ) latest_mtime = max( os.path.getmtime(os.path.join(rules_dir, f)) for f in yamls ) latest_date = datetime.fromtimestamp(latest_mtime).date() days_old = (datetime.now().date() - latest_date).days # 对比策略版本发布时间(如果策略文件比规则新,说明规则可能没更新) policy_file = settings.policy_file policy_mtime = None if os.path.isfile(policy_file): policy_mtime = os.path.getmtime(policy_file) evidence = [f"规则目录: {rules_dir}", f"规则文件数: {len(yamls)}", f"最新规则 mtime: {latest_date}"] if policy_mtime and policy_mtime > latest_mtime: evidence.append(f"策略文件比规则新(策略: {datetime.fromtimestamp(policy_mtime).date()})") return _warn( "R003", "策略文件比 YARA 规则新,规则可能未随策略版本更新", hint="同步更新规则文件或确认规则已加载", evidence=evidence, run_by=run_by, ) return _ok( "R003", f"YARA 规则正常({len(yamls)} 条规则,最新 {latest_date},{days_old} 天)", evidence=evidence, run_by=run_by, ) check_r003_yara_mtime._rule_id = "R003" # ====================================================================== # R004: 引擎健康 # ====================================================================== def check_r004_engine_health(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: try: health = engine.scanner.health() except Exception as e: # noqa: BLE001 return _crit("R004", f"引擎健康检查异常: {e}", run_by=run_by) offline: List[str] = [] for name, info in health.items(): ok = info.get("ok", info.get("available", False)) if not ok: offline.append(name) if offline: return _crit( "R004", f"以下引擎不可用: {', '.join(offline)}", hint="检查对应容器状态和网络连通", evidence=[f"{n}: {health.get(n, {})}" for n in offline], run_by=run_by, ) names = list(health.keys()) return _ok( "R004", f"所有 {len(names)} 个引擎正常在线", run_by=run_by, ) check_r004_engine_health._rule_id = "R004" # ====================================================================== # R005: 24h 无扫描 # ====================================================================== def check_r005_no_scans(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: count = _count_24h(engine) if count == 0: return _warn( "R005", "过去 24 小时内没有扫描记录", hint="摆渡系统可能停了;如果是正常停工可以忽略", run_by=run_by, ) return _ok( "R005", f"过去 24 小时 {count} 次扫描", run_by=run_by, ) check_r005_no_scans._rule_id = "R005" # ====================================================================== # R006: suspicious > malicious # ====================================================================== def check_r006_suspicious_over_malicious(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: cfg = get_diag_config() if not cfg.suspicious_exceeds_malicious: return _skip("R006", "规则已在配置中禁用", run_by=run_by) counts = _fetch_recent_verdict_counts(engine, 200) suspicious = counts.get("suspicious", 0) malicious = counts.get("malicious", 0) if suspicious > malicious and suspicious > 0: return _warn( "R006", f"可疑数({suspicious})超过恶意数({malicious})", hint="suspicious 阈值配置可能过松;建议调紧让更多文件直接判为 malicious", evidence=[f"近 200 次 verdict 分布: {counts}"], run_by=run_by, ) return _ok( "R006", f"suspicious={suspicious} vs malicious={malicious}", run_by=run_by, ) check_r006_suspicious_over_malicious._rule_id = "R006" # ====================================================================== # R007: reject 比例 # ====================================================================== def check_r007_reject_ratio(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: cfg = get_diag_config() counts = _fetch_recent_verdict_counts(engine, 200) total = sum(counts.values()) if total == 0: return _ok("R007", "暂无扫描记录", run_by=run_by) reject = counts.get("reject", 0) ratio = reject / total threshold = cfg.reject_ratio_threshold if ratio > threshold: return _warn( "R007", f"策略拒绝比例偏高({ratio:.1%} > 阈值 {threshold:.0%})", hint="检查拒绝原因 TOP;如果只是正常扩展名策略可以调宽", evidence=[f"近 200 次 verdict 分布: {counts}"], run_by=run_by, ) return _ok( "R007", f"策略拒绝比例正常({ratio:.1%})", run_by=run_by, ) check_r007_reject_ratio._rule_id = "R007" # ====================================================================== # R008: 同 SHA256 重复扫描 # ====================================================================== def check_r008_duplicate_sha(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: from app.models import ScanTask cutoff = datetime.now(timezone.utc) - timedelta(hours=1) rows = ( engine.db.query(ScanTask.sha256, func.count(ScanTask.id)) .filter(ScanTask.created_at >= cutoff) .group_by(ScanTask.sha256) .having(func.count(ScanTask.id) > 1) .all() ) if rows: dups = [(sha[:12], c) for sha, c in rows] return _crit( "R008", f"近 1 小时有 {len(rows)} 个 SHA256 被重复扫描", hint="幂等机制可能失效;检查 idempotency_records 表写入", evidence=[f"sha256={sha} count={c}" for sha, c in dups[:10]], run_by=run_by, ) return _ok("R008", "幂等机制正常(无重复 SHA256)", run_by=run_by) check_r008_duplicate_sha._rule_id = "R008" # ====================================================================== # 日志匹配型规则(需要 docker.sock) # ====================================================================== def _check_docker_available(engine: DiagnosticEngine) -> Optional[RuleResult]: """如果 Docker 不可用,返回 skip RuleResult;否则返回 None(继续执行)。""" if engine.docker_status == "none": return _skip(None, "Docker 能力未启用", run_by="") return None def check_r009_clamav_crash(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R009" return skip cfg = get_diag_config() logs = _docker_logs("meshsafe-clamav", cfg.docker_log_tail) patterns = ["Killed", "exit 137", "segfault", "oom-killed", "Out of memory"] hits = _grep_patterns(logs, patterns) # 同时检查 docker inspect 的 restart_count info = _docker_inspect("meshsafe-clamav") restart_count = info.get("restart_count", 0) if info else 0 if hits or restart_count > 3: ev = hits[:5] if hits else [] if restart_count > 3: ev.append(f"docker inspect restart_count={restart_count}") return _crit( "R009", f"ClamAV 可能崩溃(restart_count={restart_count},日志命中 {len(hits)} 行)", hint="OOM kill → 给 clamav 容器加内存限制 512m → 1g;\nsegfault → 检查 ClamAV 版本兼容性", evidence=ev, run_by=run_by, ) return _ok( "R009", f"ClamAV 容器稳定(restart_count={restart_count},无崩溃日志)", run_by=run_by, ) check_r009_clamav_crash._rule_id = "R009" def check_r010_postgres_fatal(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R010" return skip cfg = get_diag_config() logs = _docker_logs("meshsafe-postgres", cfg.docker_log_tail) patterns = ["FATAL", "PANIC", "too many connections", "database is in recovery"] hits = _grep_patterns(logs, patterns) if hits: return _crit( "R010", f"Postgres 出现严重错误(命中 {len(hits)} 行)", hint="too many connections → 调大 max_connections;\nFATAL → 检查磁盘空间和 WAL", evidence=hits[:8], run_by=run_by, ) return _ok("R010", "Postgres 日志无 FATAL/PANIC", run_by=run_by) check_r010_postgres_fatal._rule_id = "R010" def check_r011_redis_oom(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R011" return skip cfg = get_diag_config() logs = _docker_logs("meshsafe-redis", cfg.docker_log_tail) # 注意:不能用 "LOADING" — Redis 正常启动也会打 "Loading RDB / Done loading" # 只匹配真正有问题的:加载卡死循环、OOM、panic、assertion patterns = [ "OOM command not allowed", "out of memory", "LOADING loop", "still loading", "LOADING state", "panic", "Assertion", ] hits = _grep_patterns(logs, patterns) if hits: return _crit( "R011", f"Redis 可能 OOM 或 panic(命中 {len(hits)} 行)", hint="OOM → 调大 maxmemory 或清缓存;\nLOADING 循环 → 检查 RDB/AOF 恢复", evidence=hits[:5], run_by=run_by, ) return _ok("R011", "Redis 日志无 OOM/panic", run_by=run_by) check_r011_redis_oom._rule_id = "R011" def check_r012_no_space(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R012" return skip cfg = get_diag_config() all_patterns = ["No space left on device", "ENOSPC", "disk quota exceeded"] containers = [ "meshsafe-api", "meshsafe-clamav", "meshsafe-postgres", "meshsafe-redis", "meshsafe-nginx", ] all_hits: List[str] = [] for c in containers: logs = _docker_logs(c, cfg.docker_log_tail) hits = _grep_patterns(logs, all_patterns, max_lines=5) for h in hits: all_hits.append(f"[{c}] {h}") if len(all_hits) >= 10: break if all_hits: return _crit( "R012", "容器日志出现磁盘空间不足(严重!)", hint="立即清理磁盘,否则扫描会持续失败", evidence=all_hits[:10], run_by=run_by, ) return _ok("R012", "所有容器日志无磁盘空间错误", run_by=run_by) check_r012_no_space._rule_id = "R012" def check_r013_nginx_upstream(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R013" return skip cfg = get_diag_config() logs = _docker_logs("meshsafe-nginx", cfg.docker_log_tail) # 只匹配真正的 upstream 故障。 # 注意 nginx 正常日志里有 "upstream response is buffered to a temporary file", # 那是响应太大被缓冲的正常行为,绝不能在这里误伤。 patterns = [ "Connection refused", "upstream timed out", "upstream prematurely closed", "no live upstreams", "connect() failed", "502 Bad Gateway", "upstream server temporarily disabled", ] hits = _grep_patterns(logs, patterns) if hits: return _warn( "R013", f"Nginx upstream 异常(命中 {len(hits)} 行)", hint="api 容器可能临时不可用;检查 api 健康状态", evidence=hits[:5], run_by=run_by, ) return _ok("R013", "Nginx 无 upstream 异常", run_by=run_by) check_r013_nginx_upstream._rule_id = "R013" def check_r014_api_connection(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R014" return skip cfg = get_diag_config() logs = _docker_logs("meshsafe-api", cfg.docker_log_tail) patterns = ["Redis connection refused", "Postgres connection refused", "ConnectionError", "OperationalError", "can't connect"] hits = _grep_patterns(logs, patterns) if hits: return _crit( "R014", f"API 与依赖服务连接异常(命中 {len(hits)} 行)", hint="Redis/Postgres 网络断了?检查 docker 网络和依赖容器健康", evidence=hits[:5], run_by=run_by, ) return _ok("R014", "API 与依赖连接正常", run_by=run_by) check_r014_api_connection._rule_id = "R014" # ====================================================================== # R015: 容器重启次数 # ====================================================================== def check_r015_container_restart(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: skip = _check_docker_available(engine) if skip: skip.id = "R015" return skip containers = [ "meshsafe-api", "meshsafe-clamav", "meshsafe-postgres", "meshsafe-redis", "meshsafe-nginx", ] now_ts = time.time() bj = datetime.now(timezone(timedelta(hours=8))) restart_events_now: List[str] = [] # 本轮检测到的新事件(写进 evidence) new_restart_containers: List[str] = [] # 有新重启事件的容器名 for c in containers: info = _docker_inspect(c) if not info: continue cur_restart = info.get("restart_count", 0) cur_started = info.get("started_at", "") or "" old = _CONTAINER_LAST_STATE.get(c) restart_changed = False started_changed = False if old is not None: # 第一次跑没有 old,不算事件,只建缓存 restart_changed = cur_restart > old.get("restart_count", -1) started_changed = cur_started != old.get("started_at", "") if restart_changed or started_changed: trigger = "restart_count_increased" if restart_changed else "started_at_changed" uptime_before_s = 0 try: uptime_before_s = int(now_ts - old.get("checked_at", now_ts)) except Exception: uptime_before_s = 0 m, uptime_before_s = divmod(uptime_before_s, 60) h, m = divmod(m, 60) uptime_before_str = ( f"{h}h {m}m" if h > 0 else f"{m}m" if m > 0 else "刚启动" ) event_line = ( f"{bj.strftime('%H:%M:%S')} {c} " f"restart_count {old.get('restart_count','?')} → {cur_restart} " f"uptime_before={uptime_before_str} " f"trigger={trigger}" ) restart_events_now.append(event_line) new_restart_containers.append(c) # 持久化重启事件 try: from app.diagnostic.storage import ( append_restart_event, snapshot_on_restart, ) append_restart_event( container=c, restart_before=old.get("restart_count", 0), restart_after=cur_restart, uptime_before=uptime_before_str, uptime_before_seconds=now_ts - old.get("checked_at", now_ts), trigger=trigger, ) # 抓重启时的日志片段 try: snapshot_on_restart(c, tail=300) except Exception: pass except Exception: pass # 更新缓存为当前状态 _CONTAINER_LAST_STATE[c] = { "restart_count": cur_restart, "started_at": cur_started, "checked_at": now_ts, } # ---------- 判定 severity ---------- # 1. 有新重启事件 → medium(哪怕 restart_count 还没超 3) # 2. restart_count > 3 → crit # 3. 否则 → ok crit_list: List[str] = [] all_status: List[str] = [] for c in containers: info = _docker_inspect(c) if info: r = info.get("restart_count", 0) all_status.append(f"{c}: restart={r}") if r > 3: crit_list.append(f"{c} (restart_count={r})") if crit_list: # 严重:累积重启次数过多 combined_evidence = list(restart_events_now) + [f"⚠️ {x}" for x in crit_list] return _crit( "R015", f"以下容器不稳定(restart_count > 3): {', '.join(crit_list)}", hint="检查对应容器日志找 crash 根因(OOM/segmentation/配置错误)", evidence=combined_evidence + all_status, run_by=run_by, ) if new_restart_containers: # 中等:本轮检测到新重启事件(但累积次数还没超 3) return _warn( "R015", f"本轮检测到容器重启事件: {', '.join(new_restart_containers)}", hint="开发/运维可能执行了 docker restart,或容器健康检查触发了自动重启。" "日志片段已自动归档到 diagnostics_logs/*-restart-*.log", evidence=restart_events_now + all_status, run_by=run_by, ) return _ok( "R015", "所有容器稳定(restart_count 均 ≤ 3,本轮无新重启事件)", evidence=all_status, run_by=run_by, ) check_r015_container_restart._rule_id = "R015" # ====================================================================== # R016: 容器重启历史汇总(读持久化 restart_events.jsonl) # ====================================================================== def check_r016_restart_history(engine: DiagnosticEngine, run_by: str = "") -> RuleResult: """读 restart_events.jsonl + compose_state,给出过去 N 天每个容器重启次数汇总。 和 R015 的区别: R015 = 当前状态检测(restart_count 阈值 + 本轮变化) R016 = 历史回顾(持久化事件日志聚合) """ skip = _check_docker_available(engine) if skip: skip.id = "R016" return skip cfg = get_diag_config() # 1. 读持久化重启事件 events: List[Dict[str, Any]] = [] try: from app.diagnostic.storage import load_restart_events if cfg.storage_enabled: events = load_restart_events(cfg.history_retention_days) except Exception: events = [] # 2. 按容器聚合 by_container: Dict[str, List[Dict[str, Any]]] = {} for ev in events: c = ev.get("container", "?") by_container.setdefault(c, []).append(ev) # 3. 取当前 restart_count(compose_state) containers = [ "meshsafe-api", "meshsafe-clamav", "meshsafe-postgres", "meshsafe-redis", "meshsafe-nginx", ] current_counts: Dict[str, int] = {} for c in containers: info = _docker_inspect(c) if info: current_counts[c] = info.get("restart_count", 0) # 4. 构建 evidence(每个容器一行汇总) evidence: List[str] = [] total_events = 0 problem_containers: List[str] = [] for c in containers: hist_count = len(by_container.get(c, [])) cur_count = current_counts.get(c, 0) total_events += hist_count line = f"{c}: 历史重启事件={hist_count} 当前restart_count={cur_count}" if hist_count > 0: last_ev = by_container[c][-1] line += f" 最近事件={last_ev.get('ts')}" evidence.append(line) if hist_count >= 3: problem_containers.append(f"{c}({hist_count}次)") # 5. 判定 if total_events == 0: return _ok( "R016", f"过去 {cfg.history_retention_days} 天内,无容器重启事件记录", evidence=evidence, run_by=run_by, ) if problem_containers: return _warn( "R016", f"过去 {cfg.history_retention_days} 天内,以下容器重启较频繁: " f"{', '.join(problem_containers)}", hint="开发/运维可能执行了多次 docker restart," "或容器健康检查/初始化阶段触发重启。" "详见 restart_events.json 和 diagnostics_logs/*-restart-*.log", evidence=evidence, run_by=run_by, ) # 有事件但不频繁 return _ok( "R016", f"过去 {cfg.history_retention_days} 天内共 {total_events} 次重启事件(均不频繁)", evidence=evidence, run_by=run_by, ) check_r016_restart_history._rule_id = "R016"