パーサーが単一のサーバーから複数のワーカーのクラスターに移行する際、新たな問題が発生します — 複数のマシンが同時に同じプロキシプールを使用し始めるのです。調整がないと、リクエストにおけるIPの重複、1つのアドレスに対する制限の超過、大量の禁止につながります。この記事では、サーバー間でプロキシを適切にシャーディングする方法、どの分配スキームが最も効果的か、そしてPythonとRedisを使用してこれを実装する方法を解説します。
分散型パーサーにおけるプロキシの衝突の理由
典型的な状況を想定してみましょう: 500のプロキシプールと3つのサーバーがあり、それぞれが50の並行ワーカーでWildberries、Ozon、またはAvitoのパーシングを実行しています。各サーバーが調整なしに共通のリストからプロキシを取得すると、異なるマシン上の2つのワーカーが同時に同じIPから同じドメインにリクエストを送信する可能性があります。アンチボットシステムにとって、これは1つのアドレスからの異常に高いリクエスト頻度として見え、IPは数分で禁止されてしまいます。
二つ目の問題は、負荷の不均一な分配です。プロキシが単に「適当に」分けられると、一つのサーバーが最も速く新鮮なIPを取得し、別のサーバーが既に悪化した評判の残りを取得する可能性があります。その結果、サーバー間でのパーシング速度が大きく異なり、エラー率も異なるでしょう。
三つ目の問題は、状態の喪失です。あるサーバーでプロキシが禁止されると、他のサーバーはそれを知らず、同じプロキシを使用し続け、最終的に禁止されます。プロキシのステータスを中央で管理しない限り、各サーバーは「アメリカを再発見」することになり、無駄にリクエストを消費します。
これら3つの問題の解決策は、単一の調整メカニズムを導入することです: 中央集権的な状態ストレージ(Redis、PostgreSQL、Consul)を介して、または事前にプロキシプールを決定的に分割し、サーバー間での範囲の重複を避けることです。
分散型パーサーのアーキテクチャ
プロキシを分割する前に、クラスターの全体的なアーキテクチャを決定する必要があります。基本的なアプローチは2つあります。
静的スキーム (shared-nothing). プロキシプールは事前にサーバー間で分割され、起動時に一度だけ行われます。各サーバーは自分の重複しないセグメントを受け取り、他のサーバーにアクセスすることなく自律的に作業します。利点は、最大のシンプルさと耐障害性です: 一つのサーバーがダウンしても他のサーバーには影響しません。欠点は、一つのサーバーで半分のプロキシが禁止された場合、隣のサーバーから新しいIPを「借りる」ことができないことです。
動的スキーム (shared-state). すべてのサーバーは共通のストレージ(通常はRedis)にアクセスし、各プロキシのステータスを保持し、リクエストに応じてロックをかけて提供します。利点は、プールの均等な利用と禁止に対する即時反応です。欠点は、ストレージが単一の障害点となり、その利用不可時のための別のロジックが必要です。
実際には、マーケットプレイスやソーシャルネットワークのパーシングには、ハイブリッドアプローチがよく選ばれます: Redisのブローカーが短時間のロックでプロキシを提供し、Redisが利用できない場合、サーバーは一時的にローカルのフォールバックプールに切り替わります。このアプローチは、静的スキームの信頼性と動的スキームの柔軟性を組み合わせています。
サーバー間でのプロキシプールの分割方法
プールの分割に適用できる具体的なアルゴリズムを考えてみましょう。
単純なモジュールによる分割 (round-robin sharding). プロキシに0からN-1までの番号を付け、番号iのサーバーはindex % サーバー数 == iのすべてのプロキシを取得します。シンプルですが、サーバーの追加や削除に弱く、マシンの数が変わるとほぼすべてのプールが再分配されます。
一貫性のあるハッシュ化. 各サーバーと各プロキシにはハッシュリング上の位置が割り当てられます(通常はIPアドレスまたはサーバーIDのハッシュに基づきます)。プロキシはリング上の最も近いサーバーに固定されます。サーバーの追加や削除時には、プロキシのごく一部だけが再分配され、プール全体が再分配されることはありません — これは動的にスケールするクラスター(例えば、Kubernetesのオートスケーリング)にとって重要な利点です。
ロック付きの中央集権的ブローカー. プロキシをサーバーに厳密に固定する代わりに、すべてのサーバーは共通のサービス(Redis、RabbitMQ)から「空いている」プロキシをリクエストし、一時的なロックをかけて提供します。使用後、プロキシはプールに戻ります。これは最も柔軟なオプションであり、特にサーバーの負荷が不均一な場合に適しています。
ドメインまたはタスクによる分割. パーサーが複数のソース(例えば、Wildberries、Ozon、Avitoを同時に)で作業する場合、プロキシをサーバー間で分割するだけでなく、ソース間でも分割することが理にかなっています — 同じIPが複数のプラットフォームで同時に表示されないようにするためです。これにより、IPがアンチフロードシステムの共通ブラックリストに載るリスクが減ります。
表: プロキシのシャーディング方法の比較
| 方法 | 複雑さ | スケーリングの耐性 | 適用時期 |
|---|---|---|---|
| モジュールによる分割 | 低い | 悪い | 固定数のサーバー |
| 一貫性のあるハッシュ化 | 中程度 | 良好 | クラスターのオートスケーリング |
| 中央集権的ブローカー | 中程度-高い | 優れた | 不均一な負荷、高トラフィック |
| ドメインによる分割 | 低い | 中程度 | 複数のプラットフォームを同時にパーシング |
Redisによるプロキシブローカーの実装
大多数の分散型パーサーにとって最も実用的なオプションは、Redisによる中央集権的ブローカーです。Redisは高速で、原子的な操作とTTLを「箱から出して」サポートしているため適しています。アイデアはシンプルです: すべての利用可能なプロキシはRedis Setに格納され、特定のワーカーにプロキシを提供する際に、TTL付きのキーを介してロックされます。
以下は、redis-pyライブラリを使用したPythonでのブローカーの作業例です。これは、ロック付きで空いているプロキシを提供し、使用後に解放し、禁止されたプロキシとしてマークします。
import redis
import time
import random
r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
PROXY_POOL_KEY = "proxy:pool" # すべての作業プロキシのセット
LOCK_PREFIX = "proxy:lock:" # 特定のプロキシのロックプレフィックス
BANNED_PREFIX = "proxy:banned:" # 禁止マークのプレフィックス
LOCK_TTL = 30 # プロキシをロックする秒数
BAN_TTL = 3600 # プロキシを禁止されたと見なす秒数
def load_pool(proxy_list):
"""クラスター起動時に一度だけプロキシリストをRedisにロードします。"""
if proxy_list:
r.sadd(PROXY_POOL_KEY, *proxy_list)
def get_free_proxy(server_id, max_attempts=20):
"""各サーバーがこの関数を呼び出して空いているプロキシを取得します。"""
candidates = list(r.smembers(PROXY_POOL_KEY))
random.shuffle(candidates)
for proxy in candidates[:max_attempts]:
if r.exists(BANNED_PREFIX + proxy):
continue
lock_key = LOCK_PREFIX + proxy
# プロキシが空いている場合、ロックを原子的に設定します
acquired = r.set(lock_key, server_id, nx=True, ex=LOCK_TTL)
if acquired:
return proxy
return None # 空いているプロキシが見つかりませんでした、フォールバックが必要です
def release_proxy(proxy):
"""成功したリクエストの後にプロキシを解放します。"""
r.delete(LOCK_PREFIX + proxy)
def mark_banned(proxy):
"""プロキシをBAN_TTL秒間禁止されたものとしてマークし、ロックを解除します。"""
r.set(BANNED_PREFIX + proxy, 1, ex=BAN_TTL)
r.delete(LOCK_PREFIX + proxy)
r.srem(PROXY_POOL_KEY, proxy) # 一時的にプールから削除します
def return_to_pool_if_recovered():
"""定期的なタスク: 禁止が解除されたプロキシをプールに戻します。"""
for proxy in r.scan_iter(match=BANNED_PREFIX + "*"):
clean_proxy = proxy.replace(BANNED_PREFIX, "")
if not r.exists(BANNED_PREFIX + clean_proxy):
r.sadd(PROXY_POOL_KEY, clean_proxy)
ここでの重要なポイントは、RedisでのSET ... NX EXの使用です。このコマンドは原子的です: 2つのサーバーが同時に同じプロキシをロックしようとすると、成功するのは1つだけで、もう1つは失敗し、リストの次の候補に進むことになります。これにより、共通のプールへの並行アクセス時にサーバー間の衝突が排除されます。
パーサーのワーカーでのブローカーの使用例:
import requests
def fetch_with_proxy(url, server_id):
proxy = get_free_proxy(server_id)
if not proxy:
raise RuntimeError("プールに空いているプロキシがありません")
proxies = {"http": f"http://{proxy}", "https": f"http://{proxy}"}
try:
response = requests.get(url, proxies=proxies, timeout=10)
if response.status_code in (403, 429):
mark_banned(proxy)
else:
release_proxy(proxy)
return response
except requests.exceptions.RequestException:
mark_banned(proxy)
raise
このスキームは簡単にスケールします — 10台でも50台でもサーバーを追加でき、すべてが同じRedisインスタンスにアクセスし、IPの衝突は発生しません。なぜなら、ロックはストレージレベルで原子的であり、個々のサーバーレベルではないからです。
スティッキーセッションとタスクへのプロキシのバインディング
一部のタスクでは、「任意の空いているプロキシ」を単純に提供するだけでは不十分です。パーサーがユーザーの行動をエミュレートする場合 — 例えば、商品カードを順番に閲覧し、カートに追加し、カタログのページを移動する場合 — 重要なのは、同一セッションのすべてのリクエストが同じIPから行われることです。そうでなければ、サイトはセッションの途中で地理やデバイスの急激な変化を検知し、疑わしいものとしてブロックします。
これを実現するために、スティッキーセッションの概念が導入されます: プロキシは単一のリクエストにバインドされるのではなく、特定のタスクまたはユーザーセッションにその寿命の間バインドされます。Redisでは、session:{task_id} -> proxyという形式の別のキーを使用して実装され、TTLはセッションの予想される持続時間に等しくなります。
SESSION_PREFIX = "proxy:session:"
SESSION_TTL = 600 # セッションに10分
def get_sticky_proxy(task_id, server_id):
existing = r.get(SESSION_PREFIX + task_id)
if existing:
return existing
proxy = get_free_proxy(server_id)
if proxy:
r.set(SESSION_PREFIX + task_id, proxy, ex=SESSION_TTL)
return proxy
長いセッションが必要なタスク — 例えば、Wildberriesのカタログを実際の購入者の行動をエミュレートしながら巡回する場合 — では、通常のユーザーにできるだけ似た行動をするプロキシを使用することが重要です。ここでは、レジデンシャルプロキシが適しており、実際の家庭用IPにバインドされており、データセンターのアドレスに比べてマーケットプレイスのアンチボットシステムに疑いを持たれにくいです。
禁止の処理とクラスター内のローテーション
分散システムでは、プロキシの禁止情報がすぐにすべてのサーバーに利用可能になることが重要です。上記の例では、mark_banned関数がこれを担当します — 1つのサーバーがターゲットサイトから403または429を受信すると、プロキシはRedisの共通プールから削除され、他のサーバーは自動的にそれを取得しなくなります。
禁止を特定するためには、HTTPレスポンスコードだけでなく、ページの内容も分析する必要があります — 一部のサイトは200 OKを返しますが、実際のコンテンツの代わりにキャプチャやダミーが表示されることがあります。禁止検出のロジックは通常次のようになります:
- HTTPコード403、429、503 — 明らかなブロックまたはレート制限の兆候。
- キャプチャまたは認証ページへのリダイレクト — リクエスト後のURLを確認します。
- レスポンスサイズの急激な減少または期待されるCSSセレクタの欠如 — ダミーの兆候。
- 接続タイムアウト — プロキシが全く応答しなくなったことを意味します。
禁止が確認された後、プロキシをプールから永遠に削除するのではなく、TTL付きの「検疫」に置くことが理にかなっています(例のように — BAN_TTL)。その後、プロキシは自動的にプールに戻ります。マーケットプレイスやソーシャルネットワークでの多くのブロックは一時的であり、30〜60分後に解除されます。
負荷が均等に分配されているにもかかわらず、禁止が頻繁に発生する場合、通常はプロキシプール自体の品質が低いか、必要なリクエスト量に対してサイズが不十分であることを示しています。このような場合、データセンターのプロキシを使用して、あまり敏感でないタスク(例えば、ページの可用性チェック)に対してプールを拡張し、より高価なレジデンシャルIPを重要なセッションのみに使用することで、全体の禁止率を増加させずに抑えることができます。
プロキシプールの健康状態の監視
複数のサーバーからなるクラスターでは、全体像を失いやすいです: 現在アクティブなプロキシの数、禁止されているプロキシの数、各プロキシの平均応答速度など。監視がないと、パーシングが遅くなった理由を理解するのは難しい — 1つのサーバーがダウンしたのか、利用可能なプロキシがなくなったのか。
Redisや別のデータベースに収集すべき最小限のメトリクスは次のとおりです:
- 現在プール内のアクティブなプロキシの数。
- 禁止されたプロキシの数と、それらが回復するまでの平均時間。
- 過去Nリクエストに対する各プロキシの平均応答時間。
- サーバーおよびプール全体の成功率(success rate)。
- 負荷の分配 — 各サーバーが一定期間に処理したリクエストの数。
これらのメトリクスをPrometheusに収集するために、Redisからデータを読み取り、Grafanaが理解できる形式で提供するシンプルなエクスポーターを使用するのが便利です。これにより、特定の地理やプロキシタイプにおける成功率の低下を迅速に確認し、パーサーが完全に停止する前に新しいIPをプールに追加することができます。
新しいサーバーをクラスターに追加する際の禁止のピークを監視することも重要です — 3台から6台にスケールアップする際に禁止率が急激に増加した場合、問題はシャーディングにある可能性が高いです: 新しいサーバーが古いサーバーとプロキシの範囲で重複し始めました。これは、モジュールによる分割から一貫性のあるハッシュ化に移行する必要があることを示す最初の兆候です。
分散型パーサーの起動チェックリスト
パーサーを複数のサーバーに展開する前に、次の項目を確認してください:
- プールの調整メカニズムが選択されている: 静的分割、一貫性のあるハッシュ化、またはRedisによる中央集権的ブローカー。
- プロキシの原子的なロックが実装されており、同じIPを2つのサーバーが同時に使用することを排除しています。
- ロックと禁止のTTLが設定されており、ワーカーがダウンした際にプロキシが永遠に「ロックされた」状態にならないようにしています。
- ユーザーセッションのエミュレーションが必要なタスクに対して、タスクの全期間にわたるスティッキー・プロキシのバインディングが設定されています。
- HTTPコードだけでなく、レスポンスの内容に基づいて禁止を検出するロジックが設定されています。
- クラスター全体の成功率、アクティブなプロキシ、禁止されたプロキシの監視が有効になっています。
- プロキシプールがタスクの種類に応じて分割されています: データセンターのIPはあまり敏感でないリクエストに、レジデンシャルまたはモバイルプロキシは、実際のユーザーに最大限似た行動が求められるセッションに使用されます。
- 状態ストレージ(Redis)への負荷が確認されています — サーバーの数が多く、リクエストの頻度が高い場合は、単一のインスタンスの代わりにRedisクラスターが必要になることがあります。
結論
パーサーが複数のサーバーに移行する際の衝突の主な原因は、プロキシプールの状態に関する単一の真実のソースが欠如していることです。中央集権的なブローカーが原子的なロックを持つようになると(Redisや同様のストレージ上で)、IPの重複や禁止の非同期問題は自動的に解消されます。次に、クラスターの規模に応じたシャーディング戦略を選択する必要があります: 固定数のサーバーには単純な分割で十分ですが、動的に成長するインフラストラクチャには一貫性のあるハッシュ化を最初から組み込む方が良いでしょう。
あなたの分散型パーサーがマーケットプレイスやソーシャルネットワークで動作している場合、個々のセッションレベルでのトラフィックの信憑性が重要です。タスクの種類によってプールを分割することをお勧めします: 大規模なカタログのスキャンには安価なデータセンターのプロキシが適しており、ユーザーの行動をエミュレートするセッションには、レジデンシャルプロキシを使用することで、フロードシステムに対する禁止率を大幅に低下させることができます。