跳至主要内容
五折优惠 全部方案,限时优惠。起价 $2.48/mo
13 min left
开发者工具与 DevOps

如何在 CI 流水线中自托管视觉回归测试

S 作者 Sajjad 13 分钟阅读
Self-hosted visual regression testing running in a CI pipeline with BackstopJS and Docker

视觉回归测试在你的笔记本上跑得好好的。一推送,流水线跑起来,同样的测试要么让浏览器悄无声息地崩溃,要么标出五十处「变更」——而它们其实只是抗锯齿的差异。代码一行没改,CI 却不这么认为。

「本地能跑」和「CI 能跑」之间的这道坎,几乎永远是基础设施问题,而不是测试本身的问题。要在 CI 流水线里稳定地自托管视觉回归测试,你需要把四样东西正确地串起来:一个使用 Docker executor 的 CI runner;一个配置得当、不会让无头 Chromium 耗尽共享内存的容器;一个能输出 CI 可读报告的差异对比工具;以及一台按无头浏览器真实内存消耗来选配的 VPS。

本文会把这套技术栈从头搭到尾。读完之后,你会得到一份可用的 .gitlab-ci.yml(或对应的 Gitea 版本)、一个不会让 Chromium 崩溃的 Docker 环境、一套输出流水线可读 JUnit 报告的 BackstopJS,以及一台配置刚好够用的 VPS。

TL;DR(太长不看版)

  • 本地和 CI 里用同一个 Docker 镜像跑浏览器。 绝大多数误报差异来自不同环境之间的字体和渲染差异。把镜像固定住,它们就消失了。
  • 修掉 /dev/shm 默认只有 64MB 的问题。 Docker 的默认共享内存大小 会把 Chromium 的渲染进程饿死,在 CI 里悄无声息地崩溃。在 docker-compose.yml 里设置 shm_size: '2gb',或者给 Chromium 传 --disable-dev-shm-usage。
  • 把 BackstopJS 作为首选的可自托管工具。 它采用 MIT 许可(v6.3.25),通过 --docker 参数优先走 Docker,并能输出 JUnit 报告。如果你已经在用 Playwright,它内置的 toHaveScreenshot() 也是个不错的零安装起点。
  • 按 4GB RAM 作为基准来选配。 内存要留足。在实际的 CI 运行中,Chromium 截图任务在整页捕获时的内存占用,会远高于它空闲时看起来需要的量,所以对一到两个串行或轻度并行的任务来说,4GB RAM 是更稳妥的基准。

本指南不涉及的内容

这是一篇针对你已经决定要搭建的基础设施的实操指南。有几件事被刻意排除在外:

  • 它不是一篇完整的工具对比。 本文聚焦于你在自托管 CI 环境里真正能跑起来的那套东西:BackstopJS、Docker,以及你自己的 runner。
  • 它不涉及自托管 Argos。 Argos 是开源的,但它对外的产品流程和文档都围绕托管版 Argos 应用和 CI 集成展开,并没有给出一条简单的生产级自托管路径。就本文的目标而言,BackstopJS 是更稳妥的示例。
  • 它不推荐 Lost Pixel。 该项目已于 2026 年 4 月归档;新部署请使用 BackstopJS。
  • 它不再重复讨论自托管与托管 SaaS 之争。 既然你看到这里,说明你已经排除了 Percy 或 Chromatic。

先决条件

敲下第一条命令之前,你需要先准备好几样东西:

  • 一个自托管的 CI runner,或者一份部署它的计划(下文会讲)。
  • runner 主机上已安装 Docker。
  • 一个已有测试目标的 Node.js 项目(一个运行中的应用 URL,或一组要截图的组件路由)。
  • 对 runner 所在 VPS 或主机的 shell 访问权限。

为什么视觉测试本地能过、到了 CI 就挂

在 macOS 上渲染基准图的那个浏览器,和在 Linux CI 容器里渲染对比图的那个浏览器,不是同一回事。Playwright 自己的文档说得很直白:浏览器渲染结果会随宿主 OS、版本、设置、硬件、供电方式、无头模式等因素而变化。它给出的建议同样直接:想要一致的截图,就在生成 基准截图的同一环境里跑测试.

这一条建议,正是后面所有配置存在的理由。几乎所有误报失败都来自三类不一致:

  • 字体与抗锯齿差异。 你的开发机和 CI 容器带的字体包与次像素渲染方式不同。文字密集的页面即使什么都没改,每次跑都会出现差异。
  • 无头渲染与有头渲染。 有头浏览器和无头浏览器对同一个页面的布局可能会有细微差别。
  • 共享内存耗尽。 这一类最不动声色。Docker 默认的 /dev/shm 只有 64MB,而无头 Chromium 的渲染进程要用共享内存。一旦用光,Chromium 就崩溃,还不给任何有用的报错。你的任务要么直接挂掉,要么卡死。

