ナレッジ

Kubernetesでレジデンシャルプロキシのスクレイピング作業をスケールする方法

Kubernetes上でスクレイピングを実行する: プロキシ認証情報をSecretsとして管理し、Downward APIでPodごとのセッションIDを付与し、キュー駆動のオートスケーリングとグレースフルシャットダウンを行う。

Chris Collins

Chris Collins

2026年7月28日 · 2 分で読める

スクレイピングのワークロードは、理想的なKubernetesテナントに近い存在だ。並列化が容易で、タスクごとにほぼステートレスであり、オートスケーリングを求めるようなバースト性を持つ。コンテナ内でプロキシを動かすところまで進んだら、実運用レベルでデータ収集を行う人にとって、クラスタへの移行は自然な次のステップだ。しかしプロキシ層は、通常であれば当たり前に使うデフォルトのいくつかを変えてしまう。ポッドごとにどうアイデンティティを割り当てるか、何をシグナルにオートスケールするか、そしてポッドを増やすことが対象サイトに対してタダで済まない理由だ。

これはシングルコンテナ構成の上に位置するオーケストレーション層だ。スクレイパーをレジデンシャルゲートウェイに向ける方法はすでに理解している前提で、ここではKubernetes上でそれらを安全に大量に稼働させることに焦点を当てる。

本稿を通じて、ゲートウェイにはShifterのものを使う。エンドポイントはp.shifter.io:443一つで、ターゲティングはユーザー名(customer-USERNAME-country-us-sid-abc123-ttl-600)にエンコードされる。他のプロバイダーを使う場合はホストと認証情報を入れ替えればよく、パターンは同一だ。

ワークロードの形

ほとんどのケースは2つのパターンでカバーできる。

  • キューからタスクを取り出す長時間稼働ワーカーのDeployment。継続的な収集におけるデフォルトの形だ。ポッドは稼働し続け、作業キューを処理し、バックログに応じてスケールする。
  • 有限のバッチ処理向けのJobまたはCronJob。夜間クロールや一度限りのバックフィルなどだ。KubernetesはN個のポッドを完了まで実行し、その後停止する。

どちらも以下で示す同じ構成要素を共有する。ほとんどのチームが求めるのはキュー駆動のDeploymentなので、ここで示すマニフェストはそれを対象とし、Jobが異なる箇所には注記を加える。

プロキシの認証情報はSecretに、イメージには絶対に置かない

最初のルールは地味だが譲れない。プロキシの認証情報はイメージにも、ConfigMapにも、マニフェストへのハードコードにも置いてはならない。Secretに格納し、実行時に環境変数(またはファイル)としてマウントする。

apiVersion: v1
kind: Secret
metadata:
name: proxy-credentials
type: Opaque
stringData:
SHIFTER_USER: "customer-yourname"
SHIFTER_PASS: "your-gateway-password"

認証情報をイメージに焼き込むと、そのイメージをpullできる人は誰でもプロキシアカウントを手にすることになる。Secretを使えば、レジストリやgitの履歴に認証情報を残さずに済み、リビルドなしでローテーションもできる。本番環境では、誤ってコミットしかねない素のマニフェストではなく、実際のシークレットマネージャー(External Secrets Operator、Vault、あるいはクラウドのCSIドライバー)で運用すべきだ。

Downward APIでポッドごとに固有のアイデンティティを与える

ここがプロキシ特有の部分だ。すべてのポッドがまったく同じユーザー名でセッショントークンなしに認証する場合、すべてが同じ方法でプールから引き当てることになり、純粋なローテーションであればそれで問題ない。しかし各ワーカーに一貫した個別のセッションを持たせたい瞬間、つまり一つの共有された塊ではなくポッドごとに一つのアイデンティティが欲しくなった瞬間、各ポッドがユーザー名内のsidを変える必要が出てくる。

Kubernetesでこれをきれいに行う方法がDownward APIだ。ポッド自身の名前を環境変数として注入し、そこからセッショントークンを導出する。

apiVersion: apps/v1
kind: Deployment
metadata:
name: scraper
spec:
replicas: 6
selector:
matchLabels: { app: scraper }
template:
metadata:
labels: { app: scraper }
spec:
terminationGracePeriodSeconds: 90 # 処理中のリクエストを完了させる(後述)
containers:
- name: scraper
image: your-registry/scraper:1.4.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name # 例: scraper-7c9f-abcde
- name: SHIFTER_USER
valueFrom:
secretKeyRef: { name: proxy-credentials, key: SHIFTER_USER }
- name: SHIFTER_PASS
valueFrom:
secretKeyRef: { name: proxy-credentials, key: SHIFTER_PASS }
resources:
requests: { cpu: "100m", memory: "192Mi" }
limits: { cpu: "500m", memory: "384Mi" }

