手元のノート PC ではビジュアルリグレッションテストが通ります。ところが push してパイプラインが動き出すと、同じテストがブラウザを何も言わずにクラッシュさせるか、実体はアンチエイリアスの差でしかない「変更」を 50 件も検出します。コードは何も変わっていないのに、CI だけが違うと言い張るわけです。
「ローカルでは動く」と「CI でも動く」の間にあるこのギャップは、ほぼ例外なくテストではなくインフラの問題です。CI パイプラインでビジュアルリグレッションテストを安定してセルフホストするには、4 つの要素を正しく組み合わせる必要があります。Docker executor を備えた CI ランナー、ヘッドレス Chromium が共有メモリを枯渇させないよう設定されたコンテナ、CI が読める形式のレポートを出力する差分ツール、そしてヘッドレスブラウザが実際に消費するメモリに見合ったサイズの VPS です。
本ガイドでは、そのスタックを最初から最後まで組み立てます。読み終える頃には、実際に動く .gitlab-ci.yml(または Gitea 版の相当ファイル)、Chromium をクラッシュさせない Docker 環境、パイプラインが読める JUnit レポートを出力する BackstopJS、そしてそれらを動かすのに適したサイズの VPS が揃っているはずです。
要約
- ローカルでも CI でも、同じ Docker イメージでブラウザを動かします。 誤検知の差分の大半は、環境ごとのフォントとレンダリングの違いから生まれます。イメージを固定すれば、その差分は消えます。
- 64MB という /dev/shm のデフォルトを修正します。 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 のスクリーンショットジョブがフルページ撮影中にアイドル時の見かけをはるかに超えるメモリを消費することがあります。順次実行または軽い並列実行のジョブ 1〜2 本であれば、4GB RAM が安全な基準になります。
このガイドが扱わないこと
本記事は、構築すると決めたインフラを実際に組み立てるためのガイドです。いくつかの話題は意図的に対象外としています。
- ツールの網羅的な比較ではありません。 本ガイドが扱うのは、セルフホストの CI 環境で実際に運用できるスタック、つまり BackstopJS、Docker、そして自前のランナーです。
- Argos のセルフホストは扱いません。 Argos はオープンソースですが、公開されている製品導線とドキュメントは、本番運用向けのシンプルなセルフホスト手順ではなく、ホスト版 Argos アプリと CI 連携に重点を置いています。本ガイドの目的からすると、BackstopJS のほうが安全な題材です。
- Lost Pixel は推奨しません。 このプロジェクトは 2026 年 4 月にアーカイブされました。新規導入では BackstopJS を使ってください。
- セルフホストとマネージド SaaS の是非は蒸し返しません。 この記事を読んでいる時点で、Percy や Chromatic はすでに選択肢から外れているはずです。
前提条件
最初のコマンドを打つ前に、いくつか用意しておくものがあります。
- セルフホストの CI ランナー、または導入の計画(後述します)。
- ランナーのホストにインストール済みの Docker。
- テスト対象がすでにある Node.js プロジェクト(稼働中のアプリの URL、または撮影したいコンポーネントのルート一式)。
- ランナーが動く VPS またはホストへのシェルアクセス。
ビジュアルテストがローカルでは通るのに CI で壊れる理由
macOS でベースラインを描画するブラウザと、Linux の CI コンテナで比較対象を描画するブラウザは別物です。Playwright のドキュメントはこの点を率直に述べています。ブラウザのレンダリングは、ホスト OS、バージョン、設定、ハードウェア、電源、ヘッドレスモードなどの要因によって変わりうる、と。推奨事項も同じく明快です。スクリーンショットを一貫させるには、 ベースラインのスクリーンショットを生成したのと同じ環境でテストを実行してください.
この一文こそが、以降のセットアップが必要になる理由です。誤検知による失敗のほぼすべては、次の 3 つのズレで説明できます。
- フォントとアンチエイリアスの違い。 開発マシンと CI コンテナでは、同梱されるフォントパッケージもサブピクセルレンダリングも異なります。テキストの多いページは、何も変わっていなくても毎回差分が出ます。
- ヘッドレスとヘッドありのレンダリング。 ヘッドありのブラウザとヘッドレスのブラウザでは、同じページのレイアウトがわずかに変わることがあります。
- 共有メモリの枯渇。 これが最も静かな犯人です。Docker のデフォルトの /dev/shm は 64MB ですが、ヘッドレス Chromium はレンダラープロセスに共有メモリを使います。枯渇すると、Chromium は役に立つエラーを一切残さずにクラッシュします。ジョブはただ落ちるか、ハングします。
最初の 2 つは、ベースライン生成と比較の両方で単一の Docker イメージを固定すれば解決します。3 つ目は、後述の Docker 設定のセクションで解決します。3 つすべてを直せば、「CI で不安定」だったテストは決定論的になります。

