ラップトップで動作するスクレイパーがCIに持ち込まれるとき、たいてい二通りのやり方のどちらかに行き着く。誰かが「とりあえず通す」ためにプロキシのパスワードをワークフローファイルに直書きするか、あるいはパイプラインがプッシュのたびにライブスクレイプをフルで実行して、水曜日までに1か月分の帯域をひっそり使い切るかだ。どちらも避けられる問題であり、解決の大部分は、そもそもどの実行にライブプロキシが本当に必要なのかを事前に決めておくことにある。
このチュートリアルでは、プロキシの認証情報をCI内のどこに置くべきか、ログに残さない方法、ほとんどの実行がネットワークに触れずに済むようテストを分割する方法、慌てずに認証情報をローテーションする方法を扱う。例にはGitHub ActionsとGitLab CIを使い、ShifterのレジデンシャルゲートウェイをShifterで示すが、この構造はどのランナーにも当てはまる。
何が問題になるか
スクレイパーに関するCI認証情報のインシデントのほとんどは、次の4つの失敗パターンで説明できる。
| 失敗 | どう起こるか |
|---|---|
| 認証情報のコミット | .envファイルやハードコードされたプロキシURLがリポジトリに紛れ込む |
| 認証情報のログ出力 | デバッグ行がプロキシURLを出力する、またはHTTPクライアントがリクエストヘッダーをログに残す |
| 信頼できないコードへの露出 | チーム外からのプルリクエストがシークレットを利用できる状態で実行される |
| 帯域の浪費 | プッシュのたびに実際のサイトに対してライブスクレイプが走る |
最初の3つはセキュリティの問題だ。4つ目はコストの問題であると同時に、パイプラインを不安定にする原因でもある。ライブのウェブサイトは変化するものであり、他人のページが変わったせいであなたのビルドが失敗すべきではないからだ。
ステップ1: 認証情報はシークレットとして保存し、コードには決して書かない
レジデンシャルのユーザー名とパスワードはパネルのプランページにある。これらを2つのCIシークレットとして保存する。
SHIFTER_USERNAME: 表示されているとおりの完全なユーザー名、例えばcustomer-USERNAMESHIFTER_PASSWORD: パスワード
GitHub Actions。 リポジトリのSettings、Secrets and variables、Actionsの下に両方を追加する。GitHubはログからシークレットの値を伏せ字にし、GITHUB_TOKENを除いて、フォークされたリポジトリからワークフローがトリガーされた場合、シークレットはランナーに渡されない。この2つ目のルールは公開リポジトリにとって重要だ。外部の貢献者によるプルリクエストはあなたのプロキシのパスワードを読み取れないが、同時にライブテストも実行できない。これはステップ3で扱う。
GitLab CI。 両方をCI/CD変数として追加し、masked(マスク)に設定し、保護ブランチまたはタグ上のパイプラインでのみ利用できるようprotected(保護)にも設定する。ここには特筆すべき落とし穴がある。GitLabは8文字以上の単一行の値しかマスクできない。Shifterのレジデンシャルパスワードはそれより短いことがあり、その場合GitLabはマスクを拒否する。解決策は、両者を1つのマスクされた変数として保存することだ。
SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD
この値は8文字を十分に超えており、GitLabがマスク変数で許可する文字だけを使っている。コード内では最後のコロンで分割する。
.envを同じコミットで.gitignoreに追加し、ローカルファイルが認証情報と一緒にリポジトリに紛れ込まないようにする。
ステップ2: プロキシURLはコード内で組み立て、決して出力しない
実行時に環境変数からプロキシURLを1か所で組み立てることで、ターゲティングやセッションフラグを一貫して追加でき、生のパスワードを扱う箇所を他に作らずに済む。
import os
GATEWAY = "p.shifter.io:443"
def shifter_credentials():
if "SHIFTER_PROXY_AUTH" in os.environ:
username, password = os.environ["SHIFTER_PROXY_AUTH"].rsplit(":", 1)
return username, password
return os.environ["SHIFTER_USERNAME"], os.environ["SHIFTER_PASSWORD"]
def proxy_url(country=None, session=None, ttl=None):
username, password = shifter_credentials()
if country:
username += f"-country-{country}"
if session:
username += f"-sid-{session}"
if ttl:
username += f"-ttl-{ttl}"
return f"http://{username}:{password}@{GATEWAY}"
def redact(url):
# Safe to log: keeps the flags, drops the password.
creds, host = url.rsplit("@", 1)
return f"{creds.rsplit(':', 1)[0]}:***@{host}"
ある実行でどのフラグが使われたか確認する必要があればredact(url)をログに出す。URL自体は決してログに出さない。
シークレットのマスキングには知っておくべき盲点がある。マスクは保存された値と一致するものにしか効かないため、変換された値はすり抜ける。プロキシ認証はusername:passwordのbase64エンコードを含むProxy-Authorizationヘッダーとして送られ、リクエストヘッダーのデバッグログはこのエンコードを出力するが、どのCIマスカーもこれを認識しない。CIではヘッダーレベルのデバッグログを無効にしておくこと。もしそれ自体はシークレットではないが機密性のある文字列を組み立てる必要があるなら、何かが出力する前にGitHubの::add-mask::コマンドで登録する。
スクレイパーへのシークレットの受け渡しはコマンドライン引数ではなく環境変数を使う。GitHub自身のガイダンスでも、可能な限りプロセス間でコマンドライン経由でシークレットを渡すのは避けるべきとされている。引数はプロセス一覧で簡単に見え、シェルトレースに残りがちだ。
ステップ3: ほとんどの実行がプロキシを必要としないようテストを分割する
このステップがコストのほとんどと不安定さのほとんどを取り除く。スクレイパーのテストを3段階に分ける。
| 段階 | 何を確認するか | プロキシが必要か | いつ実行するか |
|---|---|---|---|
| パーサーテスト | 保存されたHTMLフィクスチャに対する抽出ロジック | 不要 | すべてのプッシュとプルリクエスト |
| ライブスモークテスト | ゲートウェイを通した少数の実際のリクエスト | 必要 | メインブランチとスケジュール実行 |
| フル実行 | 実際のスクレイプ | 必要 | 独自のスケジュール、または手動トリガー |
パーサーテストはカバレッジの大部分を占める。実際のレスポンスをフィクスチャファイルとして保存し、セレクタが正しいフィールドを抽出することをテストする。数秒で終わり、コストはゼロで、シークレットも不要、コードが間違っているときにだけ失敗する。サイトのレイアウトが変わったら、新しいフィクスチャを保存し、同じプルリクエストでパーサーを更新する。
ライブスモークテストは、すべてのページが正しくパースされることではなく、認証情報、ターゲティング、接続性が機能していることを確認する。数回のリクエストにとどめておく。シークレットが存在しない場合は、失敗ではなくスキップすべきで、これはまさにフォークからのプルリクエストで起こる状況だ。
import os
import pytest
import requests
from scraper.proxy import proxy_url
live = pytest.mark.skipif(
not (os.environ.get("SHIFTER_PASSWORD") or os.environ.get("SHIFTER_PROXY_AUTH")),
reason="no proxy credentials in this environment",
)
@live
def test_gateway_exits_in_requested_country():
url = proxy_url(country="de")
r = requests.get("https://ipinfo.io/json", proxies={"http": url, "https": url}, timeout=30)
assert r.status_code == 200
assert r.json()["country"] == "DE"
フル実行はプッシュ時ではなくスケジュールに置くべきで、マージが本番規模のスクレイプを引き起こすことのないようにする。
ステップ4: 1ジョブにつき1セッション、共有しない
スティッキーセッションは1回の実行を1つの出口IPに固定するもので、ページネーションのような複数ステップのフローにはこれが必要になる。ShifterのセッションIDは任意の文字列で選べ、デフォルトの生存時間は120秒でありttlで上書きできる。ドキュメントの注意点はCIにそのまま当てはまる。同時に実行される複数のワークフローで同じセッションIDを再利用してはならない。異なるジョブからのリクエストが同じIPに着地すると、ほとんどのアンチボットシステムから見て不審に映るからだ。
CIはすでに実行ごとに一意な値を提供してくれる。それを使い、マトリクス実行ならジョブのインデックスも加える。
import os
run = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_PIPELINE_ID", "local")
job = os.environ.get("JOB_INDEX", "0")
url = proxy_url(country="us", session=f"ci{run}j{job}", ttl=600)
ユーザー名はダッシュでフラグを区切るため、IDは英数字のみにしておく。互いに独立したリクエストであれば、セッションを指定せず、各リクエストごとに新しいIPへローテーションさせればよい。
ステップ5: 大きな実行の前にクォータを確認する
帯域を使い切って途中で止まるスケジュール実行は、そもそも始まらなかった実行より悪い。ShifterのUsage and Quota APIはプランに残っている量を返すので、プリフライトのステップで実行をきれいにスキップできる。
import os
import sys
import requests
MIN_GB = float(os.environ.get("MIN_REMAINING_GB", "5"))
r = requests.get(
f"https://shifter.io/api/v1/memberships/{os.environ['SHIFTER_MEMBERSHIP']}/usage",
params={"api_token": os.environ["SHIFTER_API_TOKEN"]},
timeout=30,
)
r.raise_for_status()
plan = r.json()["data"]
if plan["metered"] and plan["remaining_gb"] < MIN_GB:
print(f"Only {plan['remaining_gb']} GB left, resets {plan['resets_at']}. Skipping run.")
sys.exit(78)
APIトークンはパネルのAccount、API Tokensの下で生成する。パスワードと同様にシークレットとして保存すること。これは1つのワークスペースではなくアカウントに属するものであり、あなたがメンバーであるすべてのワークスペースの使用状況を読み取れてしまう。このエンドポイントは1分あたり60リクエストにレート制限されており、プリフライトには十分すぎるほどだ。
Extra Trafficを無効にした状態で実行がプランを使い切ってしまった場合、ゲートウェイは509 Bandwidth Limit Exceededを返す。これは再試行すべきものではなく、停止すべき条件として扱う。
ステップ6: 認証エラーでは即座に失敗させる
再試行は一時的なネットワークエラーには適しているが、認証情報のエラーには不適切だ。407 Proxy Authentication Requiredはユーザー名、パスワード、またはフラグのいずれかが間違っていることを意味し、次に試しても同じように間違っている。CIでは、407を巡る再試行ループが1秒の失敗を10分の失敗に変え、しかもログをわかりにくくする。クライアントには407を致命的なエラーとして扱わせ、マスクしたプロキシURLを出力させて停止させる。よくある原因とそれを確認すべき順序はfixing 407 proxy authentication errorsで扱っている。
GitHub Actionsの完全なワークフロー例
name: scraper
on:
push:
pull_request:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
parser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/parsers
smoke-test:
if: github.ref == 'refs/heads/main'
needs: parser-tests
runs-on: ubuntu-latest
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/live
full-run:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: smoke-test
runs-on: ubuntu-latest
concurrency: scraper-full-run
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
SHIFTER_API_TOKEN: ${{ secrets.SHIFTER_API_TOKEN }}
SHIFTER_MEMBERSHIP: ${{ vars.SHIFTER_MEMBERSHIP }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python scripts/check_quota.py
- run: python -m scraper.run
パーサーテストはシークレットなしでどこでも実行される。スモークテストとフル実行はシークレットが存在する場所でのみ存在する。concurrencyグループにより、2つのスケジュール実行が重なってIP予算を共有してしまうことを防ぐ。メンバーシップIDは機密情報ではないため、通常のリポジトリ変数に置いてある。
1つ調整の余地がある。このままだと、クォータスクリプトの終了コード78はジョブを失敗として扱う。赤い実行ではなくスキップされた実行として見たいなら、チェックのステップにidを付け、$GITHUB_OUTPUTにフラグを書き込ませ、スクレイプのステップをその出力でゲートすればよい。
認証情報のローテーション
スケジュールに沿ってローテーションし、シークレットが漏洩した可能性があるときは即座に行う。公開されたログ、退職する契約社員、確信の持てないフォークされたワークフローなどがそれにあたる。
Shifterでは、アカウントのオーナーまたはワークスペースの管理者(Admin)がパネルのプランページから新しいレジデンシャルパスワードを生成できる。ViewerとBillingのメンバーはできない。ゲートウェイは新しいパスワードを即座に反映し、古いパスワードは同じ瞬間に使えなくなるため、順序を計画しておく必要がある。
- スケジュールされたワークフローを一時停止するか、実行中のジョブが407で失敗することを受け入れる。
- パネルで新しいパスワードを生成する。
- すぐにCIシークレットを更新する。
- 同じプランを使う他のすべての利用箇所を更新する。パスワードは1つのパイプラインではなくプランに属するため、それを使っている他のものは同じ瞬間に壊れる。
- スモークテストを再実行して確認する。
ポイント4があるからこそ、ローテーションが必要になる前に、あるプランの認証情報がどこで使われているかを把握しておく価値がある。複数の独立したパイプラインが1つのプランを共有している場合、1回のローテーションがそのすべてに影響する。
まとめ
CIからスクレイパーを実行する作業の大部分は、プロキシを必要としない実行からプロキシを遠ざけておくことにある。フィクスチャに対するパーサーテストは、シークレットも帯域も使わずに、すべてのプッシュでロジックをカバーする。メインブランチでの小さなライブスモークテストが、認証情報が依然として機能していることを証明する。実際のスクレイプはスケジュールに沿って実行され、まずクォータを確認し、他の誰とも共有しないセッションIDを使い、最初の認証エラーで再試行せずに停止する。
認証情報自体はCIのシークレットストアに置かれ、1つの関数の中で組み立てられ、base64を含めて決して出力されない。より長時間にわたる収集については、パイプラインを監視する運用面をmonitoring a web scraping pipelineで、それを複数リージョンに広げる方法をresidential proxy failover for multi-region pipelinesで扱っている。ブラウザベースのスクレイパー向けのクライアント側のプロキシ設定はconfiguring residential proxies in Selenium and Playwrightにある。