容器化 + Playwright:我怎么让 100 个浏览器同时跑还不宕机

72 阅读6分钟

有一段时间,我在做一个多站点的网页采集项目。目标看起来挺简单的:同时运行一批 Playwright 实例去抓取数据。
但一开始,我只开了二十几个浏览器,机器就快冒烟了。CPU 一路飙满,内存狂涨,Docker 直接报错退出。那时候我才意识到——Playwright 真的是吃资源的怪兽

于是我开始折腾各种优化手段,从容器拆分、异步控制到代理分流,整整调了一个月,才终于让100 个浏览器能稳定跑起来,而且一晚上都不崩

这篇文章就分享整个过程:从最初的性能瓶颈,到最终压测结果,再到中间的那些关键“拐点”。

一、性能瓶颈:为什么 Playwright 跑多了就挂

刚开始的方案其实很“直男”:
我在一台 8 核 16G 的机器上,用 Python 异步任务批量启动 Playwright 实例。理论上讲,多核机器嘛,几十个浏览器应该轻松搞定。

结果完全相反。

运行到第 20 个实例时,CPU 就快顶不住了。到第 30 个时,Playwright 直接报错 browser disconnected,然后容器 OOM(内存溢出),服务挂掉。

我用 htopdocker stats 看了一下资源占用情况:

  • 每个浏览器进程大概吃 150~250MB 内存;
  • CPU 时间主要消耗在页面渲染和网络等待上;
  • Python 主进程成了调度瓶颈。

简单来说,就是单容器跑太多浏览器,会把系统拖垮

二、性能基线:没对比就没有改进

在优化之前,我先测了三个关键指标,算是给后面调优做参考。

  • 启动耗时:平均打开一个浏览器并创建上下文,大约 3.8 秒;
  • CPU 占用:在 20 并发下就能吃满 96% 的 CPU;
  • 稳定运行时长:大约 18 分钟容器就会崩掉。

这三个数字基本说明问题了:系统既不快,也不稳,更撑不久。

三、优化过程:三板斧搞定

1. 容器拆分:别把所有浏览器塞进一个锅

我第一步做的,就是让每个 Docker 容器只跑少量实例。具体来说,一个容器只负责 10 个浏览器。
这样做有几个好处:

  • 内存压力更分散;
  • 某个容器崩了不会拖垮全局;
  • 可以更灵活地扩容或缩容。

我用 docker-compose 启动多个轻量容器,每个容器限制 2 核 CPU、2GB 内存。这样一台 8 核机器就能稳定跑 45 组,也就是 4050 个浏览器实例。如果再开一台机器,就能轻松上百。

这种拆分方式看似简单,其实解决了大部分稳定性问题。

2. 网络层优化:用代理把出口分流开

第二个问题是IP 限制
高并发请求时,目标网站很快就把固定出口 IP 封掉。
于是我接入了爬虫代理,给每个浏览器分配独立代理。

下面是关键代码片段

import asyncio
from playwright.async_api import async_playwright

# ==== 亿牛云爬虫代理配置 (www.16yun.cn) ====
PROXY_HOST = "proxy.16yun.cn"   # 代理域名
PROXY_PORT = "3100"            # 代理端口
PROXY_USER = "your_username"    # 用户名
PROXY_PASS = "your_password"    # 密码

TARGET_URL = "https://www.baidu.com"  # 测试目标

async def fetch_page(playwright, index):
    browser = await playwright.chromium.launch(
        proxy={
            "server": f"http://{PROXY_HOST}:{PROXY_PORT}",
            "username": PROXY_USER,
            "password": PROXY_PASS
        },
        headless=True
    )
    context = await browser.new_context()
    page = await context.new_page()

    # 设置 User-Agent 模拟不同设备访问
    await page.set_extra_http_headers({
        "User-Agent": f"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Scraper-{index}"
    })

    try:
        await page.goto(TARGET_URL, timeout=15000)
        title = await page.title()
        print(f"[{index}] OK - {title}")
    except Exception as e:
        print(f"[{index}] Error: {e}")
    finally:
        await browser.close()

async def main():
    async with async_playwright() as p:
        tasks = [fetch_page(p, i) for i in range(1, 11)]  # 每个容器跑10个实例
        await asyncio.gather(*tasks)

if __name__ == "__main__":
    asyncio.run(main())

每个浏览器实例都有独立的代理和 User-Agent,这样不仅能防封禁,还能分散请求压力。
我在实际采集中发现,这个改动直接让失败率从 20% 降到了 2%。

3. 异步与延迟启动:不要让 CPU 同时尖叫

原本我是一口气启动所有浏览器任务,这会在几秒钟内拉满 CPU 和内存。
于是我改成“分批启动”,每隔几百毫秒启动一部分,让系统有喘息空间。

# 延迟分批启动浏览器,避免瞬时峰值
for i in range(0, len(tasks), 5):
    batch = tasks[i:i+5]
    await asyncio.gather(*batch)
    await asyncio.sleep(0.3)

这个小改动带来的效果非常明显:CPU 峰值从接近 100% 降到了 70% 左右。
容器不再频繁重启,整体运行更平稳。

四、压测结果:数字不会骗人

经过这三步调整后,我重新做了一轮压测。

优化前:

  • 平均启动耗时 3.8 秒
  • CPU 占用接近 100%
  • 内存 15GB 左右
  • 稳定运行不到 20 分钟

优化后:

  • 平均启动耗时降到 1.6 秒
  • CPU 占用控制在 75~80%
  • 内存下降到 8~9GB
  • 稳定运行超过 8 小时无异常

这些变化意味着什么?
意味着我不需要再盯着监控屏幕等崩溃了。容器运行稳定,任务调度更顺畅,爬取速率也稳中有升。

五、最终方案:更像一个小型集群

最后我的整体架构变成了一个小型分布式集群:

  • 一个控制器进程负责分配任务和监控状态;
  • 每个 Docker 容器负责一组浏览器实例;
  • 代理服务负责出站流量分流;
  • 所有容器的数据结果汇总回主控制器统一存储。

这种结构最大的优点是可水平扩展
要采更多网站,只需多开几组容器;要稳定性更高,就调小单容器实例数量。非常灵活。

六、经验与反思

回过头看,其实容器化只是解决方案的一部分。
真正让系统稳定下来的,是三个关键思想:

  1. 拆分负载 —— 不要把所有实例塞进同一个进程或容器;
  2. 代理隔离 —— 不让所有请求走同一个出口;
  3. 分批启动 —— 让系统有呼吸的节奏。

我后来又在别的采集项目上复用了这套方案,效果一样稳。

总结一句话:

性能优化的目标,不是让程序更快,而是让它在高负载下还能“活得久”。

如果你现在也在调 Playwright 的并发性能,不妨先别想着“多快”,先让它“别挂”。
从容器拆分、代理分流和异步控制入手,或许就能让你的系统跑得又稳又久。