Files
MeshSate/app/diagnostic/rules.py
zhaolei 16f134e328 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
2026-10-28 19:07:11 +08:00

879 lines
30 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.
"""诊断规则实现(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"