社内 API はデスクトップのブラウザでは開くのに、開発コンテナでは失敗します。これは矛盾ではなく、原因が VPN そのものであることもほとんどありません。ブラウザのリクエストと失敗したリクエストは別々のプロセスが発行しており、リゾルバ、プロキシ、経路が異なり、しかも多くの場合まったく別のネットワーク名前空間から出ています。「自分のノート PC では動く」という言葉は、Windows ホスト、WSL ディストリビューション、Docker Desktop の仮想マシン、その VM 内のコンテナという四つのコンテキストのどれかを指します。同じホスト名に対して、それぞれが違う答えを返せるのです。設定を一つも触る前に、失敗しているプロセスがどれを使っているかを突き止めてください。

設定を変える前にプロセスを特定する

VPN クライアントではなく、コンテナエンジンから始めます。コマンドラインがどのコンテキストと話しているのか、そのエンジンがどの OS 上で動いているのかを確認してください。デスクトップ VM が管理する Linux コンテナは、ローカルのデーモンが管理するコンテナとは別の経路を通るからです:

docker context ls
docker info --format '{{.OperatingSystem}} / {{.Name}}'
docker run --rm alpine ip route

Windows では wsl -l -v が、ディストリビューションが WSL 2 の下で動いているかどうかを示します。この構成はディストリビューションを独自の仮想スイッチの内側に置きます。この違いが、コンテナがホストのルーティングテーブルを共有するのか、ホストに届くために転送ポートやホスト名を必要とする NAT の背後にいるのかを決めます。

一つのリクエストを固定して比較する

社内ホスト上の無害なエンドポイントを一つ選び、すべての比較にそれを使います。ブラウザから、ホストのターミナルから、WSL ディストリビューションから、そしてコンテナの内側からです。試行ごとに四つのことを記録してください。解決されたアドレス、プロキシを使ったかどうか、失敗の種類、経過時間です。名前解決、接続確立、TLS 検証、HTTP ステータスは四つの別々の問題であり、「動かない」と一括りにすると午前中が丸ごと消えます。

比較は失敗しているコンテナの内側で、タイムアウトを付けて実行します。黙ったファイアウォールがターミナルを何分も占有しないようにするためです。新しいコンテナはエンジンのネットワークコンテキストを再現しますが、アプリケーションコンテナの名前空間、プロキシ変数、リゾルバまでは再現しません。そのため、実際のコンテナに docker exec するのが正式な検証であり、サイドカーはエンジンとコンテナのどちらに差があるかを切り分ける手段にすぎません:

docker exec -it app /bin/sh -c "getent hosts api.internal; curl -v --max-time 8 https://api.internal/ 2>&1 | tail -20"
docker run --rm --network container:app curlimages/curl:latest -sv --max-time 8 https://api.internal/ 2>&1 | tail -20

alpine のベースイメージには busybox は入っていますが curl は入っていません。コピーした診断ブロックが失敗するのは、まさにこうした細部のせいです。クライアント入りのイメージを選ぶか、同じ実行の中で追加してください。無関係な公開サイトとの比較では何も切り分けられません。コンテナから https://example.com が通るなら、わかったのはコンテナにインターネット接続があるということであり、トンネル経由でプライベートアドレスに届くかどうかは何もわかりません。同様に、新しいターミナルを開いてもうまくいくのは、その新しいターミナルが動くという証明にすぎず、失敗したアプリケーションと同じランタイムを共有している証明にはなりません。

Docker Desktop は VPN と共存できる

Docker 自身の VPN とホスト接続に関するドキュメントは、これをサポート対象の構成として説明しています。正直な要約は、自動的に壊れるのではなく動く、というものです。デスクトップ VM が設計上ホストのトンネルを迂回すると思い込まないでください。多くの構成ではトラフィックはホストのトンネルを正しく通ります。だからこそ「Docker は VPN を無視する」という俗説が、自信たっぷりの誤った助言を生むのです。

それでも重要なのは、使用しているバックエンド、クライアントのスプリットトンネルポリシー、そして VM が受け取ったリゾルバ設定です。経路は入れるが DNS は入れない VPN クライアントや、VM から届かない DNS サーバーを配るクライアントは、コンテナ内での名前解決の失敗を引き起こします。一方ホストは、追加のリゾルバやキャッシュされた応答があるため動きます。これはコンテナの衣装を着たリゾルバの問題です。VPN を再接続しても直りません。トンネルはすでに、頼んだとおりのトラフィックを運んでいるからです。

アドレスの重複と MTU は別の障害

二つの障害は、しばしばアクセス制御と取り違えられるため、独自の切り分けが必要です。一つ目はアドレスの重複です。VPN が、コンテナネットワークの既定ブリッジのサブネットと衝突する範囲を配ると、その範囲に対するコンテナの経路はローカルのブリッジを指し、トンネルには決して届きません。推測せずに実際のサブネットを調べてください:

docker network inspect bridge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
ip route get 10.20.30.40

二つ目は MTU の問題です。接続が確立し TLS も完了するのに、大きな応答だけが止まるなら、それはパケットサイズの問題であり、認可の問題ではありません。DNS レコードを書き換えても決して直りません。何かを変える前に小さなリクエストと大きなリクエストで試し、両方の結果を記録してください。

重複の解決策は、アドレス計画の所有者が選ぶ別のコンテナサブネットか、範囲を限定した経路です。MTU の停止の解決策は、経路を通る値までトンネルまたはインターフェースの MTU を絞るか下げることであり、自分が制御できる層で適用します。

プロキシ設定はマシン単位ではなくプロセス単位

コンテナの内側では、localhost はそのコンテナ自身のネットワークコンテキストを指します。したがってホストで動くプロキシには、その名前では届きません。Docker Desktop はホストへのアクセス用に host.docker.internal を提供し、Linux では --add-host=host.docker.internal:host-gateway で明示的に追加します。いずれにせよ、その名前が与えるのはホストへの経路だけです。プロキシサービスを作るわけでも、その利用を許可するわけでもありません。

実際の環境で提供されているプロキシのアドレスとポートを使い、誰がそれを読んでいるかを確認してください。対話シェルで export した変数は、すべてのエディタ拡張、言語サーバー、ビルドツール、バックグラウンドのデーモンを自動で設定するわけではありません。重要なのは失敗したリクエストを出したプロセスであり、自分のターミナルではなくそのプロセスの環境を調べるべきです。

最後に、修正そのものが説明になるようにしてください。役に立つ解決策は、どの境界が誤っていたのか、どの設定がそれを変えたのか、そしてそれが今どのプロセス群に影響するのかを名指しします。VPN、エディタ、エンジンを順に再起動すれば症状は変わるかもしれませんが、次の人の役に立つものは何も残りません。

関連記事