1544 words
8 minutes
mini-agent ECS 沙箱排障复盘

mini-agent ECS 沙箱排障复盘#

mini-agent 是我的个人 AI 助手守护进程,其中 /query/task 命令通过 pi RPC 子进程执行,而 pi 外面套了两层沙箱:env 白名单(防止 CLAUDE_API_KEYTELEGRAM_BOT_TOKEN 等凭据泄漏进子进程)+ bubblewrap 文件系统隔离。这次在 ECS 云主机上启用完整 FS 沙箱后,query 全部返回空文本。本文记录从现象到根因的完整排障过程。

背景:ECS 与本地环境的差异#

ECS 实例与本地开发机有几个关键差异,它们是这次故障的土壤:

  • ECS 运行用户是 deploy-pro,本地是 worker
  • ECS 的 config.yaml 被标记为 skip-worktree,本地对配置的改动不会随 git pull 同步到云端
  • ECS 内核禁止非特权 user namespace,普通 bwrap 直接失败
  • ECS 是 systemd-resolved 系统(这个细节后面会成为主角)

铺垫:写死路径引发的部署错位#

排障前我先修了另一个问题:上一轮临时写进 config.yaml 的沙箱只读路径是 /home/worker/...,在 ECS 上根本不匹配 deploy-pro 的 home 目录。由于 config.yaml 是 skip-worktree,本地改了也同步不过去。

修法是放弃配置写死,改为代码启动时自动推导(commit 437c0b1):

def _existing_auto_ro_paths(blog_posts_dir: str) -> list[str]:
paths = [
Path.home() / ".agents",
Path.home() / "agent-ai",
Path.home() / ".claude" / "skills" / "blog-update",
]
if blog_posts_dir:
blog_path = Path(blog_posts_dir).expanduser()
if blog_path.exists():
paths.append(_blog_repo_root(blog_path.resolve()))
# ...只保留实际存在的路径

同时加了一行启动日志,远端一看就知道推导结果是否正确:

Pi sandbox config: enabled=True ro_paths=['/home/deploy-pro/SGJki-s-blog'] rw_paths=['/home/deploy-pro/SGJki-s-blog']

部署后日志确认全部推导为 /home/deploy-pro/...,路径问题闭环。

正题:setuid bwrap 之后 query 全空#

为了让 ECS 获得完整 FS 隔离(绕过内核的 userns 限制),我把 /usr/bin/bwrap 设为 setuid root。_probe_bwrap 探测通过、启动日志也没有任何报错——但 /query/task 全部失效,返回空文本,而不走 pi 的 chat 功能完全正常。

第一步:用服务完全相同的参数复现#

写了一个复现脚本,在 ECS 上用与服务一模一样的沙箱配置直接调 PiRpcClient

PROBE: True
SUCCESS: True
TEXT:
ERROR:

对照组(关闭沙箱):TEXT: ok。问题锁定在沙箱内。注意这个现象的迷惑性:RPC 层面是成功的——pi 正常启动、正常返回 agent_end,只是没有任何文本输出。

第二步:抓沙箱内 pi 的原始 JSONL#

绕过封装,直接构造 bwrap argv 启动 pi, dump 全部 stdout/stderr,看到关键证据:

{"type":"message_start","message":{"role":"assistant","api":"openai-completions","provider":"dashscope","model":"glm-5.2","stopReason":"error","errorMessage":"Connection error."}}

stderr 里还有一行:

[alibaba] Plan catalog fetch failed (fetch failed); using cached models (15, 17m old).

pi 发出 LLM 请求后 45 毫秒就报了 Connection error.。秒失败的连接错误,指向网络层而不是模型层。

第三步:网络分层验证#

网络问题不要一把抓,按层拆开测。在沙箱内逐层验证:

测试沙箱内沙箱外结论
TCP 直连真实 IP:443✅ 通✅ 通网络命名空间是共享的,没问题
DNS 解析❌ 挂✅ 通故障层定位
curl HTTPS❌ 无法解析主机✅ 通是 DNS 的下游症状