前两类的解法是:生成基准图和做对比都固定用同一个 Docker 镜像。第三类的解法见下面的 Docker 配置一节。三个都修好,那些「在 CI 里不稳定」的测试就变得可预期了。

Three reasons visual regression tests pass locally but fail in CI: font and anti-aliasing differences between the dev machine and the CI container, headless versus headed rendering differences, and shared-memory exhaustion from Docker's 64MB /dev/shm default crashing headless Chromium

选工具:BackstopJS 还是 Playwright 内置的差异对比

如果你已经在用 Playwright 跑端到端测试,最快的路径就是它内置的 toHaveScreenshot() 断言:零额外依赖、像素级差异对比,开箱即带平台后缀的基准图。对小规模测试集来说,止步于此完全合理;对页面数在五十个上下、又没有共享组件库的团队,这可能就够用了。

它走到头的地方是评审流程。Playwright 的差异对比只有像素级,没有 PR 评审界面,所以要确认一批合理的视觉变更,就得手动重新生成快照。等基准图数量涨上来,这个手工确认的循环会变得非常磨人。

BackstopJS 就是专为这个循环而生的工具。它采用 MIT 许可(当前 v6.3.25),通过 --docker 参数优先走 Docker,并输出 CI runner 能原生读取的 JUnit XML。它的基准图工作流(生成参考图、据此测试、把认可的差异确认为新基准)正是大多数自托管 VRT 方案最终想要的运作模式。本文接下来都以 BackstopJS 为示例。

你可能在别处见过被推荐的另外两个工具,本文不予采用:Argos 不在此列,因为它对外的产品流程围绕托管应用和 CI 集成展开;Lost Pixel 则已归档。对一套务实的自托管方案来说,BackstopJS 是更稳妥的示例。

维度BackstopJSPlaywright toHaveScreenshot()
PR 评审流程内置报告和 approve 命令手动重新生成快照
基准图管理reference / test / approve 命令按测试划分的快照文件
Docker 归一化渲染--docker 参数自带镜像
安装开销需要单独安装依赖用 Playwright 的话已自带

如果想看覆盖更多工具和维度的深度横评,我们的 BackstopJS、Argos 与 Lost Pixel 对比 完整讲了选型决策。

在 VPS 上部署自托管 CI runner

真正带来渲染一致性的,是 Docker executor:runner 会在你定义的容器里跑每一个任务,也就是说浏览器环境每次都完全一样。至于选哪个 runner 平台,取决于你还想让它顺带干多少别的事。

自托管 GitLab CE 是偏重的那个选项:代码仓库、CI/CD、镜像仓库、issue 和合并请求都在一个实例里。对本文来说,重点是 GitLab Runner 的 Docker executor,它让每个视觉回归任务每次都在同一个固定的容器镜像里运行。GitLab、Gitea、Jenkins、Forgejo、Portainer 和 Docker 都可以在 Cloudzy 应用市场上一键部署,能大幅缩短搭建时间。

搭配 act-runner 的 Gitea 是轻量方案。Gitea 是一个基于 Go 的 Git 服务,带有兼容 GitHub Actions 的工作流引擎(通过 act-runner 实现),所以如果你的团队熟悉 Actions 语法,它是一个小巧、快速、容易搭起来的 runner。

无论选哪个,配置目标都一样:一个能接收任务、并用 Docker executor 执行任务的 runner。后面的所有内容(容器配置、BackstopJS、示例流水线)都默认这个 executor 已经就位。

配置 Docker,别让 Chromium 崩溃

下面这个故障,最消耗人的时间。Docker 默认把 /dev/shm 挂载为 64MB。无头 Chromium 的渲染进程要用这块共享内存分区,而整页截图时它需要的远不止 64MB。一旦用光,渲染进程就死掉,而且通常不会给出任何指向共享内存的报错信息。任务表现为卡死、超时,或者报一个笼统的浏览器崩溃。

有两种修法,用哪种都行。

修法 A:在 Compose 里调大共享内存。 shm_size 键用于设置容器 /dev/shm 分区的大小。 Docker Compose 文件参考 明确说明 shm_size 用于配置服务容器允许使用的共享内存大小。把它写在 service 块里:

