同じマシンのブラウザでは何事もなく開ける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へ書く。そうしないと、次にこのタイムアウトに当たった人が同じ午後を、トンネルはホストにあるという再発見に費やすことになる。

関連記事