コンテナがVPN限定APIに届かない?ホスト側でプロキシする
トンネルはホスト側にあり、コンテナにはプライベートアドレスへの経路がない。細いリバースプロキシを公開し、host.docker.internal として呼び出す。
同じマシンのブラウザでは何事もなく開けるAPIに対して、Composeサービスだけがタイムアウトする。その差が手がかりだ。成功するリクエストはVPNトンネルを持つホストから出ており、固まるリクエストは独自のネットワークスタックを持つコンテナから出ている。コンテナの内側ではトンネルの経路を誰も知らないため、名前をどう書いてもプライベートな宛先には届かない。解決策はホストを経由する意図的な一跳ねであり、より賢いルーティングテーブルではない。
まず二つの経路がなぜ異なるのかを押さえ、そのうえでトンネルをどこに置くかを決める。
ホストは通り、コンテナは通らない理由
トンネルを立ち上げると、ホストにはインターフェース、経路の集合、そして通常はVPNが提供するリゾルバのエントリが与えられる。コンテナはそのどれも継承しない。エンジンのブリッジ上の一つのインターフェースと、そのブリッジを指すデフォルト経路から始まるだけだ。ホストに入れた経路は、その名前空間の内側で動くものには見えない。
だから症状は単に壊れているのではなく、ちぐはぐに見える。ホストはプライベート名を解決して接続する。名前も経路もそこに存在するからだ。コンテナはブリッジ経路上の何も届かないプライベートアドレスへパケットを送り、リクエストはタイムアウトする。コンテナのhostsファイルに名前を足しても何も変わらない。アドレス自体がコンテナの居場所から届かないのだ。
コンテナの内側からホストに届く
コンテナ内のプロセスが localhost を呼べば、それはコンテナ自身を呼んでいる。Docker Desktop は host.docker.internal を、エンジンを動かすマシンを指す名前として公開する。Linuxのエンジンではサービスが要求しない限り未定義のままで、下のマッピングがそれを要求する。これによりアプリケーションはプライベートアドレスではなく名前とポートを狙う。
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
INTERNAL_API_HOST: "host.docker.internal:8666"トンネルはこの一跳ねのホスト側に残り、その変数のポートがプロキシの待ち受けるポートになる。トンネルを失ったコンテナは、決して持っていなかった経路を試すのではなく、既知の一つの跳ねで失敗するようになる。
トンネルをどこに置くかを選ぶ
プライベートAPIを必要とするComposeサービスには三つの形があり、どのプロセスがトンネルを持つかで異なる。VPNクライアントをコンテナイメージの内側で動かすこともでき、その場合はトンネルデバイスを露出させ、付随するネットワーク管理権限を与える必要がある。別のトンネルコンテナのネットワーク名前空間を共有することもでき、その場合は両方のプロセスが一つのスタックと一つのルーティングテーブルを見る。あるいはトンネルをホストに残し、ホストからコンテナへの細い転送を置く。
最初の二つはプロファイルファイル、再接続の挙動、リゾルバの方針をプロジェクトに引き込み、ファイル内のすべてのサービスをトンネルの健全性に依存させる。一回のAPI呼び出しには結合が多すぎる。単一の内部エンドポイントを読むだけの開発スタックなら、ホスト側の転送が最小の変更であり、認証情報はプラットフォームチームが管理する場所に置いたままで済む。
ホストに細い経路を一本だけ公開する
転送が引き継ぐものから始める。トンネルを持つアカウントで動かせば、外向きソケットはホストの経路とホストのリゾルバを引き継ぐ。nginxのlocationブロック一つが一本の経路を担う。
server {
listen 8666;
location /search/ {
proxy_pass http://10.20.30.40:8090/;
proxy_set_header Host internal-api.example;
proxy_read_timeout 60s;
}
}アドレスとポートは仮のものとして扱い、APIが実際に待ち受ける組に置き換える。追加のupstreamごとに独立したlocationブロックを持たせ、各ブロックはこのサービスが呼ぶ経路だけを許可すべきだ。そうしないとプロキシがプライベートネットワークへの汎用の扉になってしまう。host-gateway経由で来るクライアントにはブリッジのアドレスに、クライアントがホストのネットワーク名前空間を共有する場合に限りloopbackにバインドし、0.0.0.0には決してバインドしない。ポートも空いているものを選ぶ。すでに使われている番号では、コンテナがタイムアウトし続ける間にプロキシは待ち受けず、最初のネットワーク障害と区別がつかない。
ポート、名前、そしてTLSの判断
名前解決もルーティングと同じように分かれる。コンテナの公開検索はエンジンのリゾルバを通るため、ホストで解決するプライベート名はコンテナ内では解決しない。プライベート名を解決するのはプロキシであり、ホストのリゾルバを継承しているからだ。
TLSは後回しにされ、そのあと次の障害を説明する判断だ。プロキシがバイトをそのまま転送すれば、コンテナはAPI自身の証明書を見るため、内部CAをトラストストアに入れる必要がある。プロキシがTLSを終端すれば、その信頼はホストに移り、アプリケーションは受け取ったhostヘッダーからリンクを組み立て始める。どちらか一方に決めてすべてのリソースに適用すること。もう一方が発行するリダイレクトは、コンテナが解決できないホスト名を指す。
サービスの内側から検証する
リクエストはホストからではなくコンテナから送る。プロキシ経路に対する素の curl、そしてアプリケーション自身のヘルスチェックだ。プロキシが応答するのにアプリケーションが失敗し続ける場合は、三つを比べる。組み立てているベースURL、環境にあるプロキシ変数、信頼しているCA。どれもコンテナの環境とログにあり、どれか一つを直せばVPNには触れずに済む。
選んだ構成は新鮮なうちにプロジェクトのREADMEへ書く。そうしないと、次にこのタイムアウトに当たった人が同じ午後を、トンネルはホストにあるという再発見に費やすことになる。