residential proxyをコンテナに組み込むのは、うまくいかなくなるまでは些細なことに見える。HTTP_PROXYを設定してイメージを実行しても、リクエストはホストIPのまま出ていく。あるいはプロキシは機能しているのに、内部サービス呼び出しが別の国のレジデンシャル出口経由でトンネリングされるようになり、ヘルスチェックが失敗し始める。あるいは最悪の場合、認証情報がイメージレイヤーに焼き込まれてしまう。
これらはどれも珍しいことではない。プロキシをコンテナに導入するチームを確実に悩ませる4つの問題だ。設定がどこに適用されるか、何がバイパスされるべきか、シークレットがどう渡されるか、そしてアイデンティティがどうコンテナにマッピングされるか。 このガイドでは、DevOpsおよびプラットフォームエンジニア向けに、実際に動作する設定とともにそれぞれを解説する。
混乱の大半を説明する一つの区別
Dockerには2つの完全に別個のプロキシ概念があり、それらを混同することがほとんどの「なぜプロキシが動かないのか」というチケットの根本原因だ。
- ビルド時/デーモンプロキシ —
~/.docker/config.jsonまたは--build-arg経由で設定される。これはDockerデーモン(イメージのプル)とビルドプロセス(RUN apt-get install)を管理する。アプリのランタイムトラフィックとはまったく関係がない。 - ランタイムプロキシ — 実行中のコンテナ内の環境変数。アプリケーションコードが目にするのはこちらだ。
~/.docker/config.jsonでプロキシを設定し、Pythonスクレイパーがそれを使うと期待していたなら、それがバグだ。その設定はコンテナのランタイム環境には決して届かない。
さらにもう一つの層がある。ランタイムであっても、HTTP_PROXYは慣習であって強制ではない。カーネルがそれを通じてトラフィックをルーティングするわけではない。それを尊重するかどうかは各ライブラリが選択する。
| クライアント | HTTP_PROXY/HTTPS_PROXYを尊重するか? |
|---|---|
curl、wget | はい |
Python requests、httpx | はい(デフォルトで) |
Node fetch / undici | いいえ、明示的なdispatcher/agentが必要 |
Go net/http | はい、http.ProxyFromEnvironment経由(デフォルトのtransport) |
| Chromium / Playwright / Puppeteer | いいえ、起動フラグまたはプロキシオプションが必要 |
したがって、最初に確認すべきデバッグの質問は「環境変数は設定されているか?」ではなく「このクライアントはそれを読み取るか?」だ。
ランタイムプロキシ:基本セットアップ
プロキシはビルド時ではなくランタイムで渡し、ユーザー名にターゲティング情報をエンコードしてゲートウェイを参照する。
docker run --rm \ -e HTTP_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e HTTPS_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e NO_PROXY="localhost,127.0.0.1,postgres,redis,.internal,169.254.169.254" \ my-scraper:latestライブラリの挙動に確信が持てない場合は、大文字と小文字の両方(HTTP_PROXYとhttp_proxy)を設定すること。慣習は異なり、一部のツールはどちらか一方しか読まない。HTTPS_PROXYが依然としてhttp://のURLを指しているのは正しい。スキームはプロキシとどう通信するかを表しており、HTTPSはCONNECTを通じてトンネリングされる。
Composeでは、値をファイル自体には含めない。
services: scraper: image: my-scraper:latest environment: HTTP_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" HTTPS_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" NO_PROXY: "localhost,127.0.0.1,postgres,redis,.internal" depends_on: [postgres, redis]変数はシェルまたはコミットされていない.envファイルから補間される。
NO_PROXY:見落とされがちで、後悔することになるステップ
これが分かりにくい障害を引き起こすものだ。HTTP_PROXYが設定されると、それを尊重するすべてのクライアントはすべてをプロキシ経由で送信する。データベース、キャッシュ、内部API、クラウドメタデータエンドポイントへの呼び出しも含めてだ。結果として、内部トラフィックがネットワークを出て戻ってきて(遅く、時には壊れる)、ヘルスチェックが失敗し、本来ホストを離れるべきではなかったトラフィックにGB単位の帯域幅を消費することになる。
NO_PROXYには常に以下をカバーするよう設定する。
localhost、127.0.0.1、::1- Compose/Kubernetesのサービス名(
postgres、redis、api.default.svc.cluster.local) - 内部ドメインとプライベート範囲(
.internal、10.0.0.0/8) - クラウドメタデータ:
169.254.169.254
知っておくべき2つの注意点がある。NO_PROXYのマッチングはライブラリ間で一貫して実装されていない(特にCIDRサポートはまちまちで、サフィックスにマッチするものもあれば先頭にドットが必要なものもある)ため、想定せず検証すること。またKubernetesでは、.svc.cluster.localとポッド/サービスのCIDRもNO_PROXYに含める必要がある。
シークレット:認証情報をイメージに焼き込まない
プロキシの認証情報はシークレットだ。ルールは以下の通り。
Dockerfile内でENVやARGを介して決して設定しないこと。どちらもイメージレイヤーに残り、docker historyは喜んでそれを表示する。イメージをプルできる人は誰でもあなたの認証情報を手にすることになる。
代わりにランタイムで注入すること。Composeの場合はgitから除外された環境ファイルを、オーケストレーションの場合は本物のシークレットストアを使う。
# Kubernetes: マニフェストではなくSecretから認証情報を取得env: - name: SHIFTER_USER valueFrom: secretKeyRef: { name: proxy-creds, key: username } - name: SHIFTER_PASS valueFrom: secretKeyRef: { name: proxy-creds, key: password }その後、アプリ内でこれら2つの変数からプロキシURLを組み立てることで、完全な認証情報の文字列がマニフェスト、ログ行、docker inspectのいずれにも現れないようにする。ビルド時(パッケージのインストール)にプロキシが必要な場合は、ARGではなくBuildKitのシークレット(--mount=type=secret)を使い、何もレイヤーに残らないようにする。
さらに、ログからプロキシURLを削除すること。有効な設定を出力するクラッシュダンプは、user:pass@hostをそのままログ集約基盤に漏らしてしまう。
アイデンティティをコンテナにマッピングする
ここでコンテナアーキテクチャはプロキシアーキテクチャと交差する。ターゲティング情報はユーザー名に存在するため、各コンテナは異なる環境変数を渡されるだけで独自のアイデンティティを持てる。別個のエンドポイントもIPリストも不要だ。
ほとんどのニーズは2つのパターンでカバーできる。
コンテナ1つにつき地域1つ。 国のフラグを変えることで市場別ワーカーを実行する。
docker run -d -e HTTP_PROXY="http://${U}-country-us:${P}@p.shifter.io:443" scraper:latestdocker run -d -e HTTP_PROXY="http://${U}-country-de:${P}@p.shifter.io:443" scraper:latestコンテナ1つにつきセッション1つ(スティッキー)。 各レプリカに固有のsidを与えることで独自の出口IPを保持させる。これは、コンテナが複数ステップのフローを所有している場合に有用だ。
services: worker: image: scraper:latest environment: # {{.Task.Slot}}は各Swarmレプリカに一意で安定したセッションIDを与える HTTP_PROXY: "http://${U}-country-us-sid-w{{.Task.Slot}}-ttl-600:${P}@p.shifter.io:443" deploy: replicas: 4注意点として、コンテナレベルの環境変数はそのコンテナの生存期間中は静的なアイデンティティである。ワークロードがリクエストごと、あるいは作業単位ごとのローテーションを必要とする場合は、コンテナを再起動して偽装するのではなく、sidをジョブごとに変えられるアプリケーションコード内でプロキシを設定すること(ロードバランシングの記事のパターン)。環境変数は粗粒度のコンテナ単位のアイデンティティに適したツールであり、コードはきめ細かいローテーションに適したツールだ。
環境を無視するクライアント
コンテナ内でよく遭遇する2つのケースがある。
Nodeのfetch/undiciはプロキシの環境変数を読まない。明示的に配線する必要がある。
import { ProxyAgent, setGlobalDispatcher } from "undici";setGlobalDispatcher(new ProxyAgent(process.env.HTTP_PROXY));ヘッドレスブラウザもそれらを無視する。Chromiumは起動時にプロキシを渡す必要があり、認証情報はフレームワーク独自のメカニズムで処理する必要がある(PlaywrightがChromiumでインライン認証情報が失敗する理由も含め、詳細をカバーしている)。Pythonのrequests/httpxは環境変数を尊重するが、プロキシを明示的に渡す方が明確だ(Pythonガイド)。
もう一つ知っておくべきこと:コンテナ内では、SOCKS5を環境変数経由で使うのはクライアント間で信頼性が低い。ほとんどのコンテナワークロードでは、HTTPプロキシがデフォルトとして適切だ(SOCKS5のトレードオフ)。
コンテナ内から検証する
想定せず、実行中のコンテナ内から出口IPを確認すること。
docker exec -it my-scraper sh -c \ 'curl -s http://ip-api.com/json | head -c 200'# ホストIPではなく、ターゲットとした国のレジデンシャルIPを期待する。ホストIPが返ってくる場合、クライアントは環境変数を尊重していない(上記の表を参照)。ステージング環境でこれをスタートアップアサーションとして追加し、誤設定されたデプロイが静かにデータセンターIPからスクレイピングするのではなく、大きな音を立てて失敗するようにすること。また、内部サービス呼び出しがプロキシを経由しないことを確認してNO_PROXYが機能しているか検証すること。より広範な測定方法はプロキシの速度、成功率、位置精度をテストする方法にある。
午後を無駄にするコンテナの落とし穴
localhostはコンテナ自身を意味する。 ホスト上のプロキシはコンテナ内から127.0.0.1ではアクセスできない。host.docker.internal(Docker Desktop)またはホストのネットワークアドレスを使うこと。- ビルド時とランタイムのプロキシは別物だ。 一方を設定してももう一方は設定されない。
- 環境変数の変更には再作成が必要だ。 Composeでの環境変数の編集には、再起動ではなく
up --force-recreateが必要だ。 - DNS解決の場所は異なる。 一部のクライアントはローカルで解決し、他はプロキシに解決させる。地域依存の結果がおかしい場合、これが疑わしい。
- イメージにCA証明書が不足していることがある。 SlimやAlpineベースのイメージでは、プロキシ経由のHTTPSを検証するために
ca-certificatesのインストールが必要な場合がある。 - GB単位の課金はコンテナ単位だ。 フルページを取得するレプリカが10個あれば、帯域幅は10倍になる。帯域幅コストの削減はレプリカごとに適用される。
よくある質問
HTTP_PROXYが設定されているのに、コンテナがプロキシを使わないのはなぜか?
HTTP_PROXYは慣習であって、ルーティングではないためだ。ライブラリがそれを尊重する必要がある。Nodeのfetchとヘッドレスブラウザは尊重しない。curl、Pythonのrequests、Goのデフォルトtransportは尊重する。まずクライアントを確認し、次にコンテナ内から出口IPを確認すること。
Dockerfileでプロキシを設定すべきか?
いいえ。ENV/ARGの値はイメージレイヤーに残り、docker historyに表示され、イメージをプルできる人に認証情報を漏らしてしまう。シークレットストアからの環境変数でランタイムに注入し、ビルド時にプロキシが必要ならBuildKitのシークレットを使うこと。
内部トラフィックがプロキシを経由しないようにするには?
NO_PROXYにlocalhost、サービス名、内部ドメイン、プライベート範囲、169.254.169.254を設定すること。そうしないと、データベースとヘルスチェックのトラフィックがレジデンシャル出口経由でトンネリングされ、遅く、脆弱で、課金対象になる。
各コンテナが異なるIPや国を持てるか? はい。ターゲティング情報はプロキシのユーザー名にエンコードされているため、コンテナごとに異なる環境変数を与えれば、追加のエンドポイントなしで独自の国やスティッキーセッションを持てる。リクエストごとのローテーションには、代わりにアプリケーションコードでプロキシを設定すること。
docker buildでプロキシは機能するか?
ビルド/デーモンプロキシを別途設定した場合(~/.docker/config.jsonまたはビルド引数)のみ機能する。ランタイムのコンテナ環境変数はビルドには影響せず、ビルド時の認証情報をARGとして渡すべきではない。
まとめ
Dockerのプロキシ問題の大半は4つのことに帰着する。設定がどこに適用されるか(ビルド時かランタイムか、そもそもどのクライアントが環境変数を尊重するか)を把握すること、内部トラフィックが内部に留まるよう**NO_PROXYを設定すること、認証情報をイメージレイヤーから排除しランタイムで注入すること、そしてアイデンティティがコンテナにどうマッピングされるか**(粗粒度のコンテナ単位アイデンティティには環境変数、リクエストごとのローテーションにはアプリケーションコード)を意図的に決めることだ。そして、設定を信頼するのではなく、コンテナ内から出口IPを検証すること。
これらを正しく行えば、コンテナ化された収集作業は最良の意味で退屈なものになる。レジデンシャルゲートウェイはここで役立つ。ターゲティング情報がユーザー名に乗るため、エンドポイントは1つで、どのコンテナの地域やセッションも単なる別の環境変数になるからだ。プールの品質は依然として、そもそも何回リトライすることになるかを左右する(IPの評判)。価格ページにはGB単位のプランが掲載されているが、帯域幅はレプリカ数に応じてスケールすることを覚えておく価値がある。