Keywords
chromedp レビュー, Web スクレイピング, オープンソース, ベンチマーク
Content
chromedp は、純粋な Go で書かれた MIT লাইセンスのライブラリで、Chrome DevTools Protocol を通じて実際の Chrome を操作します。Go プログラムの中から、ページ内の JavaScript 実行後に DOM を取得でき、別途 WebDriver や Node 実行環境は必要ありません。Go モジュール自体はアプリケーションに組み込まれますが、実際に動かすには外部の Chrome 実行ファイルが必要で、その寿命は Go の context によって管理されます。
導入は go get 一発で済みましたが、Chrome 自体は自分で用意する必要がありました。allocator を作り、context を派生させ、Run に一連のアクションを渡すだけです。この macOS arm64 環境では、ディスク上に温まった headless shell があった状態で、最初の script の結果が返るまでの中央値は 102 ms でした。これはローカル環境での基準値であって、起動が常にボトルネックではないと断言するものではありません。ロード 800 ミリ秒後にリンクを注入するテストでは、4 つの読み取り戦略のうち 2 つがリンク生成前に返ってきました。
ブラウザ自体は本物で、内容も存在していたのに、コード側が待っていなかっただけです。chromedp で最も学びになったのはまさにその点でした。これはバグというより、「ページを描画した」と「本当に欲しいものを待った」は別物だ、ということです。ヘッドレスブラウザにまつわる通説はその違いを曖昧にしがちですが、データを取れるかどうかを決めるのはブラウザではなく待機戦略です。ここにある数値はすべて、自分で管理しているローカルの fixture から取ったものです。正解データは各実行前に登録済みで、生の要約は chromedp フォルダの benchmark リポジトリ に置いてあります。
chromedp とは何か
chromedp のスタックは、意図的にシンプルです。Selenium サーバーなし。WebDriver の仲介層なし。裏で Node が動くこともありません。Go プログラムが Chrome インスタンスへ WebSocket で接続し、CDP を直接話します。仕組みとしては Puppeteer に近いですが、JavaScript 部分がないイメージです。
2026 年 7 月 27 日時点で確認したところ、リポジトリは 13,212 stars、open issues は 178 件、ライセンスは MIT でした。私が試したバージョンは v0.16.0 で、これはリポジトリ内の最新タグです。紛らわしい点として先に触れておくと、GitHub の Releases ページではまだ v0.15.1(2026-04-01 公開)が最新の release object として表示されますが、go get github.com/chromedp/chromedp@latest は v0.16.0 を解決します。Go modules と GitHub の release object が食い違っている状態です。壊れているわけではありませんが、何が動いているのかを確認するときには少し面倒です。
考え方は、ひたすら Go の context です。まず allocator context を作り(Chrome の起動方法を知っている)、そこから browser context を派生させ、最後に chromedp.Run(ctx, actions...) にアクションの列を渡します。browser context の子 context は新しいタブです。context を cancel すると、その対象は消えます。Go の並行処理に触れたことがあるならすぐ馴染めるはずですし、未経験なら Go で Web スクレイピングを始めるためのガイド のほうが chromedp の godoc より入りやすいでしょう。
最初に押さえておきたい境界もあります。chromedp が渡してくれるのはレンダリング済みの DOM であって、構造化データではありません。そこから何を取り出すか——項目、テーブル、価格など——は、自分で書いて保守するコードです。これはドライバであって、スクレイピングフレームワークではありません。
内部では何が動いているのか
chromedp ではすべてが Action で、Run はそれらのスライスを順番に target に対して実行します。Navigate、Click、Evaluate、OuterHTML、WaitVisible などはすべて同じ interface で、組み合わせ可能で、要するに Go の型をまとった CDP コマンドです。この統一感は、このライブラリの最も優れた設計判断です。便利機能と生のプロトコルが同じ土俵に乗っているからです。
この点が重要なのは、便利層が意図的に薄いからです。chromedp は cdproto の上に構築されており、これは DevTools Protocol 全体をカバーする型付きの Go バインディングを自動生成したものです。さらに chromedp の godoc では、その両方の層が並んで説明されています。便利な action が存在しないときは、同じ Run の中で network.Enable()、page.CaptureScreenshot()、runtime.Evaluate() といったドメイン呼び出しに落とし込めます。「使いやすい API」と「本物の API」の間に壁がない。これはすべてのブラウザドライバに当てはまるわけではありません。
日常的な判断が最も表れるのが wait 系の action で、意外と多く用意されています。
| Wait アクション | 何を待つか |
|---|---|
WaitReady(sel) | ノードが DOM に追加される まで |
WaitVisible(sel) | ノードが 実際に表示される まで |
WaitNotPresent(sel) / WaitNotVisible(sel) | 逆条件。スピナー待ちに便利 |
Poll(js, res) | JavaScript の条件式を一定間隔で評価し、true になるまで待つ |
もう一つ知っておくべき仕組みはプロセス管理です。これにより、プログラム終了後にブラウザが残り続けるかどうかが決まります。chromedp は Go の exec.CommandContext で Chrome を起動します。context を cancel するとそのプロセスは終了します。この一つの実装詳細が、テスト中に私が遭遇した良い挙動と鋭い落とし穴の両方を説明しています。
セットアップは Go バイナリ+自前で用意する Chrome
go get github.com/chromedp/chromedp は何事もなく v0.16.0 に解決され、依存関係にも cgo の import は含まれていません。なので、よく繰り返される「純粋な Go で外部依存なし」という説明は、Go モジュールについては本当です。
ただし、実行時については当てはまりません。chromedp は 外部の Chrome を動かすため、マシンに Chrome がなければ即失敗します。私が行った計測では、chromedp.ExecPath で正確な実行ファイルを指定し、Chrome for Testing の 151.0.7922.10 headless shell を使いました。これは批判ではありません。ブラウザを操作するにはブラウザが必要です。ただし、「外部依存がない」と「155 MB の Chrome をバイナリと一緒に配布しなければならない」はまったく別のデプロイ事情で、README に出てくるのは後者ではありません。
もう一つ、セットアップでハマりやすい点があり、コードを書く前に知っておく価値があります。chromedp の issue #1591 では、Go 1.25+ の go test 実行時に NewExecAllocator が起動途中でキャンセルされる問題が報告されていますが、同じコードでもコンパイル済みバイナリとして動かすと問題ありませんでした。私は go build で検証用バイナリを作り、すべての測定をそれで行い、go test 経由では一切実行しませんでした。Go のバージョンは 1.26.5、macOS arm64 です。最初の chromedp 体験が「Chrome 起動中に落ちるテストファイル」だったなら、まずこの issue を読んでから自分のコードを疑うのがよいでしょう。
実践:同じページを 4 通りで読むと、2 つは空だった

