VPSでAIエージェント用Chromiumを安定運用する方法
VPSのヘッドレスChromiumで起きる「No usable sandbox!」や /dev/shm 不足、フォント欠落、プロセス残留を解消します。Playwrightのバージョン固定と安全な制限方法を紹介します。
実行するもの
VPS 上のヘッドレスブラウザーは、ウィンドウを表示せず、ユーザーの代わりにコードから操作する Chromium です。サーバー上では、エージェントがローカルソケット経由で通信する、長時間稼働するプロセスツリーとして動作します。インストールは 1 つのコマンドで完了します。重要なのは、その後の運用です。ブラウザーがマシンから使用できるリソースを制限し、制御エンドポイントをパブリックインターネットから隔離します。
このガイドでは、使用するツールはすでに決まっており、これから運用する段階にあることを前提とします。クローラーと抽出ツールをまだ比較している場合は、まず セルフホスト可能な Firecrawl の代替ツールを確認してから戻ってきてください。以下ではすべて Playwright の Chromium を使用します。Playwright は独自のブラウザービルドと依存関係インストーラーを提供するため、同じコマンドを素の Ubuntu VPS とコンテナ内の両方で実行できます。バージョン情報は 2026 年 8 月時点のものです。
Install Chromium without guessing at dependencies
npm i -D playwright@1.62.0
npx playwright install --with-deps chromium--with-deps runs apt for the shared libraries and fonts Chromium needs, and asks for root when it gets there. The browser build itself downloads into ~/.cache/ms-playwright for the user who ran the command. That matters on a server, because the service user is usually not the user you log in as. Install the system packages once as an admin with sudo npx playwright install-deps chromium, then set PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers in both the install command and the service unit so one copy is shared. A service that cannot see its browser fails at launch with a message naming the path it searched.
Pin the Playwright version. Each release is tied to one browser build, so an unpinned npm update can swap the browser under a running service. Playwright 1.62 is current as of August 2026.
Two Chromium builds exist and they are not the same program. The default download is the headless shell, a smaller binary that only runs headless, and npx playwright install --with-deps --only-shell installs that alone. The full browser is what you get with the chromium channel, which Playwright's browser docs call "the real Chrome browser, and is thus more authentic, reliable, and offers more features". Use the shell for bulk fetching. Use the full browser when a site behaves differently and you need to find out why.
コンテナ内でヘッドレスブラウザーがクラッシュする理由
Docker は各コンテナに 64 MB の /dev/shm を割り当てます。Docker のドキュメントには、「サイズを完全に省略すると、システムは 64m を使用する」と明記されています。Chromium はこの共有メモリ領域を使用してプロセス間でレンダリング済みコンテンツを受け渡すため、負荷の高いページ 1 つだけで領域が満杯になることがあります。その結果、renderer が終了し、ノート PC では正常に動作するページでも、クライアントにはターゲットがクラッシュしたと報告されます。変更を加える前に、コンテナ内からサイズを確認してください。
df -h /dev/shm実際の解決策は 2 つあります。これらは併用するものではなく、どちらか一方を選びます。--ipc=host を指定すると、コンテナはホストの IPC 名前空間を使用し、通常は RAM の半分であるホストの /dev/shm を使います。Playwright の Docker ガイド もこの方法を推奨しています。これを指定しないと、「Chromium はメモリ不足になり、クラッシュする可能性がある」ためです。代わりに、コンテナとホスト間の IPC 隔離が失われます。--shm-size=1g はプライベートな名前空間を維持したまま、マウントのサイズだけを大きくします。
docker run --rm -it --init --ipc=host --user pwuser mcr.microsoft.com/playwright:v1.62.0-noble /bin/bash--disable-dev-shm-usage フラグは検索結果の多くで解決策として紹介されていますが、動作は異なります。このフラグは、それらのファイルを /dev/shm から一時ディレクトリへ移動します。/tmp がディスク上にある場合、クラッシュの代わりにレンダリングの遅延とディスク書き込みが発生します。/tmp が tmpfs の場合、データはサイズ制限なしで RAM に戻されます。その結果、ブラウザーが小規模な VPS のメモリを使い果たす可能性があります。代わりに、/dev/shm のサイズを適切に設定してください。
実際に --no-sandbox を使う代償
Chromium は、Linux の user namespace を基盤とする sandbox で各 renderer を分離します。この sandbox が、悪意のあるページとサーバーの境界になります。sandbox を起動できない場合、Chromium は実行を拒否し、ログには次のような行が出力されます。
Failed to move to new namespace: PID namespaces supported, Network namespace supported, but failed: errno = Operation not permitted通常は --no-sandbox と案内されます。しかし、Chromium のセキュリティドキュメントは、その代償について明確に警告しています。このフラグは「Chromium の重要なセキュリティ機能を無効にするため、公開 Web の閲覧では決して使用しないでください」とされています。リンクをたどるエージェントは、定義上、公開 Web を閲覧しています。本当の原因を特定してください。
ほぼすべてのケースは、2 つの原因に分類できます。ブラウザーを root として実行すると sandbox が無効になります。すでに保持している権限を放棄できないためです。そのため、Playwright のイメージには pwuser という一般ユーザーが用意されています。Ubuntu 24.04 以降では、AppArmor が非特権 user namespace を制限します。配布済みのプロファイルが対象としていないパスにある Chromium バイナリは、実行を拒否されます。~/.cache/ms-playwright 下に Playwright がダウンロードするバイナリは、まさにこのケースに該当します。次の 2 点を確認してください。
id -u
sysctl kernel.apparmor_restrict_unprivileged_userns
sudo dmesg | grep -i userns_createsysctl の 1 と、apparmor="DENIED" operation="userns_create" を含むカーネル行が確認できれば、2 つ目の原因です。/etc/apparmor.d/pw-chromium で、その 1 つのバイナリだけを許可します。これにより、サーバー上の他のバイナリには制限が適用されたままになります。
abi <abi/4.0>,
include <tunables/global>
profile pw-chromium /home/*/.cache/ms-playwright/*/chrome-linux/{chrome,headless_shell} flags=(unconfined) {
userns,
}sudo apparmor_parser -r /etc/apparmor.d/pw-chromium で読み込みます。パスにはブラウザーのリビジョンが含まれるため、Playwright をアップグレードするたびに変わります。上記の glob はこの変更後も機能します。特定のパス 1 つだけを対象にしたプロファイルは、更新後に一致しなくなってもエラーが表示されません。その結果、一見無関係な更新の後に、ブラウザーが再び起動に失敗します。
スクリーンショットが空白、または四角形だらけになる場合
空白のスクリーンショットや、空の四角形が多数表示されたスクリーンショットは、通常、レンダリングの不具合ではなくフォントの問題です。install-deps は、fonts-liberation、fonts-freefont-ttf、fonts-noto-color-emoji、fonts-unifont、日本語用の fonts-ipafont-gothic、中国語用の fonts-wqy-zenhei、タイ語用の fonts-tlwg-loma-otf を基本セットとして取得します。このセットには Noto CJK が含まれないため、韓国語やその他の複数のスクリプトでは、fontconfig が見つけたフォントにフォールバックします。推測せず、fontconfig に確認させてください。
fc-match "sans-serif:lang=ko"
fc-match "sans-serif:lang=ar"
fc-list | wc -l必要な言語が unifont に解決される場合や、実際のグリフを持たないフォールバックに解決される場合は、fonts-noto-core と fonts-noto-cjk をインストールしてから、もう一度確認します。fontconfig は結果をキャッシュするため、フォントのインストール後にブラウザーを再起動してください。フォントがまったくない軽量化イメージでは、起動時に Fontconfig error: Cannot load default config file がログに記録され、すべてのページが空白でレンダリングされます。
ロケールとタイムゾーンはフォントとは別の設定であり、ページの見た目だけでなく表示内容も変えます。コンテナでは通常、LANG が未設定で、TZ は UTC になっています。そのため、サイトは英語で応答し、UTC のタイムスタンプを表示します。また、エージェントが、その国の利用者に表示される時刻と異なる時刻を報告することもあります。1 台のマシン単位ではなく、ブラウザーコンテキストごとに設定してください。これにより、1 つのブラウザーで異なる地域向けのタスクを処理できます。
const context = await browser.newContext({
locale: 'en-GB',
timezoneId: 'Europe/Paris',
});ブラウザプロセスのリークでサーバーが swap する理由
「ゾンビ」と呼ばれる問題には、異なる 2 つの種類があります。本来のゾンビは、終了済みのプロセスを親プロセスが wait() しない状態です。PID のエントリだけを保持し、それ以外は何も保持しないため、メモリは消費しません。コンテナ内でブラウザが PID 1 として実行されると、この状態が蓄積します。PID 1 にはデフォルトの reaper がないためです。Docker の --init flag は、小さな init を実行して「シグナルを転送し、プロセスを回収する」ことで、この問題を正確に解決します。Compose では同じ設定が init: true です。
実際にサーバーを swap させるリークは別の問題です。誰も終了させていない、実行中の Chromium プロセスが残ります。newContext() と close() の間でタスクが例外終了した場合や、制御スクリプトが kill されてブラウザのプロセスツリーが孤立した場合に発生します。最も悪いのは、リクエストごとに新しいブラウザを起動するコードです。数を確認します。
pgrep -c -f 'headless_shell|chrome'
ps -eo pid,ppid,rss,etime,comm --sort=-rss | head -20タスク間では、この数がアイドル時の値に戻る必要があります。1 日の間に増え続ける場合、修正すべきなのは起動 flags ではなくコードです。finally block で context を閉じ、SIGTERM で browser を閉じ、1 か月間実行し続けるのではなく、一定数のタスクごとに browser を再作成します。systemd では、stop または restart により unit の cgroup 内にあるすべてが kill されるため、sudo systemctl restart browser.service は信頼できるリセット方法です。terminal multiplexer 内で手動起動した browser にはこの保証がなく、孤立したプロセスは session の終了後も残ります。
ブラウザーコンテキスト1つに必要なRAM
質問は正確に定義してください。「ブラウザー1つ」は1つのプロセスではありません。Chromiumはブラウザープロセス、GPUプロセス、ユーティリティプロセス、サイトごとに1つのレンダラープロセスを実行します。サイト分離により、サイトをまたぐiframeにも専用のレンダラープロセスが割り当てられます。BrowserContextは同じプロセスツリー内にある、独立したCookie領域とストレージ領域です。そのため、2つ目のコンテキストによる追加負荷は小さくなります。2つ目のページは同じではありません。レンダラープロセスが起動するためです。広告が多いページでは、複数のプロセスが起動します。
測定すべき数値は、実際のワークロード全体におけるプロセスツリーのピークメモリ使用量です。他人のブログに掲載された数値は、ここでは参考になりません。エージェントが開くページによって結果が変わるためです。実際に使用するマシンで、アクセスするサイトを対象に測定してください。
sudo systemd-run --unit=browser-probe -p MemoryMax=2G -p MemorySwapMax=0 -p WorkingDirectory=/srv/agent /usr/bin/node worker.js
systemctl status browser-probeUbuntu 24.04では、この出力のMemory:行に、unitの現在の使用量とピーク使用量が表示されます。ワーカーを1ページずつ実行し、ピーク値を記録します。次に2ページを開いた状態で繰り返し、2つ目のページが実際に必要とするメモリ量を確認します。並列数は算術で求められます。総RAM容量から、システムの他の処理に必要な容量を引き、数百MBの余裕を残して、ワーカー1つあたりの測定済みピーク値で割ります。この構成で必要なマシンのサイズについては、エージェント用VPSに必要なRAMとCPUを参照してください。
この数値は2か所で適用してください。コードでは、固定ワーカープールまたはセマフォを使用します。これにより、エージェント要求が急増してもブラウザーを追加起動せず、キューに入れられます。OSではcgroup制限を使用します。キューのバグが発生しても、マシン全体を停止させないためです。
[Service]
MemoryMax=2G
MemorySwapMax=0
TasksMax=512
Restart=alwaysMemorySwapMax=0は見た目以上に重要です。これを設定しない場合、cgroupが制限に達したときにページをswapへ移動します。そのためマシンは稼働し続けますが、すべての要求が遅くなり、明確な失敗よりも原因の診断が難しくなります。これを設定すると、カーネルはそのcgroup内のブラウザープロセスツリーを強制終了し、systemdがunitを再起動します。その場合も、sshdは維持されます。Composeで使用する同等の設定はmem_limit、shm_size、initです。詳しくはDocker Composeでメモリ制限を設定するを参照してください。
ブラウザーのエンドポイントをパブリックインターネットから隔離する
Playwright はブラウザーをサーバーとして実行し、エージェントに WebSocket URL を渡せます。
const { chromium } = require('playwright');
const server = await chromium.launchServer({ port: 3000 });
console.log(server.wsEndpoint());このエンドポイントにはログイン機能がありません。Playwright の API ドキュメントにも、次のように明記されています。「wsPathを知っているプロセスまたは Web ページ(Playwright 上で実行されているものを含む)は、OS ユーザーを制御できます。」デフォルトのホストは localhost で、「loopback インターフェースからの接続だけを受け付けます」。また、ドキュメントでは 0.0.0.0 のようなアドレスを明示的に渡すと、「待ち受けポートに到達できるすべての接続元にブラウザー RPC を公開する」と警告しています。Chrome の --remote-debugging-port はさらに危険です。DevTools プロトコルには認証機能が一切なく、loopback にバインドされていることだけに依存します。
実際に何を公開しているかを確認してください。VPS だけでなく、2 台目のマシンからも確認します。
ss -ltnp0.0.0.0 にバインドされたブラウザーポートがあれば、問題として扱ってください。多くのプロバイダーは、コントロールパネルに独立したネットワークファイアウォールを用意していることにも注意してください。ufw のルールは、このファイアウォールを認識しません。別のマシンから SSH トンネルまたはプライベート VPN 経由でエンドポイントに接続してください。
ssh -N -L 3000:127.0.0.1:3000 you@your-vpsここでのリスクは、誰かにブラウザーの実行時間を盗まれることだけではありません。操作可能なブラウザーは、ネットワーク内部に置かれたリクエストフォージェリの実行基盤になります。そのソケットに到達できる者は、http://127.0.0.1:8080、データベース管理ページ、クラウドメタデータアドレスの 169.254.169.254 などを取得し、その応答をページから読み取れます。ファイアウォールから見ると、リクエストは許可済みの VPS 自身から送信されています。制御エンドポイントは、そのホスト上の shell access と同等に扱ってください。
MCP サーバーも同じ構成です。npx @playwright/mcp@latest --headless --port 8931 は localhost 上で HTTP を提供し、--host 0.0.0.0 はローカルツールをパブリックに公開するフラグです。プロジェクトの README にも、Playwright MCP は「セキュリティ境界ではない」と明記されています。ポートは loopback に限定し、同じトンネル経由でエージェントから接続できるようにしてください。
エージェントが読み取るページは信頼できない入力です
オープンな Web を閲覧するエージェントは、見知らぬ第三者が書いたテキストを、指示も保持しているモデルに入力します。ページには、タスクを中止する、ツールを呼び出す、URL にデータを送信するようモデルに指示するテキストを含められます。モデルは両方をテキストとして受け取るため、ページの文言と指示を確実に区別できません。悪意のあるページが利用できる範囲を小さくするように構成してください。
- ブラウザーは専用の OS ユーザーで実行し、その環境に SSH キーやクラウド認証情報を置かないでください。
- タスクごとに新しいコンテキストを使用し、Playwright MCP では
--isolatedを設定してください。これにより、あるサイトのセッションを次のページで利用できなくなります。 - ジョブで許可されている場合は、オリジンの許可リストを維持してください。Playwright MCP では
--allowed-originsと--blocked-originsにセミコロン区切りのリストを指定します。 - メールの送信や支払いなど、状態を変更する操作の前に人間による確認を必須にしてください。
さらに安全にするには、廃棄して再構築できるマシン上でブラウザー全体を実行してください。これは、使い捨て VM でコーディングエージェントを実行する場合と同じ考え方です。エージェントの実際の目的が検索であり、自由な Web 閲覧ではない場合は、完全なブラウザーよりも限定的なツールのほうが安全です。独自の SearXNG を利用する検索スキルなら、悪意のあるページを読み込まずに検索結果を返せます。
FAQ
Chromium は同じ VPS 上で直接実行すると正常なのに、Docker ではなぜクラッシュするのですか?
コンテナではデフォルトで 64 MB の /dev/shm しか割り当てられませんが、ホストにはそれより大きな領域があります。Chromium はレンダリングしたコンテンツをこの共有メモリ領域に渡すため、負荷の高いページを開くと領域が埋まり、renderer が終了します。コンテナ内で df -h /dev/shm を実行して確認し、--ipc=host でホストの共有メモリを使用するか、--shm-size=1g でコンテナ自身の共有メモリを増やして起動してください。--disable-dev-shm-usage は問題を /tmp に移すだけです。
VPS でほかに何も実行していない場合、--no-sandbox は安全ですか?
いいえ。sandbox は、悪意のあるページがマシンのほかの部分へ到達するのを防ぎます。Chromium のドキュメントにも、このフラグは「Chromium の重要なセキュリティ機能を無効にするため、公開 Web の閲覧時には決して使用してはいけない」と記載されています。リンクをたどる agent は公開 Web を閲覧しています。
代わりに原因を解消してください。ブラウザーを root として実行しないでください。Ubuntu 24.04 では、ブラウザーのバイナリパスに対して userns, を含む AppArmor プロファイルを追加し、そのプログラムだけで unprivileged user namespace を許可します。
小規模な VPS では、ブラウザーを何個実行できますか?
数字をそのまま流用せず、実測してください。Chromium はサイトごとに 1 つの renderer process を起動するため、開くページによって必要なリソースが変わります。MemoryMax を設定して systemd-run の下で 1 つの worker を実行し、systemctl status の Memory: 行からピーク値を読み取ります。そのピーク値で空き RAM を割り、余裕を残してください。
結果は、コード内のキューと unit file の MemoryMax の 2 箇所で適用します。これにより、リクエストが急増してもマシンが swap するのではなく、キューで待機します。
agent は別のマシンからブラウザーに接続できますか?
はい。ただし、ポートを 0.0.0.0 に bind してはいけません。Playwright server endpoint と Chrome DevTools port は、パスワードなしで到達可能なすべてのクライアントからの接続を受け付けます。listener は 127.0.0.1 に維持し、SSH tunnel または private VPN 経由で接続してください。サーバー上で ss -ltnp を実行し、外部からポートを確認してください。provider が別途提供する network firewall も確認します。
ページが明らかに読み込まれているのに、スクリーンショットが真っ白なのはなぜですか?
フォントが不足しています。ページのスクリプトに対応するフォントがないと、テキストが空のボックスとして表示されるか、まったく表示されません。そのため、画像の少ないページが真っ白に見えます。取得する各言語について fc-match "sans-serif:lang=ko" を実行し、結果が汎用フォールバックの場合は fonts-noto-core と fonts-noto-cjk をインストールしてください。その後、fontconfig がキャッシュを再読み込みできるようにブラウザーを再起動します。フォントがまったくないコンテナでは、起動時に Fontconfig error: Cannot load default config file がログに記録されます。