Files
hospital-front-agent/agent/process_guard.py
T
lianlonggangandClaude f07bbbc49d feat: RESTART 走 NSSM 服务重启 + 失败原因可观测化
- RESTART 命令优先通过 nssm restart 重启主程序服务(假死场景可用),未配置服务名回退环回 HTTP
- nssm 输出按 UTF-16 解码并记录日志,权限不足时提示需以服务方式运行
- 环回管理接口 code=3001 时明确报鉴权失败,不再误报不可达
- 新增 nssm.path / main.service.name / log.dir 配置项
- 增加文件日志(RotatingFileHandler 5MB×5),服务方式运行可查日志
- HTTP 调用失败与状态码异常统一 WARNING 记录(原 DEBUG 不可见)
- 断线日志区分「网络不通」与「被服务器拒绝(未注册/token 错误)」
- 连接 URL 自动拼接 device.id,消除双处配置不一致

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-27 17:24:17 +08:00

150 lines
5.7 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.
# -*- coding: utf-8 -*-
"""主程序进程守护(看门狗)。
周期探测主程序健康:
- 连续 3 次失败 → 认为主程序宕机 → 强杀残留 → 调启动命令拉起
- 拉起后等待健康恢复;连续 3 次拉起失败 → 上报告警(ALERT)
"""
import logging
import subprocess
import threading
log = logging.getLogger(__name__)
class ProcessGuard:
"""看门狗:周期健康探测 + 宕机拉起 + 告警上报。"""
def __init__(self, config, main_app_client, client):
self.config = config
self.main_app_client = main_app_client
self.client = client
self._fail_count = 0 # 连续健康探测失败次数
self._restart_count = 0 # 连续拉起次数
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self):
"""启动守护(首检延迟 30 秒,之后每 15 秒探测一次)。"""
if not self.config.guard_enabled:
log.info("进程守护未启用(guard.enable=false")
return
self._thread = threading.Thread(target=self._loop, name="process-guard", daemon=True)
self._thread.start()
log.info("进程守护已启动:每 15 秒探测 %s", self.config.main_health_url)
def stop(self):
"""停止守护。"""
self._stop.set()
# ================= 探测循环 =================
def _loop(self):
"""探测主循环:先等 30 秒(给主程序启动时间),再周期探测。"""
if self._stop.wait(30):
return
while not self._stop.wait(15):
try:
self._check()
except Exception as e:
log.warning("守护探测异常:%s", e)
def _check(self):
"""单次探测:健康则清零计数;连续 3 次失败则拉起。"""
health = self.main_app_client.health()
if health.get("ok") is True:
self._fail_count = 0
return
self._fail_count += 1
log.warning("主程序健康探测失败(%d/3):%s", self._fail_count, health.get("error"))
if self._fail_count < 3:
return
# 连续 3 次失败 → 拉起
self._fail_count = 0
self._restart_count += 1
attempts = self._restart_count
if attempts > 3:
self.client.send("ALERT", {
"level": "CRITICAL",
"msg": f"主程序连续 {attempts} 次拉起失败,请人工介入",
})
# 重置计数,之后继续尝试(降频可后续优化)
self._restart_count = 3
return
# 拉起方式:配置了服务名则走 nssm restart(幂等),否则回退启动命令
if self.config.main_service_name:
start_desc = f"nssm restart {self.config.main_service_name}"
else:
start_desc = self.config.main_start_cmd
log.error("主程序宕机,执行第 %d 次拉起:%s", attempts, start_desc)
self.client.send("ALERT", {
"level": "WARN",
"msg": f"主程序宕机,Agent 正在执行第 {attempts} 次拉起",
})
try:
if self.config.main_service_name:
ok, reason = restart_main_service(self.config.nssm_path, self.config.main_service_name)
if not ok:
log.error("nssm 拉起失败:%s", reason)
else:
proc = start_main_app(self.config.main_start_cmd)
log.info("启动命令已执行,pid=%s", proc.pid if proc else None)
except Exception as e:
log.error("拉起失败:%s", e)
# ================= 公共工具(upgrade.py 复用) =================
def restart_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 重启主程序服务(幂等:无论服务处于停止/运行态均到运行态)。
nssm_path 为 nssm.exe 完整路径;service_name 为注册的 Windows 服务名。
返回 (是否成功, 输出/原因)。nssm 输出打印到日志,权限不足(拒绝访问)等
失败原因一眼可见。
"""
try:
proc = subprocess.run(
[nssm_path, "restart", service_name],
capture_output=True,
timeout=60,
)
# nssm 输出为 UTF-16 编码(宽字符),需按 utf-16 解码
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm restart %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm restart %s 失败(exit=%s):%s", service_name, proc.returncode, out)
if "拒绝访问" in out or "Access" in out:
out += "(提示:重启服务需要管理员权限,请将 Agent 以 Windows 服务方式运行)"
return ok, out
except Exception as e:
log.warning("nssm restart %s 执行异常:%s", service_name, e)
return False, str(e)
def start_main_app(start_cmd: str) -> subprocess.Popen | None:
"""
拉起主程序。
Windows 下 .bat 走 cmd /c(路径转反斜杠);其余走 sh。
返回 Popen 对象(不等待进程结束)。
"""
cmd = start_cmd.replace("/", "\\") # cmd/c 要求反斜杠路径
if cmd.lower().endswith(".bat"):
args = ["cmd", "/c", cmd, "start"]
else:
args = ["sh", start_cmd, "start"]
return subprocess.Popen(
args,
stdout=subprocess.DEVNULL,
stderr=subprocess.STDOUT,
# 不设 cwd,继承 Agent 工作目录(与主程序同根目录部署)
)