fixture は 127.0.0.1 上のローカルサーバーで、DOM に入るタイミングだけが異なる 3 種類のコンテンツを返します。1 つは配信された bytes の中にある静的な <a>、1 つは初期 parse 中に inline <script> が生成する <a>、もう 1 つは load イベントの 後 に指定ミリ秒数だけ遅れて setTimeout で生成される <a> です。script 生成の 2 つのリンクの marker と href は JavaScript の文字列断片から組み立てているため、配信された bytes のどこにも連続したリテラルは存在しません。したがって「見つかった」ということは、HTML を読んだのではなく、Chrome が JavaScript を実行した証拠です。
recall は Go の probe 内ではなく、事前登録した ground-truth marker と照合する Python で計算しています。つまり、probe が答えを知っていてズルすることはできません。各戦略は 3 回ずつ実行し、found の集合は 3 回とも同一でした。
| 読み取り戦略 | 静的 HTML のリンク | parse 時に注入 | load 後 800 ms で注入 | 経過時間 |
|---|---|---|---|---|
Navigate + 読み取り、待機なし | found | found | missed | 317 ms |
WaitReady("body") | found | found | missed | 107 ms |
WaitVisible("#delayed-injected") | found | found | found | 912 ms |
| marker が現れるまで Poll | found | found | found | 972 ms |
2 行は 3 つのリンクのうち 2 つしか返していません。素朴な読み取りが失敗するのは、Navigate が load イベントで返り、3 つ目のリンクがまだ存在しないからです。WaitReady("body") が失敗する理由は、実運用ではむしろ厄介な別のものです。body は load 時点で DOM に追加されているため、wait は即座に満たされ、ちゃんと待った気になってしまいます。しかも 107 ms で返るので、待たないより速く、しかも内容が欠けた同じページを受け取ります。
仕組みを推測ではなく確認するため、注入遅延を振って両極端を再実行しました(recall-summary.json)。
| load 後の注入遅延 | 待機なしの読み取りで見えるか | WaitVisible で見えるか | WaitVisible の経過時間 |
|---|---|---|---|
| 0 ms | yes (race) | yes | 109 ms |
| 100 ms | no | yes | 208 ms |
| 400 ms | no | yes | 519 ms |
| 800 ms | no | yes | 911 ms |
| 1500 ms | no | yes | 1625 ms |
この fixture では、WaitVisible の経過時間が注入遅延に追従しています。100→208、400→519、800→911、1500→1625。つまり、早読みではなくノード出現まで待っていた証拠です。0 ms の行は境界条件です。setTimeout(..., 0) は即時読み取りより先に走ることがあり、待機なしの経路でも拾える場合があります。この sweep では 100 ms 以上だと、待機なしの経路は毎回取り逃しました。
本番では、同じタイミングのミスによって、正しい HTML を取得しているのに抽出行数が 0 件で、パイプラインが出力件数を確認しなければ exit code 0 で終わる、ということが起こりえます。これはここで実測した事故ではなく、fixture の挙動から十分ありうる失敗モードだという話です。描画は要件の半分にすぎません。読み取り側が、欲しいデータに紐づくアプリケーションレベルの条件を待つ必要があります。
WaitReady と WaitVisible は優劣ではなく、答えている質問が違う
よくある言い方では、WaitVisible のほうが WaitReady より「信頼できる」とされます。しかし、それは不正確で、かえって害になります。DOM には存在するものの display: none が付いたノードでは、2 つの挙動ははっきり分かれます(waitsem-summary.json、同一実行を 3 回)。
| 対象ノード | Action | 結果 | 時間 |
|---|---|---|---|
attached, display:none | WaitReady | 返る | ~6 ms |
attached, display:none | WaitVisible | タイムアウト、context deadline exceeded | 4000 ms |
| visible node | WaitVisible、default query | 返る | 4–12 ms |
| visible node | WaitVisible、ByID | 返る | 1–2 ms |
| visible node | WaitVisible、ByQuery | 返る | 1 ms |
WaitReady は「DOM に追加されたか」を意味し、WaitVisible は「見えているか」を意味します。違うものを尋ねると、レンダリングされないままの内容を素通りするか、最初から見えるはずのないノードで timeout いっぱい待たされることになります。deadline の挙動自体はきれいで、4 秒ちょうどで正しく context deadline exceeded が返り、ハングも zombie 状態もありません。これは一部のドライバよりよほどまともです。
一つ、報告されていた落とし穴は再現しませんでした。issue #440 には、デフォルト query で WaitVisible("#id") がハングするという報告がありますが、v0.16.0 では再現せず、default query、ByID、ByQuery のすべてが visible node に対して毎回返りました。再現しないことと修正済みであることは別です。これは 1 つのページの 1 種類の selector に対する結果であり、issue 全体を打ち消すものではありません。
飛ばした defer cancel() が屋根を支えている
見るべきなのは return 値ではなくプロセス数です。ライフサイクルの各テストでは、固有の --user-data-dir を使い、pgrep で実際の Chrome browser プロセス数を数え、renderer の子プロセスは除外しました。各経路は 3 回ずつ実行しています(lifecycle-summary.json)。
| 終了パス(macOS、各 3 回) | 起動した chrome-headless-shell に起きたこと | タイミング |
|---|---|---|
| context と allocator を cancel する | 消える | 13, 13, 12 ミリ秒 |
| cancel せずに Go プロセスを終了する | プログラム終了後も残る — probe 実行前は browser プロセス 0 個、終了後は 1 個。3 回すべて orphan 化 | — |
cancel はクリーンで速く、exec.CommandContext が約束する通りです。(残った orphan は後で harness が強制終了し、ホストは掃除済みです。)
これは既知で、文書化されていて、OS に限定された挙動です。測定したのは私ですが、発見したのは私ではありません。 chromedp の tracker はこれを複数の観点から扱っています。#774 は FreeBSD での同様の終了しなさを示し、#752 は macOS での Chromium プロセスのハングを報告し、#562 と #1566 はその仕組みを説明しています。私が追加したのは、両側のプロセス数と時間の実測です。これはそうした定性的報告にはありません。
仕組み自体は build tag の話として知っておく価値があります。v0.16.0 のソースでは、allocate_linux.go が子プロセスに対して Pdeathsig = SIGKILL を設定するため、Linux ではカーネルレベルの親死亡シグナルが働きます。一方、macOS でコンパイルされる allocate_other.go ではその呼び出しは no-op です。darwin には同等のシグナルがないため、プログラム終了時に Chrome を殺すものがありません。その一方で godoc の説明は一般的な約束のように読めます。デフォルトの command は「Go プログラム終了時に開いている browser をすべて SIGKILL する」と書かれていますが、Linux 限定のスコープは build-tag 付きソースの中にしかありません。これを「文書が言い過ぎている」と言うのは妥当ですが、「chromedp のバグだ」と言うのは違います。
実用上の帰結はどちらにせよ同じです。macOS では defer cancel() が重要部材です。これを省くと、毎回 browser プロセスが漏れます。Linux はテストしていません。 なので、そこに同じ orphan 結果を一般化するつもりはありません。ソースからは Linux の挙動が違うことが示唆されますが、示唆は測定ではありません。
コールドスタート、並行実行、そしてデプロイを左右する地味な話

