20 个定时任务挂了 12 小时零告警:systemd-run --user 在系统级服务里找不到总线
9 月 4 日早上 9 点,机器上 20 多个 AI Agent 定时任务从凌晨 0 点起一条都没跑,累计 188 次失败,零告警。手动触发正常,定时全挂——不是脚本 bug,也不是网络,问题在派发层。
背景
这套定时任务跑了两个月,覆盖行情监控、复盘、发文、数据同步,20 多个 job,最密的 5 分钟一次。另有一套看门狗:系统 crontab 每 5 分钟跑一次 cron_dispatch_watchdog.py,与 Agent 框架的 cron 物理隔离,监控失败率、进程存活、工作时段静默日志作为兜底,失败任务会在 gateway 日志留下记录。
现象
凌晨 0 点起所有定时任务全部失败,累计 188 次;看门狗一条告警都没发。任务面板上失败记录的时间戳停在 9 月 3 日,滞后一天。
排查
三个假线索:jobs.json 的 schedule 表达式、script 路径、模型配置全对;手动跑一次任务正常;ps aux | grep gateway 进程还在运行,端口正在监听,API 响应正常。手动执行正常、定时执行全部失败,说明问题在派发层。再往下查,失败的任务连 LLM 请求都没发出去,在更早的阶段就已经失败。
翻 agent.log(不是 gateway.log),满屏:
dispatch failed: Failed to start transient service unit: ...
根因
新版把派发方式从直接 fork 执行改成统一走 systemd-run --user --scope,任务被放进用户级 systemd 的 transient scope 管理,好处是能被 systemd 托管、资源统计与 cgroup 归组更规范。这是 fail-closed 设计:派发环境不满足要求就拒绝执行。
网关本身是系统级服务。systemd 服务里没有 XDG_RUNTIME_DIR,而 systemd-run --user 要连用户实例的总线,总线地址默认从 XDG_RUNTIME_DIR 推导为 /run/user/1000/bus。变量缺失 → 找不到总线 → 派发失败,用探针复现时报的是:
Failed to connect to bus
第三层问题出在看门狗上。看门狗的健康探针也要经过 gateway 的派发链路;派发层坏了,探针永远失败,而看门狗把探针失败归为已知错误类型,没有触发连续失败超阈值的升级告警。兜底机制和主机制死在了同一条链上。
零告警拖了 12 小时,是三层监控同时失效:错误只写进 agent.log,gateway.log 干净;任务面板 output 时间戳滞后一天;看门狗探针失效。
探针与修复
# 探针1:无 XDG 环境启动 scope,模拟故障态
sudo -u hnzwx env -i systemd-run --user --scope true
# 失败,报 Failed to connect to bus
# 探针2:带 XDG 环境
sudo -u hnzwx env XDG_RUNTIME_DIR=/run/user/1000 systemd-run --user --scope true
# 成功
# 探针3:确认用户 bus 存在、linger 状态
ls -l /run/user/1000/bus
loginctl show-user hnzwx -p Linger
# bus 存在,Linger=yes
当时的修复是给 gateway 的 .env 加一行 XDG_RUNTIME_DIR=/run/user/1000,重启网关,手动触发一个任务验证派发恢复。修复后自检四项:手动派发、用 systemctl --user status 看 scope、看门狗探针跑一次、次日抽查执行记录时间戳。
补记:官方与社区资料里的几条事实
事后查 systemd 文档和社区讨论,几条和这次故障直接相关:
--user连的是该 UID 的 per-user systemd 实例,通常只在存在登录会话时启动。没有会话需要loginctl enable-linger,/run/user/也由 systemd 在启用 linger 时创建。- 总线地址除默认从
XDG_RUNTIME_DIR推导,也可以显式给DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user//bus。 - 从系统级服务里起
--user --scope还有一层风险:scope 缺少共同祖先权限,无法在 cgroup 之间迁移进程,这种场景应改用--service。 su/sudo -u不等于开启新的登录会话,只是切换身份,MAC/audit/cgroup/namespace 等上下文是继承的;需要 TTY、display manager、machinectl login 或 ssh 才有足够干净的会话跑 user bus。这也解释了探针 1 为什么失败。- 如果目标用户本来就是系统用户、不用于登录,正解是在单位文件里写
User=,而不是绕到用户总线。
几种修法的取舍
- 补
XDG_RUNTIME_DIR:一行环境变量,最快;代价是把/run/user/1000固定写死,换机器或换 UID 都得跟着改。 enable-linger:重启后用户实例仍在,/run/user/会被创建;只是把前提补齐,没改变「系统服务借用用户总线」的耦合。- 改
--service:绕开 cgroup 迁移权限问题,从系统级服务拉起更稳;任务不再由用户实例托管,资源统计与归组的收益打折。 - 改造为系统级 service:最彻底,可配合
User=指定运行身份;代价是任务要落单位文件,动态派发的灵活性下降。
留在笔记里的判断
auto-update 不是免费的。这次带进来的是执行环境的隐式约定——新版要求 systemd 用户总线,升级前没有任何检查。
日志要分层看:派发类错误先查 agent 侧,连接类错误先查 gateway 侧。任务面板的时间戳会骗人,output 日期滞后一天这种「监控数据本身过期」,比没有监控更危险。
看门狗只做到进程隔离不够。这次进程确实在独立 crontab 里,但探针逻辑走了 gateway 的派发链路,故障域重合。修复方向是健康检查改为直连任务产物文件时间戳、数据库写入记录,绕开派发层。
fail-closed 要配 fail-loud。拒绝执行没问题,但失败信号必须从独立通道传出来,否则就是静默死亡。
审查冗余设计时,真正要检查的是第二套机制和第一套是否共享故障域,不在于有没有第二套机制。共享了就不是冗余。
还没想清楚的
看门狗改成直连产物文件之后,怎么区分「任务没跑」和「任务跑了但没写文件」;以及动态派发的灵活性,值不值得为它保留用户总线这条依赖。
参考资料
- systemd-run(1)
- loginctl(1)
- serverfault:Running systemd-run --user inside a systemd service
- unix.stackexchange:su / sudo -u 与登录会话、user bus 地址设置相关讨论