跳到正文
缓存
返回

一次 paseo restart 为什么让服务重启后仍然连不上

更新于:

一次看似普通的 paseo restart,让我的 Paseo 服务下线了十几分钟。更反常的是,重启整台服务器也没有恢复:systemd 一直尝试拉起服务,又一次次被“已有 daemon 正在运行”的提示挡回来。

最后发现,问题不在端口、网络或客户端,而在一个很小的文件:~/.paseo/paseo.pid。

触发条件也很有代表性:重启命令不是直接在宿主机终端中执行的,而是由一个运行在隔离环境中的编程 Agent 执行。这个环境与宿主机共享 home 目录,却使用不同的 PID namespace。两边看到了同一份 PID 文件,却对文件里的数字有完全不同的理解。

这篇文章记录完整的故障链路、为什么服务器重启无效、怎样安全恢复,以及我给 Paseo 上游提交的修复方案。

事故是怎样发生的

事情起因很简单:我刚更新了 Paseo CLI,怀疑服务进程仍然运行着旧版本,于是让 Agent 重启 Paseo。

当时宿主机上的 Paseo 由 systemd 管理:

systemd
  └─ Paseo supervisor(宿主机 PID 10257)
       └─ daemon worker(宿主机 PID 10315,版本 0.6.1)

但是 Agent 所在的执行环境有独立的 PID namespace。它可以读写宿主机的 /root/.paseo,却看不到宿主机的 Paseo 进程。在这个环境里运行 paseo restart 后,新 supervisor 获得了 namespace 内的 PID 14,并把它写进共享的 paseo.pid。

日志呈现出的时间线很清楚,以下时间均为 UTC:

时间事件
07:16:28隔离环境中的 Paseo 以 PID 14 启动 daemon worker
07:16:32宿主机原 supervisor 发现 PID 锁所有权改变,停止 worker
07:16:38原 worker 收到 SIGTERM 并退出,远程连接中断
07:19:54服务器重启,但 /root/.paseo/paseo.pid 仍保留 PID 14
07:20 起systemd 反复启动 Paseo,均报告“PID 14 已有 daemon 在运行”
07:31:57清理错误状态并重新启动后,服务恢复
07:32新 supervisor 和 worker 启动,版本为 0.7.2,relay 重新连接

最关键的一步发生在 07:16:32:共享 PID 文件被隔离环境覆盖后,原 supervisor 主动让出了锁并关闭 worker。执行重启命令的 Agent 自己也随连接断开,没能完成一个可靠的宿主机级重启。

为什么 PID 文件会骗人

PID 只在所属的 PID namespace 中有意义。

隔离环境                          宿主机

Paseo supervisor = PID 14         PID 14 = 另一个无关进程,或已被复用
          │                                   │
          └──── 写入共享文件 ────────────────┘
                 ~/.paseo/paseo.pid
                       pid: 14

当时 Paseo 判断进程是否存活的核心方法,相当于:

process.kill(pid, 0);

信号 0 不会结束进程,只用于检查当前 namespace 中是否存在这个 PID,以及调用者是否有权限向它发信号。这在单一宿主机环境中通常够用,但它只能回答“有没有 PID 为 14 的进程”,回答不了下面两个更重要的问题:

  1. 这个 PID 是否来自与当前进程相同的 namespace;
  2. 这个进程是否真的是持有锁的 Paseo supervisor。

于是两个方向都会出错:

这就是故障最麻烦的地方:它先产生了假阴性,允许第二个进程抢锁;随后又产生假阳性,阻止真正的服务恢复。

为什么重启服务器也没用

一般来说,重启服务器会清空所有进程,因此很多 PID 锁问题会自然消失。但这次 PID 文件存放在持久化的 home 目录中,并不会随重启删除。

开机后,systemd 尝试启动 Paseo。Paseo 读到 paseo.pid 中的 14,再检查宿主机 PID 14。启动早期的小 PID 很容易被 systemd 服务或其他系统进程使用,于是检查成功,Paseo 便认为“另一个 daemon 已经运行”,主动退出。

systemd 随后再次尝试,又遇到同一份 PID 文件。现场日志中,这个循环发生了一百多次:

Another Paseo daemon is already running
(PID 14, started 2026-09-04T07:16:28.997Z)

因此,“机器已经重启”并不能证明持久化 PID 文件是正确的。只检查 PID 是否存在,也不能证明对应进程就是原来的锁所有者。

与 issue #1304 有什么关系

Hermes 首先指出,这个现象与 Paseo 的 issue #1304 很像。两者的共同根因都是:PID 文件记录的是一个容易复用、缺少身份信息的数字,而存活检查把“同号进程存在”当成“原 daemon 仍存在”。

这次事故又多了一层:锁不是单纯在同一 namespace 中变旧,而是由共享文件系统、不同 PID namespace 的组合触发。隔离环境可以改锁文件,却无法观察真正的锁所有者。因此我又提交了更具体的 issue #4307,把可复现的 namespace 场景、日志时间线和 systemd 重启循环补充给上游。

另一个相关问题是 issue #1555:如果重启命令来自 Paseo 自己承载的 Agent 会话,关闭 daemon 也会切断负责执行后续动作的控制通道。这解释了为什么“从 Paseo 里面重启 Paseo”本身就需要格外谨慎。

现场怎样安全恢复

如果遇到同类故障,不建议一上来就删除 PID 文件。先确认没有真正的 Paseo daemon 在运行,否则可能启动两个实例并让状态进一步混乱。

可以按以下顺序检查:

