13. 云端环境配置与CI/CD

13.云端环境配置与CI/CD

把 Puppeteer 脚本从本地开发环境搬到云端运行,听起来像是把一只习惯在温室里生长的植物直接种到野外——总会遇到各种水土不服的问题。本地开发时,Chrome 浏览器可以正常启动、渲染页面、执行 JavaScript,但到了 Travis CI、CircleCI 或者 GitLab CI 这类持续集成环境,甚至 AWS、GCP 这些云服务器上,同样的代码可能会直接崩溃,报错信息往往让人摸不着头脑。最常见的问题集中在 Linux 系统权限、沙箱机制、依赖库缺失,以及资源限制这几个方面。这一章的目标,就是把这些问题逐一拆解,给出在主流 CI/CD 平台和云环境中稳定运行 Puppeteer 的完整方案。

Linux 系统沙箱设置

Chrome 浏览器为了安全,默认会在沙箱环境中运行渲染进程。这个沙箱机制在桌面版 Linux 上通常工作得很好,但在容器化环境或者某些精简过的云服务器系统中,沙箱所需的内核特性可能被禁用或不可用。这时候 Chrome 启动就会失败,报错信息里经常能看到 No usable sandbox 或者 Failed to move to new namespace 这样的关键词。

理解 Chrome 沙箱机制

沙箱的本质是隔离。Chrome 把每个标签页的渲染进程都放在一个受限的环境中,防止恶意网页代码突破浏览器去攻击宿主系统。这个机制依赖 Linux 内核的命名空间(namespaces)和 seccomp-bpf 系统调用过滤。在 Docker 容器里,这些能力默认是被限制的,因为容器本身已经提供了一层隔离。CI 环境为了安全,通常也不会给构建任务开启特权模式。

禁用沙箱的权衡

最直接的解决方案是禁用沙箱。在 Puppeteer 启动浏览器时,可以通过 args 参数传递 --no-sandbox 和 --disable-setuid-sandbox 标志。这种方式简单有效,但确实降低了安全性。在 CI/CD 这种一次性的、受控的环境中,风险相对可控,因为浏览器只访问我们指定的测试页面,不会暴露给外部不可信的输入。

const browser = await puppeteer.launch({
  args: ['--no-sandbox', '--disable-setuid-sandbox']
});

这段代码告诉 Chrome 不要创建沙箱命名空间,也不要使用 setuid 机制来提升沙箱进程的权限。在绝大多数 CI 环境中,这是让浏览器成功启动的第一步。需要注意的是,如果代码同时会在本地开发环境运行,最好通过环境变量来控制是否禁用沙箱,避免本地也运行在不受保护的模式下。

使用自定义沙箱可执行文件

如果安全策略要求不能禁用沙箱,还有另一种方案:使用一个自定义的、有 setuid 权限的沙箱可执行文件。Chrome 项目本身提供了一个 chrome-devel-sandbox 二进制文件,可以单独编译和配置。在 CI 环境中,可以预先构建好这个沙箱文件,然后通过 CHROME_DEVEL_SANDBOX 环境变量指定它的位置。

在 .bashrc 或 .zshenv 中添加:

export CHROME_DEVEL_SANDBOX=/usr/local/sbin/chrome-devel-sandbox

或者在 Dockerfile 中设置:

ENV CHROME_DEVEL_SANDBOX=/usr/local/sbin/chrome-devel-sandbox

这种方式保留了沙箱的安全特性,但需要确保沙箱文件有正确的权限设置(通常是 root 拥有,并且设置了 setuid 位:chmod 4755)。在大多数 CI 环境中,构建任务没有 root 权限,所以这种方式实施起来比直接禁用沙箱复杂得多。除非项目有强制的安全合规要求,否则不推荐在 CI 场景中使用。

Travis CI 与 CircleCI 集成

Travis CI 和 CircleCI 是两款老牌的持续集成服务,对 Node.js 项目支持都很成熟。它们的配置思路类似:选择一个基础镜像,安装系统依赖,配置环境变量,然后运行测试脚本。但具体到 Puppeteer,有一些细节需要特别注意。

Travis CI 配置要点

Travis CI 默认运行在 Ubuntu Xenial 或更新的版本上。对于 Puppeteer 来说,最大的挑战是缺少显示服务器。Chrome 即使运行在 headless 模式,某些底层图形库仍然依赖 X11 环境。Travis 提供了 xvfb 服务来解决这个问题。Xvfb 是一个虚拟的 X 服务器,它把所有图形输出都写到内存里,不需要真实的显示器。

一个基础的 .travis.yml 配置看起来像这样:

language: node_js
node_js: node
services:
  - xvfb
script:
  - npm test

