故障档案

一天,247 次 OOM

一场波及全站用户掉线的故障根因不是单一 bug,而是九个独立缺陷在内存压力下叠加放大;修复需要基础设施和业务代码两条线并行推进。

247 次。

一个容器一天被 cgroup OOM 杀了 247 次。每次 kill 加 supervisord 拉起新进程,22 秒的启动窗口里所有 API 调用都在超时。累计掉线时间超过 1.5 小时。而这只是冰山一角——整个故障链条里还有八个独立的缺陷在同步放大。

2026-05-24 下午 17:30 起,大量用户反馈掉线。表面上是三种完全不同的症状:网关无响应、插件拿不到 token、WebSocket 频繁断线重连。如果顺着单一假设深挖——比如"是不是 token 服务故障"——永远也理不清楚。症状看起来毫无关联,但其实源于同一条触发链的不同环节失效。

为什么症状分散,根因却统一

SSH 进服务器第一件事:

sudo dmesg -T | grep "Killed process.*openclaw" | head -20

输出:

[21:35:14] Killed process 1409836 (openclaw) total-vm:1570176kB, anon-rss:512000kB
[21:35:15] Killed process 1409924 (openclaw)
[21:35:52] Killed process 1413381 (openclaw)

2 分钟 15 次 kill。内存限制 500MB 的容器在高负载下不够用,系统开始清理。gateway 进程死后,supervisord 重启它,22 秒内启动完成才能服务请求。这个窗口里 panel API 全部超时。现象一:网关掉线。

但这还解释不了"token 过期"。进容器看日志,大量这样的:

[21:39:39] [reload] config watcher error: EMFILE: too many open files
[21:44:10] [skills] watcher error: EMFILE: too many open files

同时 registerCpaConnectorMcp 失败。查 DB 对比,容器的 openclaw.json 里的 token 与数据库的 active token 不一致。这说明配置写入本身出了问题。

检查代码 apps/api/src/connector/mcp-register.ts

if (r.exitCode !== 0) {
  logger.warn({...}, 'mcp register exited non-zero');
  return { status: 'error', reason: `exit=${r.exitCode}` };
}

openclaw CLI 用 exit code 1 表示"完成但有警告"。代码把所有非零当失败。虽然 stderr 里有 Config overwrite 痕迹(文件其实已写入),但这个误判触发了 clearReconcileCooldown,下次 WebSocket 重连时又重试一遍。每次重试就是一次 SIGTERM。最终统计:1366 次 mcp register 报失败(大部分是假失败),对应 1366 次虚假 SIGTERM。现象二:网关频繁掉线。

现象三:WebSocket 4-9 分钟就断一次。查 CDN 日志,Tencent EdgeOne 对 idle 连接有强制切断策略。客户端自动重连,形成流量风暴。

三个症状,三个独立的源头,但都指向同一条链路的不同节点失效。正因为如此,修复不能是单一 patch——需要同时拆除这条链上的所有地雷。

九个缺陷与它们的叠加效应

做完全景取证后,缺陷清单出现了:

  1. B1 容器内存限制 500MB 严重不足 → gateway 进程 OOM kill → 22s 启动窗口 → panel 调用超时
  2. B2 CLI exit code 1 被误判为失败 → 1366 次虚假 SIGTERM → 指数级恶化掉线
  3. B3 ulimit nofile=1024 太低 → EMFILE → file watcher 失败 → 配置无法热重载
  4. B4 mcp reconcile cooldown 在 skip 时也被清空 → 每次 WS 重连重试 → 无效 SIGTERM 更多
  5. B5 registry kick-on-replace → 多客户端互踢 → 工具调用中途断裂
  6. B6 /instance/provision 不幂等 → 新用户注册失败 → 14 次 AlreadyExists 错误/天
  7. B7 event loop 阻塞 7-18 秒 → tools/call 5 分钟超时 → 整机压力指数级增加
  8. B8 CDN 对 WebSocket idle 4-9 分钟强制切断 → 客户端循环重连 → 流量风暴
  9. B9 7 个用户仍跑老 EXE → 跟 Tauri 1.0.8 共存互踢 → 特定用户反复掉线