ワーカー側では、POD_NAMEからプロキシのユーザー名を組み立て、各ポッドがその生存期間中は安定したセッションになるようにする。

import os, hashlib
user = os.environ["SHIFTER_USER"]
pod = os.environ.get("POD_NAME", "local")
# ポッドごとに決定論的なセッションIDで、ポッドの生存期間中は安定する。
sid = hashlib.sha1(pod.encode()).hexdigest()[:10]
proxy_user = f"{user}-country-us-sid-{sid}-ttl-600"
proxy = f"http://{proxy_user}:{os.environ['SHIFTER_PASS']}@p.shifter.io:443"
# `proxy`をrequests / httpx / 任意のクライアントに渡す

これでポッドscraper-7c9f-abcdeはポッドscraper-7c9f-fghijとは決定論的に異なるセッションとなり、両者の間に協調は一切必要ない。ポッド内でのローテーションは、ポッド単位ではなく作業単位でsidを変えることで行う。固定セッションとローテーションの違いは、他の場面とまったく同じように当てはまる。(コンテナ内に置く一般的なワーカーコードについては、Pythonガイドがクライアントのセットアップとローテーションを扱っている。)

CPUではなくキューの深さでオートスケールする

デフォルトのHorizontalPodAutoscalerはCPUに基づいてスケールする。プロキシ越しのネットワーク往復を待つ、ほぼ完全にI/Oバウンドなスクレイパーにとって、CPUはキューにどれだけ作業が溜まっていようとほぼ横ばいのままだ。CPUに基づくスケーリングはほとんど役に立たない。低いCPU使用率のまま山のようなバックログを抱え、いつまでもスケールアップしないことになる。

バックログそのものに基づいてスケールさせる。最もきれいな方法はKEDAで、Redisリストの長さやキューの深さといった外部メトリクスに基づいてDeploymentをスケールする。

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: scraper-scaler
spec:
scaleTargetRef:
name: scraper
minReplicaCount: 2
maxReplicaCount: 40
triggers:
- type: redis
metadata:
address: redis.default.svc:6379
listName: scrape:queue
listLength: "50" # ポッドあたり約50件のキュー滞留を目安にする

これにより、まさに求めていた特性が得られる。作業があればポッドが現れ、キューが空になれば消えていくので、アイドル状態のワーカーに費用を払うことも、バックログを飢えさせることもない。maxReplicaCountの上限は任意ではなく、次に述べる問題に対する主要なガードレールだ。

同時実行数はポッド数に応じて増える、これが罠だ

最も肝に銘じるべきことは一つだ。対象サイトへの総負荷は、ポッドあたりの同時実行数にポッド数を掛けたものになる。 各ポッドが行儀よく同時リクエストを5件に抑えていたとしても、40ポッドあれば対象に対して200件の同時リクエストとなる。スループットの勝利のように感じられたオートスケーリングは、プール全体をフラグ付けされる分散ハンマーへと変わる。

二つの防御策を併用する。

  • ポッドあたりの同時実行数を上限付きで、控えめに保つ。ワーカーごとに小さなセマフォを一つ。
  • ポッド単位だけでなく、フリート全体で対象ホストごとの総同時実行数を制限する。これは単一のポッドからは見えないクラスタレベルの関心事なので、作業キュー側で扱うべきだ。ポッド数がいくつであろうと単一のサイトが許容量を超えて受け取らないよう、対象ドメインごとにタスクをシャーディングまたはレート制限する。これはプロキシのロードバランシングと同じロジックの分散版であり、良質なレジデンシャルプールが負荷をIP間に分散させるためのツールであって、負荷を強めるためのライセンスではない理由でもある。

これを省略すると、オートスケーリングは速さではなくブロックをもたらす。対象の許容量を超えた並列化は、データではなくチャレンジ(認証要求)を招くだけだ。

グレースフルシャットダウン: 完了させるか再キューするか、決して失わない

Kubernetesは日常的にポッドを終了させる。スケールダウン、ローリングアップデート、ノードのドレイン、スポットインスタンスの回収などだ。その際SIGTERMを送り、terminationGracePeriodSecondsだけ待ってからSIGKILLを送る。SIGTERMを無視するスクレイパーは、殺された瞬間に処理中のリクエストをすべて失い、それがデータ中の謎の欠落や、中途半端に終わった取得による帯域の無駄として表れる。

このシグナルを処理せよ。SIGTERMを受け取ったら新しいタスクの取得を止め、猶予期間内に処理中のリクエストを完了させ、間に合わなかったものは別のポッドが拾えるよう再キューする。

import signal
draining = False
def handle_sigterm(signum, frame):
global draining
draining = True # 新しい作業の取得を止め、処理中のものを終わらせる
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining:
task = queue.pull()
if task is None:
continue
try:
process(task) # プロキシ経由で取得し、結果を保存する
except Exception:
queue.requeue(task) # at-least-once: 別のポッドに再試行させる

