Puppeteer 运行一段时间后报 net::ERR_BLOCKED_BY_CLIENT:一次 Chromium HTTPS 自动升级问题的完整排查
本文记录一次基于 Puppeteer + Headless Chromium 的服务端打印故障排查过程。为便于公开分享,文中的服务名、IP 地址、接口路径、日志时间、容器信息和项目路径均已脱敏或泛化。
摘要
某个基于 Puppeteer 的服务端打印服务,在持续运行一段时间后开始稳定报错:
text
Error: net::ERR_BLOCKED_BY_CLIENT
奇怪的是:
- 目标 HTTP 页面可以通过
curl正常访问; - 打印服务进程和 Chromium 进程都没有退出;
- 立即重试仍然失败;
- 重启打印服务后又能暂时恢复;
- 运行一段时间后问题可能再次出现。
通过源码执行顺序、Chrome DevTools Protocol(CDP)网络事件、HTTP/HTTPS 对照测试,以及独立 Chromium 启动参数实验,最终确认:
Chromium 将原本的 HTTP 主文档请求自动升级成了 HTTPS,而目标内网服务没有开放 HTTPS 端口。无头浏览器无法处理安全提示页面,最终以
net::ERR_BLOCKED_BY_CLIENT终止导航。
临时修复是在 Puppeteer 启动参数中禁用相关 HTTPS 自动升级特性;长期方案则是为目标服务提供有效的 HTTPS 入口。
一、问题背景
系统中有一个独立的 Node.js 打印服务,主要流程如下:
- Puppeteer 创建页面;
- 访问系统登录页;
- 向
sessionStorage和localStorage写入认证信息; - 跳转到实际打印页面;
- 等待页面渲染完成并生成 PDF。
服务为了降低浏览器启动成本,会在进程生命周期内复用同一个 Chromium 实例。
运行一段时间后,打印请求开始失败,日志类似:
text
打印失败,原因:Error: net::ERR_BLOCKED_BY_CLIENT
at http://10.x.x.x/#/login
重启打印服务后,打印功能会立即恢复。
这类现象很容易被初步判断为:
- token 过期;
- 页面接口鉴权失败;
- 内网服务不通;
- Puppeteer 请求拦截配置异常;
- 浏览器扩展拦截;
- Node.js 内存不足;
- Chromium 长时间运行后状态异常。
但仅凭错误名称无法直接确认根因,需要进一步构造证据链。
二、先确认错误发生在哪个阶段
2.1 检查打印代码的执行顺序
打印逻辑的关键顺序可简化为:
ts
await page.goto('http://10.x.x.x/#/login')
await page.evaluate((authInfo) => {
sessionStorage.setItem('token', authInfo.token)
localStorage.setItem('clientId', authInfo.clientId)
}, authInfo)
await page.goto('http://10.x.x.x/#/print-page')
实际错误出现在第一次 page.goto(),也就是访问登录页时。
此时:
- token 尚未写入;
- 业务打印页尚未打开;
- 页面业务接口也尚未开始执行。
因此可以排除:
- 旧 token 导致的认证失败;
- token 过期;
- 打印页面接口返回 401 或 403;
- 打印页面内部 JavaScript 业务异常。
这一步非常重要。排查 Puppeteer 导航错误时,应先明确错误发生在:
- 浏览器启动阶段;
- 主文档导航阶段;
- 页面资源加载阶段;
- 页面脚本执行阶段;
- 业务接口调用阶段;
- PDF 生成阶段。
不同阶段的排查方向完全不同。
2.2 HTTP 200 不代表打印成功
打印服务的访问日志中,接口可能显示:
text
POST /print/batch 200
但响应体实际为:
json
{
"success": false
}
这说明 HTTP 200 只代表:
打印服务正常接收并处理了请求,同时正常返回了一个"打印失败"的业务结果。
它并不代表 PDF 已成功生成。
生产系统中建议让调用方同时检查:
- HTTP 状态码;
- 业务状态字段;
- 错误码;
- 错误消息;
- 生成文件是否真实存在。
三、检查是否是业务代码主动拦截请求
ERR_BLOCKED_BY_CLIENT 经常与请求拦截有关,因此先检查项目代码中是否存在:
ts
page.setRequestInterception(true)
request.abort()
或者通过 CDP 设置了:
text
Network.setBlockedURLs
Fetch.enable
本次检查未发现打印服务主动阻断登录页主文档请求的代码。
浏览器启动参数中还包含:
text
--disable-extensions
因此浏览器扩展拦截的可能性也较低。
需要注意的是:
没找到主动拦截代码,只能说明"业务代码直接拦截"的证据不足,不能仅凭这一点断定 Chromium 内部没有发生其他导航阻断。
四、确认 Chromium 是否仍然存活
打印失败时,先不要急着重启服务,否则会破坏现场。
可以在容器内检查 Chromium 调试端口:
bash
curl http://127.0.0.1:9222/json/version
返回结果类似:
json
{
"Browser": "Chrome/120.x",
"Protocol-Version": "1.3"
}
同时也可以检查当前 target:
bash
curl http://127.0.0.1:9222/json/list
如果可以正常返回,通常可以说明:
- Node.js 打印服务进程仍存活;
- Chromium 主进程仍存活;
- CDP 调试端口仍可用;
- 故障不是简单的浏览器进程崩溃。
本次现场中,Chromium 与调试端口均正常存活。
五、使用 CDP 捕获真正的网络失败事件
Puppeteer 抛出的异常有时只展示最终错误,CDP 的 Network.loadingFailed 可以提供更接近浏览器内部的证据。
示例监听代码:
ts
const client = await page.target().createCDPSession()
await client.send('Network.enable')
client.on('Network.loadingFailed', (event) => {
console.log(JSON.stringify(event, null, 2))
})
await page.goto('http://10.x.x.x/#/login')
现场捕获到:
json
{
"type": "Document",
"errorText": "net::ERR_BLOCKED_BY_CLIENT",
"canceled": false,
"blockedReason": "other"
}
之后又出现:
json
{
"type": "Document",
"errorText": "net::ERR_ABORTED",
"canceled": true
}
5.1 这组事件说明了什么
type: Document 表明失败的是主文档导航,而不是图片、字体、接口请求或其他子资源。
因此可以进一步判断:
- 不是页面加载完成后的业务接口失败;
- 不是某张图片或静态资源被拦截;
- 不是目标服务返回了普通的 401、403 或 500;
- 阻断发生在 Chromium 客户端侧的主导航过程中。
后续的 ERR_ABORTED 是打印代码捕获异常后关闭页面产生的次生事件,不能把它当作首因。
5.2 blockedReason: other 不等于 DevTools Request Blocking
如果是常规 DevTools Network 面板中的 Request Blocking,可能会看到更明确的阻断原因。
本次返回:
text
blockedReason: other
因此不能直接得出"有人在 DevTools 中配置了 Request Blocking"的结论。
此时更合理的判断是:
Chromium 客户端内部发生了导航阻断,但仅凭该字段还无法确认具体是哪一种内部机制。
六、逐项排除常见方向
| 排查方向 | 当前结论 | 主要依据 |
|---|---|---|
| token 失效 | 排除 | 登录页主导航在写入 token 前已失败 |
| 页面业务接口鉴权失败 | 排除 | 失败类型为 Document 主文档导航 |
| 普通网络超时 | 优先级较低 | 错误不是 DNS、连接超时或拒绝连接 |
| Node.js 堆内存耗尽 | 暂无证据 | 进程内存正常,错误类型也不符合 OOM |
| 浏览器扩展拦截 | 基本排除 | Chromium 使用 --disable-extensions |
| Service Worker 拦截 | 排除 | CDP target 中未发现相关 Service Worker |
| 外部 CDP 客户端拦截 | 经连接反查后未发现 | 活动 CDP WebSocket 属于打印服务自身 |
6.1 检查 CDP 连接来源
如果怀疑有其他程序连接 Chromium 调试端口,可以检查:
bash
grep -i ':2406' /proc/net/tcp
其中 2406 是十六进制的 9222。
常见状态:
0A:LISTEN;01:ESTABLISHED;06:TIME_WAIT。
如果发现活动连接,可根据 socket inode 继续反查对应 PID 和进程命令行。
本次最终确认,活动连接来自打印服务自身建立的 Puppeteer CDP WebSocket,而不是外部调试客户端。
七、决定性实验:请求是否被升级成 HTTPS
前面的证据已经确认:主文档被 Chromium 客户端阻断,但还不知道具体原因。
随后通过 CDP 观察实际导航地址,发现:
text
原始地址:http://10.x.x.x/#/login
实际尝试:https://10.x.x.x/
也就是说,Chromium 将 HTTP 自动升级成了 HTTPS。
接下来做对照验证。
7.1 验证 HTTP 服务本身是否正常
bash
curl -I http://10.x.x.x/
返回:
text
HTTP/1.1 200 OK
并且响应中没有:
text
Location: https://...
说明服务端 Nginx 并没有主动把 HTTP 重定向到 HTTPS。
7.2 验证目标是否提供 HTTPS
bash
curl -k -I https://10.x.x.x/
结果为 443 端口无法连接。
由此可以确认:
- HTTP 服务正常;
- 服务端没有做 HTTPS 重定向;
- HTTPS 服务不可用;
- HTTP 到 HTTPS 的变化来自 Chromium 客户端。
7.3 禁用 HttpsUpgrades 做单变量对照
使用一个独立的临时 Chromium 实例,并增加:
text
--disable-features=HttpsUpgrades
再次访问同一个地址,结果请求保持为 HTTP,并成功返回:
text
SUCCESS http://10.x.x.x/#/ 200
至此,直接故障链路得到确认:
text
Puppeteer 访问 HTTP 登录页
↓
Chromium 将主文档自动升级为 HTTPS
↓
目标内网服务没有开放 443
↓
无头浏览器无法继续处理安全提示
↓
Chromium 以 ERR_BLOCKED_BY_CLIENT 终止导航
↓
认证信息尚未写入,打印任务直接失败
八、为什么服务刚启动正常,运行一段时间后才失败
这一点是本次问题最容易让人困惑的地方。
8.1 浏览器实例和 profile 被长期复用
打印服务长期复用同一个 Chromium 实例,并使用类似以下参数:
text
--user-data-dir=/tmp/puppeteer-profile-xxxx
虽然该目录是临时目录,但只要 Chromium 进程没有重建,这个 profile 在服务生命周期内就会一直存在。
浏览器可能在 profile 中维护:
- HTTPS 升级状态;
- 站点参与度;
- 导航历史相关状态;
- 安全模式配置;
- 实验性特性的启用状态。
服务重启后,旧 Chromium 被关闭,新 profile 被创建,因此相关状态会被重置,打印也会暂时恢复。
8.2 高置信度解释:HTTPS-First Mode 启发式触发
结合以下现象:
- HTTP 地址实际被升级为 HTTPS;
- 禁用
HttpsUpgrades后立即恢复; - 故障绑定在长期运行的 Chromium/profile 上;
- 重建浏览器 profile 后可恢复;
- 服务运行时间与故障出现存在明显相关性;
高置信度判断是:Chromium 的 HTTPS-First Mode 或相关 HTTPS 自动升级机制,在长期运行的 profile 中被启用或生效。
需要强调:
"HTTP 被 Chromium 自动升级"已经通过对照实验确认;至于具体是哪条启发式规则、Field Trial 或 profile 条件触发,仍可能受到 Chromium 版本和实验配置影响。
因此在技术文档中,最好把它分成两层:
- 已确认的直接原因:Chromium 把 HTTP 导航升级成 HTTPS;
- 高置信度的触发解释:长期复用的 profile 触发了 HTTPS-First Mode 或相关升级策略。
不要把后者写成所有 Chromium 版本都完全一致的固定规则。
九、修复方案
9.1 临时直接修复:禁用相关 HTTPS 自动升级特性
在 Puppeteer 启动参数中加入:
ts
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--disable-features=HttpsUpgrades,HttpsFirstModeV2ForEngagedSites,HttpsFirstModeV2ForTypicallySecureUsers'
]
})
该方案适用于:
- 目标是受控内网服务;
- 服务当前只支持 HTTP;
- 暂时无法改造 HTTPS;
- 打印浏览器只用于访问受信任的内部页面。
需要注意:关闭浏览器安全升级能力属于兼容性方案,不应作为互联网公开页面的通用配置。
9.2 长期推荐方案:为目标服务提供 HTTPS
更理想的方案是:
- 为内网页面配置 HTTPS;
- 开放并正确代理 443;
- 配置可信证书;
- 让 Puppeteer 直接访问 HTTPS 地址;
- 避免依赖关闭 Chromium 安全特性。
从长期维护和安全角度看,这比永久禁用 HTTPS 升级更合理。
9.3 增加浏览器自愈机制
即使完成根因修复,也建议增加有限的自愈能力:
ts
async function recreateBrowser(): Promise<void> {
if (browser) {
await browser.close().catch(() => undefined)
}
browser = await createBrowser()
}
当出现明确的浏览器状态类错误时,可以:
- 记录结构化错误;
- 关闭当前页面;
- 重建 Chromium;
- 仅重试一次;
- 如果仍失败,则返回明确错误。
不要无限重试,否则可能掩盖真实问题并放大资源消耗。
9.4 使用隔离的 BrowserContext
多个打印任务共享同一个浏览器时,建议至少为每个任务创建独立上下文:
ts
const context = await browser.createBrowserContext()
const page = await context.newPage()
try {
// 执行打印
} finally {
await context.close()
}
这样可以减少以下状态在任务之间互相影响:
- Cookie;
- LocalStorage;
- SessionStorage;
- 缓存;
- 权限;
- 页面级会话状态。
但需要说明:如果问题绑定在整个 Chromium profile 或浏览器级特性上,仅使用 BrowserContext 不一定能解决,仍可能需要重建 browser。
十、构建可重复的回归测试
这类问题如果只能"运行十几天等待复现",就很难验证修复是否有效。
可以在独立临时 profile 的 Default/Preferences 中预置:
json
{
"https_only_mode_enabled": true
}
然后使用测试脚本分别运行故障模式和修复模式。
示例:
bash
# 预期:HTTP 被升级成 HTTPS,出现 ERR_BLOCKED_BY_CLIENT
node scripts/repro-https-first-mode.js broken
# 预期:保持 HTTP,并成功返回 200
node scripts/repro-https-first-mode.js fixed
建议测试脚本满足以下要求:
- 每次创建独立临时 profile;
- 测试完成后自动清理;
- 不读取生产浏览器 profile;
- 不修改正在运行的打印服务;
broken和fixed使用同一个目标地址;- 两组测试只改变 Chromium 启动参数。
这样才能形成有效的单变量对照。
十一、生产环境中容易忽略的安全问题
本次排查还暴露出一些与根因无关、但值得单独治理的问题。
11.1 日志中不要输出完整 token
打印服务可能会记录完整请求体,其中包含 token、用户信息或其他认证数据。
建议:
- 对 token 统一脱敏;
- 不记录完整 Authorization Header;
- 限制生产日志级别;
- 设置合理的日志保留周期;
- 检查历史日志是否包含敏感信息。
11.2 不要在源码或普通配置中保存明文凭据
对象存储、数据库或第三方服务的访问凭据应迁移到:
- 环境变量;
- Kubernetes Secret;
- 云厂商密钥管理服务;
- 专用配置中心的加密配置。
11.3 不要无鉴权暴露 CDP 调试能力
Chrome DevTools Protocol 可以读取和控制浏览器页面。
生产环境中:
- 调试端口只监听本机;
- 不应直接暴露 9222;
- 调试代理默认关闭;
- 如确需开放,必须增加鉴权、来源限制和审计;
- 避免公开页面枚举接口和 WebSocket 代理。
十二、推荐的通用排查流程
遇到 Puppeteer 的 ERR_BLOCKED_BY_CLIENT 时,可以按以下顺序排查。
第一步:确定失败资源类型
通过 CDP 查看:
json
{
"type": "Document"
}
区分失败的是:
- 主文档;
- Fetch/XHR;
- 图片;
- 脚本;
- 字体;
- 其他静态资源。
第二步:确认实际请求 URL
不要只看传给 page.goto() 的地址,还要确认 Chromium 最终访问的是 HTTP 还是 HTTPS。
第三步:做客户端与服务端对照
bash
curl -I http://目标地址/
curl -k -I https://目标地址/
确认:
- HTTP 是否正常;
- 服务端是否返回重定向;
- HTTPS 是否可用;
- 问题发生在服务端还是浏览器客户端。
第四步:检查请求拦截
重点搜索:
text
setRequestInterception
request.abort
Network.setBlockedURLs
Fetch.enable
同时检查是否存在外部 CDP 客户端。
第五步:使用独立 profile 做单变量实验
尝试:
- 新建无痕上下文;
- 新建 browser;
- 新建临时 user-data-dir;
- 禁用可疑 feature;
- 使用相同地址做对照。
第六步:区分直接原因与触发原因
例如本次问题中:
- 直接原因:HTTP 被升级为 HTTPS,443 不可用;
- 触发原因:长期运行 profile 中 HTTPS 自动升级策略生效;
- 表面恢复方式:重启打印服务;
- 永久修复:支持 HTTPS 或显式调整 Chromium 特性。
十三、最终结论
本次故障不是 token、业务接口、普通网络超时或 Node.js 内存问题。
已经通过 CDP 和独立 Chromium 对照实验确认:
Puppeteer 打开的 HTTP 主文档被 Chromium 自动升级成 HTTPS,而目标服务没有提供 HTTPS。无头 Chromium 最终以
net::ERR_BLOCKED_BY_CLIENT阻断导航。
服务重启后恢复,是因为 Chromium 实例和临时 profile 被重建,相关浏览器状态被清除;长期复用同一 browser/profile 后,问题可能再次出现。
推荐处理优先级:
- 长期方案:为目标页面提供可用的 HTTPS;
- 兼容方案:在受控内网打印场景中禁用相关 HTTPS 自动升级 feature;
- 稳定性方案:增加 browser 重建、有限重试和结构化错误日志;
- 隔离方案:每个任务使用独立 BrowserContext;
- 安全方案:收口 CDP 调试入口并对日志、凭据进行脱敏。
这次排查最值得复用的经验是:
遇到浏览器导航错误时,不要只看 Puppeteer 最终抛出的异常。应结合代码执行阶段、CDP 网络事件、实际导航 URL 和单变量对照实验,逐层缩小问题范围。
参考标签
Puppeteer Chromium Node.js ERR_BLOCKED_BY_CLIENT HTTPS-First Mode CDP 服务端打印 故障排查