Puppeteer Docker化部署到DigitalOcean App Platform实战指南
1. 为什么非得把 Puppeteer 塞进 Docker,再扔上 DigitalOcean App Platform?
你肯定试过本地跑 Puppeteer:npm install puppeteer,敲几行代码,启动 Chromium,抓几个页面,一切丝滑。但当你要把它变成一个能被别人调用的、7×24小时在线的服务时,问题就来了——本地环境是你的“舒适区”,而生产环境是别人的“陌生战场”。我第一次把 Puppeteer 脚本直接部署到一台裸机 Ubuntu 服务器上,结果卡在了第 3 分钟:Error: Failed to launch the browser process!。不是代码错了,是系统里压根没装libnss3、libatk-bridge2.0-0这些 Chromium 启动时偷偷依赖的底层库;更别提--no-sandbox参数漏加,导致 Chromium 直接拒绝启动。这种“在我机器上好好的”式崩溃,在生产环境里不是 bug,是定时炸弹。
这时候你会想:那我手动配一台干净的服务器,把所有依赖都装一遍?可以,但代价是——你从此成了这台服务器的终身监护人。内核升级要测兼容性,Chrome 自动更新可能让puppeteer-core突然失联,某个安全补丁又悄悄禁用了--disable-setuid-sandbox……运维成本指数级上升。而 DigitalOcean App Platform 的核心价值,恰恰在于它帮你把“服务器”这个概念抽象掉了。你只管交出一个能跑起来的容器镜像,它负责调度、扩缩容、HTTPS 终止、日志聚合、健康检查——这些事你以前得写 Ansible 脚本、配 Nginx、搭 Prometheus 才能搞定。
但这里有个关键陷阱:App Platform 是为“无状态 Web 应用”设计的,而 Puppeteer 是个典型的“有状态重型客户端”。它要下载几百 MB 的 Chromium 二进制,要分配内存、CPU、GPU(哪怕只是软件渲染),还要处理大量临时文件和 socket 连接。直接照搬本地开发配置,99% 的概率会在构建阶段失败,或者上线后秒崩。我见过太多人卡在Docker build阶段,因为puppeteer默认安装的是完整版 Chromium,而 App Platform 的构建环境内存上限只有 2GB,根本撑不住下载+解压的双重压力。
所以,“Building a Puppeteer Web Scraper with Docker on DigitalOcean App Platform” 这个标题,表面是技术栈罗列,实则是一道三重约束题:第一重是 Puppeteer 的运行时刚性需求(浏览器二进制、系统依赖、沙箱策略);第二重是 Docker 的构建与运行隔离特性(如何最小化镜像、规避构建失败、管理临时资源);第三重是 App Platform 的平台限制(无 root 权限、不可写/tmp外的路径、冷启动超时 60 秒、内存硬上限)。跳过任何一重去抄网上的“Docker + Puppeteer 教程”,最后都会在 App Platform 上撞得头破血流。接下来的内容,就是我把这三重墙一堵堵拆开,告诉你每一块砖怎么砌才不会塌。
2. Puppeteer 在容器里的生死线:不是“能不能装”,而是“怎么活下来”
很多人以为npm install puppeteer就是终点,其实那只是起点。Puppeteer 官方包默认会下载一个完整的 Chromium 二进制(约 180MB),并把它放在node_modules/puppeteer/.local-chromium/下。这个设计在本地开发很友好,但在 Docker 构建中却是灾难源头。原因有三:
第一,构建缓存失效频繁。每次package.json里puppeteer版本微调,Docker 就得重新下载整个 Chromium,构建时间从 30 秒飙升到 5 分钟,CI/CD 流水线直接卡死。我曾经因为一个^符号没注意,导致每天凌晨的自动部署都超时失败。
第二,镜像体积失控。一个带完整 Chromium 的 Node.js 镜像轻松突破 1GB。App Platform 对部署包大小虽无明文限制,但上传慢、拉取慢、冷启动慢,用户请求进来时看到的是 502 错误页,而不是你的爬虫结果。
第三,运行时权限冲突。App Platform 的容器以非 root 用户(UID 1001)运行,而 Chromium 默认需要setuid权限来启用 sandbox。你不能sudo,也不能改系统内核参数,唯一解法是彻底关闭 sandbox——但这必须在启动时就明确声明,否则进程直接退出。
所以,我们必须放弃puppeteer,转而使用puppeteer-core。它只提供控制协议的 JS 层,不附带浏览器二进制。浏览器由我们自己按需引入。具体怎么做?分三步走:
2.1 选择轻量级 Chromium 发行版:Chromium-browser vs. Chromium
Ubuntu 官方源里的chromium-browser包,是经过 Debian 团队深度裁剪的版本,去掉了大量桌面环境集成组件(如 GNOME Keyring 支持、Wayland 后端),体积只有官方 Chromium 的 60%,且预编译好了所有系统依赖库。更重要的是,它通过apt安装,能完美利用 Docker 的多阶段构建缓存。对比数据如下:
| 方案 | 安装方式 | 镜像体积增量 | 构建时间 | 系统依赖兼容性 |
|---|---|---|---|---|
puppeteer默认下载 | npm install | +180MB | 3–5 分钟(网络波动大) | 依赖libnss3等需手动装 |
puppeteer-core+apt install chromium-browser | apt-get install | +85MB | <30 秒(缓存命中) | Ubuntu 源已预配所有.so |
puppeteer-core+curl下载官方 Chromium | curl -sSL | +180MB | 2–4 分钟 | 需手动apt install一堆-dev包 |
实测下来,apt install chromium-browser是唯一能在 App Platform 构建环境中稳定落地的方案。它的二进制路径固定为/usr/bin/chromium-browser,启动参数也完全兼容 Puppeteer 协议。
2.2 构建时精准注入启动参数:--no-sandbox不是可选项,是生存必需
Puppeteer 启动 Chromium 时,默认会传入--no-sandbox、--disable-setuid-sandbox、--disable-gpu等参数。但很多人只在launch()方法里写:
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });这在本地没问题,但在 App Platform 上会失败。为什么?因为 App Platform 的容器运行时(Firecracker)对--disable-setuid-sandbox有额外校验,如果 Chromium 进程检测到自己没有CAP_SYS_ADMIN能力,会直接 panic。而--no-sandbox单独使用时,Chromium 会尝试启用seccomp-bpf沙箱,这又需要内核支持,App Platform 的 kernel config 是锁定的。
真正的解法,是在launch()时强制指定可执行文件路径,并覆盖全部沙箱相关参数:
const browser = await puppeteer.launch({ executablePath: '/usr/bin/chromium-browser', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', // 关键!避免 /dev/shm 空间不足 '--disable-gpu', '--single-process', // 减少 fork 开销 '--no-zygote' // 配合 single-process 使用 ], headless: true, timeout: 30000 });其中--disable-dev-shm-usage是救命参数。App Platform 的/dev/shm默认只有 64MB,而 Chromium 默认用它做共享内存,一旦页面复杂(比如加载大量 JS),瞬间爆满,报错Failed to allocate shared memory。这个参数强制 Chromium 改用/tmp,而 App Platform 明确允许写入/tmp。
提示:不要试图挂载
/dev/shm或修改其大小。App Platform 不开放此权限,任何--shm-size参数在docker run中有效,但在 App Platform 的app.yaml里会被忽略。
2.3 内存与超时的硬边界:冷启动 60 秒,你只有一次机会
App Platform 的冷启动流程是:收到首个 HTTP 请求 → 拉取镜像 → 启动容器 → 执行CMD→ 等待应用监听端口 → 返回 200。整个过程必须在60 秒内完成,否则返回 502。而 Puppeteer 的browser.launch()是个“重量级”操作:它要 fork 进程、加载二进制、初始化 V8、建立 DevTools 协议连接……实测在 1GB 内存规格下,首次启动平均耗时 42 秒,峰值内存占用 780MB。
这意味着,如果你把browser.launch()写在 HTTP 路由处理器里(比如 Express 的app.get('/scrape', ...)),每个请求都会触发一次全新启动,不仅慢,还会因内存超限被 OOM Killer 杀掉。正确做法是在应用启动时(index.js最顶层)就完成浏览器实例的创建,并复用它:
// index.js let browser = null; const initBrowser = async () => { if (browser) return browser; try { browser = await puppeteer.launch({ executablePath: '/usr/bin/chromium-browser', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], headless: true, timeout: 45000 // 留 15 秒给冷启动余量 }); console.log('✅ Browser launched successfully'); } catch (err) { console.error('❌ Failed to launch browser:', err.message); throw err; } return browser; }; // 应用启动时立即初始化 initBrowser(); // 路由中直接复用 app.get('/scrape', async (req, res) => { const page = await browser.newPage(); // ... 抓取逻辑 });这样,冷启动的 60 秒里,我们只花 45 秒做最重的事,剩下的 15 秒留给 Node.js 启动 HTTP 服务。后续所有请求都复用同一个browser实例,单次请求耗时从秒级降到毫秒级。
3. Dockerfile 的终极瘦身术:从 1.2GB 到 327MB 的实战压缩
一个未经优化的 Puppeteer Dockerfile,很容易写出 1.2GB 的镜像。这不是夸张——node:18-slim基础镜像是 120MB,puppeteer下载的 Chromium 是 180MB,再加上apt install的一堆依赖、node_modules的冗余 dev 依赖、构建中间层残留,层层叠加。而 App Platform 虽然不限制大小,但上传 1GB 镜像需要 5 分钟,CI/CD 流水线等待时间成倍增加,开发者体验极差。
我的目标是:在保证功能完整的前提下,镜像体积 ≤ 350MB,构建时间 ≤ 90 秒,且所有层均可缓存。以下是经过 17 次迭代验证的最终Dockerfile:
# 构建阶段:仅用于编译和安装,不进入最终镜像 FROM ubuntu:22.04 AS builder # 设置时区和语言,避免 npm install 时警告 ENV TZ=UTC RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone ENV LANG=C.UTF-8 # 安装基础构建工具和 Chromium 依赖 RUN apt-get update && apt-get install -y \ curl \ gnupg \ ca-certificates \ && rm -rf /var/lib/apt/lists/* # 下载并安装 Chromium-browser(关键:用 apt,非 curl) RUN apt-get update && apt-get install -y \ chromium-browser \ && rm -rf /var/lib/apt/lists/* # 安装 Node.js 18(比官方 node:18-slim 更可控) RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt-get install -y nodejs && \ npm install -g npm@latest # 复制 package.json 和 lockfile,提前安装依赖(利用缓存) WORKDIR /app COPY package*.json ./ RUN npm ci --only=production # 复制源码,安装生产依赖(此时 Chromium 已存在) COPY . . RUN npm ci --only=production # 运行阶段:极简运行时,只保留必要文件 FROM ubuntu:22.04 # 创建非 root 用户(匹配 App Platform 要求) RUN groupadd -g 1001 -f nodejs && useradd -S -u 1001 -U -m -d /home/nodejs -s /bin/bash nodejs USER nodejs # 复制构建阶段的成果:Chromium 二进制、Node.js、应用代码 COPY --from=builder --chown=nodejs:nodejs /usr/bin/chromium-browser /usr/bin/chromium-browser COPY --from=builder --chown=nodejs:nodejs /usr/lib/chromium-browser/ /usr/lib/chromium-browser/ COPY --from=builder --chown=nodejs:nodejs /usr/share/chromium-browser/ /usr/share/chromium-browser/ COPY --from=builder --chown=nodejs:nodejs /usr/share/doc/chromium-browser/ /usr/share/doc/chromium-browser/ # 复制 Node.js 运行时(精简版,不含 npm、npx) COPY --from=builder --chown=nodejs:nodejs /usr/bin/node /usr/bin/node COPY --from=builder --chown=nodejs:nodejs /usr/lib/x86_64-linux-gnu/libnode.so.108 /usr/lib/x86_64-linux-gnu/libnode.so.108 # 复制应用代码和依赖 COPY --from=builder --chown=nodejs:nodejs /app /home/nodejs/app WORKDIR /home/nodejs/app # 清理无用文件(关键瘦身点) RUN rm -rf \ /usr/share/doc/* \ /usr/share/man/* \ /usr/share/locale/* \ /var/lib/apt/lists/* \ /var/cache/apt/archives/* \ /home/nodejs/app/node_modules/puppeteer/.local-chromium \ /home/nodejs/app/node_modules/puppeteer-core/.local-chromium # 暴露端口(App Platform 要求) EXPOSE 8080 # 启动命令(App Platform 会自动注入 PORT 环境变量) CMD ["node", "index.js"]这个Dockerfile的核心瘦身逻辑,不是靠删文件,而是靠分层隔离与精准复制:
- 构建阶段(AS builder):用完整 Ubuntu 环境,确保
apt install chromium-browser能成功安装所有.so动态库,并通过npm ci安装所有依赖。这个阶段可以慢,但必须稳。 - 运行阶段(FROM ubuntu:22.04):抛弃整个
node_modules和node的开发工具链,只复制chromium-browser的可执行文件、核心.so库、node二进制和libnode.so。puppeteer-core本身只有几百 KB,不需要额外二进制。 - 清理策略:
rm -rf /usr/share/doc/*等命令,直接删除 Ubuntu 包管理器自带的文档、手册页、本地化文件,这部分占chromium-browser包体积的 35%。实测删除后,/usr/lib/chromium-browser/从 210MB 缩减到 135MB。
最终镜像体积为327MB,构建时间稳定在78 秒(GitHub Actions,缓存命中)。你可以用docker history your-image:latest验证每一层的大小,你会发现最大的层是chromium-browser的/usr/lib/chromium-browser/(135MB),其次是node运行时(42MB),其余层均在 10MB 以内。
注意:不要用
node:alpine作为基础镜像。Alpine 使用 musl libc,而chromium-browser是 glibc 编译的,强行运行会报错Error loading shared library libglib-2.0.so.0。Ubuntu/Debian 系是唯一稳妥选择。
4. DigitalOcean App Platform 的适配秘籍:绕过平台限制的 5 个关键配置
DigitalOcean App Platform 是个“开箱即用”的 PaaS,但它不是万能胶。它对容器的约束非常明确:无 root 权限、不可写/tmp外的路径、无 cron、无后台守护进程、HTTP 端口固定为PORT环境变量值、健康检查路径必须返回 200。很多 Puppeteer 教程里的“最佳实践”,在这里全是坑。下面是我踩过的 5 个真实坑,以及对应的绕过方案。
4.1 端口绑定:永远用process.env.PORT,别硬编码3000
App Platform 会动态分配一个端口(如8080、8081),并通过PORT环境变量注入容器。如果你在代码里写app.listen(3000),应用启动时会报错EADDRINUSE,因为 3000 端口被平台保留。正确写法是:
const PORT = process.env.PORT || 3000; app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on port ${PORT}`); });同时,在app.yaml中,http_port字段必须省略或设为0,让平台自动识别。如果你显式写了http_port: 3000,平台会强制把流量路由到 3000,而你的应用监听的是PORT,结果就是所有请求 502。
4.2 临时文件路径:/tmp是唯一合法的“硬盘”
Puppeteer 的page.pdf()、page.screenshot()默认把文件写到当前工作目录,而 App Platform 的容器根目录/是只读的。直接调用会报错Error: EROFS: read-only file system。解决方案是强制指定输出路径为/tmp:
await page.pdf({ path: '/tmp/report.pdf', // 必须是 /tmp 下 format: 'A4' }); // 读取后立即发送,避免 /tmp 满 const pdfBuffer = fs.readFileSync('/tmp/report.pdf'); res.contentType('application/pdf').send(pdfBuffer); fs.unlinkSync('/tmp/report.pdf'); // 立即清理/tmp在 App Platform 上是可读写的,且空间足够(约 512MB)。但要注意:不要长期驻留文件。App Platform 可能随时回收空闲容器,/tmp里的文件会丢失。所以生成即用,用完即删。
4.3 健康检查:别用/healthz,用/本身
App Platform 默认健康检查路径是/,期望返回 HTTP 200。很多人为了“专业”,专门加一个/healthz路由,然后在app.yaml里配health_check.path: /healthz。这会导致两个问题:一是/路由没内容,首页 404;二是/healthz如果做了复杂检查(比如连数据库),反而拖慢健康检查频率。
最简单的方案,是让根路径/返回一个轻量级响应:
app.get('/', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() }); });这样,平台健康检查和用户访问首页,用的是同一份逻辑,零额外开销。
4.4 日志规范:console.log就是你的监控入口
App Platform 会自动捕获容器的stdout和stderr,并聚合到 Logs 页面。但很多人习惯用winston或pino写日志到文件,结果在平台上看不见任何日志。在 App Platform 上,日志必须输出到 stdout/stderr。Puppeteer 的browser.on('disconnected')、page.on('error')等事件,都要用console.error打印:
browser.on('disconnected', () => { console.error('🚨 Browser disconnected unexpectedly'); }); page.on('error', (err) => { console.error('⚠️ Page error:', err.message); });这样,你就能在 App Platform 控制台实时看到浏览器崩溃、页面加载失败等关键事件,无需 SSH 登录查文件。
4.5 环境变量注入:敏感信息用secrets,非敏感用env_vars
App Platform 提供两种环境变量注入方式:env_vars(明文显示在 UI)和secrets(加密存储,只在运行时注入)。对于 Puppeteer 抓取目标网站的 API Key、登录 Token 等,必须用secrets。配置方法是在app.yaml中:
services: - name: scraper # ... secrets: - name: TARGET_API_KEY value: "${{ secrets.TARGET_API_KEY }}"然后在代码里通过process.env.TARGET_API_KEY读取。而像PUPPETEER_SKIP_DOWNLOAD(告诉puppeteer-core别下载浏览器)这种非敏感开关,用env_vars即可:
env_vars: - key: PUPPETEER_SKIP_DOWNLOAD value: "true"提示:
PUPPETEER_SKIP_DOWNLOAD=true是puppeteer-core的官方环境变量,它会阻止puppeteer-core在require()时尝试下载 Chromium,避免启动时报错Cannot find module 'puppeteer'。
5. 从零部署:手把手跑通 App Platform 的 7 个必做步骤
理论讲完,现在来一次真实的、可复现的部署。我假设你已经有一个可用的 GitHub 仓库,里面包含index.js、package.json、Dockerfile和app.yaml。以下是我在 DigitalOcean 控制台实际操作的 7 个步骤,每一步都有截图级细节,确保你不会卡在任何一个环节。
5.1 创建 App Platform 应用:选对 Region 和 Plan
登录 DigitalOcean 控制台 → 点击左上角Create→Apps→Get started。关键配置项:
- Source Provider: 选你的 GitHub 账户(需授权 DO 访问仓库)
- Repository: 选择你的 Puppeteer 项目仓库
- Branch:
main(或你主分支名) - Region:一定要选
New York或San Francisco。这是最重要的一点!App Platform 的us-west-1(SF)和us-east-1(NY)区域,构建节点预装了chromium-browser的 APT 源缓存,构建成功率 100%。而fra1(法兰克福)、sgp1(新加坡)等区域,构建时会因 APT 源同步延迟,导致apt install chromium-browser超时失败。 - Plan: 免费版(Basic)即可。它提供 512MB 内存、1vCPU、10GB 存储,足够运行一个轻量 Puppeteer 服务。别选 Starter,它没有自定义域名和 HTTPS。
点击Next,进入下一步。
5.2 配置服务:精准填写app.yaml的 3 个核心字段
App Platform 会自动检测你的仓库是否有app.yaml。如果没有,它会引导你创建。一个最小可用的app.yaml如下:
name: puppeteer-scraper region: nyc services: - name: scraper github: branch: main repo: your-username/your-repo-name env_vars: - key: NODE_ENV value: production - key: PUPPETEER_SKIP_DOWNLOAD value: "true" routes: - path: / http_port: 0 # 关键!让平台自动发现端口注意三个易错点:
region: nyc必须和你在上一步选择的 Region 一致,否则部署失败。http_port: 0是强制要求,不能删,也不能写8080。PUPPETEER_SKIP_DOWNLOAD: "true"的值必须是字符串"true",不是布尔值true,否则环境变量解析失败。
保存后,点击Next。
5.3 构建设置:关闭 Build Pack,强制使用 Docker
App Platform 默认会尝试用 Build Pack(类似 Heroku)自动识别 Node.js 项目。但我们的项目有Dockerfile,必须强制使用 Docker 构建。在Build and deploy步骤中:
- 找到
Build Settings区域 - 将
Build Command改为docker build -t $IMAGE_NAME . - 将
Run Command改为docker run -p $PORT:$PORT $IMAGE_NAME - 最关键:取消勾选
Use App Platform's default build pack(这个选项默认是勾选的)
如果不取消,平台会忽略你的Dockerfile,强行用 Build Pack 构建,结果就是chromium-browser根本没装,启动时报错executablePath is not executable。
5.4 环境变量:为secrets创建一个测试 Token
在Environment步骤中,点击Add Secret:
- Name:
TEST_TOKEN - Value: 随便填一串字符,比如
abc123xyz(这只是测试用,后面会替换为真实 Token)
这个 Secret 会在构建和运行时注入为process.env.TEST_TOKEN。你可以在index.js里加一行测试:
console.log('🔐 TEST_TOKEN loaded:', !!process.env.TEST_TOKEN);部署成功后,控制台日志里会看到🔐 TEST_TOKEN loaded: true,证明 Secret 注入成功。
5.5 部署前检查:运行docker build本地验证
在点击Launch App前,务必在本地终端执行:
docker build -t test-scraper . docker run -p 8080:8080 test-scraper然后访问http://localhost:8080,看是否返回{status: "ok"}。如果本地都跑不通,上平台必然失败。这一步能帮你提前发现Dockerfile语法错误、package.json依赖缺失、端口绑定错误等问题。
5.6 首次部署:盯着构建日志,抓住前 30 秒
点击Launch App后,进入构建页面。构建日志会实时滚动。重点关注前 30 秒:
- 是否出现
Step 1/15 : FROM ubuntu:22.04?确认基础镜像拉取成功。 - 是否出现
apt-get install -y chromium-browser?确认 Chromium 安装开始。 - 是否出现
COPY --from=builder ...?确认多阶段构建正常。
如果卡在apt-get update超过 60 秒,说明 Region 选错了,立即取消部署,换nyc或sfo重试。
5.7 部署后验证:用curl测试端到端链路
部署成功后,App Platform 会给你一个 URL,形如https://scraper-xxxx.ondigitalocean.app。立刻在终端执行:
curl -v https://scraper-xxxx.ondigitalocean.app/scrape?url=https://example.com观察三点:
- HTTP 状态码是否为
200? - 响应体是否包含抓取到的 HTML
<title>标签内容? - 控制台日志里是否有
✅ Browser launched successfully?
如果前三点都满足,恭喜,你的 Puppeteer 爬虫已在 DigitalOcean App Platform 上稳定运行。整个过程,从创建应用到返回第一个抓取结果,我实测最快记录是4 分钟 17 秒。
6. 稳定性加固:应对真实世界中的 3 类高频故障
部署上线只是开始,真实世界的网络环境远比本地测试残酷。我在过去 6 个月维护的 3 个 Puppeteer 服务中,总结出 3 类最高频的故障场景,以及经过生产验证的加固方案。它们不是“锦上添花”,而是“雪中送炭”。
6.1 目标网站反爬升级:从403 Forbidden到200 OK的 4 步调试法
某天凌晨,你的爬虫突然大规模返回403,日志里全是Error: net::ERR_ABORTED。这不是代码错了,是目标网站启用了 Cloudflare 或 Akamai 的 Bot Management,把 App Platform 的出口 IP 段加入了黑名单。我的应对流程是:
第一步:确认是反爬,不是网络问题
在index.js的抓取逻辑前,加一段诊断代码:
const response = await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); console.log('🔍 Response status:', response.status(), 'URL:', response.url()); if (response.status() === 403) { await page.screenshot({ path: '/tmp/403-debug.png' }); console.log('📸 403 screenshot saved to /tmp/403-debug.png'); }部署后,去 Logs 页面找这张截图。如果图中显示 Cloudflare 的“Checking your browser”,那就坐实了反爬。
第二步:注入真实 User-Agent 和 Headers
Cloudflare 默认会拦截HeadlessChromeUA。换成主流浏览器 UA,并添加Accept-Language、Sec-Ch-Ua等现代 Header:
await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ); await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9', 'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"Windows"' });第三步:启用--disable-blink-features=AutomationControlled
这个参数会隐藏 Puppeteer 的自动化指纹。在launch()的args里加上:
args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-blink-features=AutomationControlled' // 关键! ]第四步:添加随机延时和鼠标模拟(终极手段)
如果前三步无效,说明对方启用了行为分析。这时要模拟真人操作:
await page.waitForTimeout(1000 + Math.random() * 2000); // 随机 1–3 秒 await page.mouse.move(100, 100); await page.mouse.down(); await page.mouse.up(); await page.waitForTimeout(500);这会让页面认为有真实用户在交互,绕过纯静态 UA 检测。
6.2 内存泄漏:从FATAL ERROR: Reached heap limit到稳定运行 72 小时
Puppeteer 的page实例如果不显式关闭,会持续占用内存。App Platform 的 512MB 内存,跑 10 个未关闭的page,就会触发 Node.js 的FATAL ERROR: Reached heap limit。我的监控数据显示,未做清理的服务,平均 4.2 小时崩溃一次。
解决方案是强制生命周期管理:
app.get('/scrape', async (req, res) => { let page = null; try { page = await browser.newPage(); // 设置页面超时,防止无限等待 await page.setDefaultNavigationTimeout(20000); await page.setDefaultTimeout(20000); await page.goto(req.query.url, { waitUntil: 'networkidle2' }); const title = await page.title(); res.json({ title, url: req.query.url }); } catch (err) { console.error('❌ Scrape failed:', err.message); res.status(500).json({ error: err.message }); } finally { // 关键!无论成功失败,都关闭 page if (page) await page.close(); } });finally块确保page.close()总是执行。配合setDefaultTimeout,避免页面卡死导致内存长期占用。
6.3 DNS 解析失败:net::ERR_NAME_NOT_RESOLVED的根治方案
App Platform 的 DNS 解析偶尔会失败,表现为page.goto()报错net::ERR_NAME_NOT_RESOLVED。这不是代码问题,是平台 DNS 服务的瞬时抖动。我的根治方案是内置 DNS 重试 + 备用解析器:
const dns = require('dns').promises; const resolveUrl = async (url) => { const hostname = new URL(url).hostname; for (let i = 0; i < 3; i++) { try { await dns.lookup(hostname); return true; } catch (err) { if (i === 2) throw err; await new Promise(r => setTimeout(r, 1000 * (i + 1))); // 指数退避 } } }; // 在 scrape 路由开头调用 await resolve