做无头浏览器项目最头疼的往往不是功能实现,而是资源占用。明明只是跑个自动化脚本或者抓个页面,结果服务器内存动不动就爆,CPU 长期满负载,跑几个小时就会卡死或者被系统 OOM Kill 干掉。我自己踩过不少坑,从最开始的“开一个浏览器跑一个任务”的粗暴方案,到后来慢慢摸索出一套资源控制的组合拳,这里面的差距是非常明显的。这篇东西不讲虚的,直接围绕“无头浏览器资源占用”这个核心,把根源、优化参数、运行时管理、监控排查这些关键环节完整梳理一遍。
适合正在用 Puppeteer、Playwright 或 Selenium 无头模式做爬虫、截图服务、自动化测试渲染的同学,也适合那些被频繁报警搞得焦头烂额的技术负责人。看完你至少能少走我走过的弯路,把单个实例的内存从几百 MB 压到一两百 MB 甚至更低,性能稳定性提升一个台阶,这不是夸张——只要把下面这些点做到位,真能实现。
1. 资源占用从哪里来:先拆根源再动手
很多人一上来就堆参数,这个 flag 加上那个开关去掉,结果内存没降多少,反而页面渲染出各种诡异问题。我建议先搞清楚一件事:无头浏览器的资源到底耗在哪儿了。
1.1 多进程架构:默认就在吃资源
无论你是用 Chrome 系还是 Firefox 系的无头模式,浏览器本身都是多进程架构。一个浏览器实例至少包含 1 个主进程、1 个 GPU 进程、若干个网络进程和渲染进程。之前我用 Puppeteer 启动默认配置,什么都不干,光是打开一个空白页,看任务管理器里就有 5 到 6 个进程在跑,总内存轻松超过 300MB。如果是复杂页面,渲染进程会进一步拆分,每个 iframe、每个标签页都可能有独立进程,内存直接翻倍。
这个架构设计是为了稳定和隔离,但对于很多只需要执行简单任务的无头场景,纯属浪费。你要优化,第一步就得意识到:默认配置不是为低资源占用设计的,它是为完整桌面级体验设计的。无头模式下很多进程和模块根本用不到,却还在疯狂占着内存和 CPU。
1.2 渲染与脚本执行:看不见的 CPU 杀手
页面加载之后要经过 HTML 解析、样式计算、布局、绘制,还有几十上百个 JS 文件的下载和执行。无头浏览器虽然不真正显示画面,但为了后续可能的截图、DOM 查询和事件触发,Chromium 依然要完整执行渲染管线。JS 主线程的繁忙程度和你跑真浏览器没有区别,尤其是在客户端渲染很重的页面上,框架代码动不动就是好几兆,光解析和执行就能吃满一个 CPU 核心好几秒。
再比如,页面里的 CSS 动画、定时器、WebSocket、轮询请求,这些在你只是想要抓一段 HTML 的时候,全部是在白白消耗资源。我遇到过抓一个后台管理页面,页面里有实时日志的 WebSocket 推送,结果无头浏览器一打开,CPU 一直 100%,查了半天才意识到是那些还在运行的定时器在捣乱。
1.3 内存泄漏:跑得越久越要命
无头浏览器长时间运行会有内存泄漏问题,这个是出了名的。不是浏览器团队不修,而是很多泄漏来自页面本身的 JS、缓存机制、v8 堆的增长。对于一次性启动、打开一个页面、拿完数据就退出的场景,泄漏问题还好说;但如果你做的是常驻服务,同一个浏览器实例反复打开很多页面,内存就会像滚雪球一样越来越大。
我自己实测过一个爬虫任务,用同一个 Browser 实例连续抓取几千个页面,从开始的内存 200MB,一直涨到快 2GB。最后不是代码写错了,而是如果页面上下文得不到及时释放、JavaScript 堆没能及时 GC,浏览器就会持续涨内存。所以,优化资源占用,不只是调启动参数,还得管好运行时生命周期。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 启动参数层面的低成本优化:能省则省
启动参数是见效最快、门槛最低的优化手段,几行配置加上去,内存直接下降一截。下面我按实际效果顺序来讲,你可以直接抄作业。
2.1 禁用 GPU 和扩展,砍掉用不到的进程
无头模式下 GPU 硬件加速本来就没有画面可渲染,保留 GPU 进程纯属多余。我用 Puppeteer 时,headless: 'new' 模式下第一件事就是加 --disable-gpu,实测能省掉差不多 30 到 50MB 内存。有的同学可能听过“现在 GPU 进程不一定会有”的说法,但为了保险,参数还是建议加上,特别是你代码可能在多个版本环境里跑的时候,兼容性更稳。
扩展插件同理,无头模式里你几乎用不到任何扩展,但浏览器可能还会加载一部分默认组件。加一个 --disable-extensions,顺便把 --disable-default-apps、--disable-sync 也一起挂上,这几个参数配合起来,能减少后台进程和网络连接的开销。这些看起来都是细枝末节,积少成多就是一笔不小的账。
2.2 沙箱与共享内存参数:容器部署必看
这一块是很多人在容器环境里跑无头浏览器被搞到崩溃的重灾区。容器里跑 Chromium 默认要起沙箱,但容器的 seccomp 策略往往不允许,于是浏览器启动失败或者异常退出。常规解法是加 --no-sandbox,我自己用 Docker 部署时几乎必加。不过也要提醒一下:--no-sandbox 有安全风险,如果你处理的页面是外部传入的不可信内容,最好还是通过配置 seccomp 或者使用专门的基础镜像(比如 browserless/chrome 那种)来解决问题,不要在公网裸奔场景下无脑禁用沙箱。
另外一个容易踩的坑是 /dev/shm 空间不够。Chromium 在渲染时会用共享内存,默认 /dev/shm 如果只有 64MB,页面稍微复杂一点就崩,表现就是白屏、渲染失败或者进程直接 killed。标准加法是 --disable-dev-shm-usage,让浏览器把共享内存文件落到 /tmp 而不是 /dev/shm 里,同时挂一个 tmpfs 给 /tmp。这个参数在 Docker 和小内存服务器上简直是救命级别的存在。
2.3 其他值得用上的 Chromium 开关
下面这几个参数是我实际项目里保留了比较久、经过验证的效果组合,你按需选用就行:
| 参数 | 作用 | 我的使用建议 |
|---|---|---|
--disable-gpu |
禁用 GPU 进程 | 推荐加上 |
--no-sandbox |
关闭沙箱进程隔离 | 容器环境使用,注意安全 |
--disable-dev-shm-usage |
绕过 /dev/shm 限制 | 内存小的机器必加 |
--disable-extensions |
禁用扩展加载 | 推荐加上 |
--disable-background-networking |
关闭后台网络请求 | 推荐加上,提升纯净度 |
--disable-background-timer-throttling |
后台定时器节流策略调整 | 按需,有的自动化场景反而要开 |
--disable-renderer-backgrounding |
避免渲染进程被降权 | 如果你希望后台页面持续执行任务,这个有用 |
--disable-sync |
关闭 Chrome 同步 | 推荐加上 |
--metrics-recording-only |
后台指标上报控制 | 采集场景不关心指标就加上 |
--disable-features=TranslateUI |
关闭翻译 UI 组件 | 推荐加上 |
--js-flags="--max-old-space-size=512" |
限制 V8 堆上限 | 页面内存吃紧时非常有效 |
注意个细节,--js-flags 里的 --max-old-space-size 参数,单位是 MB,这是限制每个渲染进程里 V8 老生代堆的大小。限制之后,内存超限的脚本会自动触发 GC,而不是无限扩张。我遇到过页面有严重内存分配问题的场景,靠这个参数能让整个浏览器实例从 1.5GB 压到 500MB 以内,代价是某些吃内存的页面可能性能下降或者报内存溢出,所以要结合具体业务来调整数值。
3. 运行时优化:把浏览器实例当成资源池来管
启动参数只是入门,真正的长期稳定运行靠的是运行时管理。无头浏览器不是开起来放着就不管了,它是活着的进程,你得管理它的生命周期。
3.1 实例池与复用机制:别开一个任务就 new 一个实例
每个浏览器实例的启动时间通常 2 到 5 秒,内存起步几百 MB。如果你每来一个请求就 puppeteer.launch() 一次,跑完再 browser.close(),那系统的资源开销会非常恐怖,吞吐量也上不去。
正确做法是维护一个浏览器实例池。池里放几个预先启动好的实例,每个实例可以开多个页面。来任务时,从池里取一个实例,开一个新页面执行任务,执行完关闭页面,再把实例放回池子里。这样实例的启动开销被均摊了,页面的多进程复用也在一定程度上被池化。比如我用 Playwright 写过类似实现:
javascript复制// playwright 实例池的简化伪代码
class BrowserPool {
constructor(size) {
this.size = size;
this.idle = [];
this.busy = new Set();
}
async init() {
for (let i = 0; i < this.size; i++) {
const browser = await chromium.launch({
headless: true,
args: ['--disable-gpu', '--disable-dev-shm-usage']
});
this.idle.push(browser);
}
}
async acquire() {
if (this.idle.length > 0) {
const browser = this.idle.pop();
this.busy.add(browser);
return browser;
}
// 可根据策略等待或新建
const browser = await chromium.launch({ headless: true });
this.busy.add(browser);
return browser;
}
async release(browser) {
// 先关掉所有残留页面,再放回池中
const pages = await browser.pages();
for (const page of pages) {
if (!page.isClosed()) {
await page.close();
}
}
this.busy.delete(browser);
this.idle.push(browser);
}
}
这套思路我实际跑了很久,稳定性提升很明显。最关键的地方在 release 这一步:如果不把残留的页面关干净,页面上下文就会像没关紧的水龙头一样持续泄漏。
3.2 页面的生命周期:用完必关,控制并发数
很多人容易忽略的一点是:同一个 Browser 实例里打开的页面,并不会因为你在业务函数里返回了就自动销毁。被 GC 回收的 page 只是从你的变量引用里消失,底层的渲染进程还在。所以,页面级别一定要求有明确的创建和关闭时机。
我会把一次页面访问封装成类似 try-finally 的结构:
javascript复制async function runPage(browser, task) {
const context = await browser.createBrowserContext(); // 用独立上下文更好
const page = await context.newPage();
try {
return await task(page);
} finally {
await context.close(); // 会同步关闭所有页面和上下文相关进程
}
}
这里用 createBrowserContext() 再关闭,比直接 page.close() 更彻底。因为 BrowserContext 关闭时会把该上下文下的所有页面、service worker、storage 一起清理干净,内存释放得更彻底。这是我后来才发现的细节,之前只 close 页面,内存还是有残留,换成 context 级别关闭后好多了。
并发数控制也很重要。一个实例同时开的页面多了,CPU 和内存必然飙升。我的经验是:
- 低配服务器(1 核 2GB):全局并发 3 到 5 个页面以内;
- 正常服务器(2 核 4GB):全局并发 5 到 10 个页面以内;
- 高配服务器(4 核 16GB):也不要超过 20 个页面。
这里的指标不是随便拍的,因为你还要考虑页面本身的复杂度。有的页面一个就顶普通页面五六个的消耗,这种就得动态调整。
3.3 拦截请求和资源类型:只加载需要的部分
无头浏览器加载一个页面,会把 HTML、CSS、JS、图片、字体、视频、音频、埋点请求全部都拉下来。但爬虫场景里,很多时候你只需要 DOM 结构;截图场景里,可能只需要当前视口的内容。这时候就要用请求拦截来砍掉浪费资源的请求类型。
Puppeteer 的方式:
javascript复制await page.setRequestInterception(true);
page.on('request', (req) => {
const type = req.resourceType();
if (['image', 'media', 'font'].includes(type)) {
req.abort();
} else {
req.continue();
}
});
如果是 Playwright,用 page.route 更顺手:
javascript复制await page.route('**/*', (route) => {
const type = route.request().resourceType();
if (type === 'image' || type === 'media' || type === 'font') {
return route.abort();
}
return route.continue();
});
但这里有一个平衡问题:拦截图片和字体能省带宽和渲染开销,但如果你要截图的页面背景是图片,拦截掉就截图不完整了。同样,有些页面接口返回的数据靠 JS 渲染,你拦截了脚本就什么都拿不到。所以请求拦截要做成可配置的,根据任务类型动态指定拦截策略,而不是一把梭全拦掉。
还有一个细节:尽量晚地关掉页面,或者尽早导航到 about:blank。任务结束时不直接关页面,而是先 page.goto('about:blank'),可以触发一个非常干净的渲染进程复位,把大量资源释放出来,比直接关页面再开新页面要快很多。这个小技巧是我从爬虫社区里学到的,实测内存回收效果明显。
4. 场景化配置参考:两套可以直接用的组合
参数和策略都说清楚了,你可能还是纠结到底怎么组合。我拿两个最常见的实际场景举例,你对照业务来调整。
4.1 爬虫批量抓取场景
这个场景的特点是任务量大、单页处理时间短、结果只需要 DOM 和接口数据,不太需要跑重 JS 渲染产物。我的推荐配置是:
javascript复制const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--disable-background-networking',
'--disable-sync',
'--ignore-certificate-errors',
'--disable-default-apps',
'--disable-notifications',
'--disable-web-security'
],
ignoreDefaultArgs: ['--enable-automation']
});
请求拦截上,我会默认拦截 image、media、font,只保留 script、xhr、document、stylesheet。很多站点图片占页面体重的一半以上,拦掉之后页面加载速度明显加快,内存也平稳不少。
页面操作上,等待网络空闲的策略要用对。我会把超时设置得保守一些,waitUntil: 'networkidle2' 会比 'networkidle0' 更快返回,对资源占用也更友好,因为不用盯着所有请求结束。
4.2 截图服务场景
截图服务要的是画面完整度和清晰度,不能随便拦截资源,但可以做两件重要的事:
第一,设置合理的 viewport。截一张 1280x720 的图就不用开 2560 的视口,视口越大,渲染面积越大,内存和 GPU 开销越高。用 page.setViewport({ width: 1280, height: 720 }) 精确控制。
第二,对截图后的结果及时做处理。截完图后不要急着关页面,可以把页面导航到空白页释放渲染进程,再放回实例池。这种操作比直接 close 再新建页面要节省大量重开销。
javascript复制// 截图完成后的释放操作
await page.setViewport({ width: 800, height: 600 });
await page.goto('about:blank').catch(() => {});
await page.close();
另外,截图格式和质量也直接影响内存。JPEG 格式在编码时占用显著低于 PNG,尤其是页面内容比较复杂的场景,一张大图 PNG 编码能吃掉几百 MB 临时内存,改成 JPEG quality 80 会平滑很多。
5. 监控与排查:资源用的好不好,数据说了算
优化到了一定程度,光靠感觉是不行的,必须建立监控指标,这样才能定位问题、验证效果。
5.1 基础指标怎么拿
无头浏览器进程相关的指标,我通常会采集三个层面:
- 系统层面:直接看进程的 RSS 内存、CPU 占用,方便定位单个实例的粗粒度消耗;
- 浏览器内部:通过 CDP 的
Performance.getMetrics拿 JavaScript 堆、DOM 节点数、重绘数等; - 任务层面:统计每个任务页面加载耗时、CPU 密集耗时、内存增量。
Puppeteer 获取 JavaScript 堆的方式:
javascript复制const { exec } = require('child_process');
// 通过 CDP 拿当前页面的内存统计
const cdp = await page.createCDPSession();
const metrics = await cdp.send('Performance.getMetrics');
const jsHeapUsed = metrics.metrics.find(m => m.name === 'JSHeapUsedSize');
const jsHeapTotal = metrics.metrics.find(m => m.name === 'JSHeapTotalSize');
const domNodes = metrics.metrics.find(m => m.name === 'Nodes');
如果是自己写监控,建议把这几个指标定时上报到 InfluxDB 或者其他时序数据库,用看板一览全部实例的状态。你会发现,没有监控的时候,很多资源泄漏问题要到服务崩了才发现;有了监控曲线之后,内存涨成什么趋势、哪些页面引发峰值,基本一目了然。
5.2 内存泄漏定位的思路
遇到长期运行的内存持续上涨,先别急着怀疑浏览器本身泄漏。我这里有一套排查顺序:
- 查页面数:如果活跃页面数正常但内存还是涨,说明可能有隐藏页面没关;
- 查 context 数量:
browser.browserContexts()是否能匹配当前任务数,如果远大于任务数,那就是上下文泄漏了; - 查 JS 堆:用 CDP 的 Performance 指标看 JSHeapUsedSize 和 JSHeapTotalSize 的差值,差值持续扩大说明页面脚本本身有堆泄漏;
- 查网络连接:页面可能建立了 WebSocket,没有随着页面关闭而断开。
我以前最常踩的就是第三种,页面里注册了定时器或者全局变量没清理,导致每个页面都残留一部分堆空间。这种问题在业务侧改成单页单上下文、用完即关之后会缓解很多。
5.3 CPU 峰值怎么压下去
CPU 长期高占用会比内存更容易影响同服务器的其他服务。除了前面提到的拦截请求、限制并发,还有几个不起眼但有效的招:
一是给每个页面加一个明确的消息循环机制,不要用 page.waitForTimeout 这种容易造成空等的写法。二是能不用 page.goto 就尽量复用同一个页面,减少导航带来的重新布局开销。三是在不需要 JavaScript 输出的任务里,启动时就禁用页面脚本,比如用 page.addScriptTag({ content: '' }) 适度控制。
我这里重点提醒一个坑:不要为了压 CPU 去开 --single-process 参数。这个参数看着能砍掉进程数,但会让多个页面共用单进程,稳定性大幅下降,一个页面崩了整个浏览器就没了。我最初为了省内存踩过这个坑,后来果断弃用。
6. 再补充几个个人很受用的细节
这些零散心得不太好归类,但对实际使用特别有帮助,你自己操盘时多半会碰见。
第一个是关于浏览器版本的。同样一段优化参数,在 Chrome 114 和 Chrome 126 上的表现差距不小。新版无头模式(headless new)比旧版稳定,对资源管理也更友好。有条件的话,优先用 Playwright 自带或官方维护的较新 Chromium 版本,别用系统自带的老旧 Chrome。
第二个是关于 Node 进程的。当你用 Puppeteer 驱动无头浏览器时,资源占用不只是浏览器本身,还有你的 Node 进程。如果你用 Page 对象保存了太多引用、事件监听器没有移除,Node 端本身也会涨内存。所以我在每次页面关闭后,会先移除挂在 page 上的监听器,再关闭页面,这是很多教程里不会提的细节。
第三个是定时重启机制。即使做了所有优化,长跑项目我还是建议在业务低峰期做一次实例重启。不是代码有问题,而是浏览器这种软件本身有隐藏状态累积,定期重启能让资源基线回到干净状态。比如一个爬虫服务每天凌晨 4 点重启一次整个实例池,运维省心很多。
最后说一个很多人忽视的方向:如果只是抓内容,未必一定要用无头浏览器。部分站点可以直接请求接口或者用静态 HTML 解析,完全不碰浏览器,资源占用直接归零。无头浏览器应该是一把“最后手段”的钥匙,而不是默认答案。我在实际项目中经常遇到,某些数据用 POST 请求就能拿到,根本不需要无头浏览器重渲染,但同事习惯了无头抓取,结果把服务器资源白白耗在上面。把这些业务梳理清楚,比任何技术参数都更省资源。
