GitHubのトレンドを見ていたら、2位に lightpanda-io/browser が入っていました。説明は「the headless browser designed for AI and automation」。スターは39,602で、言語は Zig です。
ヘッドレスの Chrome は重い、という話はよく聞きます。それなら「Chrome ではないヘッドレスブラウザ」はどこまで Chrome の代わりになるのか。気になったので、自分の手で確かめてみることにしました。
試した環境は、毎回作って捨てる Docker コンテナです(node:22-bookworm、Debian 12.14、Node v22.23.1、上限は CPU 2・メモリ 4GB)。Lightpanda はリリース版の 1.0.2 を使い、比べる相手は同じコンテナに apt で入れた Chromium 156.0.8078.73 です。README に「既定で利用状況のテレメトリを送る」とあったので、全部の実行で LIGHTPANDA_DISABLE_TELEMETRY=true を付けています。
叩く相手は、第三者のサイトではなく、コンテナの中に自分で作ったページだけにしました。トップページは「JSON-LD と OGP を持ち、fetch('/data.json') で取った商品5件を JS でリストに足す」小さな店のページで、ほかに表を200行 JS で組み立てるだけのページを100枚用意しています。
1コマンドで取ってみる(fetch)
最初は全然分からなくて、README の Quick start をそのままなぞりました。入れ方は、Linux 用のバイナリを curl で落として実行権を付けるだけです。
# 以下の lightpanda は /work/bin/lightpanda。実行は全部 LIGHTPANDA_DISABLE_TELEMETRY=true 付き
# Lightpanda 1.0.2 の Linux 版を取ってくる(README は nightly を案内。今回はリリース版に固定)
curl -sSL -o /work/bin/lightpanda \
https://github.com/lightpanda-io/browser/releases/download/1.0.2/lightpanda-x86_64-linux
chmod a+x /work/bin/lightpanda
# download_ms=2907 bytes=76408136
lightpanda version
# 1.0.2
ldd lightpanda
# linux-vdso.so.1 / libm.so.6 / libc.so.6 / ld-linux-x86-64.so.2 だけ
# 比べるために curl で生の HTML を見る(JS は走らない)
curl -s http://127.0.0.1:8000/ | grep -oE 'id="status">[^<]*'
# id="status">JS未実行
# Lightpanda に取らせる(待ち方は既定のまま)
lightpanda fetch --dump html http://127.0.0.1:8000/
# rc=0 ms=20 bytes=1499
# <title>ght test shop
# id="status">JS実行済み
# (<li> は 0 件)
76MB のファイルが1つで、依存は libc と libm だけ。ダウンロードは2.9秒でした。比較用の Chromium は apt(update 込み)で63秒かかったので、ここの身軽さはかなり違います。
fetch も20ミリ秒で返ってきて、status が「JS実行済み」に変わっています。インラインの JS はちゃんと動いている。ところが、fetch('/data.json') で足すはずの商品の <li> が1件も入っていませんでした。タイトルも JS で「(5件)」を付ける前のままです。
恥ずかしながら、最初は自分のページの書き方が悪いのかと思いました。でも同じページを Chromium で撮るとリストは出ます。そこでコンテナの中の HTTP サーバーのログを見てみたところ、Lightpanda で5回取らせた間、/data.json への要求は1回も来ていませんでした。Chromium は1回で / と /data.json の両方を取りに来ています。load の時点で書き出して終わっている、ということのようです(Lightpanda の中の動きまでは追っていません)。
fetch --help を読むと --wait-until に5つの値がありました。全部試した結果がこれです。
| 指定 | 所要 | ダンプ中の li | title | data.json への要求 |
|---|---|---|---|---|
| 指定なし(既定は load) | 19 ms | 0 | ght test shop | 0回 |
| —wait-until domcontentloaded | 17 ms | 0 | ght test shop | 0回 |
| —wait-until networkalmostidle | 18 ms | 5 | ght test shop (5件) | 1回 |
| —wait-until networkidle | 17 ms | 5 | ght test shop (5件) | 1回 |
| —wait-until done | 16 ms | 5 | ght test shop (5件) | 1回 |
| —wait-selector ‘#items li’ | 17 ms | 5 | ght test shop (5件) | 1回 |
| —wait-ms 1000 | 16 ms | 0 | ght test shop | 0回 |
networkalmostidle 以降を指定すれば、ちゃんと5件入ります。しかも所要時間はほとんど変わりません。ローカルのページなので当然といえば当然ですが、「待つ」を指定しても遅くならないなら、非同期で描くページを相手にするときは最初から --wait-until networkidle を付けておけばいいんですよね。
意外だったのは最後の行です。僕は --wait-ms 1000 を「1秒待ってから書き出す」オプションだと思い込んでいて、これで足りるだろうと試したのですが、16ミリ秒で返ってきて li は0件でした。help の説明は「Wait time in milliseconds. Supersedes all other —wait parameters. Defaults to 5000.」。既定が5000なのに既定の実行が20ミリ秒で終わっていたので、少なくとも今回の動きは「その時間だけ待つ」ではなく「長くてもここまで」の上限でした。
出力の形式は html のほかに markdown・structured_data・png・pdf があります。markdown と structured_data はこんな感じでした(どちらも既定の待ち方なので、商品リストはまだありません)。
# lightpanda fetch --dump markdown http://127.0.0.1:8000/
# 小さな店(静的な見出し)
CSS で幅 300px の青い箱
1件追加
JS実行済み
# lightpanda fetch --dump structured_data http://127.0.0.1:8000/
{"jsonLd":["{\"@context\":\"https://schema.org\",\"@type\":\"Product\",\"name\":\"検証用マグカップ\",\"offers\":{\"@type\":\"Offer\",\"price\":\"1200\",\"priceCurrency\":\"JPY\"}}"],
"openGraph":{"title":"テスト用の小さな店","type":"website"},"twitterCard":{},
"meta":{"language":"ja","charset":"utf-8","title":"ght test shop"},"links":{}}
markdown には display:none で隠した段落が出てこず、ボタンの文字は残っていました。structured_data の jsonLd は、オブジェクトではなく JSON の文字列の配列で返ってきます。使う側でもう一度 JSON.parse が要るのは、地味ですが知らないと引っかかりそうです。
Puppeteer をそのままつないでみる
Lightpanda は lightpanda serve で CDP(Chrome DevTools Protocol)のサーバーとしても動きます。起動からポートが開くまで106ミリ秒。Puppeteer(puppeteer-core 25.16.0)からは接続先を browserWSEndpoint: "ws://127.0.0.1:9222" にするだけで、スクリプトは Chromium 向けと同じものが動きました。
同じ確認用スクリプトを両方に向けて、1項目ずつ結果を並べています。違った行だけ抜き出すとこうなります。
# probe.mjs を Lightpanda(serve)と Chromium 156 に向けた結果のうち、違った行
Lightpanda 1.0.2 Chromium 156
browser.version() Chrome/124.0.6367.29 Chrome/156.0.8078.73
#box の getBoundingClientRect 300 316
#box の offsetWidth 300 316
page.pdf() の大きさ 9894 bytes 18156 bytes
# 同じだった行:
# goto=200 / title「ght test shop (5件)」/ li 5件 → click 後 6件
# JSON-LD の price=1200 / getComputedStyle の width=300px
# 隠した段落の display=none / window.innerWidth=800 / page.screenshot() も両方撮れた
networkidle0 で待てば非同期のリストも入り、クリックで1件増えるところまで Chromium と同じでした。Lightpanda が自分を「Chrome/124」と名乗るのは、ブラウザの版を見て分岐するスクリプトだと効いてきそうです。
一番大事だったのは #box の幅の行でした。CSS は width: 300px; padding: 8px なので、要素の外形は 300 + 8 + 8 = 316 になるのが普通です。Chromium は316を返し、Lightpanda は300を返しました。getComputedStyle の width はどちらも 300px で合っているので、padding を含めた外形の計算が Chromium と違うということになります。要素の座標や大きさを使って何かを判断するスクリプトは、そのままだと結果がずれる可能性があります。
スクリーンショットは両方で撮れましたが、中身はまったく別物でした。

