「プロキシが遅い」というのは、顧客からよく届くメッセージのひとつです。それが事実であることもあります。しかし、実際にスクレイパーを一緒に確認してみると、多くの場合、時間はスクレイパー自身が行っていることに費やされています。ページごとに新しい接続を開く、遅いライブラリでパースする、一度に1件ずつリクエストを待つ、といったことです。
それがどれほど重要かを示すために、単純なスクレイパーを用意し、レジデンシャルプロキシを通して実行しながら、一度にひとつずつ変更を加えていきました。同じ120ページの処理が298秒から40秒へと短縮され、プロキシ自体には何も変更を加えていないにもかかわらず、7.5倍の高速化となりました。このガイドでは、そのテストの内容、遅い部分を見つけるために使ったツール、そして自分のスクレイパーを診断するためのチェックリストを紹介します。
Key takeaways
- 何かを変える前に計測する。 フェッチ、パース、書き込みにかかる時間を記録するステージタイマーがあれば、数分で時間がどこに使われているかがわかります。
- 接続を再利用する。 プロキシ経由で毎回新しい接続を開くと、テストではフェッチ1回あたりの時間が3倍以上になりました。1つの
Sessionでこれは解決しました。 - パースはフェッチと同じくらいコストがかかることがある。 HTMLパーサーを切り替えたところ、パース時間が半分以下になりました。
- 並行処理は最大の効果をもたらすが、次のボトルネックを露呈させる。 ワーカーを8つにすると実行時間が半分になり、CPU負荷の高いパース処理が新たな限界になったことが明らかになりました。
- 遅いものすべてを修正する価値があるわけではない。 データベースへの書き込みは298秒中0.3秒だったため、バッチ化しても何も変わりませんでした。
The test
ヨーロッパにある1台のマシンから、Shifterのレジデンシャルプロキシ(米国の出口)を通じて、英語版Wikipediaの記事120本を収集しました。Wikipediaの記事は大きなページで、HTMLが数百キロバイトに及ぶことも珍しくありません。各ページについて、スクレイパーはHTMLを取得し、Beautiful Soupでパースしてタイトルを読み取り、リンク数を数え、SQLiteに1行を書き込みます。
同じ処理を5回実行し、毎回ひとつずつ変更を加えました。スクレイパーはWikipediaが要求する説明的なUser-Agentを名乗り(Wikipediaのサーバーはデフォルトのpython User-Agentを拒否します)、リクエストが失敗した場合は最大2回まで再試行しました。
| Version | Change | Total time | Fetch per page | Parse per page |
|---|---|---|---|---|
| 1. Baseline | New connection per request, html.parser, one at a time | 298 s | 1,906 ms | 577 ms |
| 2. Reuse connections | One requests.Session | 125 s | 568 ms | 473 ms |
| 3. Faster parser | lxml instead of html.parser | 76 s | 426 ms | 205 ms |
| 4. Concurrency | 8 worker threads | 40 s | 551 ms | 2,024 ms |
| 5. Batch writes | One transaction instead of one per row | 42 s | 579 ms | 2,141 ms |
レジデンシャル出口経由のフェッチ時間は、実行ごとに100ミリ秒程度のばらつきがありますが、上記の差はそれよりもはるかに大きいものです。
Step one: measure where the time goes
このツールは、各ステージの経過時間(wall-clock time)をスレッド間でも安全に合計する、小さなタイマーです。
import time
from collections import defaultdict
from contextlib import contextmanager
from threading import Lock
class StageTimer:
"""Adds up wall-clock time per pipeline stage, safely across threads."""
def __init__(self):
self.totals = defaultdict(float)
self.counts = defaultdict(int)
self._lock = Lock()
@contextmanager
def stage(self, name):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
with self._lock:
self.totals[name] += elapsed
self.counts[name] += 1
def report(self, wall_seconds):
busy = sum(self.totals.values())
print(f"wall {wall_seconds:.1f}s, stage time {busy:.1f}s")
for name, total in sorted(self.totals.items(), key=lambda kv: -kv[1]):
print(f"{name:>8}: {total:7.1f}s {100 * total / busy:5.1f}% "
f"{1000 * total / self.counts[name]:7.1f} ms per call")
スクレイパーの各ステージをwith timer.stage("fetch"):、with timer.stage("parse"):のように囲み、最後にレポートを出力します。実行コストはほとんどかからず、「スクレイパーが遅い」という曖昧な話を具体的な答えに変えてくれます。
ここに、タイマーを組み込んだ完全なテストスクレイパーを示します。run()関数は、変更した4つの設定を引数として受け取ります。
import os
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from bs4 import BeautifulSoup
from stagetimer import StageTimer
from urls import URLS
user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]
PROXY = f"http://{user}-country-us:{password}@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
HEADERS = {"User-Agent": "ExampleScraper/1.0 (+https://example.com/bot)"} # name your scraper
def scrape(url, session, parser, timer):
with timer.stage("fetch"):
for attempt in range(3):
try:
if session is None: # a new connection for every request
response = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=20)
else: # reuse connections through one Session
response = session.get(url, timeout=20)
response.raise_for_status()
break
except requests.RequestException:
if attempt == 2:
raise
html = response.text
with timer.stage("parse"):
soup = BeautifulSoup(html, parser)
title = soup.find("h1").get_text(strip=True)
links = len(soup.select("a[href]"))
return url, title, links
def run(reuse=False, parser="html.parser", workers=1, batch_writes=False):
timer = StageTimer()
session = None
if reuse:
session = requests.Session()
session.proxies = PROXIES
session.headers.update(HEADERS)
adapter = requests.adapters.HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
session.mount("https://", adapter)
db = sqlite3.connect("pages.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, title TEXT, links INTEGER)")
db.execute("DELETE FROM pages")
db.commit()
start = time.perf_counter()
with ThreadPoolExecutor(workers) as pool:
rows = pool.map(lambda u: scrape(u, session, parser, timer), URLS)
if batch_writes:
rows = list(rows)
with timer.stage("write"):
db.executemany("INSERT INTO pages VALUES (?, ?, ?)", rows)
db.commit()
else:
for row in rows:
with timer.stage("write"):
db.execute("INSERT INTO pages VALUES (?, ?, ?)", row)
db.commit() # one transaction per row
wall = time.perf_counter() - start
timer.report(wall)
return wall
if __name__ == "__main__":
steps = [
("baseline", {}),
("reuse connections", {"reuse": True}),
("faster parser", {"reuse": True, "parser": "lxml"}),
("8 workers", {"reuse": True, "parser": "lxml", "workers": 8}),
("batch writes", {"reuse": True, "parser": "lxml", "workers": 8, "batch_writes": True}),
]
for name, config in steps:
print(f"\n== {name}")
run(**config)
urls.pyには120本の記事URLのリストが入っています。実行すると、次のように出力されました。
== baseline
wall 298.2s, stage time 298.1s
fetch: 228.7s 76.7% 1905.7 ms per call
parse: 69.2s 23.2% 576.6 ms per call
write: 0.3s 0.1% 2.2 ms per call
== reuse connections
wall 125.0s, stage time 125.2s
fetch: 68.2s 54.5% 568.3 ms per call
parse: 56.7s 45.3% 472.7 ms per call
write: 0.3s 0.2% 2.1 ms per call
== faster parser
wall 75.7s, stage time 75.9s
fetch: 51.1s 67.4% 425.8 ms per call
parse: 24.6s 32.4% 204.8 ms per call
write: 0.2s 0.3% 1.6 ms per call
== 8 workers
wall 39.7s, stage time 309.8s
parse: 242.8s 78.4% 2023.6 ms per call
fetch: 66.1s 21.3% 551.0 ms per call
write: 0.8s 0.3% 6.9 ms per call
== batch writes
wall 42.1s, stage time 326.4s
parse: 256.9s 78.7% 2141.0 ms per call
fetch: 69.5s 21.3% 579.0 ms per call
write: 0.0s 0.0% 1.8 ms per call
What each change did
接続の再利用により、フェッチ時間が70%短縮されました。 ベースラインでは、requests.get()を呼び出すたびに新しい接続が開かれます。プロキシ経由の場合、これはプロキシへのTCP接続、トンネルリクエスト、そして出口を通じた対象サーバーとの完全なTLSハンドシェイクを意味し、ページの最初の1バイトが届く前にこれらすべてが発生します。requests.Sessionは接続を開いたまま再利用するため、ほとんどのリクエストはこの準備段階を省略できます。ページあたりのフェッチ時間は1.9秒から0.57秒に短縮されました。ほぼすべてのHTTPライブラリには同等の仕組み、つまり一度作成して共有するクライアントオブジェクトがあります。
より高速なパーサーにより、パース時間が半分になりました。 Beautiful Soupは複数のパーサーを使用できます。組み込みのhtml.parserは純粋なPythonで書かれており、lxmlはCで書かれています。これらの大きなページでは、引数を1つ変更するだけで、パース時間がページあたり473ミリ秒から205ミリ秒に短縮されました。必要なフィールドが少数であれば、lxmlで直接選択するか、埋め込まれたJSONを抽出する方がさらに高速です。
並行処理により、合計時間が半分になり、ボトルネックが移動しました。 フェッチの大部分はネットワークの待機であり、スレッドは並行して待機できます。ワーカーを8つにすると、実行時間は76秒から40秒に短縮されました。しかしパースの列を見ると、ページあたり205ミリ秒だったものが2秒を超えています。CPythonではスレッドが1つのインタープリターロックを共有するため、パースのようなCPU負荷の高い処理は並行実行されません。8つのスレッドが同じコアを奪い合うことになります。ステージタイマーはこれを可視化します。パースはステージ時間の3分の1からほぼ80%にまで増加しました。この時点でスクレイパーはCPUによって制限されており、次のステップは別プロセスでパースするか、パースする量を減らすことです。
**バッチ書き込みは何も変えませんでした。**なぜなら、書き込みはそもそも問題ではなかったからです。ベースラインでは298秒中0.3秒にすぎません。バージョン4と5のわずかな差は、実行ごとのノイズです。これが、まず計測することの意味です。データベースの書き込みをバッチ化すること自体は一般的には良いアドバイスですが、ここでは無駄な労力だったことになります。
A checklist for a slow scraper
| What you see | Likely cause | What to check |
|---|---|---|
| すべてのリクエストが遅く、CPUはアイドル状態 | 一度に1件ずつのリクエスト | 並行処理を追加する。ほとんどのスクレイパーは待機に時間を費やしている |
| 各ホストへの最初のリクエストだけ、他よりずっと遅い | 接続の確立 | セッションやクライアントを再利用する。keep-aliveが無効化されていないか確認する |
| 1つのコアでCPUが100%になっている | パースなどCPU負荷の高い処理 | より高速なパーサーを使う、抽出するデータを減らす、またはパース処理を別プロセスに移す |
| 並行度を上げるとスループットが下がる | 対象サイトによるスロットリング、または自分のマシンが飽和している | 対象ごとのステータスコードとレスポンス時間を監視する。リクエスト量と並行度の計画に関するガイドを参照 |
| ときどきリクエストが20から30秒かかる | タイムアウトと再試行 | 明示的なタイムアウトを設定し、再試行回数の上限を決め、ログに記録する。Webスクレイパーの再試行とバックオフを参照 |
| スクレイパーが数時間経つと遅くなる | キューの増大、メモリ圧迫、無制限のバッファ | バックプレッシャーを追加する。バックプレッシャーとフロー制御を参照 |
| ページが遅く、重い | HTMLだけで済むのにブラウザをレンダリングしている | HTMLを直接取得する、または画像・メディア・フォントをブロックする |
When it really is the proxy
プロキシがボトルネックになっていることもあり、その見分け方を知っておく価値があります。
- 直接アクセスのベースラインと比較する。 同じマシンから、プロキシなしで同じリクエストの時間を計測します。両方とも遅い場合、問題は対象サイトか自分のコードにあります。
- 接続再利用後のフェッチ時間を確認する。 セッションを再利用してもフェッチが遅く、対象サイトに直接アクセスすると速い場合、プロキシを経由する経路が時間を追加しています。
- ターゲティングを確認する。 特定の都市といった絞り込んだフィルターは、選べる出口の数を減らします。対象から遠い出口は、すべてのリクエストに往復時間を追加します。
- 平均ではなくパーセンタイルを見る。 一部の非常に遅い出口が、ほとんどのリクエストは問題ないにもかかわらず平均値を押し上げることがあります。
レジデンシャルプロキシを使用する際のレイテンシ低減に関するガイドでは、プロキシ側の設定について詳しく解説しています。計測した結果、数値がプロキシを示している場合は、サポートチームに送ってください。上記のようなステージごとの計測値があれば、プロキシが遅いという報告の調査がはるかに速く進みます。
FAQ
プロキシを使うとスクレイパーが遅くなるのはなぜですか?
最も多い原因はスクレイパー自体にあります。リクエストごとに新しい接続を開くこと、一度に1ページずつ取得すること、パースが遅いことです。このテストでは、これらを修正することで、プロキシを変えずに同じ処理が7.5倍速くなりました。
接続の再利用はプロキシ経由でも機能しますか?
はい。プロキシを設定したrequests.Sessionは接続を開いたままにするため、リクエストごとの接続確立とTLSのセットアップを省くことができます。このテストでは、ページあたりのフェッチ時間が1.9秒から0.57秒に短縮されました。
スクレイパーはいくつのスレッドを使うべきですか?
対象サイトと自分のプランが許す範囲で、ネットワークを稼働させ続けられるだけの数です。増やしながら計測してください。ページあたりのパース時間やエラー率が上がり始めたら、そこが限界です。パースのようなCPU負荷の高い処理は、CPythonではスレッドを増やしても速くなりません。
BeautifulSoupは遅いのですか?
Beautiful Soup自体は便利なレイヤーであり、その速度の大部分は内部で使われるパーサーに依存します。組み込みのhtml.parserの代わりにlxmlを使うと、このテストではパースが2倍以上速くなりました。
The bottom line
プロキシを責める前に、スクレイパーを計測してください。ステージタイマーを使えば、時間がフェッチ、パース、書き込みのどこに使われているかが数分でわかります。このテストでは、接続の再利用、パーサーの切り替え、並行処理の追加により、同じプロキシのまま同じ処理が7.5倍速くなりました。まず最も大きなステージを修正し、再度計測し、残りの時間が本当にネットワークによるものになったら、そこで止めてください。