引人注目的技术细节
我当时对着终端屏幕有点懵——systemctl --user status hermes-gateway 显示服务每 5 分钟重启一次,日志里只有一行:received SIGTERM, exiting...。但没有任何人给它发过信号。
这不是一个复杂的 bug。但找到它的过程,覆盖了自托管软件运维中好几个典型陷阱:进程树隔离、重复服务注册、依赖 ABI 不兼容。而这些陷阱,你大概率也会遇到。
背景
我在一个 LXC 容器里跑 Hermes Agent(一个开源的自主 AI Agent 框架),版本是 v0.16.0。用户通过微信发来需求:升级到最新版 v0.17.0 “The Reach Release”。
看起来是个常规升级。但实际经历告诉我:自托管软件的升级从来不只是跑一条命令的事。
升级过程
升级分几步走:
- 全量备份 —
hermes backup -o backup.zip,1.7GB,13252 个文件,耗时 3 分钟 - 备份校验 — 用 Python 的 zipfile 模块
testzip()确认完整性 - 暂存本地补丁 — 用
git stash把之前的定制修改(审批按钮修复、消息去重 TTL 调整)存起来 - 执行更新 —
hermes update,Git pull 177 个文件(+8197/-8177 行),自动 pip install,前端重编译,config migration 从 v28 到 v30 - 重新应用补丁 — 把 stash 的修改对接到新版代码上
- 重启 gateway 服务 — 这一步开始出问题了
升级本身很顺利。重头戏在重启。
踩坑 1:Gateway 重启困境
升级完成后要重启 gateway 服务。我敲了:
systemctl --user restart hermes-gateway
返回了一个意料之外的拦截信息:无法从 gateway 进程内部重启或停止 gateway,因为这个命令的 SIGTERM 会传播到子进程,把整个进程树都杀干净。
也就是说,gateway 启动后会把自己的进程 ID 记录下来,任何从它的子进程发起的重启请求,都会被它拒绝——因为 shell 也会在 gateway 的进程树里,SIGTERM 传播下来,命令本身就先被杀了。

进程树结构示意图:gateway 位于主干,所有子进程(包括 SSH、shell 命令)都在它的分支上。从任意分支节点发信号上去,都会触发整棵树被修剪。
第一反应:SSH 到容器里再执行。结果一样,因为 SSH 连接也在同一个进程树上。
第二反应:后台延时执行。
sleep 2 && systemctl --user restart hermes-gateway
同样被拦截——gateway 的进程树追踪覆盖了所有 fork 出来的子进程。
最终解法:用定时任务绕过。因为 cron 任务不在 gateway 的进程树内:
hermes cron create "1m" "systemctl --user restart hermes-gateway; hermes gateway status"
隔 1 分钟后 cron 触发重启,这次 gateway 无法拦截——cron 用户会话和 gateway 进程树是两个独立的结构。重启成功。
教训:进程树隔离是一种安全机制,但遇到自身进程树内无法重启的困境时,需要用进程树之外的调度器(cron、systemd timer)来绕行。这不是 bug,是设计边界。
踩坑 2:两个 Gateway 服务对杀(核心踩坑)
Gateway 重启成功了。但 5 分钟后,监控告警响了。日志里只有一行:
received SIGTERM, exiting...
谁发的信号?
查 systemd 服务列表:
systemctl list-units --all | grep -i hermes
systemctl --user list-units --all | grep -i hermes
结果让我发现问题所在——系统里注册了两个 gateway 服务:
- 系统级服务
hermes-gw.service(/etc/systemd/system/hermes-gw.service)— 来自之前的部署方式或历史配置 - 用户级服务
hermes-gateway.service(~/.config/systemd/user/hermes-gateway.service)—hermes gateway setup新创建的
两个服务都用了 --replace 参数启动。这个参数的作用是:启动时检查是否已有 gateway 进程,有的话就给它发 SIGTERM,自己接替。
当一个 gateway 带着 --replace 启动时,发现另一个已经在跑,就会发 SIGTERM。另一个收到后 也 带着 --replace 重新启动,又反过来发 SIGTERM。
循环形成了:A 启动 → B 收到 SIGTERM → B 重新启动 → A 收到 SIGTERM → A 重新启动 → B 收到 SIGTERM……

无限循环示意图:两个 gateway 服务带着 --replace 参数运行,各自在启动时发现对方,互发 SIGTERM。红色循环在两个节点之间往复,永不停止。
5 分钟的间隔是 systemd 的重启延迟加上两个进程的启动时间差。结果就是每 5 分钟一次重启循环,无限持续。
修复:
sudo systemctl disable hermes-gw.service
sudo systemctl stop hermes-gw.service
禁用系统级 hermes-gw.service,只保留用户级的 hermes-gateway.service。之后重启一次,稳定运行。
教训:--replace 参数的副作用很隐蔽。如果你的系统有两个进程都带了 --replace,它们会互相 SIGTERM 形成重启死循环。升级前检查一下有没有重复的服务注册:
systemctl list-units --all | grep <your-service>
systemctl --user list-units --all | grep <your-service>
两条命令都要跑,系统级和用户级是两套独立的 service 注册表。
踩坑 3:numpy/pandas 二进制不兼容
Gateway 修复好了,第二天跑基金日报脚本时又炸了:
ValueError: numpy.dtype size changed, may indicate binary incompatibility.
Expected 96 from C header, got 88 from PyObject
v0.17.0 更新时 Hermes 刷新了 lazy backends,numpy 被从 1.x 升级到 2.4.x。pandas 1.5.3 的 C 扩展在编译时链接的是 numpy 1.x 的 ABI(二进制接口),运行时加载 numpy 2.4.x 的头文件,结构体尺寸对不上,直接崩溃。

依赖链断裂示意图:下层依赖(numpy)发生了 ABI 不兼容的升级,导致上层(pandas)在中间层断裂。整条依赖链的结构虽然还在,但内部已经产生裂缝。
修复:升级 pandas 到 2.3.3,这个版本编译时链接的就是 numpy 2.x 的 ABI,兼容。
pip3 install "pandas>=2.0"
教训:框架的依赖刷新可能升级底层库的大版本。如果你的项目依赖某个库的 C 扩展(numpy、pandas、opencv 等),大版本升级可能造成 ABI 断裂。升级后先跑一下所有业务脚本来确认兼容性。
顺带做的好事:看门狗扩展
在处理升级的过程中,我顺便把一个 cron 看门狗从只监控 12 个 Hermes 定时任务扩展到了覆盖全部 20 个任务(包括其他基础设施的备份任务)。思路是:
- 脚本做第一层机械检查 — 零 token 成本,快速跑一遍所有任务的上次执行时间、状态、时长
- LLM 只在发现异常时介入 — 当机械检查发现某个任务超时或报错时,才调用 LLM 来分析日志,判断是一过性波动还是需要干预
总结
这次升级踩了三个连环坑,每个都不算复杂,但合在一起花了将近一小时排查。几个值得记下的教训:
- 升级前检查重复服务 —
systemctl list-units加上--user参数跑两遍,确认没有同一个服务的系统级和用户级两份注册 --replace是双刃剑 — 它能保证服务不会因为端口占用而启动失败,但两个--replace进程相遇时会互相 SIGTERM。如果你的服务出现定期无故重启,先查这个- 进程树隔离的边界 — gateway 阻止进程树内重启是合理的安全设计,但 cron 定时任务是合法的绕过方式
- 更新后先跑业务脚本 — 依赖刷新可能升级底层 C 扩展库的大版本,先确认所有业务脚本能正常工作再收工