← blog 技术原理与实践 · 2026-08-03

mini-agent 多租户后 pi RPC 起不来的排障复盘

多租户更新后云端 /task 静默失败:pi RPC 起不来。根因有两个——config.yaml 被 git pull 覆盖掉 pi_path,以及 _workspace_for 把相对路径误当成 bwrap 绑定目标,破坏了整个文件系统命名空间。

7 min read

mini-agent 是我的个人 AI 助手守护进程。Cloud 版(ECS 云主机)走上多租户后,/task、/query、/chat 这些命令统一走 pi RPC 子进程,子进程外面套着两层沙箱:env 白名单(防止 CLAUDE_API_KEY、TELEGRAM_BOT_TOKEN 等凭据泄漏进去)+ bubblewrap 文件系统隔离。多租户更新部署完后,用户反馈云端服务”pi RPC 又起不来了”。这次排查最后发现不止一个根因,而且中间踩了不少”测试假象”的坑。本文按时间线保留完整的技术探索过程。

现象:一次静默失败

先看服务状态:

systemctl --user status mini-agent   # active,进程活着

服务在跑,但 /task 返回 HTTP 444,且是空响应体。这个 444 是 silent_errors 模式的产物——channels/web.py 里 _unauthorized 和 _bad_request 在静默模式下都返回 444 而不是 JSON 错误体,让 nginx 能无痕关闭连接。所以”444”本身并不能告诉我们失败在哪一层,这是第一层迷雾。

更关键的是:web 接口的请求体字段是 text,不是 message。 我一开始用 {"message": "..."} 去测,返回的一直是 444(_bad_request),我误以为是鉴权失败,绕了一大圈。换成 {"text": "..."} 之后,才终于看到真正的 pi 错误:

{"ok": false, "error": "Pi RPC process exited unexpectedly"}

到这里才确认:鉴权是过的,走到 pi 执行了,但 pi 子进程一启动就崩。

第一层根因:config 被 git pull 覆盖

直接看 ECS 的 config.yaml:

pi_path: ${PI_PATH:-/home/worker/.local/bin/pi}   # ← 默认值是本机路径

ECS 用户是 deploy-pro,/home/worker 这个目录根本不存在。而 PiRpcClient 用 create_subprocess_exec(self._pi_path, ...) 直接 exec 配置里的路径,没有 PATH 兜底,所以 spawn 就失败。

为什么会被覆盖?ECS 的 config.yaml 是 skip-worktree 的(云端专属配置,不让 git pull 覆盖)。但多租户部署那天 skip-worktree 一度丢失,在丢失的窗口里 git pull 把 tracked 的本机 config 覆盖到了云端,pi_path 被重置回本机默认值。之后虽然重新设了 skip-worktree,但覆盖已经发生。

修复:在 ECS 的 .env 里加一行。.env 是 gitignored,git pull 永远碰不到它,正好堵住这个复发根因:

PI_PATH=/home/deploy-pro/.local/share/pi-node/node-v22.23.1-linux-x64/bin/pi

这里有个值得注意的细节:真正的 pi 二进制其实是个符号链接

bin/pi -> ../lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js

它的 shebang 是 #!/usr/bin/env node。这意味着 pi 到底跑在哪个 node 上,取决于 PATH 里 node 解析到谁——这为后面埋了一个坑。

第二层根因:相对路径破坏 bwrap 的整个文件系统

修好 pi_path、重启服务后,/task 依然报 “Pi RPC process exited unexpectedly”。我直接复现了 tenant 模式(多租户下每个用户有自己的 workspace)的调用:

# code_task.py:_workspace_for
base = Path("data/workspaces") / str(user_id)   # ← 相对路径!

_workspace_for 返回的是相对路径 data/workspaces/1。这个相对路径被同时用作两处:

  1. bwrap 的 --bind data/workspaces/1 data/workspaces/1 的目标(dest)
  2. pi 子进程的 cwd

我为了定位,直接在 shell 里跑 bwrap 探针,结果发现一个惊人的现象:连 /bin/true 都 exec 不了,报 bwrap: execvp /bin/true: No such file or directory。而 _probe_bwrap 里用最小绑定集却是能跑通的。