这里有一个假阳性陷阱值得单独说:我最初用阿里云内网 DNS 服务器 100.100.100.200 测 443 端口的 TCP 连通性,结果超时,一度怀疑是 ECS 安全 agent 拦截了沙箱内流量。实际上这台服务器只监听 53 端口,沙箱外测 443 同样超时。换用真实业务 IP(dashscope.aliyuncs.com 解析出的 8.140.217.18)后,沙箱内 TCP 直连成功,才排除了网络命名空间隔离的可能。

第四步:根因——悬空的 resolv.conf 符号链接#

DNS 挂在沙箱内、网络命名空间又是共享的,那问题只能是解析配置本身。一查:

Terminal window
$ ls -l /etc/resolv.conf
lrwxrwxrwx 1 root root 39 ... /etc/resolv.conf -> ../run/systemd/resolve/stub-resolv.conf

systemd-resolved 系统上,/etc/resolv.conf 是个符号链接,指向 /run/systemd/resolve/stub-resolv.conf。而 bwrap 参数只 ro-bind 了 /etc/run 根本不在沙箱的 mount namespace 里——符号链接悬空,DNS 配置不存在。网络是通的,但没有任何域名能解析。

第五步:假设验证#

手动在 bwrap 参数里加上目标文件的绑定:

Terminal window
bwrap ... --ro-bind /etc /etc \
--ro-bind /run/systemd/resolve/stub-resolv.conf /run/systemd/resolve/stub-resolv.conf \
... -- curl -sS -m 10 https://dashscope.aliyuncs.com

DNS 解析恢复,curl 返回 404(到达服务器,路径不存在——这正是期望的网络通畅信号)。假设确认。

修复#

修复落在 _build_bwrap_argv(commit 5a2eb2e),通用化处理而不是写死 ECS 的路径:

# /etc/resolv.conf 常是指向 /run 的符号链接(systemd-resolved)。
# 只 ro-bind /etc 会让符号链接在沙箱内悬空,DNS 全挂。
resolv_target = os.path.realpath("/etc/resolv.conf")
if (
resolv_target != "/etc/resolv.conf"
and os.path.exists(resolv_target)
and resolv_target not in rw_path_set
):
argv += ["--ro-bind", resolv_target, resolv_target]

任何 systemd-resolved 发行版(Ubuntu、Debian、Fedora……)都自动受益;非符号链接的系统(比如用传统静态 resolv.conf 的)行为不变。

配套加了两个回归测试:符号链接场景断言目标文件被 ro-bind,非符号链接场景断言不会多加绑定。全套测试 356 passed。部署到 ECS 后,端到端验证沙箱内真实 pi RPC:

PROBE: True
SUCCESS: True
TEXT: ok

经验总结#

「成功但空」是最具迷惑性的故障形态。 pi 进程正常启动、agent_end 事件正常返回、RPC 层面 success=true——每一层监控信号都是绿的,只有业务输出是空的。排这类问题必须穿透到原始协议事件看 stopReason,不能只看封装层的返回值。

网络问题按层验证,警惕假阳性。 TCP → DNS → TLS → HTTP 逐层拆开测,每层都要和对照组(沙箱外)对比。我拿只开 53 端口的内网 DNS 服务器测 443,得出的”沙箱拦截流量”结论就是典型的测试方法错误,差点把排障方向带偏。

容器/沙箱里 DNS 全挂,先查 resolv.conf 是不是符号链接。 systemd-resolved 是主流发行版默认,/etc/resolv.conf -> /run/... 这个布局在 mount namespace 里极易踩坑。这是个会反复出现的坑,值得记住。

环境差异用代码吸收,不用配置对齐。 ECS 和本地的 home 目录、路径布局不同,靠 skip-worktree 的配置文件保持两端一致是脆弱的;改为 Path.home() 启动时推导后,同一份代码在两个环境自然得到各自正确的结果。

mini-agent ECS 沙箱排障复盘
https://sgjki547.top/posts/2026-07-30-mini-agent-ecs-沙箱排障复盘/
Author
SGJki
Published at
2026-07-30
License
CC BY-NC-SA 4.0