スクレイピング

レジデンシャルプロキシマネージャーの構築:ローテーション、ヘルスチェック、リトライ

どの出口を使うか、いつそれを引退させるか、どう回復するかを決めるロジックは、一つのコンポーネントにまとめるべきである。ここでは、その設計方法と必要な状態について説明する。

Chris Collins

Chris Collins

2026年8月26日 · 2 分で読める

ほとんどのスクレイピングコードベースは、誰かが設計するかどうかにかかわらず、プロキシマネージャーへと成長していく。最初はプロキシURLを組み立てるだけのヘルパーだったものが、誰かがリトライを追加し、スティッキーセッションが必要なターゲット向けの特別処理が加わり、失敗し続けるアドレスへの叩き過ぎを止めるカウンターが追加される。18か月後にはそのロジックが4つのモジュールに散らばり、同じセッションでリクエストが2回失敗したときに何が起こるのか、誰にも説明できなくなっている。

これは意図的に構築する価値がある。なぜなら、そこに関わる判断は本質的に共有されるものだからだ。このリクエストはどの出口を使うのか、その出口は健全か、失敗したらどうするか、そしてどうペース配分するか。ここでは、そのコンポーネントの設計と、それが保持すべき状態について述べる。

マネージャーが責任を持つ範囲

境界は厳密に引くべきだ。プロキシマネージャーは、リクエストがインフラをどう出ていくか、そして失敗したときに何をするかを決定する。ページを解析することはなく、商品リストがどのような見た目かを知る必要もなく、どのURLを訪れるかを決めることもない。ビジネスロジックについて無知であり続けることが、あらゆるジョブがこれを共有できる理由になる。

そうなると責任は4つに絞られる。ポリシーに従って各リクエストの出口を選ぶこと、渡した出口の健全性を追跡すること、何かが失敗したときに障害処理を適用すること、そして個々の呼び出し元がターゲットを単独で圧倒できないようペース配分を強制すること。それ以外はすべて他の場所に属する。

選択:ポリシーはグローバルではなくジョブ単位

マネージャーがまず必要とするのは、単一のグローバルモードではなく、呼び出し元が求める出口の「種類」という概念だ。実際には3種類ある。

ローテーティングは、各リクエストが新しい出口を得るものだ。これは一括収集におけるデフォルトであり、セッション識別子を省略することで表現され、これはローテーションモデルがそう使われるように設計されている理由でもある。

スティッキーは、呼び出し元が一連の処理のために1つの出口を保持するもので、マネージャーはそのシーケンスの間、同じセッション識別子を返し続け、終了時にそれを解放しなければならない。トレードオフについてはスティッキー対ローテーティングで述べている。

ジオピン留めは、出口が特定の国や都市になければならないもので、マルチマーケットの作業においては前述の2つとは直交する。つまり、あるジョブはドイツでローテーティング、あるいはシカゴでスティッキーになりうる。

これらをグローバル設定ではなく、リクエストスコープのリースとしてモデル化し、呼び出し元が必要なものを要求し、マネージャーがそれを満たすようにする。

from dataclasses import dataclass
from typing import Optional
import itertools, time

@dataclass
class Lease:
    country: str
    session: Optional[str] = None          # None means rotate per request
    ttl: Optional[int] = None              # only meaningful with a session

    def username(self, customer="USERNAME"):
        parts = [f"customer-{customer}", f"country-{self.country}"]
        if self.session:
            parts.append(f"sid-{self.session}")
            if self.ttl:
                parts.append(f"ttl-{self.ttl}")
        return "-".join(parts)

    def proxies(self, password="PASSWORD", host="p.shifter.io:443"):
        url = f"http://{self.username()}:{password}@{host}"
        return {"http": url, "https": url}

マネージャーの仕事はLeaseを生成し、それを呼び出し元に渡し、その後に何が起こるかを観察することだ。

健全性:アドレスではなくセッションを追跡する

ここが、自前で作られたマネージャーの多くが間違える部分だ。プールされたゲートウェイ上では、良いか悪いかをマークするIPアドレスのリストを保持することはない。なぜなら、それらを自分で選んだわけではなく、保持し続けることもできないからだ。追跡できるのは、セッションの健全性と、ルート、つまり国とターゲットの組み合わせの集計された健全性だ。

