容器共享内存默认 64MB:浏览器进程频繁崩溃

容器内 headless Chromium 高并发渲染时随机崩溃,根因是 Docker 默认 /dev/shm 仅 64MB,渲染进程写共享内存触发 SIGBUS;调大 shm-size 后恢复稳定。

问题背景

报表中心有一套 PDF 生成服务:业务系统把报表模板和参数交给它,它用 headless Chromium 渲染成 PDF 返回。服务跑在 Docker 里,Node 进程启动 8 个渲染 worker,通过 Puppeteer 控制浏览器。日均生成 3000 余份报表,白天高峰集中在上午十点和下午三点。

上线一年多一直稳定。上个月业务量上涨,我们把 worker 并发从 4 调到 10,容器规格没动:4 核、4GB 内存限制。扩容后吞吐确实上去了,但稳定性开始出问题——每天总有几次任务失败,重试往往又能成功,像随机故障。

故障现象

  • 失败任务的日志基本是 Page crashed! 或 Target closed,偶尔能看到 Bus error (core dumped)。
  • 容器本身没有重启,Node 主进程一直活着,健康检查正常。
  • docker stats 显示容器内存长期在 1.2–1.4GB,离 4GB 限制很远;宿主机也还有大量空闲内存。
  • 失败集中在含图表的复杂报表上;简单纯文字报表几乎不出问题。
  • 并发越高失败越频繁,单并发压测 500 次一次没挂。
  • 失败没有固定时间点,和流量高峰大致相关,但同一时刻也有大量任务正常完成。

排查过程

第一步先排除 OOM。容器限制了 4GB,但实际用量只有 1.3GB 左右,看起来不像内存问题。查 cgroup 计数确认:/sys/fs/cgroup/memory.events 里 oom 和 oom_kill 都是 0;dmesg 里没有 oom-killer 记录;容器 RestartCount 也是 0。主进程没被杀,说明不是容器级 OOM。

第二步看失败形态。崩溃的不是 Node 主进程,而是 Chromium 的 renderer 子进程——Puppeteer 通过 DevTools 协议和浏览器通信,renderer 一死,页面就报 Page crashed!。这解释了为什么容器健康检查看不出来:监控只看主进程和容器状态,子进程崩溃被完全遮住了。

第三步对比变量。把并发降回 4,连续跑一天零失败;升到 10,失败立刻回来。失败页面又集中在图表多的模板上——图表渲染要传大量位图,正是 Chromium 多进程架构里共享内存的主要用途。怀疑点开始从“内存不够”转向“共享内存不够”。

第四步验证假设。进容器执行 df -h /dev/shm,输出是 64M、已用 100%、可用 0。再看 docker inspect 的 HostConfig.ShmSize,67108864 字节,正好 64MB。ls /dev/shm 下面是一堆 .org.chromium.Chromium.* 文件。也就是说,Chromium 的共享内存段已经被写满,renderer 再往 mmap 区域写就会收到 SIGBUS——日志里的 Bus error 正是这个信号。

第五步复现。写了个压测脚本,10 并发连续渲染图表报表,每秒采样 /dev/shm 使用率:大约 20 秒后冲到 100%,随后必然出现 renderer 崩溃;同一脚本加上 --disable-dev-shm-usage 参数后不再复现。

再估算一下量级:单个复杂报表在渲染期间占用的共享内存可达数十 MB,10 个并发同时渲染,64MB 的池子连两个页面都撑不住。这也解释了为什么单并发永远测不出来——单页面峰值低于 64MB,池子够用;并发一上来,多个 renderer 的共享内存段叠加,才把上限顶穿。

解决方案

  1. 调整共享内存:容器启动参数加 --shm-size=1g;docker-compose 里写 shm_size: 1gb;K8s 则挂一个 emptyDir: {medium: Memory, sizeLimit: 1Gi} 到 /dev/shm。
  2. 同步核对内存预算:tmpfs 的页会计入容器 memory cgroup,1GB shm 不是“额外白送”的。当前 4GB 限制下,应用 1.4GB 加 shm 1GB 仍有充足余量,所以限制保持不变;如果容器规格更小,就要把 shm 和 limit 一起算。另外要明确:shm-size 是上限而不是预分配,tmpfs 只按实际写入的页计费,调大到 1GB 不会立刻占掉 1GB 内存。
  3. 保留兜底参数:在镜像启动脚本里给 Chromium 加 --disable-dev-shm-usage 作为应急开关,它让 Chrome 改用 /tmp 存共享内存。代价是走磁盘 IO、且可能撑爆 /tmp,只作为临时手段,不作为默认配置。
  4. 加监控:采集每个容器 /dev/shm 使用率,80% 告警;同时监控 renderer 崩溃日志关键字,而不是只看容器是否重启。

根因分析

直接根因是 Docker 默认给容器的 /dev/shm 只有 64MB,高并发下 Chromium 的共享内存需求撞上硬顶,renderer 写入 mmap 区域时触发 SIGBUS 而崩溃。64MB 是容器运行时沿用多年的保守默认值,为最小化共享内存占用而设,适合轻量进程,不适合浏览器这种重度依赖共享内存的多进程应用。同样的 64MB 默认值在 Kubernetes 里也存在,不显式挂载 /dev/shm 的 Pod 一样会遇到这个问题。

更深一层,问题被两层“看起来正常”掩盖:容器内存只用了三分之一,所以没人往内存方向想;崩溃发生在子进程,容器和主进程都健康,监控也不报警。并发从 4 提到 10 时,我们只评估了 CPU 和内存余量,没有评估共享内存这类“隐形资源”——它不在常规容量评估表里,却在默认值里埋着上限。

预防措施

  • 容器运行模板显式声明易被忽略的默认值:shm-size、ulimit nofile、pids-limit、stop grace period,不再依赖运行时默认。
  • 浏览器、桌面、机器学习这类多进程/共享内存型负载,上线前必须确认 /dev/shm 容量,并写入部署清单。
  • 监控从“容器是否活着”下沉到“子进程是否健康”:采集关键子进程退出、SIGBUS/SIGSEGV 计数和业务失败率。
  • 扩容或调并发前,按目标并发做一次压测,压测指标包含 /dev/shm、cgroup memory.events,而不只是 CPU/内存。
  • 基础镜像或运行模板变更时,把 shm-size、内存限制、ulimit 纳入回归清单;新镜像上线前用 docker run --rm <image> df -h /dev/shm 确认默认值,避免同类问题换个服务再犯一次。

总结

这次故障的教训不是“该调大 shm”,而是“默认值也是容量决策”。容器的每一个默认参数,都是运行时替我们做的一个假设;负载轻时它沉默,负载上来它就变成天花板。docker stats 里内存只用三分之一,和“内存没问题”是两回事——真正被耗尽的资源可能根本不在那张表上。把隐式默认值显式化,是容器化负载从“能跑”到“跑得稳”之间最便宜的一步。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计