Chrome 无头浏览器规模化(一):孤儿进程与 24GB 内存泄漏的生产排查

SIGTERM 杀不掉、僵尸进程占满 CPU、内存泄漏 24GB 无人回收——每个做浏览器自动化的团队迟早都会撞上这个问题。

亿牛云技术团队2026年4月2日5 分钟阅读

问题:你关了浏览器,但它还活着

我第一次意识到这个问题的严重性,是在帮客户排查一个"服务器越来越慢"的工单时。客户用 Steel 自建版本跑了几天采集任务,服务器的可用内存从 16GB 降到了不到 2GB,CPU 平均负载翻了三倍。重启 Steel 容器没用,问题依旧。

ps aux | grep chrome 的结果让我明白了原因:系统里躺着 47 个 Chrome 进程,其中 30 多个的父进程已经不存在了。

这些就是孤儿进程(Orphan Process)——浏览器实例已经结束了,但 Chrome 的子进程(渲染器、GPU 进程、网络进程)没有被清理,继续在后台消耗 CPU 和内存。

这不是 Steel 独有的问题。Chrome 的多进程架构决定了每个标签页、每个扩展、每个渲染器都是一个独立的操作系统进程。当上层工具(Puppeteer、Playwright、agent-browser)通过 CDP 管理浏览器时,如果生命周期管理不够严谨,子进程就会脱落。

Chrome 进程模型:一个标签页为什么等于五个进程

要理解为什么孤儿进程是规模化自动化中不可避免的问题,先要理解 Chrome 的进程架构。

一个浏览器标签页

    ├── Browser 进程(主进程)
    ├── Renderer 进程(渲染引擎,每个标签页一个)
    ├── GPU 进程(硬件加速)
    ├── Network 进程(网络请求)
    └── Utility 进程(解码、插件等)

每个标签页至少对应 3-5 个操作系统级别进程。如果一次打开 100 个标签页,就是 300-500 个进程。这不是多开几个线程的问题——Chrome 是通过多进程来实现隔离的,每个进程都有自己的虚拟地址空间。

当 CDP 发来 Browser.close() 命令时:

  1. Browser 进程收到信号
  2. Browser 进程通知 Renderer、GPU、Network 子进程退出
  3. 正常情况下,子进程收到 SIGTERM,自行清理并退出
  4. 非正常情况下,子进程卡住,Browser 进程不等它们直接退出

第 4 步就是孤儿进程的来源。

孤儿进程为什么杀不掉

杀掉一个孤儿进程比你想象的难:

# 第一次尝试:SIGTERM
kill <pid>
# 进程还在
 
# 第二次尝试:SIGKILL(信号 9)
kill -9 <pid>
# 进程还在,但变成了 defunct
 
# 第三次尝试:检查进程状态
ps aux | grep chrome | grep Z
# 大量 Z(zombie)状态的 Chrome 进程

僵尸进程杀不掉是因为它的父进程(Shell 或 init 进程)没有调用 wait() 来读取它的退出状态。对于 Chrome 的孤儿进程来说,更常见的情况是:进程没有被真正杀死,因为它处于不可中断的睡眠状态(D 状态),等待 I/O 或锁资源。

从 Playwright MCP 项目的一个 Issue 中可以看到具体的数量级——一个 21 小时 28 分钟的会话,Node 进程消耗了 24,646,376 KB 虚拟内存(约 24.6 GB),RSS 598MB。被 OOM Killer 杀死时,还留下了 Chrome 的相关进程没有被清理。

May 26 23:30:36 host kernel: oom-kill: constraint=CONSTRAINT_NONE
  task=node, pid=832511, uid=1000
May 26 23:30:36 host kernel: Out of memory: Killed process 832511 (node)
  total-vm:24646376kB, anon-rss:598188kB

这个问题的连锁反应更严重:OOM Killer 在杀死目标进程后,如果内存仍然不够,会继续杀死其他进程。同一个 Issue 中,systemd-resolveddbus 被连带杀死,导致整个网络堆栈无法恢复,必须重启机器。

什么场景最容易产生孤儿进程

根据我们的排查经验,以下三种场景是孤儿进程的高发区:

场景 1:CDP 连接异常断开 智能体在执行操作时,网络抖动导致 CDP WebSocket 断开。上层工具检测到连接超时后直接退出,但没有发 Browser.close()。Chrome 进程没有收到退出信号,继续运行。

场景 2:超时强制中断 设置了 30 秒超时,页面加载在第 31 秒完成,但上层工具已经在第 30 秒时丢掉了 Browser 引用。Chrome 进程在没有 CDP 连接的情况下继续运行,等待永远不会回来的指令。

场景 3:容器被强制销毁 Docker/K8s 环境中的常见问题:kubectl delete pod 发送 SIGTERM,Chrome 进程 10 秒内没有退出,Kubelet 发送 SIGKILL。如果 Chrome 的 GPU 或 Network 进程没有响应 SIGKILL(卡在 D 状态),它们就会变成孤儿进程继续运行。

怎么处理

处理孤儿进程没有完美的方案,只有 systematic 的管理手段。

方案一:cgroup 资源限制(实际操作过的方案)

来自社区的经验:在 systemd 层面限制用户进程的内存上限,确保 OOM 杀死时只影响目标进程,不波及系统服务。

# /etc/systemd/system/user-1000.slice.d/override.conf
[Slice]
MemoryHigh=1500M
MemoryMax=2G

这样如果 Chrome 进程泄漏超限,OOM Killer 只杀死该用户的进程,不会连带杀死 systemd-resolveddbus

方案二:会话结束时的进程清理

在自动化任务结束后,显式清理可能遗留的 Chrome 进程:

# 任务完成后清理 Chrome 进程
pkill -f "chrome.*--headless"
pkill -f "chrome.*--remote-debugging"

或者更精确的,根据用户的 UID 清理:

# PAM session-close hook:SSH 断开时清理
pkill -u <user> -f playwright
pkill -u <user> -f "chrome.*--headless"

方案三:定期 recycle 浏览器实例

Playwright MCP Issue 里提到的方案——不要指望一个浏览器实例能长时间运行不泄漏。设置定时回收:

# 伪代码:每 N 次操作或 M 分钟回收一次
if operation_count >= RECYCLE_AFTER_N_OPS or time_since_last_recycle > RECYCLE_INTERVAL:
    await browser.close()
    browser = await launch_browser()
    operation_count = 0

对于 AI 智能体来说,recycle 的代价不大——Agent 在下一次操作时自然会重新导航到目标页面。

方案四:监控 + 告警

这是最简单却最容易被忽略的一步。设置进程数监控:

# 每分钟检查一次 Chrome 进程数
watch -n 60 'pgrep -c chrome || echo "no chrome"'
# 超过阈值时告警
if [ $(pgrep -c chrome) -gt 50 ]; then
  echo "WARNING: $(pgrep -c chrome) Chrome processes running"
fi

性价比最高的操作

如果你现在就要上线生产环境,下面三个操作回报最大:

  1. systemd cgroup 内存限制(5 分钟配置好,防止单进程拖垮整个服务器)
  2. 任务结束后的 pkill 清理(3 行脚本,防止孤儿进程累积)
  3. Chrome 进程数监控(1 行 crontab,至少知道自己什么时候出了问题)

这三个做完了,再去考虑 recycle 策略和优雅关闭的完善。不要在不该优化的时候优化——先把生存问题解决掉。

下一篇文章会分析第二个规模化瓶颈:连接池耗尽和 Tab 累积问题。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。