左の Lightpanda は、日本語が全部豆腐(四角)になっています。そして青い箱の背景色もボタンの枠もありません。help に「Text-only rendering of the page」とある通り、文字だけを並べた絵でした。
日本語のフォントが無いせいかと思い、コンテナに IPA ゴシック(fonts-ipafont-gothic)を入れて撮り直しました。Chromium のスクリーンショットは11,318バイトから26,854バイトに変わって日本語が出たのに、Lightpanda は9,667バイトのまま1バイトも変わらず、豆腐も残ったままでした。システムに入れたフォントは使っていないようです。
調べてみたところ、help に --render-font <PATH> があり、「同梱の DejaVu フォントに無い文字(CJK など)に使うフォントファイル」と書かれていました。clone した main の HEAD(c3b9247c)でも、src/rust/render/fonts/ に DejaVuSans 系の ttf が入っています。serve に --render-font /usr/share/fonts/opentype/ipafont-gothic/ipag.ttf を渡して撮り直したのが次の画像です。

今度は日本語が読めます(18,191バイト)。fetch --dump png でも同じオプションが使え、--wait-until networkidle と一緒に付けると商品リスト入りの PNG が33ミリ秒で出てきました。ただ、色や枠が無いのは変わらないので、見た目の崩れを確かめる用途には使えない、と割り切るのがよさそうです。
100ページで Chromium と比べる
最後に、README の売りの「軽さ」と「速さ」を自分のページで測りました。表を200行 JS で組み立てるページを100枚、同じスクリプトで順に開いて tr と a を数えます。ブラウザは毎回起動し直し、0.2秒ごとにブラウザのプロセスのメモリを /proc/<pid>/smaps_rollup から足し上げて最大値を取りました。
// bench.mjs(抜粋)。接続先だけ変えて、同じ処理を Lightpanda と Chromium に向ける
// 改善の余地あり: 自前の軽いページを 127.0.0.1 で回しただけなので、画像や重い JS のある
// 実サイトだと差は変わるはず(そこは未確認)。測る対象のページ次第です
const opts = mode === 'lp'
? { browserWSEndpoint: 'ws://127.0.0.1:9222' }
: { browserURL: 'http://127.0.0.1:9333' };
const browser = await puppeteer.connect(opts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
for (let i = 1; i <= N; i++) {
await page.goto(`http://127.0.0.1:8000/pages/p${String(i).padStart(3, '0')}.html`, { waitUntil: 'load' });
const r = await page.evaluate(() => [document.querySelectorAll('tr').length, document.querySelectorAll('a').length]);
rows += r[0]; links += r[1];
}
結果は2回ずつです。
| 項目(1回目 / 2回目) | Lightpanda 1.0.2 | Chromium 156 |
|---|---|---|
| 100ページの所要(接続から切断まで) | 399 ms / 413 ms | 2,801 ms / 3,114 ms |
| 数えた tr と a / 失敗 | 20,000 と 20,000 / 0 | 20,000 と 20,000 / 0 |
| プロセス数(最大) | 1 | 11 |
| メモリのピーク(PSS の合計) | 25,985 kB / 25,736 kB | 599,805 kB / 610,327 kB |
| 参考: RSS の合計 | 27,472 kB / 27,264 kB | 1,394,168 kB / 1,411,028 kB |
時間は約7倍、メモリ(PSS)は約23分の1です。Chromium はプロセスが11個に分かれていて、RSS をそのまま足すと共有している部分を何度も数えてしまうので、比べるのは PSS にしました。
README の表は「EC2 の m5.large で実在のページをネットワーク越しに取る」条件で、100ページでメモリ 123MB 対 2GB、時間 5秒 対 46秒です。僕の測定は小さな自作ページを同じコンテナの中で開いただけなので、倍率をそのまま比べるものではありません。それでも、接続先以外は同じ Puppeteer のスクリプトがこの差で回るのは、正直なところ予想以上でした。
試してみての所感
コンテナを作ってから捨てるまでは4分32秒。その間に覚えたことを書き出しておきます。
- 入れるのはバイナリ1つ(76MB・2.9秒)。Puppeteer は接続先を変えるだけで動いた
fetchの既定は load まで。JS で後から取ってくる中身が欲しいなら--wait-until networkidleか--wait-selector。--wait-msは待つ時間ではなく上限だったgetBoundingClientRectなどの外形は padding の扱いが Chromium と違った(316 対 300)- スクリーンショットと PNG/PDF は文字だけの描画。日本語は
--render-fontを渡すまで豆腐 - 自作の100ページでは、時間が約7倍速く、メモリは約23分の1
僕が使うとしたら、JS で組み立てるページから本文や構造化データを集める側です。fetch --dump markdown や structured_data と --wait-until networkidle の組み合わせは、その用途にはかなり手軽でした。
逆に、画面の見た目を確かめるテストや、要素の位置や大きさに頼る操作は、しばらく Chromium のままにしておこうと思います。ここは好みが分かれるところですが、「速くて軽いけれど、レイアウトは Chrome と同じではない」と分かったうえで使い分けるのが、今の僕にはちょうどいい距離感です。