Tony Wang13 分で読めます1,000万のウェブサイトを10分でスクレイピングする方法
システム設計のウォークスルー:毎秒16,000リクエストの分散ウェブスクレイパーを支えるキュー、ワーカー群、言語選択、そしてリソースのチューニング。
1,000万のウェブサイトを10分で、というのは、10分間ずっと毎秒およそ16,700リクエストを出し続ける計算になります。これを1台のマシンでこなせるものはありませんし、個々のリクエストをどれだけ賢くしてもたどり着けません。唯一それを実現するのは、1つの共有キューから引き出す、安価で同一のワーカー群です。総スループットはワーカー数に比例して伸び、どのワーカーが消えても仕事を失うことはありません。
この記事では、そのシステムを端から端まで組み立てます。疎結合のアーキテクチャ、並行処理を安くする言語選択、箱を足したときに実際にスループットが増えるかを左右する数個のワーカーごとの設定、そしてそれを走らせながら動かし続ける障害処理です。ワーカーは自分のノートPCでも動かせる小さなオープンソースのバイナリなので、どのステップも手を振って済ませるのではなく再現可能です。
これはリクエストの問題ではなく、スループットの問題
すべての土台になるので、まず算数から始めます。10,000,000 URLs ÷ 600 seconds ≈ 16,667 requests/second。よくチューニングされた1台の箱で、それぞれ1〜2秒かかるI/Oバウンドなプローブを同時に約500本抱えても、着地するのは毎秒数千リクエストあたりです。したがって目標はおおよそ4〜10台、ただし箱を足すごとにスループットがきれいにスケールするならば、です。以下の設計の仕事はまさにこの「ただし」を勝ち取ること、つまり群れがほぼ線形にスケールするようにし、走っている間それを生かし続けることです。
アーキテクチャ:疎結合のパイプライン
すべてが1つの考えにぶら下がっています。各ステージが互いをブロックしないよう疎結合にし、永続的な状態はすべて1か所にまとめることです。ワーカーは使い捨てになり、キューがシステムの記録元になります。
- シードローダー。 URLリストをチャンクに分けてキューへ流し込みます。1,000万行をメモリに読み込むことは決してしません。冪等にして(Redisのセットへ
SADDする、あるいはリストへプッシュする)、再実行が二重登録ではなく再開になるようにします。 - 作業キュー(Redis)。 その心臓部です。プロデューサーとコンシューマーを疎結合にし、バースト(バックプレッシャー)を吸収し、システム全体をat-least-onceにします。ワーカーはジョブをポップし、処理の途中で死んでも、そのジョブは別のワーカーが単に再ポップするだけです。訪問済みURLの重複排除のために
SETやブルームフィルタを、そしてリトライを使い切ったジョブのためにデッドレターキューを足します。 - ワーカー群。 同一のコードを走らせるステートレスなpod。各podは小さなバッチ(約100〜200ジョブ)をバッファし、内部の並行処理で処理し、結果を一括書き込みし、統計を定期的に、そしてシャットダウン時に同期します。スケールとは単に「podを足す」ことです。クラッシュとは単に「ほかのpodが動き続ける」ことです。
- 結果シンク。 バッチ書き込みを受け付けるストアなら何でも構いません(Elasticsearch、Postgresの
COPY、Parquetファイル)。domain|run_idのような冪等なidをキーにすることで、再処理されたジョブは複製ではなくその場で上書きされます。
リアルタイムのスケジューリングを行う中央の「コーディネーター」が存在しないことに注意してください。キューこそが調整役です。それが、中央に脆いブレインを置かずに群れを水平にスケールさせるものです。
ボトルネックに合わせて言語を選ぶ
これを決める軸は、単一リクエストの生の速さではありません。フェッチはI/Oバウンドで、時間のほぼすべてをネットワーク待ちに費やします。ですから効いてくるのは、ランタイムが数千本のリクエストを同時に飛行状態でどれだけ安く抱えられるか、あらゆるコアをどれだけうまく使うか、そしてどれだけ小さく出荷できるかです。人はまず「どれがメモリを少なく使うか?」に手を伸ばすので、そこから始めます。そして答えはミームが予測するものではありません。
- Goはここでの最小抵抗の道です。ただし通常言われる理由からではありません。ゴルーチンはグリーンスレッド(それぞれ約2〜8 KBのスタックで、組み込みのネットワークポーラーを備えてOSスレッドに多重化される)なので、同期的に見えるふつうのコードを書けば、スケジューラがI/Oでそれらをスレッドを占有せずに待機させます。1台あたり10万本以上の飛行中リクエストは珍しくもありません。真の勝ち筋はタダで手に入る真のマルチコア(GILなし、プロセス群なし)とデプロイです。ミリ秒のコールドスタートを持つ約10〜20 MBのscratchイメージに収まる単一の静的バイナリは、「podを足す」ことを安くします。自動的な勝ちにならないのがメモリで、下の実測を参照してください。
- Pythonは評判よりも競争力があります。
asyncio+aiohttpは本物のI/O並行処理を与え、しかもわかってみれば本当に無駄のないメモリフットプリントを持ちます。そのコストは別のところにあります。1コアあたり1つのイベントループ(協調的で、迷い込んだ1つのブロッキング呼び出しがループ全体を止めます)なので、全コアを使うにはプロセスの群れを走らせる必要があり、イメージはインタプリタ+依存関係(数百MB)でコールドスタートが遅いのです。GILについて:GILなしのfree-threadedビルドはPython 3.14で公式にサポートされました(まだオプション)が、それが主に助けるのはCPUバウンドのマルチスレッドです。I/Oのファンアウトでは、そもそもGILが制約だったことはないので、フェッチャーにとってはほとんど変わりません。
| ランタイム | 並行処理のプリミティブ | マルチコア | イメージ / コールドスタート | 向いている場面 |
|---|---|---|---|---|
| Go | ゴルーチン(グリーンスレッド + netpoller) | 組み込み | 静的バイナリ、約10〜20 MB、ミリ秒 | 高密度なI/Oファンアウト(このビルド) |
| Rust | 非同期タスク(tokio) | 組み込み | 小さい、ミリ秒、GCなし | リソース曲線を最も強く絞り込む |
| Python | asyncioタスク(1ループ / コア) | マルチプロセス | インタプリタ + 依存関係、数百MB、遅い | 抽出やML中心のパイプライン |
| Node | イベントループのpromise | worker_threads | ランタイム + 依存関係、中程度 | JSショップのチーム、中規模 |
| JVM(Loom) | 仮想スレッド | 組み込み | JRE + jar、大きい、ウォームアップ | 既存のJVMプラットフォーム |
で、実際どれがメモリを少なく使うのか?実測しました
俗説は「ゴルーチンは小さいのでGoはRAMをちびちびしか使わない」というものです。それはむき出しのゴルーチンについては真ですが、接続については偽です。ローカルサーバーに対して1万本の同時HTTPリクエストを開いたままにして、ピーク時のresidentメモリを記録しました(平文HTTP、TLSなし。フルスクレイプではなく、クリーンなフットプリントのテストです):
| 1万本の同時接続 | ピークRSS |
|---|---|
Go — デフォルト(GOGC=100) | 319 MB |
Go — GOMEMLIMIT=200MiB | 218 MB |
Go — GOGC=20 | 201 MB |
Python — asyncio + aiohttp | 200 MB |
素の状態では、Goは少ないどころかPythonの約1.6倍のメモリを使いました。Goの各接続は、成長したゴルーチンのスタックに加えてnet/httpの読み込み・書き込みバッファを抱え、ガベージコレクタはデフォルトでライブヒープのおよそ2倍を常駐させます。この最後の部分が、チューニングの節で再び出会う「residentメモリが倍になる」現象です。修正は同じレバーです。GCに上限をかければ(GOMEMLIMIT、あるいは低めのGOGC)、GoはPythonと同等まで下がります。
ですからメモリはここでGoを選ぶ理由ではありません。チューニングしなければ、むしろ選ばない理由になります。GoはCPUとマルチコアの効率、小さな静的イメージ、そして低いベースフットプリント(ここではアイドル時8 MB、Pythonの28 MBに対して)のために選んでください。ただしGCを開けっ放しにしたまま出荷しないことです。
正直な逃げ道:もし高価なステージが抽出(pandas、BeautifulSoup、LLM呼び出し)なら、Pythonのエコシステムが圧倒しうるので、正しい一手は1つの言語に両方をやらせるのではなく、パイプラインを分割すること(GoのフェッチャーがPythonの抽出器に流し込む)です。言語は、実際にボトルネックになっているステージに合わせましょう。
そして、ここが本当に変わった部分です。この決定はかつて「チームがすでに知っているもので書け」で片付いていました。1つの高スループットなコンポーネントのためにGoへ立ち上げるのに何週間もかかったからです。だからPythonショップは、より無駄のないランタイムが仕事に合う場面でもPythonのままでした。LLMを使ったコーディングは、その立ち上げをおおむね溶かしました。 モデルは、Goを一度も出荷したことのないチームにでも、ゴルーチンプール、チューニングされたhttp.Transport、チャネルとセマフォによるバックプレッシャー、そしてDockerfileを慣用的に組み立ててくれます。仕事に合った道具は、いまや贅沢ではなく現実的な既定になりました。
ワーカー:安く、正直な1本のプローブ
作業の単位はあえて小さくしてあります。以下は、分散する前に1台のマシンでcrawlora-deadwebを走らせるベースラインです。これはワーカーとして使うオープンなGoのプローブです。
cat domains.txt | crawlora-deadweb --json --concurrency 50 > out.ndjson
1つの良いワーカーの中身は、丁寧に行われる2つのことだけです。
- 高い並行処理。仕事がI/Oバウンドだから。 上限のあるゴルーチン(またはasyncioタスク)のプールが、1台あたり数百本のリクエストを飛行状態に保ちます。CPUはほぼ遊んでいて、仕事はネットワークです。
- URLごとの正直で安い判定。 DNS → TCP接続 → 1回の
GET /、それから結果にラベルを付けて次へ進みます。肝心なのは、本当に消えたホストのリトライで予算を燃やさないことです。DNSがない、あるいは接続が拒否されたならdead——捨てましょう。403/429は生きているがこのクライアントを拒んでいる——これはまったく別の問題で、リトライでも直りません。この両者を見分けることが、大規模な実行が屍に何時間も無駄にせずに済むことのほとんどです。
ワーカーを速くする5つの設定
群れが線形にスケールするのは各箱がチューニングされている場合だけです。そうでなければ、箱を足しても全部が同じ共有ボトルネックに突っ込みます。ほぼすべてを担うのは5つのつまみです。
| つまみ | 典型値 | なぜ効くか |
|---|---|---|
| ワーカーあたりの並行数 | 飛行中50〜500 | I/Oバウンド:箱が待つのはCPUではなくネットワーク |
| コネクション再利用 | MaxIdleConnsPerHostを上げ、keep-aliveをオンに | 毎リクエストで新しいTCP + TLSハンドシェイクを省く |
| DNS | キャッシュ + 複数のリゾルバ | 何百万回もの名前解決は単一のリゾルバを溶かす——これが隠れたボトルネック |
| 試行あたりのタイムアウト | 数秒、一時的エラーは1回リトライ | 遅いホストが並行処理のスロットを占有してはならない |
| 書き込みバッチ | 約100件 / 約2秒ごとに一括 | シンクはDNSに次ぐ2つ目のボトルネック——1行ずつ書き込んではならない |
大規模なクロールが理由もなく遅いなら、その答えは人が思うよりはるかに高い頻度でDNSです。積極的にキャッシュし、複数のリゾルバに名前解決を分散させ、各ノードでローカルのキャッシュリゾルバを走らせましょう。
スケールアウト:箱を足し、ボトルネックを見張る
チューニングされたワーカーがあれば、スケールとはおおむねレプリカ数です。実際の実行では、フェッチ群を4から10 podに増やすと、1日ではなく数時間でキューを空にしました。狙う形はこうです。
Kubernetesでは、その衛生管理は小さいながらも荷重を担います。再プッシュされた:latestが実際にロールするようimagePullPolicy: Alwaysを設定し、podに実のあるrequests/limitsを与え、必要ならCPUではなくキューの深さでHPAを駆動します。そして過剰にシャーディングしないこと。スループットは最も遅い共有リソースで頭打ちになるので、曲線が平らに折れ曲がっているなら、箱を足すのは仕事ではなくボトルネックをスケールするだけです——まずDNSかシンクを直しましょう。
動かし続ける:障害こそが通常運転
1,000万リクエストともなれば、稀な事象がひっきりなしに起きます。ワーカーはOOMKillされ、デプロイは実行の途中でロールし、ホストはタイムアウトし始めます。高可用性は後付けする機能ではありません。それらの一つひとつを非イベントに変える、既定値の集合です。
| こうなったとき | 何が救うか |
|---|---|
| ワーカーがジョブの途中でクラッシュ | at-least-onceのキューがジョブを別のワーカーへ再ポップ |
| 同じURLが二度処理される | 安定したdomain + run_idのidをキーにした冪等な書き込み——リトライは上書きし、複製しない |
| ローリングデプロイが全podを再起動 | 優雅なSIGTERMドレイン:引き出しを止め、飛行中を完了し、統計をフラッシュ |
| 1つの毒入りURLがワーカーを詰まらせる | 試行あたりのタイムアウト + リトライ予算 → デッドレターキュー |
| あるホストやシンクが全呼び出しで失敗 | サーキットブレーカーが作動し、ワーカーはNタスク後にリサイクル |
| 1,000万のバーストが残りのインフラを脅かす | 専用のnamespace + リソースクォータ + 非プリエンプティブなpriority class |
一貫した筋:ステートレスなワーカーと永続的なキューは、単一障害点がないことを意味します。 生き残らねばならない唯一のものはキューであり、それが唯一レプリケートするものです。
実際に壊れる部分:リソース
実際には、この規模のシステムがロジックで倒れることはめったにありません。倒れるのはメモリです——OOMKill(exit 137)の再起動ループです。原因はほぼ必ず同じで、ランタイムがcgroupを認識していないため、コンテナの中ではpodの上限ではなくノードのコアとRAMが見えてしまうのです。Go 1.25より前では、GOMAXPROCSはノードのコア数がデフォルトになり(端数のCPU上限に対してGCとTLSハンドシェイクを過剰に並列化する)、メモリ上限もないため、GCが走る前にヒープが上限をほぼ倍まで超えてしまいます。
修正を、どれだけ効いたかの順に並べます。
| 設定 | 値 | 効果 |
|---|---|---|
GOMAXPROCS | = コンテナのCPU上限 | 端数のCPU上限に対するGC + ハンドシェイクの過剰並列化を止める。単独で最大のレバー——OOMループを終わらせ、podあたりのRSSを約730 MiBから約150〜420 MiBへ下げた。 |
GOMEMLIMIT | メモリ上限の約85% | カーネルがpodを殺す前にGCを走らせるソフトな天井 |
automaxprocs + automemlimit | 両方をblank-import | バイナリが起動時にcgroupを読み、どんな上限に対しても両方を自分でチューニングする |
--concurrency | 上限より高くせず、CPU上限に合わせる | 上限を超えれば、並行処理を増やしても競合が増えるだけ |
GOGC=off | やめておく | このような高チャーンのアロケータでは裏目に出る |
この教訓はGoを超えて一般化します。ランタイムが何であれ、その並列度とメモリはホストではなくコンテナのクォータに合わせて決めましょう。(Pythonの場合:プロセス/ワーカープールをos.cpu_count()ではなくCPUクォータに合わせ、ワーカーあたりのRSSを見張ること。)
本当に終わったのかを知る
終盤で誰もが噛まれる、数え方の罠が2つあります。
そして、キューが空になった瞬間に群れをゼロへ縮めてはいけません。ワーカーは先読みでバッファするので、queue == 0で殺すと、バッファの途中でSIGTERMを送ることになり、飛行中の仕事を落とします——過少カウントです。処理済み ≈ 目標になるまで(シンクに対して確認したうえで)待ち、そのあとで初めてスケールダウンしましょう。
良き市民であれ
これのどれも、ウェブを叩きまくる許可ではありません。ホストごとのレート上限を保ち、robots.txtと利用規約を尊重し、認証なしのGETだけを送り、あなたのボットを名乗りましょう。1,000万件の相異なるホストのリストは自然に負荷を分散させますが、1つのサイトへの1,000万ページには本物のホストごとのスロットリングが必要です。そして、むき出しの1つのクラウドIPから広いファンアウトを走らせないこと。1つの管理されていないアドレスから多数の相異なるホストへ接続すると、ネットワークスキャンのように見え、不正利用の報告を招きます。制御された送信元、あるいは管理されたプールを使いましょう。
ワーカーはオープンソースです
crawlora-deadweb は、上でワーカーとして使った小さな Go のプローブです——DNS、TCP、1回の正直な GET、そして判定。あなた自身のリストで走らせるも、classify パッケージを読んで作業の単位がどう組み立てられているかを見るもよし。強制される分だけしかエスカレートしないという同じパターンを、Crawlora はホスト型 API として運用しています。
よくある質問
1,000万のウェブサイトを10分でスクレイピングするにはどうすればよいですか?
1台のマシンからでも、より賢いリクエストでもありません——共有キューからURLを引き出す、水平にスケールしたステートレスなワーカー群を走らせます。10分で1,000万は毎秒およそ16,700リクエストで、チューニングされた各箱が数百本の同時I/Oバウンドなリクエストを抱えれば、おおよそ4〜10ワーカーになります。ただしスループットが箱の数にきれいにスケールする限りで、です。
大規模なウェブスクレイピングにはどんなアーキテクチャが使われますか?
疎結合のパイプラインです。ローダーがURLリストを永続的なキュー(Redis)へ流し込み、ステートレスなワーカーpodの群れがジョブを引き出して各URLをプローブし、結果は冪等なidを付けてシンクへ一括書き込みされます。状態を持つのはキューだけなので、ワーカーは使い捨てで、システムはat-least-onceです——クラッシュしたワーカーのジョブは単に別のワーカーへ再ポップされます。
大規模なウェブスクレイピングにはGoとPythonのどちらが向いていますか?
高並行でI/OバウンドなフェッチにおけるGoの真の強みは、真のマルチコア利用(GILなし)と、コールドスタートの速い単一の小さな静的バイナリであって、メモリではありません。1万接続のテストでは、Goのデフォルトのnet/httpは、ガベージコレクタをGOMEMLIMITで抑えて同等になるまで、Pythonのasyncio + aiohttpの約1.6倍のRAM(319 MB対200 MB)を使いました。Pythonはコアごとにプロセスを必要としますがメモリには無駄がありません。抽出やMLが重いステージなら、そのエコシステムが勝ちうるので、GoのフェッチャーとPythonの抽出器でパイプラインを分割しましょう。
分散スクレイパーがOOMKillされ続けるのはなぜですか?
たいていは、ランタイムがcgroupを認識していないからです。コンテナの中では、podの上限ではなくノードのコアとRAMが見えてしまいます。Goでは、GOMAXPROCSをCPU上限に、GOMEMLIMITをメモリ上限の約85%に設定するか、automaxprocsとautomemlimitをblank-importして自己チューニングさせましょう。GOMAXPROCSを上限に合わせることが通常は単独で最大の修正です——私たちの実行では再起動ループを終わらせ、podあたりのメモリを約730 MiBから約150〜420 MiBへ下げました。
ウェブスクレイパーを高可用にするにはどうすればよいですか?
ワーカーをステートレスに保ち、永続的な状態はすべてキューに置くことで、どのワーカーが死んでも仕事を失いません(at-least-once)。書き込みを冪等にし(domain|run_idをキーにする)、再処理を安全にし、SIGTERMで優雅にドレインし、恒久的に失敗するジョブはデッドレターキューへ送り、群れを非プリエンプティブなpriority classを持つ隔離されたnamespaceで走らせて、バーストが残りのインフラを飢えさせないようにしましょう。