概述
v0.9.3 在生产环境反复出现 内核级 OOM(非 V8 heap OOM):进程 anon-rss 涨到 13.514.2GB 被内核 SIGKILL,周期 47 分钟,单日 8 次并多次拖垮整机。
内存增长发生在 V8 堆外(ArrayBuffer backing store),因此 --max-old-space-size 无效,也不会产生 fatal report。
与 #1408 不同:#1408 是 V8 heap OOM(有 fatal report),其修复(#1414)已包含在 v0.9.3 中;本 issue 是堆外内存,失效模式和触发路径都不同。
环境
- 镜像:
ghcr.io/ding113/claude-code-hub:v0.9.3
- Node:v26.7.0,V8 heap 上限 4.09GB
- 宿主:15GB 内存 / 8 核,Docker Compose(app 初始无
mem_limit)
- 配置:除
ENABLE_MULTI_PROVIDER_TYPES=true、STORE_SESSION_MESSAGES=false 外基本为默认值
- 负载:约 200~550 请求/分钟,客户端为 Claude Code 与 Codex,上游为第三方中转站
观测数据
用 5 秒分辨率采样器记录,起爆时自动抓取 smaps 与 socket 队列。
增长形态(一次典型起爆):
09:22:22 813,868 kB
09:22:27 1,759,180 kB +945 MB / 5s
09:22:33 2,676,196 kB +917 MB / 5s
09:22:38 3,934,496 kB +1258 MB / 5s
09:22:44 5,386,984 kB +1452 MB / 5s
内存位置 —— 增长集中在单块匿名映射内,映射数量恒定:
爆炸期: 3,602,476 kB @ 7f7567ff9000-7f7667ffa000 regions=1779
(4GB 边界对齐,单块 > heap 上限 4.09GB → V8 cage 内的 ArrayBuffer)
正常期: 97,220 kB @ 2bc40000-31c4c000 regions=1101
早期一次采样更明显:regions=1213 全程不变,而 total_anon 从 5.08GB 涨到 8.09GB —— 是既有映射原地长大,不是新建缓冲区。
socket 队列 —— 这是最有指向性的一组:
爆炸期:
recvq=273,206 peer=<上游 CF>:443
recvq=121,701 peer=<容器网关>:33186
recvq=119,274 peer=<容器网关>:44196
recvq=119,274 peer=<容器网关>:44170 ← 多条连接堆积完全相同的字节数
recvq=119,274 peer=<容器网关>:33220
recvq=119,274 peer=<容器网关>:33218
total_recvq=1,337,790 total_sendq=0
正常期:
total_recvq=878 total_sendq=694,177
爆炸时 recvq 大量积压而 sendq 归零,正常时恰好相反。多条连接堆积完全相同的 119,274 字节说明不是随机积压。
其他排除项:连接数在爆炸期恒定(48 / 61~72,不随内存增长);线程恒定 11;data/reports/ 始终为空(V8 从未触及自身上限);replay_payloads 仅 92MB;Redis 399MB。
暴涨期间应用完全静默:日志从 908 行/分钟衰减到 0,采样器记录 req5s=0,即无新请求进入时内存仍在以 370~500MB/s 增长。事件循环被饿死是结果而非原因。
可疑代码路径
起爆前的错误榜首是 undici body stream error (caught early),一个容器生命周期内 141 次,同时伴随 Provider error occurred 122 次、All providers failed 46 次、熔断器触发 —— 上游中转站大面积出错时触发。
src/app/v1/_lib/proxy/forwarder.ts:8292 附近:
const rawBody = undiciRes.body as Readable;
rawBody.on("error", (err) => {
const code = (err as NodeJS.ErrnoException).code;
const isExpectedDisconnect = code === "ECONNRESET" || ...;
const log = isExpectedDisconnect ? logger.debug : logger.warn;
log("ProxyForwarder: undici body stream error (caught early)", {...});
}); // 仅记录日志,未调用 destroy() 或 resume()
同一函数内的 gzip 分支有对应的销毁处理(gunzip.destroy(err),注释引用了 #1147 的段错误教训),但非 gzip 分支的 rawBody 自身错误路径没有。
需要说明:nodeStreamToWebStreamSafe 内部确实注册了 nodeStream.on("error", onError) 并会 controller.error(err) 向下游传播,所以错误并非完全未被处理。我们未能证明具体的对象保留链 —— 抓 heap snapshot 需要在泄漏进行中操作,而当时进程已达 13GB、宿主机在 thrashing,无法安全采集。
一个可能相关的组合是 v0.9.0 引入的需求驱动背压(node-stream-to-web.ts:71 默认 pause(),仅在 pull() 时 resume()):上游流出错后如果下游不再 pull、且该流未被 destroy,socket 数据会持续留在内核 recvq,这与观测到的 recvq 积压 / sendq=0 吻合。这一点是推断,仅供参考。
版本线索
rawBody.on("error") 只记日志的写法自 v0.3.20(2025-11-30,commit eecdb757)就存在
nodeStream.pause() 需求驱动背压在 v0.9.0(2026-07-17,commit 3724315b / 3673105a)引入
- v0.8.10 的
node-stream-to-web.ts 中 pause() 出现 0 次
即单看那个 error handler 长期存在且未致泄漏,问题在 v0.9.x 才显现。
缓解措施与效果
给 app 容器加 mem_limit: 3g + memswap_limit: 3g(后来放宽到 6g)后:
|
加之前 |
加之后 |
| 全机 OOM |
8 次 |
0 次 |
| load average 峰值 |
95.74 |
7 以下 |
| 最低可用内存 |
105MB |
9GB+ |
| 故障影响面 |
整机(16 个容器) |
仅 app 容器自愈重启 |
另外设 ENABLE_REQUEST_REPLAY=false 后观察 10.5 小时未复发,但负载回到白天水位(连接数 20 → 72)后立即再次爆发两次 —— replay 不是根因。
附带一个发现:本部署中 replay 命中率为 0(ReplaySpool 完成 5694 次,日志与源码中均无缓存取用事件)。原因是 replayId 由 bodyHash 派生,而 Claude Code 在流中断后会把已接收内容并入对话历史再发起新请求,body 变化导致 replayId 永不匹配。这与本 issue 无关,但或许值得单独关注。
建议
rawBody.on("error") 中补充 rawBody.destroy(err)(或至少 resume() 排空),与同函数 gzip 分支保持一致
- 为堆外缓冲增加全局配额 —— 目前
REPLAY_MAX_PAYLOAD_BYTES(8MB)、STREAM_GATE_PREBUFFER_BYTE_CAP(10MB)都是单流上限,并发流总量无天花板
- 建议在
docker-compose.yaml 中为 app 服务提供默认 mem_limit,避免单进程失控拖垮整机
复现条件
无法稳定构造,但触发条件较明确:上游供应商大面积返回错误或断流(表现为 undici body stream error 密集出现、All providers failed、熔断器触发),同时保持 200+ 请求/分钟的并发流式负载。我们环境下白天高峰期必现,夜间低负载时不出现。
如需进一步数据(完整采样日志、smaps 序列、socket 队列快照)可以提供。
概述
v0.9.3 在生产环境反复出现 内核级 OOM(非 V8 heap OOM):进程 anon-rss 涨到 13.5
14.2GB 被内核 SIGKILL,周期 47 分钟,单日 8 次并多次拖垮整机。内存增长发生在 V8 堆外(ArrayBuffer backing store),因此
--max-old-space-size无效,也不会产生 fatal report。与 #1408 不同:#1408 是 V8 heap OOM(有 fatal report),其修复(#1414)已包含在 v0.9.3 中;本 issue 是堆外内存,失效模式和触发路径都不同。
环境
ghcr.io/ding113/claude-code-hub:v0.9.3mem_limit)ENABLE_MULTI_PROVIDER_TYPES=true、STORE_SESSION_MESSAGES=false外基本为默认值观测数据
用 5 秒分辨率采样器记录,起爆时自动抓取 smaps 与 socket 队列。
增长形态(一次典型起爆):
内存位置 —— 增长集中在单块匿名映射内,映射数量恒定:
早期一次采样更明显:
regions=1213全程不变,而 total_anon 从 5.08GB 涨到 8.09GB —— 是既有映射原地长大,不是新建缓冲区。socket 队列 —— 这是最有指向性的一组:
爆炸时
recvq大量积压而sendq归零,正常时恰好相反。多条连接堆积完全相同的 119,274 字节说明不是随机积压。其他排除项:连接数在爆炸期恒定(48 / 61~72,不随内存增长);线程恒定 11;
data/reports/始终为空(V8 从未触及自身上限);replay_payloads仅 92MB;Redis 399MB。暴涨期间应用完全静默:日志从 908 行/分钟衰减到 0,采样器记录
req5s=0,即无新请求进入时内存仍在以 370~500MB/s 增长。事件循环被饿死是结果而非原因。可疑代码路径
起爆前的错误榜首是
undici body stream error (caught early),一个容器生命周期内 141 次,同时伴随Provider error occurred122 次、All providers failed46 次、熔断器触发 —— 上游中转站大面积出错时触发。src/app/v1/_lib/proxy/forwarder.ts:8292附近:同一函数内的 gzip 分支有对应的销毁处理(
gunzip.destroy(err),注释引用了 #1147 的段错误教训),但非 gzip 分支的rawBody自身错误路径没有。需要说明:
nodeStreamToWebStreamSafe内部确实注册了nodeStream.on("error", onError)并会controller.error(err)向下游传播,所以错误并非完全未被处理。我们未能证明具体的对象保留链 —— 抓 heap snapshot 需要在泄漏进行中操作,而当时进程已达 13GB、宿主机在 thrashing,无法安全采集。一个可能相关的组合是 v0.9.0 引入的需求驱动背压(
node-stream-to-web.ts:71默认pause(),仅在pull()时resume()):上游流出错后如果下游不再 pull、且该流未被 destroy,socket 数据会持续留在内核 recvq,这与观测到的recvq积压 /sendq=0吻合。这一点是推断,仅供参考。版本线索
rawBody.on("error")只记日志的写法自 v0.3.20(2025-11-30,commiteecdb757)就存在nodeStream.pause()需求驱动背压在 v0.9.0(2026-07-17,commit3724315b/3673105a)引入node-stream-to-web.ts中pause()出现 0 次即单看那个 error handler 长期存在且未致泄漏,问题在 v0.9.x 才显现。
缓解措施与效果
给 app 容器加
mem_limit: 3g+memswap_limit: 3g(后来放宽到 6g)后:另外设
ENABLE_REQUEST_REPLAY=false后观察 10.5 小时未复发,但负载回到白天水位(连接数 20 → 72)后立即再次爆发两次 —— replay 不是根因。附带一个发现:本部署中 replay 命中率为 0(
ReplaySpool完成 5694 次,日志与源码中均无缓存取用事件)。原因是replayId由 bodyHash 派生,而 Claude Code 在流中断后会把已接收内容并入对话历史再发起新请求,body 变化导致replayId永不匹配。这与本 issue 无关,但或许值得单独关注。建议
rawBody.on("error")中补充rawBody.destroy(err)(或至少resume()排空),与同函数 gzip 分支保持一致REPLAY_MAX_PAYLOAD_BYTES(8MB)、STREAM_GATE_PREBUFFER_BYTE_CAP(10MB)都是单流上限,并发流总量无天花板docker-compose.yaml中为 app 服务提供默认mem_limit,避免单进程失控拖垮整机复现条件
无法稳定构造,但触发条件较明确:上游供应商大面积返回错误或断流(表现为
undici body stream error密集出现、All providers failed、熔断器触发),同时保持 200+ 请求/分钟的并发流式负载。我们环境下白天高峰期必现,夜间低负载时不出现。如需进一步数据(完整采样日志、smaps 序列、socket 队列快照)可以提供。