ホスト VPN を無視する Docker と WSL の通信を直す
コンテナと WSL は独自のルート、リゾルバー、転送経路を持つため、ホストのトンネルが正常でも古い DNS や出口なしのまま取り残されます。各層を修正します。
この春、ある朝の大半を、内部名を一つも解決できないコンテナの追跡に費やしました。隣のブラウザーは何事もなく wiki を開いていたのに、トンネルは上がっており、ブラウザーがそれを証明していました。しかしコンテナは別のネットワーク名前空間にあり、ルートテーブルもリゾルバーも別でした。以下は現在私が確認する順序です。まずルートテーブル、次に名前解決、三番目に転送、最後にホスト側のプラットフォームです。すでに失敗している最初の手順で止めてください。スタックの下層にルールを足すと、境界を動かすのではなく隠してしまうからです。
ルールを変える前に 三つのルートテーブルを比較する
Docker の既定ブリッジ上のコンテナには docker0 ゲートウェイを向く既定ルートが 一本しかなく、tun0 の存在を知りません。ホストには tun0、VPN が押し込んだルート、そして場合によっては、想定と違うインターフェースから調査用トラフィックを出すポリシールールがあります。WSL は独自の仮想アダプターの背後に 三つ目のテーブルを追加します。どれかを編集する前に 三つすべてを出力してください。
| Context | Command | What a wrong answer looks like |
|---|---|---|
| Host | ip route get 10.8.0.20 | Leaves a physical interface instead of tun0 |
| Container | ip route | Only 172.17.0.0/16 via 172.17.0.1 |
| Container DNS | cat /etc/resolv.conf | A public resolver, or an upstream the tunnel never pushed |
| WSL | ip addr; ip route | Only the WSL subnet, no Windows VPN adapter |
コンテナの唯一のルートがブリッジで、その名前空間に tun0 が一度も現れないなら、下のどれも役に立ちません。ネームサーバーの変更はルートを作らず、ルートはピアが受け入れるトンネル ID を作らないからです。順序が重要なのは、シェルの中から見ると DNS の失敗とルートの失敗が同じに見えるからです。
リゾルバーを公開のものではなくトンネルに向ける
ホストでは、resolvectl status がリンクごとの DNS サーバーを一覧し、readlink -f /etc/resolv.conf はそのファイルが systemd のスタブか、NetworkManager のファイルか、ベンダーのクライアントが書き換えたものかを教えてくれます。ユーザー定義ネットワーク上のコンテナの中では、/etc/resolv.conf は 127.0.0.11 を指します。Docker の組み込みリゾルバーで、デーモンが設定された upstream に転送します。既定ブリッジ上のコンテナは代わりに、作成時にホストのファイルのコピーを受け取り、それを保持します。
そのコピーのせいで、コンテナ起動後に上がったトンネルは、コンテナを古い upstream への問い合わせのままにします。VPN のリゾルバーを明示的に渡すか、docker run --dns 10.8.0.53、あるいは "dns": ["10.8.0.53"] に /etc/docker/daemon.json を設定してデーモンを再起動するか、トンネルが上がってからコンテナを作り直してください。作り直すほうが、一時間前に正しかったリゾルバーをデバッグするより安く済むのが普通です。
公開リゾルバーに差し替えるのは、症状を残す修理です。インターネットは解決できますが、あらゆるプライベートゾーンで失敗し、トンネルが存在する理由であるフィルタリングを静かに回避できます。実行中のコンテナの /etc/resolv.conf の中に書き込もうとすれば、Device or resource busy が返ります。Docker はそのファイルを bind マウントしているので、編集にはまずアンマウントが必要です。--dns を作成時に設定するのが、再起動に耐える手順です。
コンテナがトンネルを借りるときは転送と変換を行う
既定ブリッジでは、ホストは docker0 と tun0 の間のルーターであり、既定ではどちらの仕事もしません。転送を有効にし、送信元アドレスを変換して、ピアには戻りルートのないアドレスではなくホストのトンネルアドレスが見えるようにします(tun0 はクライアントが付けるインターフェース名に置き換えてください)。
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o tun0 -j MASQUERADE
iptables -A FORWARD -i docker0 -o tun0 -j ACCEPT
iptables -A FORWARD -i tun0 -o docker0 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPTFORWARD ルールは、転送ポリシーが DROP のホストで重要です。Docker は独自のチェーンを挿入するので、iptables -S を読むのではなく、パケットで試してください。コンテナから ip route get 10.8.0.20 がブリッジのゲートウェイを示し、nc -vz 10.8.0.20 443 がリゾルバーではなくトンネルで接続するかタイムアウトするはずです。
MASQUERADE がないと、ピアは 172.17.0.0/16 への戻りルートを必要とし、ほとんどのリモートアクセス VPN はそれを押し込みません。ここでポリシーベースのクライアントが計画を壊すこともあります。特定の UID や cgroups のトラフィックしか捕捉しないなら、転送ルールはトンネル内のパケットを決して見ません。修正は iptables ではなくクライアントのルーティングポリシー側にあります。手で足したルールは再起動で消えるので、systemd ユニットか netfilter-persistent のテーブルに入れ、再起動後に再テストしてください。遅れて上がるトンネルがそれらを流し去ることがあるからです。
分離が目的ならトンネルをコンテナの中に置く
一つのコンテナが特定のノードから出る必要があるなら、そのコンテナでクライアントを実行します。これには --cap-add NET_ADMIN と --device /dev/net/tun が必要で、そうすれば独自の tun0、ルート、ファイアウォールを持ちます。他のコンテナがそこを通るなら、コンテナ内に転送と MASQUERADE ルールも必要で、それらのピアにはそこを指す明示的なルートかプロキシーが要ります。Docker の既定ブリッジはそのルートを代わりに追加してくれません。
ホストネットワーク、docker run --network host は安価な版です。コンテナはホストの名前空間に加わり、トンネルのルートを直接見ます。ただしネットワーク分離を捨て、ポート衝突を招き、実際には Linux の選択肢です。公開ポートは代わりになりません。-p 8080:80 はサービスをホストから到達可能にしますが、内部の 10.x アドレスをコンテナ内から到達可能にはしません。両者を混同することが、ルーティングの問題を露出したデータベースポートに変える道です。
名前の失敗と重複サブネットを区別する
Docker の既定ブリッジは 172.17.0.0/16 で、多くの企業 VPN プールは 172.16.0.0/12 の中にあります。トンネルがその範囲を持つと、コンテナ宛ての通信が代わりにトンネルへ流れることがあります。名前が失敗し、エラーは DNS に見え、問題はアドレスです。リゾルバーではなく宛先を確認してください。ip route get 172.17.0.5 をホストで実行し、ブリッジのプレフィックスを VPN プールと docker network inspect bridge --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}' と比較します。
修正はどちらかの範囲を動かすことです。daemon.json で Docker の既定アドレスプールを変えるか、コンセントレーター側のプールを変えます。ここで別のネームサーバーを足しても何も起きず、ssh -b も同じです。クライアントをインターフェースに縛っても変わるのは送信元アドレスで、トンネルへ向かう宛先ルートではありません。素早い切り分けはこうです。ホストで解決できてコンテナで解決できない名前はリゾルバーの問題、コンテナで止まってホストで動く直接の IP 接続はルートか転送チェーンの問題です。
WSL と Docker Desktop は Linux のルールに従わない
既定の NAT モードでは、WSL は独自の仮想アダプターの背後にあり、Windows VPN の内部ルートも DNS サフィックスも受け取りません。だからブラウザーは動き、ping はディストリビューション内で動きません。最近の Windows 11 ビルドで使えるミラー型ネットワークは代わりにホストのインターフェースを共有しますが、.wslconfig で有効にし、ディストリビューションを wsl --shutdown で再起動する必要があります。修正を適用する前に、wslinfo --networking-mode で有効なモードを確認してください。NAT の答えはミラー型モードには間違いで、その逆も同じです。
Docker Desktop はさらに層を増やします。Windows 上の Linux コンテナは、歴史的に vpnkit と呼ばれた NAT 実装の背後にあり、その経路はネイティブの Linux ホストとは異なります。Windows コンテナは Hyper-V の仮想スイッチ経由で接続し、企業クライアントがそのスイッチからの通信を遮断することがあるため、同じマシン上の Linux コンテナはネットワークに届いても、Windows コンテナは何も届かないことがあります。物理アダプターに結び付けた外部スイッチがよく使う回避策で、ポリシーが許さない形でコンテナのネットワーク ID を変えてしまいます。
別のルールを書く前に、四つの層を順にたどり、どこが間違っているかで止めてください。
- コンテナの中では、
ip routeがプライベートプレフィックスに届くか、/etc/resolv.confがそれを解決する upstream を示すか。 - ホストでは、転送が有効か、NAT がトンネルのインターフェースに結び付いているか、転送チェーンがブリッジからトンネルを許可するか。
- ピアでは、戻り経路を知っているか、コンテナのサブネットを隠すために NAT に頼っていないか。
- プラットフォームでは、WSL が NAT かミラー型か、コンテナが Linux か Windows か。
すべてのテストを新しいコンテナかシェルで再実行してください。キャッシュされた答えや確立済みの接続を持つ長時間動くプロセスは、設定が正しくなった後も失敗し続け、その失敗は効果のなかった修正とまったく同じに見えます。