# docker-compose.yml
services:
  vrt:
    image: backstopjs/backstopjs:6.3.25
    shm_size: '2gb'          # override the 64MB default
    volumes:
      - ./:/src
    working_dir: /src

修法 B:干脆让 Chromium 不用 /dev/shm。 --disable-dev-shm-usage 参数会让 Chromium 把共享内存文件写到 /tmp,而不是共享内存分区。把它加到浏览器启动参数里。在 backstop.json 配置中对应 engineOptions;在 Playwright 里对应 launchOptions:

// backstop.json (fragment)
{
  "engine": "puppeteer",
  "engineOptions": {
    "args": ["--disable-dev-shm-usage", "--no-sandbox"]
  }
}

如果 Compose 文件在你手上,修法 A 更干净;如果你只能改浏览器启动参数,修法 B 更通用。两个一起用也没坏处。

小贴士: 本地和 CI 里用完全相同的 Docker 镜像。BackstopJS 通过 --docker 参数把这件事白送给你:截图在一个固定的参考镜像里进行,于是你的机器和 runner 渲染出来一模一样。字体和抗锯齿引起的误报就是这样消掉的:把镜像固定一次,从此不用再追那些幻影差异。

Two fixes for headless Chromium crashing in Docker CI: raising the container's shared memory with shm_size set to 2gb in docker-compose.yml, or passing the --disable-dev-shm-usage flag so Chromium writes shared-memory files to /tmp instead of the 64MB /dev/shm partition

为 CI 配置 BackstopJS

BackstopJS 会生成一份 JUnit XML 报告,CI runner 读取它来判定构建成功还是失败——这正是把它接进流水线、而不是手动运行的全部意义。安装并初始化配置:

npm install --save-dev backstopjs
npx backstop init

然后把 backstop.json 指向你要截图的页面,并打开 CI 报告:

// backstop.json (fragment)
{
  "id": "my_app_vrt",
  "viewports": [
    { "label": "desktop", "width": 1440, "height": 900 }
  ],
  "scenarios": [
    {
      "label": "Homepage",
      "url": "http://app:3000/",
      "selectors": ["document"]
    }
  ],
  "report": ["CI"],
  "engine": "puppeteer",
  "engineOptions": {
    "args": ["--disable-dev-shm-usage", "--no-sandbox"]
  }
}

输出 JUnit XML 的正是这个 CI 报告设置。在本机运行这些命令时加上 --docker 参数。而在已经使用 backstopjs/backstopjs 镜像的 CI 任务里,直接运行 backstop test 即可。基准图工作流就是三条命令:

npx backstop reference --docker   # capture the approved baseline
npx backstop test --docker        # run the comparison, emit the report
npx backstop approve              # accept the current diffs as the new baseline

关于基准图管理有一点要说清楚:每一次合理的 UI 变更,都意味着要运行 approve 来认可新截图。场景数超过一百个之后,这个确认步骤就是实打实的运维成本:得有人挨个看差异、判断哪些是有意为之,而且团队用得越多,负担越重。这份维护工作才是大规模视觉回归测试的真实代价,在投入一个庞大的测试集之前,值得先把它算进预算。

一份可用的 CI 流水线配置

流水线在逻辑上分两部分:先生成一次基准图(或按需生成),之后每次变更都拿它来对比。下面这份示例是使用 Docker executor 的 GitLab CI 配置,共享内存的修复已经通过 backstop.json 里的启动参数应用好了。

# .gitlab-ci.yml
stages:
  - visual-regression

visual_regression:
  stage: visual-regression
  image:
    name: backstopjs/backstopjs:6.3.25
    entrypoint: [""]
  script:
    - backstop test
  artifacts:
    when: always
    reports:
      junit: backstop_data/ci_report/xunit.xml
    paths:
      - backstop_data/bitmaps_test
      - backstop_data/html_report
    expire_in: 1 week

junit 那一行把报告交给 GitLab,失败就会显示在合并请求界面上;paths 那一行保留差异位图,方便你查看到底改了什么。在 Gitea 搭配 act-runner 的场景下,同样的步骤可以映射成 Actions 风格的工作流:

# .gitea/workflows/visual-regression.yaml
name: Visual Regression
on: [push, pull_request]

jobs:
  vrt:
    runs-on: ubuntu-latest
    container:
      image: backstopjs/backstopjs:6.3.25
      options: --shm-size=2gb          # raise /dev/shm for the job container
    steps:
      - uses: actions/checkout@v4
      - run: backstop test

