レジデンシャルプロキシ

レジデンシャルプロキシによるレート制限とリクエストスロットリング

大規模なプロキシプールがあっても、レート制限を免除されるわけではなく、適用される場所が変わるだけです。ここでは、ターゲットごと、IPごと、そしてフリート全体でリクエストの間隔を調整する方法を紹介します。

Matt Brown

Matt Brown

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

レート制限はIPごとに課される、レジデンシャルIPプールなら多数のIPを使える、だからレート制限はもう関係ない。多くのチームを引っかけてしまう、魅力的だがまちがった論理だ。最初の2つの節は正しく、結論はまちがっている。そしてこの2つの間のギャップにこそ、驚くほど多くのブロックされたスクレイパーが存在している。

プールが変えるのは制限がどこで発動するかであって、制限そのものの有無ではない。単一IPの構成とは違う軸に沿ってペース配分をする必要があることに変わりはなく、人々が見落としがちなのはまさにその軸であり、それが原因で捕まる。ここでは、リクエストがローテーティングプロキシのプールに分散されている場合、スロットリングが実際にはどう機能するかを説明する。

1つではなく3つの制限

プールを介してスクレイピングする場合、少なくとも3つの独立した上限が同時に適用され、スループットはそのうちどれに最初に達するかで決まる。

IPごと、対象ごと。 これは古典的なものだ。どんな単一のアドレスも、特定のサイトに送れるリクエスト数には限りがあり、それを超えるとサイトはスロットリングまたはブロックを行う。ローテーションはこの制限を下回るために存在し、それこそがロードバランシングで説明されているように、プール全体に負荷を分散させることの意義そのものだ。

対象ごと、合算での。 これは人々を驚かせるものだ。サイトはアドレスごとにカウントするだけではなく、自身への総流入負荷も把握しており、特にリクエストがタイミング、パス、挙動を共有している場合、多数のアドレスにまたがる協調的なパターンを認識できる。50個のIPがそれぞれ毎秒2リクエストを送っていれば、1つのオリジンには毎秒100リクエストが到達していることになり、どれだけアドレスをローテーションしても、それが通常のオーガニックトラフィックに見えることはない。アンチボットシステムはますますこのレベルで判断するようになっている。

自分自身のキャパシティ。 実際に維持できる並行性、つまり開いている接続数、ワーカースレッド数、メモリ、そしてレジデンシャルの遅延が直接接続より高いという事実。これにより、固定のスレッド数から得られる毎秒リクエスト数は、想定より少なくなる。

実務上の帰結として、スロットルはグローバルにではなく対象ごとに表現する必要がある。単一のグローバルレート制限は、もっと多く受け入れられる50個のサイトを飢えさせるか、耐えられない1つのサイトを叩きのめすかのどちらかになる。

自分の野心ではなく対象から始める

スロットリングのコードを書く前に、対象が実際にどこまで許容するかを調べること。推測は、無駄に遅いジョブか、ブロックされるジョブのどちらかを生む。

サイトが出すシグナルを読み取ること。429は明示的に「速すぎる」という表明だ。Retry-Afterヘッダーは、どれだけ待つべきかをサイトがそのまま教えてくれるものであり、それに従うことは正しくもあり、試行錯誤で答えを見つけるよりはるかに安上がりだ。一部のAPIはドキュメントに制限を公開しているか、残りクォータをヘッダーで返す。そしてサイトのrobots.txtにクロール遅延の指示があれば、それは尊重に値する明示的な希望だ。

何も公開されていない場合は経験的に較正すること。控えめに始め、徐々に増やし、ステータスコードではなく検証済みの成功率を見ること。サイトは負荷がかかると拒否する前に劣化することが多く、200ステータスのチャレンジページは、単純なカウンターには成功に見えるからだ。これはブロックまたは偽コンテンツの検出で扱われている通りだ。成功率が下がり始める地点があなたの実際の上限であり、そこで運用するのではなく、その下で運用したい。

スロットルの実装

対象ごとのトークンバケットが標準的なツールであり、ゼロから書けるほどシンプルだ。トークンは選んだレートで補充され、各リクエストは1つを消費し、バケットが空のときリクエストは待機する。これにより、厳格な「N秒ごとに1リクエスト」の遅延よりも実際のトラフィックの挙動に近い、一定の平均値と制御されたバースト許容量が得られる。

import time, threading

class TargetLimiter:
    """Token bucket, one instance per target host."""
    def __init__(self, rate_per_sec, burst=5):
        self.rate, self.capacity = rate_per_sec, burst
        self.tokens, self.updated = burst, time.monotonic()
        self.lock = threading.Lock()

    def acquire(self):
        while True:
            with self.lock:
                now = time.monotonic()
                self.tokens = min(self.capacity,
                                  self.tokens + (now - self.updated) * self.rate)
                self.updated = now
                if self.tokens >= 1:
                    self.tokens -= 1
                    return
                wait = (1 - self.tokens) / self.rate
            time.sleep(wait)               # sleep outside the lock

LIMITS = {                                  # per target, tuned per target
    "shop.example.com":  TargetLimiter(2.0),
    "search.example.com": TargetLimiter(0.5),
}

def fetch(url, host, proxies):
    LIMITS[host].acquire()
    return requests.get(url, proxies=proxies, timeout=20)