这个配置非常简洁。language: node_js 告诉 Travis 使用 Node.js 环境,node_js: node 表示使用最新的稳定版。services: xvfb 这一行是关键,它会自动启动虚拟显示服务器,并设置好 DISPLAY 环境变量。Chrome 启动时会检测到这个变量,从而正常初始化图形相关的组件。

Travis 默认会执行 npm install,并且缓存 node_modules 目录,这能显著加快后续构建的速度。如果项目中把 Puppeteer 放在 devDependencies,这个默认行为完全够用。但如果 Puppeteer 是 dependencies,并且项目需要特定版本的 Chrome,可能需要在 before_install 阶段额外处理。

对于需要非无头模式(headful)运行的特殊场景,比如测试 Chrome 扩展的弹出页面,Xvfb 更是必不可少。此时可能还需要调整虚拟屏幕的分辨率:

before_script:
  - export DISPLAY=:99.0
  - sh -e /etc/init.d/xvfb start
  - sleep 3
  - /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16

这种方式提供了更细粒度的控制,但现代 Travis 环境通常不需要这么复杂,简单的 services: xvfb 已经足够。

CircleCI 配置策略

CircleCI 的配置更加灵活,采用 YAML 格式的 .circleci/config.yml。它推荐使用 Docker 镜像作为执行环境,这对 Puppeteer 来说既是机遇也是挑战。机遇在于环境可以高度定制,挑战在于基础镜像通常极度精简,缺少 Chrome 运行所需的众多依赖库。

CircleCI 官方提供了一个 Node.js 镜像家族,比如 cimg/node:18.0。但这些镜像默认不包含 Chrome 所需的图形库、字体库等依赖。手动安装这些依赖非常繁琐,而且版本容易出错。好在社区已经解决了这个问题。

使用 Puppeteer Orb

CircleCI 的 Orb 机制允许复用预定义的配置片段。threetreeslight/puppeteer 这个 Orb 封装了所有必要的系统依赖安装步骤。使用它,配置文件可以大幅简化:

version: 2.1
orbs:
  puppeteer: threetreeslight/puppeteer@0.1.3
jobs:
  test:
    docker:
      - image: cimg/node:18.0
    steps:
      - checkout
      - puppeteer/install
      - run:
          name: Install dependencies
          command: npm ci
      - run:
          name: Run tests
          command: npm test
workflows:
  test:
    jobs:
      - test

puppeteer/install 这一步会自动执行 apt-get update 并安装包括 libxtst6、libnss3、libxss1 在内的十几个依赖包。这背后其实是 Orb 的源码中定义了一个完整的安装列表,这个列表会随着 Chrome 版本更新而维护,使用 Orb 相当于把维护工作交给了社区。

如果不想使用 Orb,也可以手动复制它的安装命令。Orb 的源码是公开的,核心就是一段 apt-get install 命令列表。但直接引用 Orb 更清晰,也更容易维护。

Jest 进程数问题

很多项目使用 Jest 作为测试运行器。Jest 默认会检测机器的 CPU 核心数,并启动相应数量的 worker 进程来并行执行测试。在 CircleCI 的 Docker 环境中,Jest 检测到的是宿主机的核心数(可能高达 36 核),但实际分配给容器的 CPU 配额可能只有 2 核。这会导致 Jest 尝试启动 36 个进程,迅速耗尽内存,报错 Error: spawn ENOMEM。

解决这个问题的方法是手动限制 Jest 的 worker 数量:

- run:
    name: Run tests
    command: npm test -- --maxWorkers=2

或者在 package.json 的 Jest 配置中设置:

{
  "jest": {
    "maxWorkers": "50%"
  }
}

"50%" 表示使用可用 CPU 核心数的一半,这样在不同环境中都能保持相对合理的并行度。这个技巧不仅适用于 CircleCI,在任何资源受限的容器环境中都值得采用。

GitLab CI/CD 流水线

GitLab CI/CD 的配置文件是 .gitlab-ci.yml,它的语法和 Travis/CircleCI 有些差异,但核心思想一致:定义阶段(stages)、任务(jobs)和运行环境。GitLab 提供了共享 Runner 和自建 Runner 两种模式,对 Puppeteer 来说,配置策略略有不同。

基础 Runner 配置

GitLab 的共享 Runner 通常运行在 Docker 容器中,和 CircleCI 的环境类似。一个基础的 Puppeteer 测试任务可以这样写:

image: node:18

stages:
  - test

test:puppeteer:
  stage: test
  before_script:
    - apt-get update
    - apt-get install -y wget gnupg
    - wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
    - sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
    - apt-get update
    - apt-get install -y google-chrome-stable fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libgtk-3-0 libnspr4 libnss3 libx11-xcb1 libxss1 libxtst6 xdg-utils
    - npm ci
  script:
    - npm test
  variables:
    PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: "true"
    PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable"

