Files
inkreach-prefect/flows/monitor/device_check.py
T

268 lines
11 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.
"""设备/系统健康日报(每日一次):内存/磁盘/GPU 使用率 + 系统级错误检查。
对应 device-check deployment,每天 09:00(可调)。检查项:
- 内存/swap、宿主机磁盘(经 /:/host:ro 挂载)、GPU(nvidia-smi)——使用率 ≥ 阈值(默认 80%)标 🔴
- Docker 容器状态(经 /var/run/docker.sock):应运行但退出 / restarting / unhealthy / OOMKilled
- Prefect 近 24h FAILED/CRASHED flow runs、OCR 服务探活、PG 连通性、系统负载
依赖 docker-compose 给 prefect-worker 挂载 /:/host:ro、/var/run/docker.sock、GPU;
未挂载时对应检查项自动降级为"不可用",不影响其余检查。
"""
from __future__ import annotations
import os
import subprocess
from datetime import datetime, timedelta
import httpx
import psutil
from prefect import flow
from flows.shared import SyncDB, emit_sync_summary, send_markdown
# (容器内路径, 展示名);/host 为 compose 挂载的宿主机根目录
DISK_PATHS = [("/host", "/"), ("/host/home", "/home")]
DOCKER_SOCK = "/var/run/docker.sock"
_OCR_BASE = "http://192.168.124.92:9801"
def _gb(nbytes: float) -> str:
return f"{nbytes / (1 << 30):.1f}G"
def _check_memory() -> dict:
vm = psutil.virtual_memory()
sw = psutil.swap_memory()
return {
"pct": vm.percent, "used": _gb(vm.used), "total": _gb(vm.total),
"swapPct": sw.percent, "swapUsed": _gb(sw.used), "swapTotal": _gb(sw.total),
}
def _check_disks() -> list[dict]:
paths = DISK_PATHS if os.path.isdir(DISK_PATHS[0][0]) else [("/", "/")]
out = []
for raw, label in paths:
try:
du = psutil.disk_usage(raw)
out.append({"label": label, "pct": du.percent, "used": _gb(du.used), "total": _gb(du.total)})
except Exception as err: # noqa: BLE001
out.append({"label": label, "error": str(err)[:80]})
return out
def _check_gpus() -> list[dict] | str:
"""nvidia-smi 查 GPU 利用率与显存。容器无 GPU 访问时返回说明文字。"""
try:
proc = subprocess.run(
["nvidia-smi", "--query-gpu=index,name,utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=15,
)
if proc.returncode != 0:
return f"nvidia-smi 退出码 {proc.returncode}: {proc.stderr.strip()[:100]}"
gpus = []
for line in proc.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) != 5:
continue
idx, name, util, mem_used, mem_total = parts
mem_pct = float(mem_used) / float(mem_total) * 100 if float(mem_total) > 0 else 0.0
gpus.append({"name": f"GPU{idx} {name[:24]}", "utilPct": float(util), "memPct": mem_pct,
"memUsed": f"{float(mem_used)/1024:.1f}G", "memTotal": f"{float(mem_total)/1024:.1f}G"})
return gpus or "未检测到 GPU"
except FileNotFoundError:
return "不可用(容器内无 nvidia-smi,需 compose 配置 GPU)"
except Exception as err: # noqa: BLE001
return f"检查失败: {str(err)[:80]}"
def _inspect_container(client: httpx.Client, cid: str) -> dict:
try:
resp = client.get(f"http://localhost/containers/{cid}/json", timeout=10)
resp.raise_for_status()
return resp.json()
except Exception: # noqa: BLE001
return {}
def _check_docker() -> dict:
"""容器状态汇总 + 异常容器列表(应运行但退出/restarting/unhealthy/OOMKilled)。"""
if not os.path.exists(DOCKER_SOCK):
return {"running": None, "issues": [{"name": "Docker", "issue": f"不可用(未挂载 {DOCKER_SOCK})"}]}
with httpx.Client(transport=httpx.HTTPTransport(uds=DOCKER_SOCK), timeout=10) as client:
resp = client.get("http://localhost/containers/json", params={"all": "1"})
resp.raise_for_status()
containers = resp.json()
running = sum(1 for c in containers if c.get("State") == "running")
issues = []
stopped = 0
for c in containers:
name = (c.get("Names") or ["?"])[0].lstrip("/")
state, status = c.get("State", ""), c.get("Status", "")
if state == "restarting":
issues.append({"name": name, "issue": f"反复重启 ({status})"})
elif state == "running" and "(unhealthy)" in status:
issues.append({"name": name, "issue": f"健康检查失败 ({status})"})
elif state in ("exited", "dead"):
detail = _inspect_container(client, c["Id"])
policy = ((detail.get("HostConfig") or {}).get("RestartPolicy") or {}).get("Name", "no")
oom = (detail.get("State") or {}).get("OOMKilled", False)
finished_at = (detail.get("State") or {}).get("FinishedAt", "")
if oom and _exited_recently(finished_at):
issues.append({"name": name, "issue": f"被 OOM 杀死 ({status})"})
elif policy in ("always", "unless-stopped"):
if _exited_recently(finished_at):
issues.append({"name": name, "issue": f"应常驻但刚退出 ({status}, restart={policy})"})
else:
stopped += 1 # 数周前人为停用的旧服务,不算异常
return {"running": running, "total": len(containers), "issues": issues, "stopped": stopped}
def _exited_recently(finished_at: str, hours: int = 24) -> bool:
try:
from datetime import timezone
dt = datetime.fromisoformat(finished_at.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - dt).total_seconds() < hours * 3600
except (ValueError, TypeError):
return True # 解析失败按新退出处理,宁可多报
def check_prefect_failures(hours: int = 24) -> list[dict]:
api_url = os.environ.get("PREFECT_API_URL", "http://localhost:4200/api")
since = (datetime.now().astimezone() - timedelta(hours=hours)).isoformat()
resp = httpx.post(
f"{api_url}/flow_runs/filter",
json={
"flow_runs": {"state": {"type": {"any_": ["FAILED", "CRASHED"]}}, "start_time": {"after_": since}},
"limit": 50,
"sort": "START_TIME_DESC",
},
timeout=15,
)
resp.raise_for_status()
out = []
for r in resp.json():
start = (r.get("start_time") or "")[:16].replace("T", " ")
out.append({"name": r.get("name", "?"), "state": (r.get("state") or {}).get("type", "?"),
"message": ((r.get("state") or {}).get("message") or "").strip()[:100], "start": start})
return out
def _check_services() -> dict:
from api.ocr import OcrClient
ocr_ok = False
try:
ocr_ok = OcrClient().health()
except Exception: # noqa: BLE001
pass
pg_ok = False
try:
pg_ok = bool(SyncDB().ping())
except Exception: # noqa: BLE001
pass
return {"ocr": ocr_ok, "pg": pg_ok}
def _mark(pct: float | None, threshold: int) -> str:
"""百分比展示:超阈值加粗+🔴(WeCom aibot markdown 不支持 font 标签和表格)。"""
if pct is None:
return "—"
if pct >= threshold:
return f"**{pct:.0f}%**🔴"
return f"{pct:.0f}%"
@flow(name="device-check", log_prints=True)
def device_check(alarm_threshold: int = 80) -> dict:
"""设备/系统健康日报:检查并直接发企微群。单项检查失败不影响其余项。"""
checks: dict[str, object] = {}
for name, fn in [
("memory", _check_memory), ("disks", _check_disks), ("gpus", _check_gpus),
("docker", _check_docker), ("prefectFailures", check_prefect_failures), ("services", _check_services),
]:
try:
checks[name] = fn()
except Exception as err: # noqa: BLE001
checks[name] = None
print(f"[device-check] {name} 检查失败: {err}")
threshold = alarm_threshold
lines = [f"🖥 **设备健康日报** {datetime.now().strftime('%m-%d %H:%M')}", ""]
mem = checks.get("memory") or {}
if mem:
lines.append(f"**内存** {_mark(mem['pct'], threshold)}({mem['used']}/{mem['total']})"
f"|swap {_mark(mem['swapPct'], threshold)}({mem['swapUsed']}/{mem['swapTotal']})")
else:
lines.append("**内存** 检查失败")
disks = checks.get("disks") or []
if disks:
lines.append("**磁盘**")
for d in disks:
if "error" in d:
lines.append(f"> {d['label']} 检查失败:{d['error']}")
else:
lines.append(f"> {d['label']} {_mark(d['pct'], threshold)}({d['used']}/{d['total']})")
gpus = checks.get("gpus")
if isinstance(gpus, list) and gpus:
lines.append("**GPU**")
for g in gpus:
lines.append(f"> {g['name']}|利用率 {_mark(g['utilPct'], threshold)}"
f"|显存 {_mark(g['memPct'], threshold)}({g['memUsed']}/{g['memTotal']})")
else:
lines.append(f"**GPU** {gpus if isinstance(gpus, str) else '未检测到'}")
docker = checks.get("docker") or {}
issues = docker.get("issues") or []
if docker.get("running") is None:
lines.append(f"**Docker** {issues[0]['issue'] if issues else '不可用'}")
else:
docker_line = f"**Docker** 运行 {docker['running']}/{docker.get('total', '?')}"
if docker.get("stopped"):
docker_line += f" · 已停用 {docker['stopped']}"
if issues:
docker_line += f" · 异常 **{len(issues)}**🔴"
else:
docker_line += " · 异常 0"
lines.append(docker_line)
for it in issues[:8]:
lines.append(f"> 🔴 {it['name']}:{it['issue']}")
failures = checks.get("prefectFailures") or []
lines.append(f"**Prefect 失败(24h)** {len(failures)} 个" + (" ✅" if not failures else ""))
for f in failures[:5]:
lines.append(f"> 🔴 {f['name']}({f['state']}){f['start']}")
svc = checks.get("services") or {}
lines.append(f"**探活** OCR {'✅' if svc.get('ocr') else '🔴'}|PG {'✅' if svc.get('pg') else '🔴'}"
f"|负载 {os.getloadavg()[0]:.1f}")
alarms = (
(mem and (mem["pct"] >= threshold or mem["swapPct"] >= threshold))
or any(d.get("pct", 0) >= threshold for d in (checks.get("disks") or []))
or (isinstance(gpus, list) and any(g["utilPct"] >= threshold or g["memPct"] >= threshold for g in gpus))
or bool(issues) or bool(failures) or not svc.get("ocr") or not svc.get("pg")
)
lines += ["", ("🔴 **有项目需要处理,见上**" if alarms else "✅ **一切正常**")]
content = "\n".join(lines)
sent = send_markdown(content)
emit_sync_summary("设备健康日报", [
("内存", f"{_mark(mem['pct'], threshold)}" if mem else "—"),
("Docker 异常", len(issues)),
("Prefect 失败(24h)", len(failures)),
("OCR", "✅" if svc.get("ocr") else "🔴"), ("PG", "✅" if svc.get("pg") else "🔴"),
("已发企微", "是" if sent else "否"),
])
return {"alarms": bool(alarms), "sent": sent, "dockerIssues": len(issues), "prefectFailures": len(failures)}
if __name__ == "__main__":
device_check.serve(name="device-check", cron="0 9 * * *")