「プロキシのロードバランシング」という言葉は、かつてとはまったく違う意味を持つようになった。従来のモデルでは、IPのリストを購入し、ローテーターを自前で構築していた。IPを選び、その健全性を追跡し、死んだら取り除き、後で戻す、という具合だ。多くのチームは今でも習慣でそのミドルウェアを書いているが、モダンなゲートウェイの上ではそれはほとんど死んだコードになっている。
residential proxyのゲートウェイでは、プール自体がバランスを取ってくれる。 エンドポイントは一つで、プロバイダーがリクエストごと、あるいはセッションごとに、数百万のIPの中からサーバー側でエグジットを割り当てる。あなたはIPのロードバランシングをしているのではない。あなたが本当に設計すべきなのは、その上のレイヤーだ。作業がどのようにアイデンティティにマッピングされるか、ターゲットがどれだけの同時実行数を許容するか、そしてリクエストが失敗したときに何が起きるか。この3つを正しく設計すればシステムはスケールするし、間違えれば、どれだけプロキシの品質が良くても救われない。
これは、本番環境で収集パイプラインを運用するチーム向けに、この3つのレイヤーを深掘りする記事だ。
ゲートウェイが担うこと、あなたのアーキテクチャが始まる場所
この分担を正確に理解しておく価値がある。それが、何を構築すべきで何を構築すべきでないかを決めるからだ。
ゲートウェイが担うこと: プールからのエグジットIPの選択、地域のマッチング、個々のIPの健全性、そしてローテーションの仕組み。あなたはユーザー名(country、city、sid、ttl)を通じて意図を表現し、ゲートウェイがそれをエグジットに解決する。
あなたが担うこと: アイデンティティの単位(何が独自のIPを持ち、どれくらいの期間持つか)、同時実行数の制御(各ターゲットにどれだけ強く負荷をかけるか)、リトライポリシー(失敗時に何が起きるか)、そして観測性。
もっともよくあるアーキテクチャ上の間違いは、ゲートウェイと重複するプロキシローテーター層を構築してしまうことだ。もしIPの健全性テーブルを維持していることに気づいたら、そこで手を止めよう。それはプロバイダーの仕事であり、あなたのバージョンはプロバイダーよりもプールに対する見通しが劣っているはずだ。あなたの仕事は、リクエストポリシーのレベルから始まる。
レイヤー1: ローテーションアーキテクチャ、アイデンティティの単位を選ぶ
これは、他のすべてがそこにぶら下がる設計上の決定だ。問いは「どれくらいの頻度でローテーションすべきか」ではなく、**「一つのアイデンティティとは何で、どの作業がそれに属するのか」**である。
2つの基本パターン:
- リクエストごとのローテーション(
sidを省略): すべてのリクエストが新しいエグジットIPを取得する。IPの多様性は最大になり、コネクションの再利用はゼロになる。前のリクエストに依存しない大量の独立したフェッチに理想的。 - スティッキーセッション(
sid+ttl): そのセッションIDを持つすべてのリクエストは、TTLが切れるまで一つのエグジットIPを共有する。一連のフローが一人のユーザーに見える必要がある場合に必須であり、これはプールされたコネクションの再利用を可能にするものでもある(sticky vs rotatingを参照)。
設計上の原則: 論理的な作業単位の境界でローテーションし、恣意的にローテーションしない。 作業単位とは、内部的に一貫していなければならないもの、例えば一つの商品のページ分割されたレビュー、一つの検索フロー、一つのアカウントのセッションなどだ。それをきれいにマッピングする:
one worker = one unit of work = one sid = one exit IP (for its TTL)セッションIDはランダムではなく作業単位から導出することで、動作を再現可能にし、リトライがアイデンティティを維持するか変更するかを意図的に決められるようにする:
def session_id(job_id: str, attempt: int = 0) -> str: # Same job → same identity. Bump `attempt` to deliberately get a new IP. return f"{job_id}-a{attempt}"
def proxy_for(job_id, attempt=0, country="us", ttl=600): sid = session_id(job_id, attempt) user = f"{USER}-country-{country}-sid-{sid}-ttl-{ttl}" url = f"http://{user}:{PASS}@p.shifter.io:443" return {"http": url, "https": url}この単一のattemptパラメータは、実際にアーキテクチャ上の重要な仕事をしている。それは「異なるアイデンティティでリトライする」ことを、偶然ではなく、第一級の意図的な操作にしているのだ。
レイヤー2: 同時実行数アーキテクチャ、そしてそれがホストごとである理由
同時実行数の制約は、ほとんどの場合あなたのプロキシプランではない(同時接続数は通常制約にならない)。制約となるのはターゲットが何を許容するかである。つまり、単一のグローバルな同時実行数の上限は誤った形なのだ。それは一つの寛容なターゲットが一つの脆弱なターゲットを飢えさせることを許し、脆弱なターゲットがパイプライン全体をスロットルすることを許してしまう。
グローバルにではなく、ホストごとに制限する:
import asynciofrom collections import defaultdict
# One semaphore per target host, tuned to what that host tolerates_limits = {"tough-site.example": 4, "open-site.example": 32}_sems = defaultdict(lambda: asyncio.Semaphore(8)) # sensible defaultfor host, n in _limits.items(): _sems[host] = asyncio.Semaphore(n)
async def fetch(client, url, host): async with _sems[host]: # backpressure applied per host return await client.get(url, timeout=30)これによって得られる性質は2つある。分離性: 遅い、あるいは敵対的なターゲットが、すべてのワーカーを消費することができなくなる。チューニング可能性: 気にしないターゲットでは同時実行数を上げ、ブロックしてくるターゲットでは下げる、ということを独立して行える。
そして、数値を無理に上げたい衝動には抗おう。ターゲットの許容範囲を超えると、追加の並列性はスループットを買わない。それはブロックを買うのであり、ブロックはリトライという形で、得られた同時実行数以上のコストをあなたに支払わせる(レイテンシの削減がそのトレードオフを扱っている)。
レイヤー3: リトライアーキテクチャ、成否を分ける部分
ほとんどのパイプラインはここで失敗する。単純なfor attempt in range(3): retry()は、ちょっとした悪い午後を、ブロックを増幅し帯域を浪費するリトライストームに変えてしまう。本物のリトライ層は次の4つのことを行う。
1. リトライする前に分類する。 すべての失敗が同じではなく、それぞれに対する適切な応答も異なる:
| 失敗 | 例 | 正しい応答 |
|---|---|---|
| トランスポート | タイムアウト、コネクションリセット | リトライ、同じアイデンティティで問題ない |
| レート制限 | 429 | 強くバックオフし、そのホスト全体を減速させる |
| ブロック | 403、CAPTCHA、200を返すソフトブロック | 新しいアイデンティティでリトライ、IPを再利用しない |
| 恒久的 | 404、400 | リトライしない、記録して先に進む |
ソフトブロックに注意: CAPTCHAページを返す200は失敗だ。ステータスコードだけで分類すると、ブロックを成功としてカウントしてしまい、パイプラインは静かにゴミで満たされていく(スクレイパーがブロックされる理由)。
2. ジッターを伴ってバックオフする。 ジッターのない指数バックオフは、ワーカーを同期させてサンダリングハードを引き起こし、ターゲットに波状にアクセスを集中させてしまう。常にランダム性を加えること。
3. ブロック時にはアイデンティティを変える。 同じスティッキーIPでブロックをリトライするのは、同じ拒絶を再び求めているだけだ。attemptカウンターを増やしてセッションIDを変え、ゲートウェイに別のエグジットを割り当ててもらう。
import random, asyncio
async def fetch_with_policy(client, url, job_id, host, max_attempts=4): for attempt in range(max_attempts): proxies = proxy_for(job_id, attempt=attempt) # new identity per attempt try: r = await client.get(url, proxies=proxies, timeout=30) kind = classify(r) # ok | ratelimit | block | permanent if kind == "ok": return r if kind == "permanent": return None # don't waste attempts if kind == "ratelimit": await slow_down(host) # widen the host's pacing except (asyncio.TimeoutError, ConnectionError): pass # transport: retry backoff = (2 ** attempt) + random.uniform(0, 1) # jitter, always await asyncio.sleep(backoff) await dead_letter(job_id, url) # park it, don't block the pipeline return None4. 潔く諦める。 N回試行した後は、そのアイテムをデッドレターキューに入れて先に進む。無制限のリトライはジョブを救わない。それは一つの失敗を、すでにあなたを拒絶しているターゲットに対する持続的な負荷に変換するだけだ。
規模が拡大したら構築しておく価値のある2つのパターンがさらにある。ホストごとのサーキットブレーカー(成功率が崩壊したら、無理に送り続けるのではなくしばらく送信を止める)と、冪等な作業アイテム(リトライがいつでも安全に実行できるようにする)だ。
リファレンス構成
まとめると、パイプラインは次のような形になる:
work queue ↓per-host limiter (semaphore + pacing) ↓worker → session id (sid) → gateway → target ↓classify response ├── ok → validate content → emit ├── ratelimit → slow host → backoff + requeue ├── block → new identity → backoff + requeue └── permanent → record, drop ↓ (after N attempts)dead-letter queueすべての矢印は、そうしなければ暗黙的に、かつまずい形で行っていたであろう決定だ。明示的にすることで、システムは崩壊するのではなく、優雅に劣化していく。
観測性: これを盲目に運用することはできない
最低限計測すべきもの: ホストごとの成功率(ステータスコードではなくコンテンツ検証を伴うもの)、p50/p95レイテンシ、リトライ率と失敗タイプの内訳、そして成功したレコードあたりのバイト数。この4つが、どこに手を打つべきかを教えてくれる。あるホストでリトライ率が上昇しているなら、そのホストの同時実行数を絞るべきだというサインだ。レコードあたりのバイト数が上昇しているなら、パースしていないペイロードを引っ張ってきているということだ(帯域コストの削減)。計測方法はプロキシの速度、成功率、位置精度をテストする方法にある。
避けるべきアンチパターン
- ゲートウェイの上にIPローテーターを構築すること。 冗長であり、プロバイダー自身のバランシングよりも情報に劣る。
- 単一のグローバルな同時実行数の上限。 無関係なターゲットを結合してしまう。ホストごとに制限すること。
- 同じスティッキーIPでブロックをリトライすること。 同じIPからは同じ答えしか返ってこない。
- ジッターのないバックオフ。 ワーカーを波状に同期させてしまう。
- 無制限のリトライ。 ブロックを増幅し、帯域を浪費し、避けられない結果を遅らせるだけだ。
- 200を成功として扱うこと。 ソフトブロックも200を返す。コンテンツを検証しなければ、データは静かに腐っていく。
- フローの途中でローテーションすること。 複数ステップのセッション中にIPを変えることは検出のシグナルになる。作業単位の境界でローテーションすること。
FAQ
プロキシのロードバランシングは自分で構築する必要があるか? IPレベルでは必要ない。ゲートウェイがサーバー側でプールからエグジットを割り当てるため、あなたの側でのIPローテーターや健全性テーブルは冗長だ。その上のレイヤー、つまりアイデンティティの単位、ホストごとの同時実行数、リトライポリシーを設計すればよい。
同時にいくつのリクエストを実行すべきか? それはあなたのプランではなくターゲット次第だ。そのホストが許容するものに基づいてホストごとに上限を設定し、それぞれ独立してチューニングすること。その点を超えると、より多くの並列性はブロックを生むだけで、スループットは生まない。
リトライは同じIPを使うべきか、新しいIPを使うべきか? 失敗の種類による。トランスポートエラー(タイムアウト)は同じアイデンティティでリトライして構わない。ブロックとCAPTCHAは新しいアイデンティティ、つまりセッションIDを変えてリトライすべきだ。拒絶されたのはそのIPだからだ。
リトライがブロックを悪化させないようにするにはどうすればよいか? 失敗を分類し、ジッターを伴う指数バックオフを行い、試行回数の上限を設け、成功しないものはデッドレターに送り、ホストごとのサーキットブレーカーを追加すること。無制限で分類されていないリトライは、小さなブロックの問題が大きな問題になる典型的な経路だ。
プロキシパイプラインで何を監視すべきか? ホストごとの成功率(コンテンツ検証を伴うもの)、p50/p95レイテンシ、失敗タイプの内訳を伴うリトライ率、成功したレコードあたりのバイト数。この4つが、修正すべきほぼすべての問題を浮かび上がらせる。
結論
ゲートウェイの上では、プールのロードバランシングはあなたの問題ではない。それが自分の問題だと思い込むと、チームは間違ったものを構築してしまう。収集パイプラインが実際にスケールするかどうかを決めるアーキテクチャは、3つの決定に宿っている。何が一つのアイデンティティを構成し、どの作業がそれに属するか。各ターゲットがどれだけの同時実行数を許容するか。そして失敗がどのように分類され、バックオフされ、アイデンティティを変えられ、最終的に見放されるか。これらを正しく実装し、計測すれば、パイプラインは負荷の下で崩れるのではなく、優雅に劣化していく。
残りはゲートウェイが担ってくれる。これを構築しているなら、residential gatewayはユーザー名を通じてローテーションとスティッキーセッションを提供してくれるので、あなたのアーキテクチャはプロキシ管理層ではなく、あなたのコードの中に留まる。そして、プールの品質が、そのリトライパスがそもそもどれくらいの頻度で行使されるかを決める(IPレピュテーション)。大規模な検討事項については大規模スクレイピングに最適なレジデンシャルプロキシネットワークを参照してほしい。また、価格ページには、あなた自身のワークロードに対してこの設計をテストするためのGBあたりのプランが掲載されている。