← 返回文档索引

name: hermes-health-monitoring description: "Hermes 系统健康监控体系 — 每日+每周两级主动探测,在问题发生前发现。基于 5/11 脚本误报教训(SSL、微信检查脚本写死路径导致假告警)。" version: 1.1.0 author: Hermes Agent metadata: hermes: tags: [monitoring, health-check, cron, devops, resilience] prerequisites: commands: [hermes, systemctl, openssl, curl, python3]


Hermes 健康监控体系

两级主动探测:每日简单检查 + 每周全面检查。核心理念:不等报错,提前验证关键链路是否通畅。

架构

层级 cron schedule 模式 脚本 范围
每日 82cddbdae47 0 22 * * * no-agent daily_health_check.sh gateway 存活、依赖完整、SSL、微信 token、gbrain 清理
每周 46d7431f440c 0 10 * * 0 no-agent weekly_health_check.sh 以上 + 飞书 API 权限 + wiki 成员 + 配置一致性

脚本路径:~/.hermes/scripts/daily_health_check.sh~/.hermes/scripts/weekly_health_check.sh

交付方式:异常才发飞书群 oc_569a01e75d67f5f0422a994aa7132365,静默=健康。

设计原则

1. 零 token(no-agent 模式)

所有检查用 bash 脚本完成,不经过 LLM。脚本 stdout 直接交付 cron delivery。原因:简单检查不需要 LLM 判断,节省 token 且避免 agent 误判。

2. 从配置读取,不硬编码

5/11 核心教训:profiles 隔离后,脚本中的硬编码路径/账号必然失效。所有脚本必须从环境变量或配置文件动态读取:

# ❌ 错误:硬编码路径
TOKEN_FILE="$HOME/.hermes/weixin/accounts/e7bd38ce7e39@im.bot.json"

# ✅ 正确:从 aide .env 读取
ACCOUNT_ID=$(grep "^WEIXIN_ACCOUNT_ID=" "$HOME/.hermes/profiles/aide/.env" | cut -d= -f2)
TOKEN_DIR="$HOME/.hermes/profiles/aide/weixin/accounts"

3. 网络验证优先于文件验证

# ❌ 错误:直接读 /etc/letsencrypt/live/(需要 root)
[ -f "/etc/letsencrypt/live/$domain/fullchain.pem" ]

# ✅ 正确:通过 HTTPS 连接验证证书
echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null | openssl x509 -noout -enddate

4. 自愈能力

检查脚本发现可自动修复的问题时直接修复(如 gbrain 孤儿进程清理),修复后不再告警。

5. parent-PID-based 孤儿检测,不依赖进程计数

# ❌ 错误:count-based 会误杀 CLI 会话的 gbrain
COUNT=$(pgrep -f "bun.*cli.ts" | wc -l)
[ "$COUNT" -gt 2 ] && kill_oldest

# ✅ 正确:parent PID 精确判断孤儿
ppid=$(ps -o ppid= -p "$pid")
[ "$ppid" = "$GATEWAY_PID" ] && continue  # gateway 的合法子进程

进程计数无法区分"gateway 的子进程"和"CLI 会话的子进程",在 multi-gateway + CLI 会话并存的环境下必然误判。parent PID 链检查是唯一准确的孤儿判断方式。

常见 Pitfall

Pitfall 0:systemd crash-loop + 端口占用(address already in use)

症状systemctl status 显示 activating (auto-restart),重启计数器暴涨(数万次),journal 里每 5 秒报 address already in use

根因:旧进程僵尸占用端口,systemd 新进程绑不上去,陷入死循环。

诊断

# 1. 确认端口被谁占着(ss 比 netstat 更快)
ss -tlnp | grep <PORT>

# 2. 看 systemd 崩溃日志
journalctl -u <service>.service --no-pager -n 20

修复

# 杀僵尸进程,systemd 自动拉起新进程
kill <OLD_PID>
# 等几秒验证
sleep 3 && journalctl -u <service>.service --no-pager -n 5

防范(三层)

  1. systemd 加固(阻止 crash-loop 发生):
[Service]
ExecStartPre=/bin/bash -c 'fuser -k <PORT>/tcp 2>/dev/null; sleep 1'
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=10
StartLimitBurst=5
StartLimitIntervalSec=60

ExecStartPre 在启动前强制释放端口,KillMode=mixed 确保子进程一并清理,StartLimitBurst 防止无限重启刷日志。

  1. 健康检查用 HTTP 连接测试,不用 systemctl is-active: crash-loop 状态下 systemctl 可能显示 active (running) 瞬间然后退出,systemctl is-active 可能漏报。必须用 curl --max-time 5 http://127.0.0.1:<PORT>/ 实际探测。

  2. 健康检查覆盖所有关键端口:不仅是 gateway,还包括 admin-auth(7891)、dashboard(9119) 等所有用户可见链路上的服务。

Pitfall 1:日志轮转导致 grep 漏匹配