这个配置做了几件事:首先使用官方的 Node.js 镜像作为基础环境;然后在 before_script 阶段添加了 Google 的 Chrome 源,并安装了 Chrome 浏览器和所有必要的依赖库;最后通过环境变量告诉 Puppeteer 不要下载自带的 Chromium,而是使用系统安装的 Chrome。

PUPPETEER_SKIP_CHROMIUM_DOWNLOAD 这个变量很重要。在 CI 环境中,每次构建都重新下载 Chromium 会浪费大量时间和带宽。通过系统包管理器安装 Chrome 不仅更快,而且更容易控制版本。PUPPETEER_EXECUTABLE_PATH 则明确指定了 Chrome 的可执行文件路径,确保 Puppeteer 启动的是我们刚刚安装的那个版本。

使用 Xvfb 运行 Headful 测试

和 Travis 一样,如果测试需要真正的图形界面(比如测试 WebGL 内容),需要启动 Xvfb。GitLab CI 没有内置的 Xvfb 服务,需要手动启动:

test:puppeteer:ui:
  stage: test
  before_script:
    - apt-get update
    - apt-get install -y xvfb
    - npm ci
  script:
    - xvfb-run -a npm test
  variables:
    DISPLAY: ":99"

xvfb-run 是一个封装脚本,它会自动启动 Xvfb,设置 DISPLAY 环境变量,然后在那个环境中执行后面的命令。-a 参数表示自动选择一个可用的显示编号,避免冲突。这种方式比手动管理 Xvfb 进程更简洁可靠。

缓存与性能优化

GitLab CI 提供了缓存机制,可以显著加快构建速度。对于 Node.js 项目,通常缓存 node_modules 目录:

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - node_modules/

test:puppeteer:
  stage: test
  cache:
    policy: pull-push
  # ... 其余配置

缓存键使用分支名或标签名,确保不同分支有独立的缓存。policy: pull-push 表示任务开始前拉取缓存,结束后更新缓存。第一次运行时没有缓存,会完整安装依赖;后续运行只要 package-lock.json 没变,就能直接复用,节省大量时间。

对于 Docker 镜像本身,也可以考虑使用自定义镜像,预装好 Chrome 和所有依赖,这样 before_script 阶段就只剩下 npm ci 这一步。GitLab 支持使用私有镜像仓库,可以把构建好的镜像推送到 GitLab 自带的 Container Registry 中,在 CI 中直接使用。

云服务平台部署方案

当 CI/CD 流水线验证通过后,下一步可能是把 Puppeteer 应用部署到生产环境,比如 AWS Lambda、Google Cloud Functions、Azure Functions 这些无服务器平台,或者 ECS、GKE、AKS 这些容器编排服务。每种平台都有自己的限制和最佳实践。

无服务器平台限制

无服务器平台对运行环境有严格限制,最典型的就是 AWS Lambda 的执行时间上限(15 分钟)和内存限制(最大 10GB)。Puppeteer 启动 Chrome 需要消耗大量内存,一个空白标签页可能就要占用 100-200MB,复杂页面轻松超过 500MB。如果 Lambda 内存配置过低,浏览器会直接崩溃。

另一个挑战是部署包大小。Puppeteer 自带的 Chromium 二进制文件体积巨大,解压后超过 300MB。AWS Lambda 的部署包解压后上限是 250MB,直接包含 Chromium 会超出限制。解决方案是使用 chrome-aws-lambda 这个社区包,它提供了一个精简版的 Chromium,去除了调试符号和不必要的组件,体积缩小到约 50MB。

const chromium = require('chrome-aws-lambda');
const puppeteer = require('puppeteer-core');

exports.handler = async (event) => {
  let browser = null;
  try {
    browser = await puppeteer.launch({
      args: chromium.args,
      defaultViewport: chromium.defaultViewport,
      executablePath: await chromium.executablePath,
      headless: chromium.headless,
    });
    
    const page = await browser.newPage();
    await page.goto('https://example.com');
    const result = await page.title();
    
    return {
      statusCode: 200,
      body: JSON.stringify({ title: result }),
    };
  } catch (error) {
    return {
      statusCode: 500,
      body: JSON.stringify({ error: error.message }),
    };
  } finally {
    if (browser !== null) {
      await browser.close();
    }
  }
};

这段代码展示了在 Lambda 环境中启动 Puppeteer 的标准模式。关键点在于使用 chrome-aws-lambda 提供的 args 和 executablePath,这些参数已经针对 Lambda 环境做了优化,包括禁用沙箱、设置合适的窗口大小、禁用 GPU 等。puppeteer-core 是 Puppeteer 的轻量版本,不包含 Chromium,适合在已经提供浏览器二进制文件的环境中使用。

容器编排服务配置