systemctl status paseo
journalctl -u paseo --since "30 minutes ago"
cat ~/.paseo/paseo.pid
ss -ltnp | grep 6767
pgrep -af paseo

确认 systemd 服务已经停止、Paseo 没有监听端口、也没有真实的 daemon 进程后,再把可疑锁文件移走。使用 mv 比直接删除更方便复盘和恢复:

systemctl stop paseo
mv ~/.paseo/paseo.pid ~/.paseo/paseo.pid.stale
systemctl start paseo
systemctl status paseo

最后还应检查 daemon 是否真的监听、客户端或 relay 是否重新连接,而不是只看到 systemctl start 返回成功:

ss -ltnp | grep 6767
journalctl -u paseo -n 100 --no-pager

我的现场在清理错误锁状态并重新启动后恢复。新 worker 使用 0.7.2,监听 127.0.0.1:6767,relay 也重新建立了连接。

提交给上游的修复方案

单纯把“看不见 PID”改成“锁一定有效”也不行。容器崩溃、namespace 消失或进程异常退出后,锁必须能够自动回收,否则只是从误抢锁变成永久死锁。

Paseo 的 PID 文件本身已有 heartbeat。运行中的 supervisor 会周期性更新锁的时间戳,因此可以把 PID 探测和 heartbeat 组合起来:

能看见 PID
  └─ 锁有效

看不见 PID,但 heartbeat 仍然新鲜
  └─ 可能位于另一个 PID namespace,暂时保护锁

看不见 PID,heartbeat 也已超过宽限期
  └─ 所有者大概率已经消失,允许回收锁

我在 PR #4308 中实现的策略是:

这个改动专门堵住“共享状态目录、不同 PID namespace”造成的即时抢锁窗口,同时不会让已经死亡的隔离环境永久占锁。

Code review 又发现了一个竞态

PR 转为 Ready 后,Greptile 在 review comment 中指出了一个 TOCTOU(time-of-check to time-of-use)问题。初版代码先检查 heartbeat 是否陈旧,稍后再删除锁;这两个操作之间存在时间窗口:

新进程读到陈旧 heartbeat
          ↓
原 owner 恢复并刷新 heartbeat
          ↓
新进程仍按旧判断删除锁并创建自己的锁
          ↓
原 owner 下次刷新时发现所有权丢失并退出

这会在 heartbeat 恰好越过陈旧边界时重新制造“活锁被删除”的故障。评论是正确的,因此我在提交 8139b8c67 中增加了删除前的二次复核:重新读取锁,确认 owner 没有变化,再检查一次 heartbeat;任一条件发生变化都放弃回收。

为了稳定复现这个极短的时间窗口,测试使用可替换的文件系统接口,在第一次读取到陈旧 mtime 后立即刷新真实临时文件。旧代码会错误地成功抢锁,新代码则拒绝回收并保留原 owner。

最终测试覆盖了三个边界:

  1. 不可见 PID 持续刷新 heartbeat 时,新进程不能覆盖它的锁;
  2. 不可见 PID 的 heartbeat 陈旧两分钟后,新进程可以回收它的锁。
  3. 第一次检查后、真正删除前到达的 heartbeat 必须阻止锁被回收。

第三个测试在初版补丁上会稳定失败,完成二次复核后 PID 锁测试 11 项全部通过,server 构建、server 类型检查、全仓 lint 和 format 也通过了。修复说明已回复到原 review thread,Greptile 复审通过且没有提出新问题。

该 PR 已转为 Ready for review,但更新本文时仍在等待上游维护者审批,尚未合并,因此不能把它当成已发布版本的行为保证。

运维上可以先做什么

在上游修复发布之前,我会遵循几条更保守的规则。

第一,如果 Paseo 由 systemd 管理,就从独立的宿主机管理通道执行:

systemctl restart paseo

不要在 Paseo 自己启动的 Agent 会话或共享 home 的容器中执行 paseo restart。重启控制平面时,至少要保留一个不依赖该控制平面的 SSH 或控制台会话。

第二,升级 CLI 后不要只看包管理器显示的版本。应同时核对正在运行的 daemon 日志或进程版本,确认旧 worker 已经退出、新 worker 已经就绪。

第三,把 PID 文件看成一种带租约的协调状态,而不是进程身份。只保存 PID 的锁在容器、namespace、快速 PID 复用和持久化 home 目录下都很脆弱。更完整的长期方案可以继续加入进程启动时间、boot ID 或随机实例 token;heartbeat 宽限期则是一个改动较小、兼容现有格式的修补。

这次故障真正提醒我的事

“沙箱”不是一个简单的隔离开关。执行环境可能隔离网络和 PID,却共享文件系统;也可能能修改服务状态文件,却没有能力管理宿主机服务。这种不对称权限尤其危险,因为命令看起来执行成功,影响却跨过了隔离边界。

对编程 Agent 来说也是如此。让 Agent 管理它所依赖的控制平面,会形成一个自我终止链路:它可以发出停止命令,却可能在完成恢复和验证之前就失去连接。真正可靠的自动化不仅要有“重启”动作,还要考虑动作由谁执行、从哪个 namespace 执行、失败后由谁接管,以及验证路径是否独立于被重启的服务。

这次事故最终只损失了十几分钟,却暴露了一个很典型的现代 Linux 协调问题:同一个路径,不代表同一个进程视图;同一个 PID,更不代表同一个进程。

相关链接


分享这篇文章:
编辑文章
上一篇
三次鹈鹕骑车实验:一次失败,能说明模型不行吗?
下一篇
从 Bitwarden 到 Vaultwarden:一次比想象中轻松的自托管迁移