视觉回归测试在你的笔记本上跑得好好的。一推送,流水线跑起来,同样的测试要么让浏览器悄无声息地崩溃,要么标出五十处「变更」——而它们其实只是抗锯齿的差异。代码一行没改,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 里不稳定」的测试就变得可预期了。

选工具:BackstopJS 还是 Playwright 内置的差异对比
如果你已经在用 Playwright 跑端到端测试,最快的路径就是它内置的 toHaveScreenshot() 断言:零额外依赖、像素级差异对比,开箱即带平台后缀的基准图。对小规模测试集来说,止步于此完全合理;对页面数在五十个上下、又没有共享组件库的团队,这可能就够用了。
它走到头的地方是评审流程。Playwright 的差异对比只有像素级,没有 PR 评审界面,所以要确认一批合理的视觉变更,就得手动重新生成快照。等基准图数量涨上来,这个手工确认的循环会变得非常磨人。
BackstopJS 就是专为这个循环而生的工具。它采用 MIT 许可(当前 v6.3.25),通过 --docker 参数优先走 Docker,并输出 CI runner 能原生读取的 JUnit XML。它的基准图工作流(生成参考图、据此测试、把认可的差异确认为新基准)正是大多数自托管 VRT 方案最终想要的运作模式。本文接下来都以 BackstopJS 为示例。
你可能在别处见过被推荐的另外两个工具,本文不予采用:Argos 不在此列,因为它对外的产品流程围绕托管应用和 CI 集成展开;Lost Pixel 则已归档。对一套务实的自托管方案来说,BackstopJS 是更稳妥的示例。
| 维度 | BackstopJS | Playwright 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 渲染出来一模一样。字体和抗锯齿引起的误报就是这样消掉的:把镜像固定一次,从此不用再追那些幻影差异。

为 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 个并发任务 |
| 8GB | 3–4 个并发任务 |
| 16GB | 大型测试集与并行流水线 |
这些数字来自真实 CI 运行的实践经验,不是厂商跑分,所以请把它们当作大致的起点,并盯住你自己的内存峰值。
本节要点:4GB RAM 是可靠的基准;每多一个并发的视觉回归任务,再加大约 2GB 余量。
自托管 VRT 的麻烦在于,内存不足的 runner 不会大声报错,而是让 Chromium 悄悄崩溃——正是这整套方案要避免的症状。预留足够的内存余量,是你能买到的最便宜的保险。如果你不想从零搭建一台 runner 主机,Cloudzy 应用市场提供了一键部署,对象包括 自托管 GitLab 和 自托管 Gitea 的一键部署,几分钟就能跑起一个 CI 实例,而且 VPS 配置可以按你的测试集所需的无头 Chromium 内存余量精准选择。

常见问题
如何在 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。