新規プロセス、allocator、context、localhost への navigation、最初の Evaluate までを 5 プロセスで測ったところ、中央値は 102 ms でした。範囲は 98〜111 ms です(coldstart-summary.json)。この macOS arm64 の fixture では、オンディスクの headless shell が温まっていたため、起動コストは遅延コンテンツの待機より小さく収まりました。コンテナ、冷えたファイルシステム、CI、serverless、実運用の navigation は測定していません。
並行実行については、chromedp には 2 つの形があります。1 つの browser に複数の子 context(タブ)をぶら下げるか、独立した browser を複数立てるかです。4 つの navigation を 3 回ずつ実行しました(concurrency-summary.json)。
| モード | 壁時計時間 (p50) | 範囲 | Chrome browser プロセスのピーク数 |
|---|---|---|---|
| 共有 browser、4 つの child context | 214 ms | 209–219 | 1 |
| 4 つの独立 browser | 264 ms | 261–278 | 4 |
ここで実測として確かなのはプロセス数です。この 4 つの軽いローカル navigation において、Chrome browser プロセスは 1 個か 4 個かに分かれました。壁時計時間の範囲は重なりませんでしたが、これはあくまで傾向であって、スループットベンチマークではありません。RSS や PSS は測っていないので、このテストからメモリ削減までは断定できません。
小さなエラーパスの probe は、ここでは robust さを主張するには十分な情報がありません。草稿では、HTTP status、navigation error、event、harness ロジックのどれが各条件を表面化したのかが示されていません。したがって、500 / dead-link の扱いは、正確な API 結果と raw artifact が公開されるまで未報告として扱うべきです。
未実施、したがってこの数値の範囲外なのは、Linux のライフサイクル挙動、N=4 を超える並行性や実際のページ処理を伴うケース、メモリ差分(私は RSS ではなくプロセス数を数えました)、ネットワークインターセプトと request capture、そして #168 と #1593 にある WaitReady の timeout 報告です。これらは断続的な timeout を述べていますが、私が測ったのは wait の 意味論 であり、別の問いです。1 台のマシン、1 つの Chrome ビルドだけを使っています。
chromedp はこのベンチで唯一の Go CDP ドライバではありません。rod も、同じ fixture、同じ harness、同じ host、同じ Chrome build で同時に検証しており、別途記事があります。
長所と短所
長所:
- 本物の CDP に直接アクセスできる。便利な action と生の
cdprotoドメイン呼び出しを同じRunで組み合わせられるので、API の上限にぶつからない。 - 測定した macOS fixture では、ローカルの cold cycle 基準が p50 102 ms、範囲は 98–111 ms。
- cancel すると Chrome が約 13 ms で確実に回収される。
- child context を使えば 1 browser プロセスで複数タブを共有できる(独立 browser の 4 プロセスに対して 1 プロセス)。
- テストでの再現性が高い。recall 集合、wait の意味論、ライフサイクル結果は各 3 回で完全一致。
- deadline の扱いがきれい。到達不能な条件に対する
WaitVisibleは、ハングせず正しく 4 秒ちょうどでcontext deadline exceededを返した。 - 純粋な Go モジュール(cgo なし)で MIT ライセンス。とはいえ実行時には外部 Chrome 実行ファイルが必要。
短所:
- 実行時に外部 Chrome が必要。「依存なし」という評判は Go モジュールに対してだけ。
WaitReady("body")は、一見正しそうに見えるのに load 後コンテンツを静かに取り逃す罠。完全でないページを 107 ms で返した。- 素朴な
Navigate+ 読み取りでは、load 後およそ 100 ms 以上で注入されたものを決定的に取り逃し、エラーも出ない。 - macOS では
cancel()せずに終了すると browser が orphan 化する(3/3 回)。既知かつ OS 限定の挙動だが、つまずきやすい。 - godoc の「終了時に SIGKILL」という表現は全 OS 共通に読めるが、実際の仕組みは Linux 限定の build-tag 付きソースにある。
- Go 1.25+ では
go testが allocator 起動をキャンセルすることがある(#1591)。代わりにバイナリをビルドすべき。 - DOM は返すが構造化データは返さない。欲しい各フィールドのパースコードは自分で書いて保守する必要がある。
- 最新タグ(v0.16.0)が GitHub Release object の最新(v0.15.1)より先行していて、バージョン確認が少し紛らわしい。
chromedp が向いている人、向いていない人
サービス自体が Go で書かれていて、その中で本物のブラウザが必要なら、chromedp はかなり自然な選択です。Node プロセスを監視する必要もなく、WebDriver サーバーを維持する必要もなく、コンパイル済みバイナリ 1 つと、配布またはインストールした Chrome だけで済みます。context モデルは Go の並行処理プリミティブと非常に素直に対応しているため、browser の寿命もコードベースの他のものと同じ defer 規律で管理できます。さらに、便利 API で足りないもの——CDP のネットワークイベント、正確な page lifecycle フック、プロトコルレベルの工夫——が必要なら、ライブラリを出ずに cdproto に降りられます。
待機を明示的に制御したい場合にも向いています。wait 系 action はヒューリスティックではなくプリミティブです。言っていることをそのままやってくれるので、正しく選ぶ責任が自分にあると受け入れられるなら、それは強みです。
チームが Go を書かないなら、避けたほうがいいです。コストの本体はライブラリではなく言語そのものです。こちらの意図を汲んで賢く自動待機してほしいなら、chromedp は推測しません。要求した通りにだけ動き、その瞬間のページ内容を返します。もし本当に欲しいのが DOM ではなく構造化されたレコードなら、避けるか、かなりの工数を見込むべきです。すべてのフィールドは自分で selector を書き、テストし、サイト変更時に修正しなければなりません。「500 個の URL からデータを取れればそれでいい」だけなら、Go でブラウザを組み立てるのは要求に対してかなり大げさな仕組みです。ブラウザ自動化ガイド では、その仕組みが本当に必要なときと、そうでないときを整理しています。
代替手段と、自社スタックの位置づけ
ブラウザドライバのカテゴリでは、rod も Go の CDP ドライバですし、Playwright と Puppeteer は Node 側の選択肢で、Playwright と Puppeteer の比較記事 で扱っています。wait の契約は互換ではありません。Playwright は多くの action の前に actionability を自動で待ちますが、それでも action の後にアプリケーションデータの到着が完了したかまでは分かりません。chromedp はより低レベルの wait プリミティブを提供し、actionability もアプリケーションレベルの readiness 条件も呼び出し側に委ねます。より広い選択肢を見たいなら、テスト済みのオープンソーススクレイパー一覧 が、この線の向こう側にある静的クローラーや抽出ライブラリをまとめています。
関連レビュー: Browserless レビュー。
管理型の抽出サービスは別カテゴリです。ブラウザや selector の制御を手放す代わりに、レンダリングとスキーマ整形を外部に委ねます。構造化レコードが成果物で DOM は不要、という場合には便利です。一方、ブラウザを Go サービスの制御下に置いたままにしたいなら chromedp のほうが適しています。私たちは Thunderbit という同種のサービスを提供していますが、この fixture に対しては検証していません。したがって、chromedp との等価性、レイテンシ、抽出品質、コストの比較はこのテストからは導けません。
結論
chromedp は使うべきでしょうか。Go を書き、自分で制御できる本物のブラウザが欲しいなら、答えは yes です。このローカル fixture では、最初の script 結果まで p50 102 ms、キャンセル後の Chrome 回収は約 13 ms、4 つの同時タブに対して browser プロセスは 1 つでした。これらは限定された観測であって、普遍的な性能保証ではありません。真の魅力は、便利層の限界を超えたときに Go から CDP へ直接触れることにあります。
ただし、主張の大きさは正しく見積もるべきです。"純粋な Go、依存なし" が指しているのはモジュールであって、実行時には Chrome バイナリを配布し、管理する必要があります。また、"ヘッドレスブラウザを使えば動的コンテンツが取れる" というのも、待機が欲しいノードに対して適切に設定されている場合に限ります。素朴な読み取りと WaitReady("body") は、load 800 ms 後に注入されたコンテンツが欠けたページを、毎回静かに返してきました。macOS では defer cancel() は作法ではなく必須部品です。これを外すと 1 実行ごとに browser が漏れます。これは既知の platform 行動ですが、対処する責任はあなたにあります。この 3 点を押さえれば、chromedp は私が測定した中でもかなり予測しやすい browser driver の一つです。逆に外すと、黙って失敗します。スクレイパーにとって、それは最悪の失敗の仕方です。
Web データ抽出に Thunderbit を試す Get Started Free
FAQ
chromedp は JavaScript で描画された内容を本当に取得できますか?
はい。ただし、欲しいノードに合わせた wait が必要です。リンクが load イベントの 800 ms 後に注入される fixture では、Navigate + 読み取りは取り逃し、WaitReady("body") も取り逃しました。一方、そのノードに対する WaitVisible と JavaScript の poll は、いずれも 3 回中 3 回で回収できました。遅延を振ってみると、待機なしの読み取りは 100 ms 以上の設定で毎回取り逃しました。描画は必要条件であり、正しく待つことが十分条件にする鍵です。
WaitReady と WaitVisible の違いは何ですか?
WaitReady はノードが DOM に追加されるまで待ちます。WaitVisible は実際に表示されるまで待ちます。DOM にはあるのに display: none のノードでは、WaitReady は約 6 ms で返ったのに対し、WaitVisible は 4 秒の context deadline まで待ち続け、きれいに context deadline exceeded を返しました。どちらが「より信頼できる」かではありません。答えている質問が違うだけで、選び間違えることが本当の落とし穴です。
chromedp では defer cancel() が本当に必要ですか?
macOS では必要です。context と allocator を cancel すると、起動した Chrome は毎回 12〜13 ms で回収されましたが、cancel せずに Go プロセスを終了すると、3 回すべてで browser プロセスが orphan として残りました。これは既知の platform 限定挙動です。chromedp の tracker は Linux 以外で同じ「終了しない」パターンを文書化しており、それを回収する parent-death kill は Linux 限定の build-tag 付きソースにあります。Linux はテストしていないため、orphan の結果は macOS 限定として扱ってください。
chromedp には別途 Chrome をインストールする必要がありますか?
はい。Go モジュール自体は pure Go で cgo なしですが、外部ブラウザを動かすため、なければ即失敗します。私は chromedp.ExecPath で Chrome for Testing 151.0.7922.10 の headless shell を明示的に指定しました。利点は起動コストが小さいことです。最初の script 結果までの cold cycle は、5 つの新規プロセスで中央値 102 ms、範囲は 98〜111 ms でした。
タブを共有して 1 つの browser を使うべきですか、それとも browser を別々に立てるべきですか? browser プロセス数を抑えたいなら、child context を共有する構成が有利です。4 つの localhost navigation では、1 つの browser を共有した場合は Chrome browser プロセスが 1、4 つの独立 browser では 4 でした。壁時計時間も共有構成のほうが少し良く(214 ms 対 264 ms の中央値)、ただし 4 つの単純なローカルページはスループットベンチマークではありません。メモリは測っていません。セッション分離を強くしたい、プロキシを変えたい、クラッシュ時の影響範囲を小さくしたいといった場合は、独立 browser のほうが適していることもあります。そうしたトレードオフは今回のテスト範囲外です。


