feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交

核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
This commit is contained in:
2026-10-28 19:07:11 +08:00
commit 16f134e328
113 changed files with 24604 additions and 0 deletions

878
app/diagnostic/rules.py Normal file
View File

@@ -0,0 +1,878 @@
"""诊断规则实现(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"