したがって、2つのものを維持する。アクティブなスティッキーセッションごとに、連続した失敗とチャレンジ応答の小さな記録を持ち、明らかに悪化したセッションを引退させ、新しい識別子に置き換えられるようにする。ルートごとには、ローリングの成功率を持ち、これによって1つの運の悪いセッションではなく、国とターゲットの組み合わせ全体が劣化しているかどうかがわかる。

この世界におけるヘルスチェックは、定期的なpingではない。出口にpingを送っても、それがテストエンドポイントに到達できることがわかるだけであり、それは求めている問いではない。問いは、それがターゲットに到達し、実際のコンテンツを取得できるかどうかだ。したがって、受動的なヘルスチェックを使う。すべての実際のリクエストがヘルスチェックであり、その検証済みの結果が記録を更新する。能動的なプロービングは、各ターゲットに対する小さく安価なカナリアとしてのみ価値があり、「このターゲットは全員にとってダウンしている」のか「自分たちのルートが劣化している」のかを見分けるのに役立つ。

重要なのは、ステータスコードではなく、検証済みの応答に基づいて健全性を判断することだ。200を返すチャレンジページはヘルスの観点では失敗であり、それを成功としてカウントするマネージャーは、ターゲットが既に判断を下したセッションを使い続けてしまう。これはブロックまたは偽のコンテンツの検出と同じ検証規律だ。

class RouteHealth:
    def __init__(self, window=50):
        self.window, self.results = window, []

    def record(self, ok: bool):
        self.results.append(ok)
        if len(self.results) > self.window:
            self.results.pop(0)

    @property
    def success_rate(self):
        return sum(self.results) / len(self.results) if self.results else 1.0

    @property
    def degraded(self):
        return len(self.results) >= 10 and self.success_rate < 0.7

障害処理:分類してから行動する

マネージャーは失敗が何を意味するかの判断を所有し、それがそのロジックがジョブごとに再発明されることを防ぐ。3つのクラスでカバーできる。

レート信号は減速すべきことを意味する。429やRetry-Afterだ。正しい対応は待つことであり、特に新しい出口に切り替えないことだ。同じペースを継続できるようにするためであり、これがスロットリングされたルートを焼き切ってしまう原因になる。

アイデンティティ信号は、この出口がこのターゲットに対しては終わったことを意味する。ブロックページ、持続的な403、あるいは1つのセッションでの繰り返されるチャレンジだ。正しい対応は、セッションを引退させ、新しいものを取得して続行することであり、これがフェイルオーバーのパターンだ。

終端エラーは停止することを意味する。404、不正なURL、あるいは407だ。これは認証の問題であり、どのリトライでも同様に失敗し続けるものであり、ループさせるのではなく大きく失敗を知らせるべきだ。これは407と認証情報エラーの修正で扱われている。

それ以外はすべて一時的なものであり、リトライとバックオフに従い、試行回数の上限に制約された、ジッター付きの指数バックオフを受ける。重要なアーキテクチャ上のポイントは、リトライがマネージャー内に存在することで、すべての呼び出し元が同じ挙動を継承し、マネージャーが呼び出し元をまたいでリトライ予算を強制できることだ。各ジョブが同じ苦戦しているターゲットに向けて独立してリトライすることを防げる。

ついでにルートごとのサーキットブレーカーも追加する。あるルートの健全性が閾値を下回ったら、クールダウンの間そこへの送信を止め、その後回復をテストするために少量だけ通す。これにより、こちらの集中攻撃からターゲットを保護し、応答していないサイトに対する失敗の蓄積からアカウントを保護できる。

ペース配分もここに属する

マネージャーはすべてのアウトバウンドリクエストを見るため、ターゲットごとのレート制限と同時実行数の上限を強制する自然な場所となる。これはレート制限とリクエストスロットリングで説明されているメカニズムだ。ここに置くことには特定の利点がある。リトライは他のあらゆるリクエストと同じ経路を通るため、自動的にリミッターを尊重する。ペース配分を回避するリトライこそが、苦戦しているターゲットをブロックされたターゲットに変えてしまう原因だ。

すべてをまとめる

全体の表面は小さく、それがポイントだ。