terminationGracePeriodSecondsは想定される最長リクエストより十分に長く設定し、at-least-onceのキューに頼ることで、殺されたタスクが失われるのではなく再試行されるようにする。これはマルチリージョンパイプラインにおけるフェイルオーバーのポッドレベル版であり、キューこそが単一のポッドを使い捨てにできるようにする永続的なバッファだ。

プロキシを理解したヘルスプローブ

LivenessプローブとReadinessプローブは、ポッドを維持するかどうか、そして作業を受け取るかどうかを決める。スクレイパーの場合、これらは実際の状態に結びつけるべきだ。プロキシのegressが壊れている、出口IPが間違っている、認証失敗を繰り返している、セッションが死んでいるといったポッドはreadinessを失敗させ、スケジューラがタスクを送らないようにし、壊れたままであればlivenessも失敗させて新しいアイデンティティで再起動させるべきだ。

プローブは軽量かつ正直に保つ。プローブのたびにプロキシへ実際に発呼するのではなく(それは帯域を消費し、それ自体がタイムアウトしかねない)、直近の数リクエストが成功したかを確認する軽量な内部チェックにする。目的は、健全なIPを経由しなくなったことに静かに陥ったポッドを、何も返さないままキューの一部を無駄に消費する前に察知することだ。

リソースを適正なサイズにする: スクレイパーはI/Oバウンドだ

過剰にプロビジョニングしないこと。スクレイピング用のポッドは、その生涯の大半をCPUを消費するのではなく、プロキシ経由のネットワークI/Oでブロックされて過ごす。控えめなrequests(コアの何分の一か、数百MB程度)にしておけば、スケジューラは1ノードに多くのポッドを詰め込める。これはまさに、軽量で並行なワーカー群、つまり無制限の同時接続モデルをクラスタの形で実現するために求められることだ。暴走を捕まえるためにlimitsは設定するが、オートスケーリングが実際にポッドを配置できるようrequestsは軽く保つ。CPUよりもメモリを注視すること。大きなレスポンスをバッファするスクレイパーは、CPU制限よりもOOM制限に達する可能性がはるかに高い。

対象を殺到させずにロールアウトする

40個のポッドを一斉に入れ替えるローリングアップデートは、新しいセッションが一斉に対象へ押し寄せる、自ら招いたトラフィックの急増を生み出す。控えめなmaxSurge/maxUnavailableを使って入れ替えを段階的にし、デプロイがリクエストの急増につながらないよう、同じキューと上限の規律に頼るべきだ。対象からは、デプロイが行われたことに気づかれるべきではない。

検証と観測

初日から組み込んでおく価値のあるものが二つある。一つ目は、ポッド起動時の出口IPチェックで、ポッドの送出IPと国をログに残す。これにより、設定ミスのSecretやローカルegressに固定されたポッドを、無駄な実行の後ではなくすぐに把握できる。二つ目は、ポッドごとの成功率とレイテンシだ。フリートにおける障害はめったに一様ではなく、一つの不良ノード、一つの消耗したセッション、あるいはチャレンジを出し始めた一つの対象が、単一ポッドのメトリクスの乖離として現れるからだ。プールの品質がそもそも何回リトライすることになるかを左右するため、それを追跡すること。良好なレピュテーションを持つクリーンなIPは、ポッドあたりのリトライが減り、同じデータ量に対する総負荷も減ることを意味する。そしてレイテンシタイムアウト率を注視し、対象の姿勢が変わったことへの早期警戒とせよ。

結論

Kubernetesはスクレイピングと相性が良い。ワークロードが並列かつバースト性を持ち、プラットフォームのオートスケーリング、自己修復、ロールアウトの仕組みがワーカー群にきれいに対応するからだ。プロキシ層はその上に短いルールを加える。認証情報はSecretに保管する、Downward APIで各ポッドに固有のセッションを与える、CPUではなくキューの深さでオートスケールする、対象への総負荷はポッドあたりの同時実行数×ポッド数であることを忘れずフリート全体で上限を設ける、そして殺されたポッドが作業を失うのではなく再キューするようSIGTERMを処理する。これらを正しく行えば、対象にまったく気づかれることなく収集を6ポッドから60ポッドへとスケールできる。それこそが肝心な点だ。

その基盤となるプールについて言えば、私たちのレジデンシャルプロキシはフリートの負荷を多数のクリーンなIPに分散するよう構築されており、ギガバイト単位の課金であるため、ポッドのスケーリングはプロキシ料金を増やすことなくスループットを増やす。実際に移動したデータだけがカウントされる。大規模なデータ収集を行っているなら、これこそが速さと静けさを両立させる組み合わせだ。

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

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

始める