Docker コンテナの通信はホストの VPN を迂回する:確認する方法
ホストの VPN が覆うのはホストのネットワーク名前空間だけです。ブリッジ上のコンテナの通信がトンネルに入っているか、直接出ているかをこれらの確認で見極めます。
ホストのブラウザには VPN の出口アドレスが表示され、コンテナは自宅の回線を報告し、誰かがそれを漏洩と呼ぶ。何も漏れていない。ホストのトンネルとは、ホストのネットワーク名前空間に属するインターフェース、ルート、ルールの集まりであり、ブリッジ上のコンテナは独自のデフォルトルートを持つ別の名前空間である。VPN クライアントが何週間も接続されたままの Compose スタックで、コンテナのパケットが一度もそこに入っていなかった例に私は何度も座ってきた。
トレイアイコンより先に名前空間を確認する
名前空間がこの議論のすべてだ。Docker がブリッジネットワーク上でコンテナを起動すると、ネットワーク名前空間を作り、veth ペアの片端をその中に接続し、その名前空間にアドレスとデフォルトルートを与える。トンネルのインターフェースは、何と呼ばれていてもホストの名前空間に存在する。コンテナ内の通信からは見えず、外側からクライアントの設定を変えてもそれは変わらない。
分離から抜け出す方法は二つある。--network host はコンテナをホストの名前空間の中に置くので、ホストのルート、つまりトンネルを継承する。--network container:vpn は別のコンテナの名前空間を共有する方法で、ほとんどのサイドカー構成はこれでワークロードを VPN コンテナ経由にしている。どちらも明示的な選択であり、通常は片方だけがデプロイした人の意図である。
ホストモードは無料のアップグレードではない。コンテナは独自のアドレスを失い、ポート公開は効かなくなり、そこに NET_ADMIN を持つプロセスはホストのルーティングを書き換えられる。それを受け入れるのが正しい判断のこともある。すでに有効だと決めつけることが、経路に一度も入っていないトンネルを一晩デバッグする羽目になる原因だ。
ブリッジから出たパケットを追う
ブリッジ上のコンテナは、自分のパケットをデフォルトゲートウェイ、つまりホスト側のブリッジインターフェースに送る。ホストはそのパケットを転送し、既定の構成ではパケットが出ていく前に送信元アドレスを自分のアドレスへ変換する。この変換の工程が、ポリシーを静かに無効化する。
パケットがトンネルの終端に届く頃には、ホストのアドレスを帯びている。コンテナの元のアドレスを前提にしたルール、ポリシールート、分割トンネルの判断はどれも一致しなくなり、別々の出口から出るはずの二つのコンテナを送信元だけで区別することはできない。コンテナごとのルーティングが必要なら、判断は変換の前か、自分が制御する名前空間の中で行う必要がある。
Docker 自身がこれらのルールを保守している。デーモンが設定されたファイアウォールバックエンドによって管理される。転送通信のためのサポート対象フックである DOCKER-USER の外側に手書きしたルールは、デーモンの再起動やホストの更新で並べ替えられたり消えたりする。Docker のパケットフィルタリングのドキュメントにはチェーンの順序が示されている。実務上の要点は、どのルールにも所有者と、実際に試した再読み込みの経路が必要だということだ。
二つのシステムを別々に試験する
ある名前空間での証拠は、もう一方について何も語らない。VPN コンテナの中で実行したリクエストは、そのコンテナにとってトンネルが機能することを示す。ホストについての証拠ではなく、同じコマンドを --network host コンテナから実行してもブリッジについての証拠にはならない。
# host namespace
ip route get 1.1.1.1
ip -brief addr show
# a fresh container on the same bridge network
docker run --rm --network appnet alpine sh -c 'ip route; cat /etc/resolv.conf; ip -6 route'
次に、似たイメージではなく実際のアプリケーションコンテナの中で試験を繰り返す。アプリは追加のルート、プロキシの環境変数、別のリゾルバを持っていることがあり、どれも答えを変える。名前解決と二番目のアドレスファミリーは毎回確認すること。ユーザー定義ブリッジ上のコンテナは Docker 内蔵のリゾルバで 127.0.0.11 を通じて解決され、ブリッジ上の IPv6 デフォルトルートは、IPv4 だけのトンネルを試験している間に通信を外へ運び出しうる。
認証情報に触る前にパターンを読む
ホストが動いてコンテナが動かないとき、原因はほぼ毎回構造的であり、鍵を回したりクライアントを入れ直したりしても半日が無駄になる。まず症状を分類すれば、修正はたいてい自分から名乗り出る。
| What you see | Usual cause | First check |
|---|---|---|
| Host exits through the VPN, container shows the ISP address | The container never entered the tunnel | Default route inside the container |
| Container works until the daemon restarts, then stops | Rules outside the supported hook, or a changed backend | Daemon's reported firewall backend and DOCKER-USER |
| Container loses internal hosts after the tunnel comes up | VPN prefix overlaps the Docker bridge subnet | ip route on the host, then docker network inspect |
| Small replies arrive, large ones hang | Tunnel MTU or missing MSS clamping | Link MTU on both ends of the tunnel |
| Container falls back to the ISP address when the tunnel drops | Nothing enforces the kill switch for forwarded traffic | Stop the tunnel with a request in flight |
重複の行には独自の一文が必要だ。172.17.0.0/16 へのルート、あるいは偶然ブリッジのプレフィックスを含む 10.0.0.0/8 へのルートを VPN プロファイルが押し込むと、トンネルがコンテナのサブネットを主張し、変換されたアドレスへの応答は誤った方向へ向かうか消えてしまう。Docker ネットワークの採番をやり直すほうが、例外を積み上げるよりたいていきれいだ。
適用範囲を明示し、そのうえで意図的に壊す
レビューに耐える設計は二つある。VPN コンテナの名前空間を共有すれば、対象のワークロードはすべて一つの再起動境界を持つ一つのトンネルを使う。あるサービスが別の出口や独自の DNS 経路を必要とするまでは単純だ。あるいは コンテナのネットワークをホスト上の VPN ゲートウェイへルーティングする。転送を有効にし、ブリッジのプレフィックスをトンネルへ向け、戻り通信を明示的に扱う。これでネットワークごとの制御を得る代わりに、デーモンとクライアントの更新をまたいで保守すべきルール群を抱えることになる。
いずれにせよ、重要な試験は故障時の挙動だ。コンテナのリクエストが飛んでいる最中にトンネルを止め、パケットがどこへ行くかを見る。直接経路に戻るなら、適用範囲は一度も強制されていなかった。クライアントの kill switch はクライアント自身の通信を制御するもので、ブリッジから転送されるパケットを制御するものではないからだ。その後デーモンとホストを再起動し、結果をもう一度確認する。手書きのルールが消えるのはその瞬間だ。
完了と言う前に四点を確認する。トンネルのインターフェースが通信と同じ名前空間にあること、コンテナの試験が実際のコンテナ内で行われたこと、名前解決と両方のアドレスファミリーを確認したこと、トンネル故障時の試験が今もフェイルクローズすること。そのうえでファイアウォールバックエンド、クライアントのバージョン、各ルールの所有者を書き留める。この記録が、次の更新で古い前提が無記録のまま混ざるのを防ぐ。