ツールの選択:BackstopJS か、Playwright の組み込み差分機能か
すでにエンドツーエンドテストで Playwright を使っているなら、最短ルートは組み込みの toHaveScreenshot() アサーションです。追加の依存関係はゼロ、ピクセル単位の差分、プラットフォーム名を付けたベースラインが最初から使えます。小規模なテストスイートならここで止めても十分ですし、共有コンポーネントライブラリを持たない画面数 50 程度までのチームなら、これだけで事足りるかもしれません。
行き詰まるのはレビューのワークフローです。Playwright の差分はピクセル比較のみで PR レビュー用の UI がないため、正当な見た目の変更をまとめて承認するには、スナップショットを手作業で再生成することになります。ベースラインの数が増えるにつれ、この手動承認のループは苦痛になります。
BackstopJS は、まさにそのループのために作られた専用ツールです。MIT ライセンス(現在は v6.3.25)で、--docker フラグによる Docker ファースト設計、そして CI ランナーがそのまま読み取れる JUnit XML を出力します。リファレンスを生成し、それに対してテストし、受け入れた差分を新しいベースラインとして approve するというベースラインのワークフローは、セルフホストの VRT 構成が最終的に求めることになる運用モデルです。以降、本ガイドでは BackstopJS を題材にします。
他所で推奨されているのを見かけるツールのうち 2 つは、本記事では対象外です。Argos は公開されている製品導線がホスト版アプリと CI 連携を中心にしているため扱わず、Lost Pixel はアーカイブ済みです。実用的なセルフホスト構成の題材としては、BackstopJS のほうが安全です。
| 軸 | BackstopJS | Playwright toHaveScreenshot() |
|---|---|---|
| PR レビューのワークフロー | レポートと approve コマンドを内蔵 | スナップショットの手動再生成 |
| ベースライン管理 | reference / test / approve コマンド | テストごとのスナップショットファイル |
| Docker で正規化されたレンダリング | --docker フラグ | 自前のイメージを持ち込み可能 |
| 導入の手間 | 別途依存関係が必要 | Playwright を使っていれば導入済み |
より多くのツールと評価軸を含めた掘り下げた比較については、 BackstopJS、Argos、Lost Pixel の比較記事 で選定の判断材料をひととおり解説しています。
VPS にセルフホストの CI ランナーを構築する
レンダリングの一貫性を実際に生み出しているのは Docker executor です。ランナーは各ジョブを自分で定義したコンテナ内で実行するため、ブラウザ環境は毎回まったく同じになります。ランナーのプラットフォームは、それ以外にどこまでやらせたいかで選んでください。
セルフホストの GitLab CE は重量級の選択肢です。リポジトリ、CI/CD、レジストリ、イシュー、マージリクエストが 1 つのインスタンスに揃います。本ガイドで重要なのは GitLab Runner の Docker executorで、これにより各ビジュアルリグレッションジョブを毎回同じ固定コンテナイメージ内で実行できます。GitLab、Gitea、Jenkins、Forgejo、Portainer、Docker はいずれも Cloudzyのマーケットプレイスのワンクリックデプロイとして利用でき、セットアップを大幅に短縮できます。
act-runner を組み合わせた Gitea の組み合わせは軽量な代替案です。Gitea は Go 製の Git サービスで、act-runner を介した GitHub Actions 互換のワークフローエンジンを備えています。チームが Actions の記法に慣れているなら、小さくすばやく立ち上げられるランナーです。
どちらを選んでも、設定のゴールは同じです。ジョブを受け取り、Docker executor で実行するランナーを用意することです。これ以降の内容(コンテナ設定、BackstopJS、サンプルパイプライン)は、その executor が揃っている前提で進みます。
Chromium をクラッシュさせない Docker 設定
最も多くの時間を奪う失敗がこれです。Docker はデフォルトで /dev/shm を 64MB でマウントします。ヘッドレス Chromium はレンダラープロセスにその共有メモリ領域を使いますが、フルページのスクリーンショットでは 64MB をはるかに超える容量が必要になります。枯渇するとレンダラーが死に、しかも共有メモリを指し示すエラーメッセージは出ないことがほとんどです。ジョブはハングするか、タイムアウトするか、漠然としたブラウザのクラッシュを報告します。
修正方法は 2 つあり、どちらでも解決します。
対処 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 フラグひとつでそれが手に入ります。撮影が固定されたリファレンスイメージ内で実行されるため、手元のマシンとランナーのレンダリング結果が一致します。フォントとアンチエイリアスによる誤検知を消し去るのはこれです。イメージを一度固定してしまえば、幻の差分を追いかける必要はなくなります。

