OpenClaw Docker `healthy` 阶段内存排查记录
- 日期: 2026-03-29
- 记录位置:
/home/jay/openclaw-docker-memory-investigation-2026-03-29.md - 目标: 分别启动以下 3 个镜像,记录从
docker run -d到容器首次进入healthy这段期间的最大内存,并继续排查为什么latest明显更重
涉及镜像:
ghcr.io/openclaw/openclaw:latestghcr.io/openclaw/openclaw:2026.3.24ghcr.io/openclaw/openclaw:2026.3.23
1. 先说结论
这次排查最后拿到的核心结论有 4 条:
1. latest 的启动内存峰值明显异常,基线实测约 2.111 GiB,而 2026.3.24 和 2026.3.23 分别只有 587.2 MiB 和 427.3 MiB。
2. 问题不在 Docker 自己,也不在 canvasHost 或 controlUi,因为把这些关掉以后,latest 还是会冲到接近 2.0 GiB。
3. 问题高度集中在插件启动链路。最关键的对照实验是: latest 在配置里设 plugins.enabled=false 后,峰值直接掉到 478.9 MiB,并且 7s 左右就进入 healthy。
4. 更像是“插件系统在启动早期先把一大坨 bundled plugin / provider catalog / manifest 读进来登记一遍”,而不是某一个单独功能比如 UI 或 canvas 把内存吃爆。
一句人话版:
latest 像是开机先把整个仓库所有纸箱都拆开点数,再慢慢往回塞;老版本更像是只拿当天要用的几个箱子。
2. 测试环境
2.1 Docker 环境
实测环境里的 Docker 版本:
Docker version 29.3.0, build 5927d80
ServerVersion: 29.3.0
2.2 镜像基础信息
镜像元信息里有这些关键点:
- 3 个镜像的默认启动命令一致:
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node","openclaw.mjs","gateway","--allow-unconfigured"]
- 健康检查也一致:
CMD-SHELL node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
Interval: 3m
Timeout: 10s
StartPeriod: 15s
Retries: 3
这个点很重要,因为它解释了为什么有些镜像“应用其实早就起来了”,但 healthy 状态还要等一阵子。容器是否已经可用,和 Docker 什么时候把它标成 healthy,不是同一件事。
2.3 本次测试使用的镜像 digest
| 镜像 | Digest |
|---|---|
| `latest` | `sha256:5900559f795ef15ea2f0b1fc488726d9b27bb2de398424c14d13c9b1f1ff0d66` |
| `2026.3.24` | `sha256:7091859602df6b8cdd59b38adbaed723a6d94806fdd4274d488400dd2fcf0fb6` |
| `2026.3.23` | `sha256:f34cc7c00de4bea450c3459ca38b35364603b5be592873d86faf4d03c1492f4b` |
3. 测试口径
为了保证这次数据能横向比较,我统一用了下面这套口径:
- 3 个镜像是“分别启动”,不是同时启动。
- 统计窗口是:
从 docker run -d 成功返回开始,
到 docker inspect 里 State.Health.Status 第一次变成 healthy 为止。
- 内存数据来自
docker stats --no-stream。 - 采样策略:
- 启动前 30 秒采样更密
- 后面每秒或每几秒继续采样
- 记录整个窗口里的最大值
- 每个镜像测完就删掉测试容器,避免互相污染。
换句话说,这次测的是:
“容器从点火到 Docker 正式判它健康这段路上,最高冲到了多少内存。”
4. 基线结果
4.1 最终结果表
| 镜像 | 进入 `healthy` 用时 | 峰值内存 | 峰值出现时间 |
|---|---|---|---|
| `latest` | `199s` | `2.111 GiB` (`2161.66 MiB`) | `45s` |
| `2026.3.24` | `11s` | `587.2 MiB` | `9s` |
| `2026.3.23` | `11s` | `427.3 MiB` | `9s` |
4.2 第一眼能看出的事
latest不只是更慢,还是“明显更胖”。latest的峰值大约是:
- 2026.3.24 的 3.6x
- 2026.3.23 的 5x
2026.3.24和2026.3.23在启动行为上很接近。- 真正怪的是
latest,不是“老版本都轻,最新版本也差不多”这种情况。
5. 我是怎么一步步排的
这一节按真实排查顺序写,不只写结果,也写中间判断是怎么做的。
5.1 先确认不是环境问题
先做了 3 件基础检查:
1. 看 Docker 是否正常。
2. 把 3 个目标镜像都拉下来。
3. 看镜像自带的健康检查到底怎么配的。
这样做的目的很简单:
- 如果 Docker 本身异常,后面的数据都不可信。
- 如果 3 个镜像启动命令不同,也没法直接比。
- 如果健康检查策略不同,也会影响“进入 healthy 用时”的解释。
结果:
- Docker 正常。
- 3 个镜像的
ENTRYPOINT/CMD一致。 - 健康检查也一致。
所以可以继续往下比。
5.2 先跑一轮基线,别一上来就猜
我没有直接开始脑补“是不是某个插件”,而是先按统一口径把 3 个镜像都跑完,拿到第一手数据。
这一步最重要的价值是:
- 先确认异常到底有多大
- 确认是不是稳定可复现
- 先把“问题在 latest”这件事坐实
结果就是前面的基线表:
latest:2.111 GiB2026.3.24:587.2 MiB2026.3.23:427.3 MiB
到这里为止,还只能说“latest 异常”,还不能说“为什么异常”。
5.3 先看健康检查时间,避免被假象带偏
latest 有一个很迷惑人的现象:
- 它很长时间都停在
starting - 但这不一定等于“应用没起来”
所以我单独盯了它的健康状态变化和日志,确认了两件事:
1. 它最终是能进入 healthy 的,不是容器挂了。
2. healthy 很晚这件事,除了应用启动慢,也和 Docker 健康检查节奏有关。
观察到的一个典型时间线:
- 前几次 health check 很快失败
- 真正首次成功是在大约
196s
所以后面分析时,我把“应用日志什么时候出来”和“Docker 什么时候判 healthy”分开看,不混为一谈。
5.4 看进程级内存,先找是谁在吃
接下来最关键的一步,是在容器里直接看进程 RSS。
目的是确认:
- 是不是多个子进程一起吃
- 还是一个主进程自己吃爆
对 latest 采样时看到的典型情况:
| 时间 | 容器总内存 | 主要进程 |
|---|---|---|
| `0s` | `228.6 MiB` | `openclaw` |
| `7s` | `643.3 MiB` | `openclaw-gateway` RSS 约 `696152 KB` |
| `14s` | `1.358 GiB` | `openclaw-gateway` RSS 约 `1409208 KB` |
| `21s` | `1.821 GiB` | `openclaw-gateway` RSS 继续涨 |
| `28s` | `1.982 GiB` | `openclaw-gateway` RSS 接近峰值 |
| `35s` | `1.989 GiB` | 仍然是 `openclaw-gateway` 为主 |
这一步的结论非常明确:
- 不是一堆子进程围殴
- 基本就是单个
openclaw-gateway主进程自己吃上去的
人话版:
不是一群蚂蚁搬空冰箱,是一头河马自己把整盆饲料干了。
5.5 和 `2026.3.24` 做同样的进程对照
同样方式看 2026.3.24,看到的是另一种节奏:
| 时间 | 容器总内存 | 主要进程 |
|---|---|---|
| `0s` | `249.2 MiB` | `openclaw` |
| `4s` | `607.7 MiB` | `openclaw-gateway` |
| `9s` | `608.4 MiB` | 基本稳定 |
| `15s` | `healthy` | 仍然约 `608 MiB` |
这一步说明:
2026.3.24也会把主进程拉起来- 但它的启动内存涨幅更像“正常范围”
- 没出现
latest那种两波夸张的巨峰
5.6 看镜像内容差异,先查“带了什么”
因为 latest 的 Docker 配置基本没变,所以我去看镜像内容本身。
主要看了几类东西:
/app目录结构package.json/app/extensions/app/dist/extensionsnode_modules和dist的体积分布
5.6.1 `/app` 体积对比
latest:
/app/node_modules:1876 MB/app/dist:139 MB/app/extensions:48 MB
2026.3.24:
/app/node_modules:1861 MB/app/dist:134 MB/app/extensions:43 MB
这个结果说明:
node_modules总体积差距不算夸张- 但
dist和extensions确实变大了一点
5.6.2 内置扩展数量对比
统计到的目录数量:
latest:872026.3.24:82
也就是说,latest 确实多带了几类能力。
5.6.3 `latest` 相比 `2026.3.24` 新出现的扩展
/app/extensions 里只在 latest 出现的:
browserimage-generation-corelitellmmedia-understanding-coremicrosoft-foundryspeech-core
只在 2026.3.24 出现、但 latest 没有的:
qwen-portal-auth
/app/dist/extensions 里只在 latest 出现的:
browserlitellmmicrosoft-foundry
这一步只能说明:
latest比2026.3.24确实多了几块扩展- 但还不能证明“就是这几个新增扩展导致 2 GiB 峰值”
所以还得继续做剥离实验。
5.7 看镜像 revision,对应源码提交范围
镜像 label 里可以看到 git revision:
2026.3.24:97a7e93db40118e28e81a709587e3867cb66672elatest/v2026.3.28:f9b1079283a8ee25a7cee77c8f8225d5c813bc30
我把这两个 revision 中间的提交列表和 diff stat 拉出来看了一遍。
不是每个提交都跟问题有关,但里面有几条非常可疑:
refactor: load bundled provider catalogs dynamicallyrefactor: derive channel metadata from plugin manifests- 一系列和
x_search、code_execution、plugin-owned capability 有关的改动 - 新增
bundled-capability-runtime/bundled-capability-metadata一类代码
这一步的意义不是“直接定罪”,而是给后面的实验找方向。
因为这些名字都指向同一个味道:
启动时扫描 bundled plugins / provider catalogs / manifests。
6. 关键剥离实验
这一部分是最有价值的,因为它们不是猜,是直接动手做对照。
6.1 实验 A: 关掉 `canvasHost`
做法:
- 启动
latest - 加环境变量
OPENCLAW_SKIP_CANVAS_HOST=1
结果:
- 峰值仍然约
1.998 GiB - 启动中间那段长空窗几乎没变化
结论:
- 问题不在
canvasHost
6.2 实验 B: 配置里同时关 `controlUi` 和 `canvasHost`
做法:
{
"gateway": {
"controlUi": { "enabled": false }
},
"canvasHost": {
"enabled": false
}
}
结果:
- 峰值仍然约
2.0 GiB - 启动行为和默认
latest还是很像
结论:
- 问题也不在
controlUi - 至少不是 UI / canvas 这类表层功能导致的
6.3 实验 C: 直接全局关插件
做法:
{
"plugins": {
"enabled": false
},
"gateway": {
"controlUi": { "enabled": false }
},
"canvasHost": {
"enabled": false
}
}
结果:
| 指标 | 数值 |
|---|---|
| 峰值内存 | `478.9 MiB` |
| 进入 `healthy` 用时 | `7s` |
日志里还能看到一条:
[plugins] memory slot plugin not found or not marked as memory: memory-core
这很正常,因为插件都被关掉了。
这组实验是整次排查里最关键的一锤子。
它直接证明:
latest的重,不是 Node 自己平白无故变胖- 不是 Docker 自己抽风
- 是插件相关启动逻辑把内存抬上去的
6.4 实验 D: 只关掉这次新增的几个插件
为了避免“是不是 just 某个新插件特别肥”,我把 latest 新增的这几项都关了:
browserimage-generation-corelitellmmedia-understanding-coremicrosoft-foundryspeech-core
并且继续关掉 controlUi / canvasHost。
结果:
- 峰值还是约
1.999 GiB
结论:
- 问题不是简单地由“这几个新增插件”单独造成
- 更像是更底层的插件加载框架,或者插件清单扫描方式变化
6.5 实验 E: 尝试只放行 `anthropic`
为了验证是不是某个 provider 插件特别重,我试了:
{
"plugins": {
"allow": ["anthropic"]
}
}
配合关闭 controlUi / canvasHost。
在 latest 上看到:
- 峰值约
2.013 GiB
在 2026.3.24 上看到:
- 峰值约
1.688 GiB
这个实验给了两个信号:
1. anthropic 这条链路本身并不轻。
2. 但 latest 即使在相近场景下,仍然比 2026.3.24 更重。
不过这里有一个要特别说明的坑:
- 这个实验不是完全干净的单插件隔离
- 因为在
2026.3.24的日志里仍然看到了browser/server启动日志
所以 plugins.allow 更像是“有参考价值,但不是绝对硬隔离”的实验。
6.6 实验 F: 显式把 `anthropic` 关掉
我还试了:
{
"plugins": {
"entries": {
"anthropic": { "enabled": false }
}
}
}
再配合关 controlUi / canvasHost。
结果:
- 峰值还是约
1.99 GiB - 启动日志里仍然有
agent model: anthropic/claude-opus-4-6
这说明了一个很有意思的现象:
- 要么这个配置并没有在内存峰值出现之前生效
- 要么启动早期已经把相关 provider / plugin 代码 import 完了,后面即便“禁用”,该吃的内存也已经吃进来了
这反而更加支持“问题在插件启动早期的加载 / 注册 / 扫描阶段”这个判断。
7. 这次排查中遇到的现场问题
中间碰到过一次宿主机磁盘满了,不记下来容易让后面复现的人踩坑。
当时看到的情况:
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 48G 48G 0 100% /
docker system df 显示:
- Images:
22.59GB - Build Cache:
17.46GB
我没有去清你的运行中容器或大范围 prune,只删了我自己临时克隆的 Git 仓库,大约腾出 205M,然后继续完成后面的实验。
这个插曲本身不影响结论,但说明:
- 在 Docker 层做这类启动排查时,宿主机磁盘也可能成为额外变量
- 如果后面要继续更大规模复测,先留一点磁盘余量会省很多麻烦
8. 为什么我最后判断是“插件启动链路”而不是别的
不是因为某一条日志看起来像,而是因为几组对照实验一起指向它。
证据链大概是这样:
1. latest 默认峰值 2.111 GiB,老版本显著更低。
2. 进程级采样显示,主要吃内存的是单个 openclaw-gateway 主进程。
3. 关 canvasHost 没用。
4. 关 controlUi 也没用。
5. 直接 plugins.enabled=false 后,峰值瞬间掉到 478.9 MiB。
6. 只关几个新增插件不够。
7. 单独禁 anthropic 也不够,说明问题可能发生在“插件框架先整体加载”这一步。
这串证据拼起来,比单看源码提交名更靠谱。
9. 目前最合理的技术判断
9.1 高置信度判断
latest的高内存峰值主要来自插件系统启动过程。- 这个问题发生得很早,早到某些插件级开关可能还没来得及真正阻止 import / 注册 / 扫描。
latest相比2026.3.24,插件相关启动路径更重。
9.2 中等置信度判断
下面这些改动方向很可疑,但我这次没有直接做到源码级定罪:
- bundled provider catalog 动态加载
- 从 plugin manifests 派生 channel metadata
- plugin-owned capability / bundled capability runtime 相关改动
它们可疑,是因为:
- 时间范围对得上
- 代码职责对得上
- 实验现象也对得上
但要说“就是哪一行代码”,还需要下一轮更偏源码/heap profile 的排查。
9.3 暂时没法 100% 定论的点
- 到底是某一个具体 provider 被预加载过重
- 还是 bundled plugin metadata / manifest / catalog 的整体扫描方式变化
plugins.allow和plugins.entries.为什么没能在早期完全阻断相关启动成本.enabled=false
这些问题都值得继续挖,但已经不影响本次“先定位大方向”的结论。
10. 复现建议
如果后面要继续追源码根因,我建议按这个顺序继续,不容易走弯路:
10.1 先保留这次已经证实的最强对照
优先保留这两组:
1. latest 默认启动
2. latest + plugins.enabled=false
这两组差距最大,也最能快速复现问题。
10.2 下一步最值得做的不是再关 UI
controlUi / canvasHost 已经基本排除了,再围着它们转意义不大。
更值得做的是:
- 给
loadGatewayStartupPlugins一类启动入口打分段日志 - 在插件 registry / manifest registry / provider catalog 加耗时和内存埋点
- 如果环境允许,直接对
latest启动过程做 heap profile
10.3 业务层面的临时绕法
如果现在只是想先把服务稳住:
- 先用
ghcr.io/openclaw/openclaw:2026.3.24 - 或者在不需要插件能力的场景下用
plugins.enabled=false
这不是根治,但能立刻止血。
11. 本次关键实验汇总表
| 实验 | 配置 / 条件 | 结果 | 结论 |
|---|---|---|---|
| 基线 `latest` | 默认 | `2.111 GiB`, `199s` | 异常明显 |
| 基线 `2026.3.24` | 默认 | `587.2 MiB`, `11s` | 正常范围 |
| 基线 `2026.3.23` | 默认 | `427.3 MiB`, `11s` | 更低 |
| 关 canvas | `OPENCLAW_SKIP_CANVAS_HOST=1` | 约 `1.998 GiB` | 不是 canvas |
| 关 UI + canvas | `controlUi=false`, `canvasHost=false` | 约 `2.0 GiB` | 不是 UI / canvas |
| 全关插件 | `plugins.enabled=false` | `478.9 MiB`, `7s` | 关键证据,问题在插件链路 |
| 关新增插件组 | 关 `browser` / `litellm` / `speech-core` 等 | 约 `1.999 GiB` | 不是单纯新增插件组 |
| 只放行 anthropic | `plugins.allow=["anthropic"]` | `latest` 约 `2.013 GiB` | provider 链路本身不轻,但实验不够干净 |
| 老版本只放行 anthropic | 同上,镜像改为 `2026.3.24` | 约 `1.688 GiB` | `latest` 仍更重 |
| 显式禁 anthropic | `plugins.entries.anthropic.enabled=false` | 约 `1.99 GiB` | 禁用可能晚于加载成本发生 |
12. 最终归纳
这次排查的结论,不是“某个小开关写错了”,而是:
latest 在启动阶段的插件相关加载路径,比 2026.3.24 明显更重,而且这个重度足以把容器启动峰值拉到 2 GiB 级别。
如果只看表面,很容易怀疑 UI、canvas、浏览器服务这些“看得见的东西”。
但真正一轮轮剥离下来,最有杀伤力的证据反而是:
plugins.enabled=false 一关,世界立刻清净。
这就像排查家里跳闸:
- 一开始你会怀疑是不是空调、是不是电磁炉、是不是热水器
- 结果最后一拉总闸,发现是“整条厨房回路”有问题
这次排查做到的,就是已经把问题从“整栋楼”缩到了“厨房回路”。
下一步如果要继续追,就该去厨房里一条线一条线拆,而不是再回楼下看电表了。