class ProxyManager:
    def __init__(self, limiter_factory, health_factory):
        self.limiters = {}          # host -> TargetLimiter
        self.health = {}            # (country, host) -> RouteHealth
        self.limiter_factory, self.health_factory = limiter_factory, health_factory

    def get(self, url, host, country, session=None, attempts=4):
        route = self.health.setdefault((country, host), self.health_factory())
        limiter = self.limiters.setdefault(host, self.limiter_factory(host))
        sid = session
        for attempt in range(attempts):
            if route.degraded:
                raise RouteUnavailable(country, host)      # circuit open
            limiter.acquire()                              # pacing, retries included
            lease = Lease(country=country, session=sid, ttl=600 if sid else None)
            outcome = self._send(url, lease)               # returns (klass, response)
            route.record(outcome.klass == "ok")
            if outcome.klass == "ok":
                return outcome.response
            if outcome.klass == "terminal":
                raise TerminalError(url, outcome.response)
            if outcome.klass == "identity" and sid:
                sid = new_session_id()                     # retire, do not reuse
            if outcome.klass == "rate":
                time.sleep(outcome.retry_after or backoff(attempt))
            else:
                time.sleep(backoff(attempt))               # transient
        raise Exhausted(url)

設計上の注意点が2つある。マネージャーはNoneを返すのではなく、レスポンスを返し型付きのエラーを送出するため、呼び出し元は失敗を静かに空のデータとして扱うことができない。そして、終端エラーを決して飲み込まない。認証や設定の問題は、見えないところでリトライされ続けるのではなく、ジョブを停止させるべきだからだ。

運用上の懸念

観測可能にする。 マネージャーはすべてのリクエストを見るため、ルートごとの成功率、リトライ比率、リクエストあたりのバイト数を発信すべき場所であり、これはまさにプロキシKPIにおける指標が組み立てられるデータであり、パイプライン監視が消費するものだ。

プロセス間で状態を共有する。 プロセスごとのマネージャーは、実効レートをワーカー数倍にし、健全性の追跡を分断してしまう。分散デプロイメントでは、リミッター、ブレーカー、ルートの健全性は共有ストレージに属するべきであり、これはKubernetesでのスケーリングと同じ考慮事項だ。

認証情報をそこから排除する。 マネージャーはユーザー名を組み立てるが、パスワードは設定に保持するのではなく、シークレットストアから読み込むべきであり、ローテーションはコード変更ではなくデプロイメントのステップであるべきだ。

過度に抽象化しない。 仮定のプロバイダー向けのプラグインアーキテクチャは避ける。1つのゲートウェイを明確に表現する方が、使っていないベンダーに対する抽象化レイヤーよりも理解しやすい。

結論

プロキシマネージャーは、リクエストがシステムをどう出ていくか、失敗したときに何が起こるかを決定するコンポーネントであり、これらの判断を一元化することが、同じロジックがジョブごとに一貫性なく再実装されることを防ぐ。呼び出し元にリクエストスコープのリースを与え、ローテーション、スティッキー性、地理がグローバルではなくジョブ単位になるようにする。個々のアドレスを管理しているふりをするのではなく、セッションとルートのレベルで健全性を追跡し、検証済みの応答に基づいて判断することで、チャレンジページがそれが実際にそうである失敗としてカウントされるようにする。障害の分類は1か所で所有する。レート信号では待ち、アイデンティティ信号ではセッションを引退させ、終端エラーでは大きく失敗を知らせ、それ以外のすべてではジッター付きでバックオフし、ルートごとのサーキットブレーカーを設ける。ペース配分を同じ経路に置き、リトライがそれを回避できないようにする。そしてそこからメトリクスを発信する。なぜならそれはすべてを見る唯一のコンポーネントだからだ。

その下にはレジデンシャルプロキシネットワークがあり、国、都市、セッション、TTLがすべてユーザー名で表現される単一のゲートウェイだ。これが、このようなマネージャーを統合プロジェクトではなく少量のコードにしてくれる理由であり、GB単位の価格設定によって、それがもたらす効率が請求書に直接反映される。

始める準備はできていますか?

Shifterのレジデンシャルプロキシをお試しください。IP 205M+件、195+カ国、$0.75/GBから。

始める