单独看,B3(文件描述符限制)只会导致某些 watcher 失败。B4(cooldown 清空)只是重试次数多一些。但当 B1(内存溢出)把整机资源挤到极限时,B2(虚假 SIGTERM)的流量放大、B4 的无限重试、B8 的 CDN 断线形成了一个正反馈循环。12GiB swap 活跃交换,主线程不断触发 page fault,event loop 阻塞最高达 18.8 秒。整个服务器的 system load 从正常的 2-3 飙升到 18.96。

不止受害的那个 247 次 OOM 的容器宕掉了。所有 372 个容器的网关进程都在 event loop 阻塞中挣扎,形成连锁故障。

凌晨部署与并行推进

修复分两条线并行。

基础设施层(立即上线):部署 WebSocket 直连入口,绕过 CDN 的 idle timeout。DNS 添加新直连域名的 A 记录,Caddy 仅暴露 /api/connector/ws/health。客户端 1.0.9 OTA 时读 ws_endpoint 字段,动态切换通路。

业务代码层(业务低谷部署):五个 patch,530 行改动:

  • B1:500MB → 2GB hard limit
  • B2:exit code 判定加 stderr marker 检查
  • B3:ulimit nofile 1024 → 65536
  • B4:cooldown skip 时不清
  • B6:/instance/provision 加 AlreadyExists 幂等处理

P1 级(稍后):B5 改为拒绝新连接而非踢掉旧连接;B9 的老 EXE 用户做 revoke 和自检。

凌晨 02:00 开始。前置备份 30 分钟(PG、repo、Caddyfile、image、swarm spec、用户数据)。

阶段 1 代码部署:pnpm build,rsync dist 到服务器,原子切换(mv),restart cpa-api。全程 5 秒,用户感知的掉线比预估的 30 秒更短。

阶段 2 监控验证:部署后 5 分钟内检查指标。B2 生效(1366 失败 → 9h 内 132)、B4 生效(cooldown 日志出现)、B6 生效(新注册零错误)。全部通过。

阶段 3 滚动升级:优先升级 6 个重灾户(含 247 次那个),5.5 分钟完成。升级后检查 dmesg,无 OOM kill。剩余 ~366 个容器一次 10 个并行,间隔 30 秒。单容器 docker service update 耗时 60 秒(含 swarm scheduler delay)。总耗时 6h42min(预估 3 小时,实际慢一倍)。但系统压力从升级到 ~80 个容器时就开始回落。

0 failed,0 回滚。

数据说话

部署 24 小时后对比:

指标部署前部署后 9h改善
容器 OOM kill(重灾户)247 次/天0100%
Swap 使用12 GiB9 MiB-99.9%
System load(1min)18.968.46-55%
可用内存58 GiB95 GiB+64%
mcp register 真失败1366/天(混+假)132/9h~88% ↓
mcp register 假失败救活026 次新指标
AlreadyExists 新用户错误14/天0100%
connection replaced 工具中断18/天0100%
tools/call exception35/天2~95% ↓

用户反馈中"网关掉线"消失,"插件用不了"归零。

防回归与巡检

五个 patch 配套 19 个单元测试 case(vitest,135ms 跑完),覆盖 B1/B2/B3/B6 的临界条件。新增 PG 自动备份 cron(每日 03:00),swarm spec 全量快照存档用于灾难重建。部署 checklist 和回滚脚本纳入标准流程。

但多缺陷叠加的"系统级阈值"仍缺持续巡检——这不能用传统的单点告警解决。需要在后续单独设计。

调查多缺陷故障的方法

多缺陷叠加时,症状看起来统一但根因分散。不能顺着单一假设深挖(比如假设"一定是 token 问题"),会被带进死胡同。正确的做法:

  1. 全景取证:把所有异常信号(OOM、EMFILE、token 不一致、event loop 阻塞、WebSocket thrash)列出来,不预判因果关系。
  2. 分类而非追踪:不要从某个症状开始追链条,而是把信号按系统层级分组,看哪些是独立根因、哪些是被放大的现象。
  3. 按影响面排优先级:单个 bug 的修复收益不一定最大。有时修复跨层级放大的系统级缺陷(比如错误的 exit code 判定 + 无 cooldown 保护)的收益,远超修复看起来最直接的单个 bug(比如内存限制)。

这次故障的真正杀伤力不来自 B1 的内存溢出,而来自 B2(虚假 SIGTERM)+ B4(cooldown 清空)的组合——它们把单个容器的故障放大成了全站连锁反应。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。