注意 Gitea 工作流里的 --shm-size=2gb 容器选项。它和修法 A 是同一个共享内存修复,只是在没有单独 Compose 文件可改的情况下,作用于任务容器这一层。

为无头 Chromium 选配 VPS

这里的瓶颈是 RAM,不是 CPU。无头 Chromium 的 CPU 占用是突发式的(截图时飙高,间隙里基本空闲),所以核心数一般够用就行。内存才是决定你能同时跑多少任务的天花板。一个无头 Chromium 进程空闲时大约占 300–500MB,做整页截图时会升到 1–2GB,所以选配的关键指标是每个并发任务需要多少 RAM。

内存适用场景
2GB勉强够用的下限:单个串行任务
4GB推荐基准:1–2 个并发任务
8GB3–4 个并发任务
16GB大型测试集与并行流水线

这些数字来自真实 CI 运行的实践经验,不是厂商跑分,所以请把它们当作大致的起点,并盯住你自己的内存峰值。

本节要点:4GB RAM 是可靠的基准;每多一个并发的视觉回归任务,再加大约 2GB 余量。

自托管 VRT 的麻烦在于,内存不足的 runner 不会大声报错,而是让 Chromium 悄悄崩溃——正是这整套方案要避免的症状。预留足够的内存余量,是你能买到的最便宜的保险。如果你不想从零搭建一台 runner 主机,Cloudzy 应用市场提供了一键部署,对象包括 自托管 GitLab自托管 Gitea 的一键部署,几分钟就能跑起一个 CI 实例,而且 VPS 配置可以按你的测试集所需的无头 Chromium 内存余量精准选择。

VPS memory sizing guide for headless Chromium visual regression jobs: 2GB for a single sequential job, 4GB as the recommended baseline for one to two concurrent jobs, 8GB for three to four concurrent jobs, and 16GB for large suites and parallel pipelines

常见问题

如何在 CI/CD 流水线中搭建视觉回归测试?

安装 BackstopJS,把 backstop.json 指向目标页面,并把 CI 报告选项设置为输出 JUnit XML。在本机运行时使用 backstop reference --docker 和 backstop test --docker,这样截图会在固定的 Docker 镜像里渲染。在 CI 中,让任务运行在 backstopjs/backstopjs 镜像里并直接调用 backstop test,然后发布 backstop_data/ci_report/xunit.xml 中的 JUnit 报告。使用带 Docker executor 的自托管 runner,并为无头 Chromium 配备至少 4GB RAM。

无头 Chromium 在 CI 容器里需要多少 RAM?

一个无头 Chromium 进程空闲时约占 300–500MB,做整页截图时会飙到 1–2GB。要让 CI 稳定,请按每一到两个并发任务约 4GB RAM 来规划,每多一个并行任务再加大约 2GB。CPU 只是突发式占用,并非瓶颈;决定你能同时跑多少任务的是 RAM。

为什么我的视觉回归测试本地能过、在 CI 里却失败?

浏览器渲染在不同环境下就是不一样:字体、抗锯齿、无头与有头模式、OS,都会改变输出结果。解法是让基准图生成和对比在同一个 Docker 镜像里进行,这样两边渲染完全一致。BackstopJS 的 --docker 参数正是这么做的——截图在一个固定的参考镜像里完成。

如何修复 Chromium 在 Docker CI 中的 /dev/shm 报错?

Docker 默认的 /dev/shm 只有 64MB,对无头 Chromium 的渲染进程来说太小,会导致无声崩溃。要么在 docker-compose.yml 里用 shm_size: '2gb' 调大它(或在任务容器上加 --shm-size=2gb),要么在 Chromium 的启动参数里传 --disable-dev-shm-usage,让它改把共享内存文件写到 /tmp。

该用 Playwright 的 toHaveScreenshot 还是专门的视觉回归工具?

对小规模测试集来说,Playwright 内置的 toHaveScreenshot() 就够了:页面数在五十个上下、没有共享组件库、也不需要 PR 评审界面。当你需要一套像样的基准图确认流程、一份评审报告以及 Docker 归一化渲染时,就该换成 BackstopJS 这类专用工具——基准图数量一涨上来,这些就变得重要了。

Lost Pixel 还在维护吗?

不在了。Lost Pixel 已于 2026 年 4 月归档,不适合用于新部署。搭建自托管视觉回归测试请使用 BackstopJS。

分享

博客更多内容

继续阅读。

准备好部署了吗? 起价 $2.48/月。

独立云厂商,自 2008 年起。AMD EPYC、NVMe、40 Gbps。14 天退款保证。