Pitfall 1:日志轮转导致 grep 漏匹配

问题:单搜 gateway.log 会在日志轮转后(06:30)找不到 "Gateway running with" 记录,误报为"平台数未知"。

修复:用 ls -tr + zcat -f 扫所有轮转日志:

LATEST_ENTRY=$(ls -tr "$LOG_DIR"/gateway.log* 2>/dev/null | xargs zcat -f 2>/dev/null | grep "Gateway running with" | tail -1)

原理:ls -tr 按 mtime 最旧→最新排序文件,zcat -f 同时处理 .gz 和纯文本,tail -1 取全局最新条目。

同样的模式适用于任何跨轮转日志的 grep 检查。

Pitfall 2:ps aux | sort -k9 日期/时间混排失效

问题ps aux 第 9 列(START)跨天时混排 "May11" 和 "22:51" 两种格式。sort -k9 按字典序排,新进程的 "22:51" 会被排到旧进程 "May11" 前面,导致清理脚本保留了旧的、杀了新的。

修复:按 PID(第 2 列)排序,PID 越大=越新:

ps aux | sort -k2 -n | head -n -N | awk '{print $2}' | xargs kill
# 保留最新的 N 个进程(head -n -N 表示去掉最后 N 行)

Pitfall 3:按进程计数清理孤儿 — 误杀 CLI 会话

问题:原始脚本统计 ps aux | grep "bun.*cli.ts.*serve" | wc -l,如果 >2 就 kill 最旧的。但这个 count 包括 CLI 会话产生的 gbrain(1-2 个),导致健康检查误杀正常 CLI 会话的 gbrain。

修复:改用 parent PID 精确识别孤儿。只清理"不在任何 gateway 和 CLI 会话进程树下的 standalone gbrain":

MAIN_GW_PID=$(systemctl --user show hermes-gateway --property=MainPID --value 2>/dev/null)
AIDE_GW_PID=$(systemctl --user show hermes-gateway-aide --property=MainPID --value 2>/dev/null)
for pid in $(pgrep -f "bun.*cli.ts.*serve" 2>/dev/null); do
    ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')
    [ "$ppid" = "$MAIN_GW_PID" ] && continue    # gateway 的子进程
    [ "$ppid" = "$AIDE_GW_PID" ] && continue    # aide 的子进程
    parent_cmd=$(ps -p "$ppid" -o comm= 2>/dev/null)
    echo "$parent_cmd" | grep -qE "hermes|python3" && continue  # CLI 会话的子进程
    kill "$pid" 2>/dev/null   # 真孤儿,清理
done

教训:任何涉及多个父进程的进程清理,count-based 方法必然误杀或漏杀。parent-PID-based 检测更精确。

Pitfall 4(历史教训 — 已被 parent-PID 方案取代):架构变更时忘记同步检查阈值

问题:添加 aide gateway profile 后,系统从 1 个 gateway 变成 2 个。每个 gateway 各跑一个 gbrain MCP 进程。但脚本的阈值仍为 >1,导致每次检查都告警"僵尸进程"。

注意:此 Pitfall 已被 parent-PID-based 检测方案取代(见 Pitfall 3),新脚本不再依赖固定计数阈值,理论上不受进程数量变化影响。保留此记录仅作历史参考。

# ❌ 单 gateway 时期的阈值
if [ "$BUN_COUNT" -gt 1 ]; then ...

# ✅ 双 gateway 时期的阈值
if [ "$BUN_COUNT" -gt 2 ]; then ...

飞书 API 调用

lark-cli 在服务器上不可用(keychain 依赖 secret-tool,未安装)。飞书 API 改用 curl 直调:

# 1. 获取 tenant_access_token
TOKEN=$(curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
    -H "Content-Type: application/json" \
    -d "{\"app_id\":\"$APP_ID\",\"app_secret\":\"$APP_SECRET\"}" \
    | python3 -c "import sys,json; print(json.load(sys.stdin)['tenant_access_token'])")

# 2. 调业务 API
curl -s "https://open.feishu.cn/open-apis/wiki/v2/spaces/7634951676289387725/members" \
    -H "Authorization: Bearer $TOKEN"

凭据从 ~/.hermes/.env 读取:FEISHU_APP_IDFEISHU_APP_SECRET

检查项清单

每日检查(22:00)

每周检查(周日 10:00)

添加新检查项

两步都要做,不能只写文档不写脚本(5/26 教训:skill 文档已列出 admin-auth 和 dashboard 检查但脚本缺失,导致 7891 崩溃 58K 次无人知晓)。

  1. 编辑脚本,在 ISSUES 变量追加异常信息。格式:
if [ 异常条件 ]; then
    ISSUES="${ISSUES}\n❌ 检查项描述"
fi
  1. 更新上方检查清单,勾选对应项。

脚本末尾自动判断:ISSUES 非空则输出异常信息,空则静默退出。

依赖

参考文档