问题出在 /bin/true 是动态链接的,interpreter 是 /lib64/ld-linux-x86-64.so.2:

file /bin/true   # ELF ... dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
ldd /bin/true    # /lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2

bwrap 对相对的 bind 目标会误解析,把它当成名字空间根目录上的一层,等于把 /lib64/ld-linux-x86-64.so.2 这个动态链接器挡在了名字空间之外。于是任何动态链接的二进制都起不来——不只是 pi,连最小的 /bin/true 都不能 exec。这就是为什么错误信息跟 pi 无关、却总在 pi 这一步暴露。

我用二分法定位到罪魁:

# 只加 cwd 绑定,/bin/true 就崩;加 pi 的各个子目录 ro-bind 都没事
+pi-bin:   rc=0   # 正常
+dist:     rc=0   # 正常
+5-subdirs: rc=0  # 正常
+cwd:      rc=1   # bwrap: execvp /bin/true: No such file or directory

修复是一行:把 workspace 先 resolve 成绝对路径,再 mkdir、返回。这跟 agent.py:359 对共享 pi_workspace 的处理方式保持一致:

base = (Path("data/workspaces") / str(user_id)).resolve()
base.mkdir(parents=True, exist_ok=True)
return base

commit 9ed51f2。

三个差点误导的”测试假象”

这次排查最花时间的地方,是我自己造出来的假象,值得单独记下来:

  1. cwd="." 会复现相对目标 bug。 我最初直接测 PiRpcClient 时传了 cwd=".",结果 _build_bwrap_argv 生成 --bind . .——相对目标,同样砸碎名字空间。所以”直接测也崩”一度让我以为 bug 在沙箱包装上,其实是我传参传错了。所有直接测试必须传绝对 cwd。

  2. ssh shell 的 PATH 和 systemd 服务的 PATH 不一样。 ssh 登录 shell 的 PATH 把系统 /usr/bin/node(v20)排前面;而 systemd 服务的 PATH 把 pi-node 的 v22 bin 排前面。pi 的 cli.js shebang 是 #!/usr/bin/env node,所以在 ssh 里直接跑 pi 会撞上 undici 的版本不兼容:

TypeError: webidl.util.markAsUncloneable is not a function
    at new CacheStorage (.../undici/lib/web/cache/cachestorage.js:20)

而 v22 下跑得好好的(pi --version → 0.83.0)。所以”真机上 pi 是坏的”也曾经是假象——服务里用的是 v22。

  1. 启动日志里的 ro_paths 不列 pi 路径,是正常的。 agent.py 打印的 Pi sandbox config: ro_paths=[...] 只包含 config 里的额外项 + HOME 自动推导的路径(.ssh、.gitconfig、blog 仓库)。真正给 pi 用的 ro-bind(bin/、node_modules/ 等)是在 _build_bwrap_argv 里运行时单独算的,不打印。所以”日志里看不到 pi 路径”容易被误读成”pi 没挂载”,其实挂载是好的。

验证

修复后,ECS 上端到端跑通:

POST /task  →  HTTP 200  {"ok": true, "response": "PI_OK"}
result-2026-08-03.log  →  [task] u1  reply with exactly: PI_OK

本地 uv run pytest 全量 408 通过,本地服务也重启加载了新代码。

复盘

这次两个根因都来自”多租户更新”:

  • config 层面的:git pull 对 skip-worktree 的防护是”靠 flag 存在”,flag 一旦丢失就裸奔。把云端特有变量(PI_PATH)挪到 gitignored 的 .env,从根上免疫 git 同步。
  • 代码层面的:bwrap 的绑定目标必须是绝对路径,这是它的硬约束。相对路径的 --bind 不是”报错”而是”静默砸掉整个名字空间”,所以最隐蔽——它不报 pi 相关的错,却让所有动态链接二进制都起不来。

调试时最贵的不是 bug 本身,而是我自己造成的假象。凡是”直接测也崩、服务里却可能正常”的结论,先检查测试参数(cwd 绝对/相对、PATH 顺序、请求字段)是否和生产环境一致,再下结论。

相关文章:《mini-agent ECS 沙箱排障复盘》(resolv.conf DNS 那一次)、《mini-agent 多租户隔离工程复盘》。

Sources

No external sources for this entry.

Related