在 AWS ECS、Google Cloud Run 或 Azure Container Instances 上运行 Puppeteer,本质上和 CircleCI/GitLab CI 的 Docker 环境类似。需要基于一个包含 Node.js 和 Chrome 依赖的镜像来构建应用镜像。

一个生产可用的 Dockerfile 可能长这样:

FROM node:18-slim

# Install Chrome dependencies
RUN apt-get update \
    && apt-get install -y wget gnupg \
    && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
    && sh -c 'echo "deb http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
    && apt-get update \
    && apt-get install -y google-chrome-stable fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libgtk-3-0 libnspr4 libnss3 libx11-xcb1 libxss1 libxtst6 xdg-utils --no-install-recommends \
    && rm -rf /var/lib/apt/lists/*

# Add Chrome as a user
RUN groupadd -r chrome && useradd -r -g chrome -G audio,video chrome \
    && mkdir -p /home/chrome && chown -R chrome:chrome /home/chrome

# Set environment variables
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
    PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

USER chrome

EXPOSE 3000

CMD ["node", "server.js"]

这个 Dockerfile 做了几件事:安装 Chrome 和依赖、创建非 root 用户、设置 Puppeteer 环境变量、以生产模式安装 npm 包、最后切换到非 root 用户运行应用。使用非 root 用户运行容器是安全最佳实践,虽然 Puppeteer 需要 --no-sandbox 参数,但至少避免了容器内的 root 权限滥用。

在 Kubernetes 环境中,还需要为 Pod 设置资源请求和限制。Puppeteer 的内存使用波动较大,建议设置较高的请求值和限制值,避免 OOMKilled:

resources:
  requests:
    memory: "512Mi"
    cpu: "500m"
  limits:
    memory: "1Gi"
    cpu: "1000m"

请求值设置得相对保守,确保 Pod 能被调度到大多数节点上;限制值则给足空间,应对峰值内存使用。CPU 的限制相对宽松,因为 Puppeteer 主要是 IO 密集型,CPU 不是主要瓶颈。

云平台特定优化

不同云平台对容器有一些特定优化。Google Cloud Run 完全托管,自动扩缩容到零,适合低频调用场景。但它对冷启动时间敏感,镜像体积越小越好。可以考虑使用 Alpine Linux 作为基础镜像,虽然 Puppeteer 在 Alpine 上需要额外配置(参见第 12 章),但体积优势巨大。

AWS ECS 支持 Fargate 启动类型,无需管理 EC2 实例。Fargate 的任务定义中可以精确配置 CPU 和内存,建议从 1 vCPU 和 2GB 内存起步,根据实际监控数据调整。ECS 还支持任务角色(Task Role),可以给容器分配 IAM 角色,方便访问 S3、DynamoDB 等 AWS 服务,这在截图上传、结果存储等场景中很有用。

Azure Container Instances 的优势在于启动速度快,适合批量任务。它的计费精确到秒,对于短时运行的 Puppeteer 任务成本效益高。但 ACI 的网络性能相对较弱,如果 Puppeteer 需要加载大量外部资源,可能需要考虑缓存策略或预热机制。

无论选择哪个平台,监控都是必不可少的。应该记录每次浏览器启动时间、页面加载时间、内存使用峰值等指标。这些数据不仅能帮助优化资源配置,还能在出现问题时快速定位瓶颈。可以在代码中注入性能打点:

const start = Date.now();
const browser = await puppeteer.launch();
console.log(`Browser launched in ${Date.now() - start}ms`);

const page = await browser.newPage();
const pageStart = Date.now();
await page.goto('https://example.com');
console.log(`Page loaded in ${Date.now() - pageStart}ms`);

const metrics = await page.metrics();
console.log('Page metrics:', JSON.stringify(metrics));

page.metrics() 提供了 JavaScript 堆大小、DOM 节点数、布局计算次数等详细数据,结合云平台的监控服务(如 CloudWatch、Stackdriver),可以构建完整的性能视图。

把 Puppeteer 从本地搬到云端,核心在于理解环境差异并做出相应调整。Linux 沙箱、依赖库、虚拟显示、资源限制,这些概念在本地开发时几乎不会接触,但在云端却决定了应用的生死。Travis CI 的 Xvfb 服务、CircleCI 的 Orb 机制、GitLab 的灵活流水线,各自提供了不同的便利。云平台的多样性则要求我们在通用方案基础上,针对具体平台的特性做优化。掌握了这些配置技巧,Puppeteer 应用就能在云端稳定运行,成为自动化测试、网页截图、数据抓取等场景的可靠工具。

下一章将探讨如何让 Puppeteer 突破 Chrome 的边界,支持 Firefox 等其他浏览器,以及 WebDriver BiDi 这个新兴标准协议如何改变跨浏览器自动化的格局。