CI 向けに BackstopJS をセットアップする
BackstopJS は JUnit XML レポートを生成し、CI ランナーがそれを読んでビルドの成否を判定します。手動で実行するのではなくパイプラインに組み込む意味は、まさにここにあります。インストールして設定を初期化します:
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 をそのまま実行します。ベースラインのワークフローは 3 つのコマンドで完結します:
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
ベースライン管理について 1 つ注意があります。UI の正当な変更が発生するたびに、approve を実行して新しいスクリーンショットを承認することになります。シナリオが 100 件を超えたあたりから、この承認は無視できない運用コストになります。誰かが差分を目視し、どれが意図した変更かを判断しなければならず、その負担は導入するチームの規模に比例して増えます。この保守作業こそが、大規模なビジュアルリグレッションテストの実際のコストです。大きなテストスイートに踏み切る前に、その分を見込んでおく価値があります。
実際に動く CI パイプラインの設定
パイプラインは論理的に 2 つの部分に分かれます。ベースラインを一度(または必要に応じて)生成し、以後は変更のたびにそれと比較します。以下のサンプルは 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 に引き渡し、失敗がマージリクエストの UI に表示されるようにします。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 をサイジングする
ここでの制約は CPU ではなく RAM です。ヘッドレス Chromium の CPU 使用率はバースト型で、撮影中に跳ね上がり、その間はほぼアイドルなので、コア数は控えめでも十分間に合います。同時に実行できるジョブ数の上限を決めるのはメモリです。ヘッドレス Chromium のプロセスはアイドル時でおよそ 300–500MB、フルページのスクリーンショット撮影中は 1–2GB まで増えます。したがってサイジングの基準は、同時実行ジョブ 1 本あたりの RAM になります。
| RAM | 適した用途 |
|---|---|
| 2GB | ぎりぎりの最小構成:順次実行のジョブ 1 本 |
| 4GB | 推奨の基準:同時実行 1–2 ジョブ |
| 8GB | 同時実行 3–4 ジョブ |
| 16GB | 大規模なテストスイートと並列パイプライン |
これらの数値はベンダーのベンチマークではなく、実際の CI 実行から得られた実務上の目安です。おおまかな出発点として扱い、自分の環境でのピークメモリを必ず確認してください。
このセクションの要点:信頼できる基準は 4GB RAM です。ビジュアルリグレッションジョブを 1 本並列に増やすごとに、約 2GB のヘッドルームを追加してください。
セルフホストの VRT で厄介なのは、メモリが不足したランナーが派手に失敗してくれない点です。Chromium を静かにクラッシュさせるだけで、まさにこの構成全体が避けようとしている症状そのものになります。十分な余裕を確保しておくことが、いちばん安上がりな保険です。ランナー用のホストをゼロから組み立てたくない場合は、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 を備えたセルフホスト構成とし、ヘッドレス Chromium 向けに少なくとも 4GB RAM を確保してください。
CI コンテナ内のヘッドレス Chromium にはどれくらいの RAM が必要ですか?
ヘッドレス Chromium のプロセスは、アイドル時でおよそ 300–500MB、フルページのスクリーンショット撮影中は 1–2GB まで跳ね上がります。CI を安定させるには、同時実行 1〜2 ジョブあたり約 4GB の RAM を見込み、並列ジョブを 1 本増やすごとに約 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() で十分です。画面数がおよそ 50 未満で、共有コンポーネントライブラリがなく、PR レビュー用の UI も不要な場合です。きちんとしたベースライン承認のワークフロー、レビュー用のレポート、Docker で正規化されたレンダリングが必要になったら、BackstopJS のような専用ツールに移行してください。ベースラインの数が増えるほど、これらの重要性は増します。
Lost Pixel は現在もメンテナンスされていますか?
いいえ。Lost Pixel は 2026 年 4 月にアーカイブされており、新規導入の選択肢にはなりません。セルフホストのビジュアルリグレッションテスト環境には BackstopJS を使ってください。