本番環境で重要になる改良点が2つある。まず、リクエストが正確な間隔で発生しないようジッターを加えること。完全に規則的な間隔はそれ自体が機械的なシグナルであり、さらにワーカー同士を波状に同期させてしまう。次に、リミッターをプロセスごとではなくワーカー間で共有すること。そうしなければ、10個のプロセスがそれぞれ礼儀正しく毎秒2リクエストずつ行っていても、合計では毎秒20リクエストになってしまう。分散構成では、これはRedisなどでの共有カウンターを意味し、Kubernetesでのスクレイピングのスケーリングが解決しなければならないのと同じ問題だ。

並行性はもう半分の要素

レートと並行性は別のダイヤルであり、どちらにも制限が必要だ。レートはリクエストを開始する頻度を制御し、並行性は同時に処理中のリクエスト数を制御する。レート制限が控えめでも並行性が無制限なジョブは、オリジンが遅くなった瞬間に、そのオリジンへ数百の同時接続を開いてしまう。遅いレスポンスはリクエストの滞留を引き起こすからだ。

対象ごとの並行性はセマフォで上限を設け、自分のマシンが開ける数ではなく、対象が許容する数に合わせてサイズを決めること。プロキシ側では、同時接続数は一般に自分の制約にはならないため、対象側の許容量こそが制約だという事実を忘れがちになる。それが示唆するアドレスの分散台数の問題は、実際に必要なプロキシIP数で扱われている。

適応的スロットリングは固定値より優れている

静的なレートは、いずれ間違いになる推測にすぎない。サイトは許容度を変え、負荷がかかると遅くなり、ピーク時間帯には制限を厳しくする。より堅牢なパターンは、観測結果に基づいて調整することであり、加算的増加・乗算的減少というスタイルだ。すなわち、健全な間はゆっくりとレートを上げ、負荷の兆候が最初に見えた時点で急激に下げる。

減少のトリガーは、単一のステータスコードではなく複合的なシグナルにすること。429、チャレンジページの割合の上昇、レイテンシの上昇、あるいは検証済み成功率の低下だ。そのうえで、以前のレートに一気に戻すのではなく徐々に回復させること。ブロック直後にフルスピードへ即座に戻る挙動は、それ自体が識別可能なパターンだからだ。

class AdaptiveRate:
    def __init__(self, start=2.0, floor=0.2, ceiling=10.0):
        self.rate, self.floor, self.ceiling = start, floor, ceiling

    def ok(self):                       # healthy response
        self.rate = min(self.ceiling, self.rate * 1.02)   # creep up

    def strained(self):                 # 429, challenge, timeout, latency spike
        self.rate = max(self.floor, self.rate * 0.5)      # back off hard

スロットリングがローテーションおよびリトライと交わるところ

明示的に押さえておくべき相互作用が3つある。ここを誤ると、スロットルの意味がなくなるからだ。

レートのシグナルにローテーションで応じてはいけない。 429は「速度を落とせ」という意味だ。同じペースを維持するために新しいアドレスに切り替えることは、1つのスロットルされたアドレスをプール全体の焼失に変えてしまう挙動であり、まさにトラフィックを単に熱心なだけではなく回避的に見せてしまう原因だ。シグナルがレートについてのものなら待機し、シグナルがアドレスについてのものならローテーションすること。

リトライはリミッターに従わなければならない。 リトライも別のリクエストであり、他のリクエストと同様にトークンを取得しなければならない。さもないと、エラー経路が構築したペース配分をひそかに迂回してしまう。リトライの嵐は、スロットルされたジョブがブロックされたジョブに変わる最も一般的な原因であり、これはリトライとバックオフで扱われているテーマだ。

スティッキーセッションは負荷を集中させる。 複数ステップのフローで1つのアドレスを保持し続けるということは、そのアドレスがシーケンス全体を担うことを意味する。そのため、負荷が自然に分散するローテーティングの作業よりも、スティッキーセッション内ではIPごとのペース配分がより重要になる。

礼儀正しさは自己利益である

これらすべてをコンプライアンス上の義務と読むのは簡単だが、インセンティブは同じ方向を向いている。ペース配分は依存しているアドレスのレピュテーションを保ち、成功率を高く保つことでチャレンジページに帯域を無駄に払わずに済み、積極的な収集がより厳しい防御を招き、それが結果として自分自身を含む誰にとっても対象を扱いにくくするというエスカレーションの循環を避けられる。次の四半期の自分も含めてだ。サイトが表明している制限、robotsの指示、利用規約を尊重することは、正しいことであると同時に安上がりなことでもある。

結論

プールはレート制限をなくすのではなく、それを移動させるだけだ。単一の数値はあなたが扱うすべてのサイトにとって間違っているのだから、グローバルにではなく対象ごとにスロットルすること。それぞれの対象の実際の許容度を、そのサイト自身のシグナルから見つけ出し、Retry-After429を障害物ではなく指示として尊重し、ステータスコードではなく検証済みの成功率を基準に較正すること。共有トークンバケットとジッターで実装し、並行性はレートとは別に上限を設け、素早く劣化して緩やかに回復するよう制限を適応的にすること。そして相互作用を整理しておくこと。ペース配分のシグナルにローテーションで応じない、リトライは必ずリミッターを経由させる、スティッキーセッション内ではより強くペース配分する。うまくやれば、スロットリングはプールを消耗させるのではなく、プールが実際のキャパシティで動作することを可能にするものになる。

プールそのものが作業の余地を与えてくれる。レジデンシャルプロキシは、適切にペース配分されたジョブを、国や都市を指定できる多数の実在の家庭グレードのアドレスに分散させ、GBあたりの価格設定により、うまくペース配分して必要な分だけ取得するジョブは、叩きまくってリトライを繰り返すジョブよりも安く済む。

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

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

始める