Browserless 使用笔记:把 Chrome 变成一个可以被并发调用的网络服务
版本说明
本文基于 Browserless v2(ghcr.io/browserless/chromium)。文中的环境变量清单与路由清单,
均取自上游仓库 src/config.ts 与 src/routes/ 的实际代码,而非文档摘抄——v2 的官方文档有若干
链接已失效,源码是唯一可靠的口径。Docker Hub 上的 browserless/chrome 是已废弃的 v1,本文不适用。
# 1. 容器里的 Puppeteer,为什么总是死在第二天
把 Puppeteer 塞进容器跑定时任务,第一天一切正常,第二天开始出现三类症状:
- 容器内存缓慢爬升,最后 OOM——
page.close()了,但 Chrome 进程没退,僵尸进程越堆越多; - 偶发
Protocol error: Target closed,重跑又好了; Error: Failed to launch the browser process ... /dev/shm,共享内存被 64MB 的默认值卡死。
根因不在业务代码,而在架构:puppeteer.launch() 意味着每个脚本进程自己负责一个 Chrome 的完整生命周期——启动、崩溃恢复、超时兜底、临时目录清理、并发上限。这些全是基础设施问题,却被放进了业务脚本里,于是每个脚本都要自己解决一遍,而且都解决不好。
Browserless 的思路是把这一层剥出去:浏览器不再是脚本 launch 出来的子进程,而是一个监听端口、有队列、有超时、有指标的网络服务。业务侧只剩一行 connect。
docker run -p 3000:3000 ghcr.io/browserless/chromium
服务起来之后,原来的 launch 改成 connect,业务逻辑一行不用动:
// Before —— 每个脚本自己养一个 Chrome
const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
// After —— 连到远端的浏览器池
const browser = await puppeteer.connect({
browserWSEndpoint: 'ws://browserless:3000?token=YOUR_TOKEN',
});
2
3
4
5
6
7
# 2. 这个架构切换到底买到了什么
三件在 launch 模式下很难做对、在 connect 模式下白送的事:
并发与排队被服务端接管。 服务端持有一个浏览器池,CONCURRENT 控制同时跑几个会话,QUEUE_LENGTH 控制排队多长。超出队列的请求直接被拒绝,而不是把宿主机 CPU 打满后所有任务一起变慢。这是自己写脚本时最容易漏掉的一环——大多数人只会加信号量,不会做「拒绝」。
生命周期兜底与业务解耦。 TIMEOUT 是服务端强制的会话上限,脚本卡在某个 waitForSelector 上不动,超时后浏览器进程一定会被回收。业务脚本崩溃、网络断开、客户端进程被 kill——服务端都能感知到 WebSocket 断连并清理,不会留下孤儿 Chrome。
依赖收敛到一个镜像。 字体、emoji、--no-sandbox、/dev/shm 这些环境级的坑,官方镜像已经处理好了。业务侧只需要 puppeteer-core(不含浏览器二进制),镜像体积和构建时间都下来一个数量级。
代价也要说清楚:多了一跳网络,而 CDP 是个极其啰嗦的协议——高频 DOM 操作会明显比本地慢。所以 Browserless 适合「抓取 / 渲染 / 截图 / 生成 PDF」这类批量、单向、粗粒度的任务;不适合把一个跑几千次交互的 E2E 测试搬到远端。
# 3. 两套 API:REST 打完就走,WebSocket 拿住控制权
v2 的路由是按目录结构生成的,src/routes/<browser>/http/ 下的每个文件对应一个 HTTP 端点,ws/ 下的对应一个 WebSocket 端点。这决定了它有两种完全不同的用法。
# 3.1 REST:一次请求换一个结果
src/routes/chromium/http/ 下的实际端点:
| 端点 | 作用 |
|---|---|
/content | 返回渲染后的完整 HTML(SPA 抓取的主力) |
/screenshot | 截图,支持整页 / 元素 / 视口 |
/pdf | 生成 PDF |
/scrape | 按 selector 批量提取结构化数据 |
/download | 触发页面下载并把文件回传 |
/function | 把一段 JS 发到服务端执行,返回结果 |
/performance | 跑 Lighthouse 性能审计 |
/json/version、/json/list、/json/new、/json/protocol | 标准 CDP 发现端点 |
最常用的两个:
# 拿渲染后的 HTML —— 对付前端渲染的页面
curl -X POST 'http://localhost:3000/chromium/content?token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"gotoOptions": { "waitUntil": "networkidle2" }
}'
2
3
4
5
6
7
预期输出:直接是一段 HTML 文本(Content-Type: text/html),而不是 JSON 包装。
# 结构化提取 —— 不用自己写 evaluate
curl -X POST 'http://localhost:3000/chromium/scrape?token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com",
"elements": [{ "selector": "h1" }, { "selector": "a" }]
}'
2
3
4
5
6
7
预期输出:
{
"data": [
{ "selector": "h1", "results": [{ "text": "Example Domain", "html": "Example Domain" }] },
{ "selector": "a", "results": [{ "text": "More information...", "attributes": [...] }] }
]
}
2
3
4
5
6
/function 是被严重低估的一个——它让你把逻辑推到服务端执行,省掉全部 CDP 往返:
curl -X POST 'http://localhost:3000/chromium/function?token=YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"code": "export default async ({ page }) => { await page.goto(\"https://example.com\"); return { data: await page.title(), type: \"application/json\" }; }",
"context": {}
}'
2
3
4
5
6
一个需要 20 次 DOM 交互的任务,用 puppeteer.connect 是 20 个网络往返,用 /function 是 1 个。跨洋链路下这是数量级的差距。
注:
/smart-scrape、/crawl、/map、/search这几个高级端点属于商业版功能,开源镜像里没有。别照着首页文档去调,会 404。
# 3.2 WebSocket:需要多步交互时才用
src/routes/chromium/ws/ 下有 browser.ts、page.ts、cdp.ts、playwright.ts、function-connect.ts,对应不同的接入方式:
// Puppeteer
const browser = await puppeteer.connect({
browserWSEndpoint: 'ws://localhost:3000?token=YOUR_TOKEN',
});
// Playwright(注意路径要带浏览器名)
const browser = await pw.chromium.connectOverCDP('ws://localhost:3000?token=YOUR_TOKEN');
const firefox = await pw.firefox.connect('ws://localhost:3000/firefox/playwright?token=YOUR_TOKEN');
2
3
4
5
6
7
8
Playwright 的 Firefox / WebKit 需要 ghcr.io/browserless/firefox 或 ghcr.io/browserless/multi 镜像,Chromium 镜像里没有那两个引擎。
# 3.3 launch 参数透传:连接时定制浏览器
服务端的浏览器不是黑盒——连接时可以用 ?launch= 传一段 URL-encoded 的 JSON,指定这次会话的启动参数:
const launchArgs = {
headless: false,
stealth: true,
args: ['--window-size=1920,1080', '--lang=zh-CN'],
};
const browser = await puppeteer.connect({
browserWSEndpoint:
`ws://localhost:3000?token=YOUR_TOKEN&launch=${encodeURIComponent(JSON.stringify(launchArgs))}`,
});
2
3
4
5
6
7
8
9
10
这是整个服务里最灵活的一个口子——代理、UA、窗口尺寸、用户数据目录,全从这里进去。下一节的登录态复用就是靠它。
# 3.4 观测:服务端到底在忙什么
src/routes/management/http/ 提供了一组管理端点,排障时按顺序看这三个:
curl 'http://localhost:3000/sessions?token=YOUR_TOKEN' # 当前活跃会话(有没有泄漏的会话)
curl 'http://localhost:3000/pressure?token=YOUR_TOKEN' # 是否已过载/拒绝新请求
curl 'http://localhost:3000/metrics?token=YOUR_TOKEN' # 成功/失败/超时/排队计数
curl 'http://localhost:3000/config?token=YOUR_TOKEN' # 服务端实际生效的配置
2
3
4
/pressure 是接入监控的首选:它直接回答「现在还能不能接活」,比自己拿 metrics 算阈值可靠。/config 则用来验证环境变量到底有没有生效——排障时先看这个,能省掉一半瞎猜。
另外,ENABLE_DEBUGGER 默认就是 true,浏览器打开 http://localhost:3000/debugger/?token=YOUR_TOKEN 就是一个完整的交互式 Chrome DevTools:可以写脚本、下 debugger; 断点、看 console、查 DOM、盯网络请求。调爬虫选择器时比盲写 page.evaluate 快得多。
# 4. 一个真正有用的技巧:让登录态跨会话活下来
「先人工登录一次,后续脚本免登录跑」是自动化里最高频的需求。常见做法是导出 cookies 再注入——但这对 localStorage、IndexedDB、以及 Chrome 加密存储的凭据基本无效。
v2 提供了更本质的解法。翻 src/browsers/index.ts 的会话逻辑能确认三件事:
- 客户端可以通过
launchOptions.userDataDir,或args里的--user-data-dir=,显式指定用户数据目录; - 一旦是客户端指定的,Session 上会记
isTempDataDir: false,标记为「调用方自管」; - 会话结束时的清理逻辑在
finally块里,只在isTempDataDir为 true 时才删目录——自动生成的临时目录会被清掉,你手动指定的不会。
也就是说,只要每次连接都传同一个 userDataDir,profile 就是持久的,登录态自然跨会话保留,不需要任何 cookie 注入代码:
const launchArgs = { args: ['--user-data-dir=/profile'] }; // /profile 是容器内挂载的卷
const browser = await puppeteer.connect({
browserWSEndpoint:
`ws://localhost:3000?token=YOUR_TOKEN&launch=${encodeURIComponent(JSON.stringify(launchArgs))}`,
});
const page = await browser.newPage();
await page.goto('https://example.com/dashboard'); // 已登录状态
2
3
4
5
6
7
8
完整的落地模式是人工环境与自动化环境分离:
┌──────────────────────┐ ┌───────────────────────────┐
│ 带 GUI 的浏览器容器 │ │ browserless/chromium │
│ (人工登录用) │ │ (自动化执行用) │
│ Web GUI :3000 │ │ WS :3000 │
└──────────┬───────────┘ └────────────┬──────────────┘
│ 登录后写入 profile │ 挂载副本,--user-data-dir
▼ ▼
/data/chrome-manual-profile ──复制──▶ /data/automation-profile
2
3
4
5
6
7
8
不要让两个 Chrome 进程同时打开同一个 profile 目录——Chrome 有 SingletonLock,会直接冲突。正确顺序是:人工登录 → 关掉人工浏览器 → cp -a 或 rsync 复制一份给自动化侧 → 脚本挂载副本运行。session 过期后重复一遍。
复制时不必搬整个 profile,Cookies、Local Storage、Local State、Login Data 这几个就够了,体积小、无关状态污染也少。
还有一个只有真跑过才会遇到的后遗症:容器被强杀或宿主机重启后,SingletonLock 会残留(它是个指向已死容器 hostname 的符号链接)。此后任何指定 --user-data-dir=/profile 的任务都会失败:
Failed to launch the browser process: profile appears to be in use by another Chromium process
确认没有进程真的在占用之后,删掉三个锁文件即可恢复:
docker exec <container> sh -c \
'ps aux | grep "user-data-dir=/profile" | grep -v grep; \
rm -f /profile/SingletonLock /profile/SingletonCookie /profile/SingletonSocket'
2
3
注意区分:不带 --user-data-dir 的普通任务用的是临时 scratch 目录,不受这个锁影响——所以会出现「普通抓取正常、只有需要登录态的任务全挂」这种迷惑现象。
安全提醒
这个 profile 目录里装着等同于账号密码的登录凭据。目录权限要收紧(不要 777),不要挂进不相干的容器, 也不要把它放进会被打包上传的备份路径。
# 5. 六个必踩的坑
① 拉错镜像,功能对不上。
症状:文档里写的参数全都不生效,DATA_DIR 之类的环境变量像不存在。
原因:Docker Hub 上的 browserless/chrome 是废弃的 v1,两代的配置项和路由都不一样。
解药:只用 GHCR —— ghcr.io/browserless/chromium / firefox / multi。
② TOKEN 默认为空,服务在裸奔。
症状:内网任何容器都能连上你的浏览器服务。
原因:src/config.ts 里 TOKEN 的默认值就是 null,不设就是无鉴权。而一个无鉴权的浏览器服务等于给了别人一个能访问你内网、能读 file://(若开了 ALLOW_FILE_PROTOCOL)的跳板。
解药:永远显式设置 TOKEN,并且不要把 3000 端口直接暴露到公网。
③ 并发打满后直接被拒,不是变慢。
症状:压测到一定量后大量请求快速失败。
原因:CONCURRENT 默认 10,QUEUE_LENGTH 默认 10。并发满了进队列,队列也满了就拒绝——这是设计如此,不是 bug。
解药:按 CPU 核数调 CONCURRENT(经验值:每会话预留 1 核 + 500MB~1GB 内存),客户端侧必须实现重试与退避。
④ 30 秒超时杀掉长任务。
症状:复杂页面渲染到一半会话被断,报 Target closed。
原因:TIMEOUT 默认 30000 毫秒,是服务端强制的会话上限。
解药:全局调大 TIMEOUT,或在单次连接上用 ?timeout=120000 覆盖。别只在客户端加 page.setDefaultTimeout——那管不到服务端的刀。
⑤ 大截图 / 大 PDF 被 payload 上限截断。
症状:整页截图或多页 PDF 请求失败。
原因:MAX_PAYLOAD_SIZE 默认 10485760(10MB)。
解药:调大该值,或改用 /download 让文件走下载通道而不是响应体。
⑥ 临时目录堆积撑爆磁盘。
症状:跑了几周后宿主机磁盘告警,DATA_DIR/DOWNLOAD_DIR 下一堆残留目录。
原因:正常会话结束会清理,但进程被强杀 / 容器 OOM 时清理逻辑走不到,留下孤儿目录。
解药:把 DATA_DIR、DOWNLOAD_DIR、SCRATCH_DIR 显式指到独立的卷上,加定时清理;同时把这些路径排除出备份。
# 6. 常用环境变量速查
以下取自 src/config.ts,是实际读取 process.env 的键名与默认值:
| 变量 | 默认值 | 说明 |
|---|---|---|
TOKEN | null | 鉴权 token,必设 |
PORT / HOST | 3000 / localhost | 监听地址,容器内通常要设 HOST=0.0.0.0 |
CONCURRENT(别名 MAX_CONCURRENT_SESSIONS) | 10 | 最大并发会话 |
QUEUE_LENGTH(别名 QUEUED) | 10 | 最大排队长度 |
TIMEOUT(别名 CONNECTION_TIMEOUT) | 30000 | 会话超时(毫秒) |
DATA_DIR | 系统临时目录 | 用户数据目录根路径 |
DOWNLOAD_DIR / SCRATCH_DIR | 系统临时目录 | 下载目录 / 会话临时文件目录 |
MAX_PAYLOAD_SIZE | 10485760 | 请求体上限(字节) |
MAX_CPU_PERCENT / MAX_MEMORY_PERCENT | 99 / 99 | 超过阈值后拒绝新会话 |
HEALTH(别名 PRE_REQUEST_HEALTH_CHECK) | false | 请求前健康检查 |
ENABLE_DEBUGGER | true | 交互式 Debugger 开关 |
ALLOW_FILE_PROTOCOL | false | 是否允许 file://,保持关闭 |
ALLOW_GET(别名 ENABLE_API_GET) | false | 允许以 GET 调用 API |
CORS(别名 ENABLE_CORS) | false | CORS 开关 |
PROXY_URL / EXTERNAL | null | 反代场景下对外声明的地址 |
RETRIES | 5 | 内部重试次数 |
METRICS_JSON_PATH | 系统临时目录 | 指标持久化路径 |
FAILED_HEALTH_URL、QUEUE_ALERT_URL、REJECT_ALERT_URL、TIMEOUT_ALERT_URL、ERROR_ALERT_URL | null | 各类事件的 Webhook 告警回调 |
最后这组 *_ALERT_URL 值得单独说:它们让服务在排队积压、请求被拒、会话超时、健康检查失败时主动回调你的 Webhook。接一个 IM 机器人,容量问题就从「事后翻日志」变成「实时推送」,比外挂一套 exporter 省事得多。
一份可用的生产配置:
services:
browserless:
image: ghcr.io/browserless/chromium:latest # 生产环境请钉住具体 tag
restart: unless-stopped
environment:
- HOST=0.0.0.0
- TOKEN=${BROWSERLESS_TOKEN}
- CONCURRENT=5
- QUEUE_LENGTH=20
- TIMEOUT=120000
- MAX_PAYLOAD_SIZE=52428800
- MAX_CPU_PERCENT=80
- MAX_MEMORY_PERCENT=80
- HEALTH=true
- ALLOW_FILE_PROTOCOL=false
- DATA_DIR=/data/user-data
- DOWNLOAD_DIR=/data/downloads
volumes:
- ./data:/data
- ./profiles:/profile # 登录态复用用的 profile
shm_size: '2gb' # 关键:默认 64MB 会让 Chrome 崩
ports:
- '127.0.0.1:3000:3000' # 只绑本地,对外走反代
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
shm_size: 2gb 这一行不能省——它是容器里跑 Chrome 最经典的一个坑,不设的话页面稍复杂就会随机崩溃。
# 7. 可复用要点
- 判断该不该用:任务是「批量、单向、粗粒度」(抓取 / 渲染 / 截图 / PDF)就上;是「高频交互的 E2E 测试」就别上——CDP 的网络往返会吃掉全部收益。
- 能用
/function就别用connect:把逻辑推到服务端执行,N 次 CDP 往返压成 1 次 HTTP 请求。跨地域部署时这是决定性的。 - 登录态持久化靠
--user-data-dir,不靠 cookie 注入:客户端显式指定的目录服务端不会清理(isTempDataDir: false);但同一目录绝不能被两个 Chrome 同时打开,务必人工环境与自动化环境分离、复制副本使用。 - 上线前必调的四个值:
TOKEN(否则裸奔)、shm_size(否则随机崩)、TIMEOUT(否则 30 秒被杀)、CONCURRENT(否则打满即拒)。 - 排障顺序固定:先
/config确认配置真的生效 → 再/pressure看是否过载 → 最后/sessions找泄漏的会话。顺序反了会浪费大量时间在猜测上。
# 8. 下一篇
浏览器服务解决的是「怎么把数据抓回来」,抓回来之后往往要落进 Elasticsearch。下一篇换个方向,讲日志索引治理里一个反直觉的坑:为什么 docs.count 不能用来判断一个索引是否还在写入——别名指针、ILM Rollover 静默失效、以及只有 index_total 增量才能给出的正确答案。
# 9. Agent 可直接解析的元数据块
{
"_meta": {
"doc_version": "2026-08-07",
"article_id": "browserless-headless-chrome",
"profile_context": "any",
"estimated_setup_time": "20min"
},
"quick_start": {
"step_1": "docker run -d --name browserless --shm-size=2gb -p 127.0.0.1:3000:3000 -e HOST=0.0.0.0 -e TOKEN=$BROWSERLESS_TOKEN -e CONCURRENT=5 -e TIMEOUT=120000 ghcr.io/browserless/chromium",
"step_2": "curl -s \"http://localhost:3000/config?token=$BROWSERLESS_TOKEN\"",
"step_3": "curl -s -X POST \"http://localhost:3000/chromium/content?token=$BROWSERLESS_TOKEN\" -H 'Content-Type: application/json' -d '{\"url\":\"https://example.com\"}'",
"step_4_persist_login": "connect with launch args {\"args\":[\"--user-data-dir=/profile\"]} url-encoded into ?launch="
},
"key_env": {
"TOKEN": "null by default — MUST set, otherwise unauthenticated",
"CONCURRENT": "10",
"QUEUE_LENGTH": "10",
"TIMEOUT": "30000 ms",
"MAX_PAYLOAD_SIZE": "10485760 bytes",
"DATA_DIR": "user-data root; manual --user-data-dir is never auto-cleaned",
"ENABLE_DEBUGGER": "true",
"ALLOW_FILE_PROTOCOL": "false — keep it false"
},
"endpoints": {
"rest": ["/content", "/screenshot", "/pdf", "/scrape", "/download", "/function", "/performance"],
"ws": ["/", "/chromium/playwright", "/firefox/playwright"],
"management": ["/sessions", "/pressure", "/metrics", "/config", "/debugger/"]
},
"safety_rules": [
"Never expose port 3000 to the public internet; bind to 127.0.0.1 and reverse-proxy",
"Never run without TOKEN",
"Never let two Chrome processes open the same user-data-dir (SingletonLock)",
"Never enable ALLOW_FILE_PROTOCOL on a shared service",
"Treat the persisted profile directory as credential-grade data; tighten permissions and exclude from backups",
"Pin the image tag in production; a Chromium major-version drift can break encrypted cookies in a copied profile"
],
"verification": {
"service_up": "curl -sf \"http://localhost:3000/config?token=$TOKEN\" > /dev/null && echo OK",
"not_overloaded": "curl -s \"http://localhost:3000/pressure?token=$TOKEN\"",
"no_leaked_sessions": "curl -s \"http://localhost:3000/sessions?token=$TOKEN\"",
"login_persisted": "after a session ends, the manually specified user-data-dir must still exist on disk"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
AI Agent 使用指南:
- 当用户说「部署一个浏览器自动化服务」 → 执行
quick_start.step_1,然后verification.service_up - 当用户说「抓取的页面是空的 / 只有骨架」 → 目标是 SPA,改用
/content并加gotoOptions.waitUntil=networkidle2 - 当用户说「Browserless 报 Target closed」 → 依次检查
TIMEOUT是否过小、shm_size是否为默认 64MB、/pressure是否已过载 - 当用户说「让脚本免登录跑」 → 执行
quick_start.step_4_persist_login,并遵守safety_rules中的 SingletonLock 与凭据保护条款 - 当用户说「请求经常失败」 → 先看
verification.not_overloaded,再对照key_env.CONCURRENT/QUEUE_LENGTH调容量
- 01
- ORDER BY 配合 LIMIT 触发的索引选择陷阱 原创08-07
- 02
- Nginx 运维知识地图:从配置基础到反向代理实战 原创07-29
- 03
- MySQL 运维知识地图:从入门配置到高可用排障 原创07-29