WSL2 ミラーモードでDocker使えないのは困る

WSL 2.0.0以降で試験的に提供されているネットワークミラーモードは非常に魅力的でが、まだ試験的ということでいくつか課題があるのも事実です。ここではDockerでの課題と**回避策**を交えつつミラーモードの深淵を覗いてみたいと思います。

:::note warn
[2024.9.22]Docker 27.3.0 に WSL用の特別ルールが入りました。これによりWindowsホストからの127.0.0.1へのアクセスはコンテナでポートマップしたサービスへアクセスできるようになりました。

https://github.com/moby/moby/releases/tag/v27.3.0

ということで、この記事に書いた内容はすでに昔話です。安心してミラーモードをご利用ください。
:::
:::note info
[2023.12.6]Docker Desktopでのポート重複は [4.26.0](https://docs.docker.com/desktop/release-notes/#4260) で対応となったようです。
> Added support for WSL mirrored mode networking (requires WSL v2.0.4 and up).

リリースノートより
:::

:::note info
[2023.10.25] ミラーモードはじめいくつかの新機能はWindows11 22H2通常版に機能開放されました。最新のWindowsUpdate適用の上、ストア版WSLをアップデートすることにより以下のオプションが.wslconfigで使えるようになります。
```
[wsl2]
networkingMode=mirrored
dnsTunneling=true
firewall=true
autoProxy=true
```
WSLストア版のアップデートは
```
wsl --update; wsl --update --pre-release
```
:::

## Dockerつかえないですけどー
### ミラーモードではポート転送できない？
ミラーモードが提供されてまもなく、[Dockerがポート転送できないというissue](https://github.com/microsoft/WSL/issues/10494)が立ちます。WSL+Dockerという組み合わせは比較的多いと思うのでここがノーマークなんだーと純粋な驚きがあります。

そして、ほどなく[ignoredPortsを設定すれば解決するぜ](https://github.com/microsoft/WSL/issues/10494#issuecomment-1732847249)と仰る方が現れます。要はWSLでlistenしてもWindows側からは無視するポートを番号指定するというものです。

このtipsに対して "that wold work", "Nice find" など謝辞が届くのですが、「**いやいや、そんなことするとコンテナがListenしているポートがWindowsから見えないよ**」と異論を唱える声が出てきます。初期のコメントにWindowsからアクセスできないけど**別のホストからはアクセスできる**というものもあり、これとも符合しません。[別のissue](https://github.com/docker/for-win/issues/13686)ではポート競合でコンテナを起動できないというものもありなんだかよくわからない状態です。

### 原因は一つではない
「Dockerはポート転送できなくなる」の`Docker`が**人によって見ているものが異なる**と考えると理解が進みます。Docker Desktopを利用していた時の記憶では通信を中継するプロセスがWindowsにも起動していて、そのプロセスがDockerコンテナとの通信を中継していました。つまり、Docker DesktopではWSL、Windows双方で同様のプロセスがlistenするためポートが競合します。

[心の声] いやいや、いままでもそうじゃん。

その通りなのですが、これまでとミラーモードで使われるWindowsとの中継方法が異なっていて今回は競合を許さないようです。

[心の声] いやまて、WindowsでlistenしていたのはDocker DesktopではなくWSLの仕組みじゃないのか？

WSLにもローカルホストフォワーディングという仕組みが存在してWindowsプロセスが通信を中継します。この仕組み自体がDocker Desktopとほぼ同じもの(dockerから提供を受けた？)です。Docker Desktopの場合、WSL上のDocker、Windows上のWSLプロセス、Windows上のDockerプロセスの3つがListenすることになるので、後発のWindows上のWSLプロセスは競合を避ける仕組みになっているのでしょう。

片やWSL側にLinuxパッケージとしてDocker(区別するためにDocker-ceとします)をインストールしている場合にはWindows側にヘルパープロセスは起動しませんからこのような競合は発生しません。しかし、競合はなくともやはりWindowsからはアクセスできません。状況をまとめると。

|   |Docker Desktop|Docker-ce|
|---|---|---|
|ignoredPort  |効果あり  |効果なし  |
|コンテナ |起動不可？  |起動可  |
|別のホスト |アクセス不可？  |アクセス可  |

### Docker Desktopでの回避方法
現状の回避方法は.wslconfigにignoredPortsを指定してポートの競合を避ける方法が有効のようです。根本的な解決方法としてはWSLとWindowsでポートが競合する場合、Windowsを優先すればよさそうですが最初からそうなっていないのは実現が難しいのかもしれません。また、WSL上でネットワークモードを取得するコマンドが唐突に提供されたのでDocker Desktopがネットワークモードを見て動作を変えるという話が進んでいるのかもしれません。
```:.wslconfig
[experimental]
ignoredPorts=80,443
```
https://learn.microsoft.com/ja-jp/windows/wsl/wsl-config

### Docker-ceでの回避方法
Docker-ceでは回避方法として二つのプランを試行しています。

/etc/docker/daemon.json に以下の設定を行う
```:/etc/docker/daemon.json
{
    "iptables": false
}
```

nftablesに以下の設定を行う
```
$ nft insert rule ip nat PREROUTING iif loopback0 counter accept
```

個人的な興味はこちらにあったので、更に深堀していきます。


## Docker-ceはどんな非互換を踏んだのか

Docker Desktopではポートの競合というわかりやすい理由ですが、Docker-ceでは何が問題だったのでしょうか？少なくともWindowsプロセスとの競合ではありません。

### ミラーモードの正体


ミラーモードに切り替えると、以下のようなネットワークインタフェースが生えています。
```
3: loopback0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether 00:15:5d:1d:1c:a2 brd ff:ff:ff:ff:ff:ff
```

どうやらこれがミラーモードの重要な要素のようです。

Windows から 127.0.0.1 へのアクセスをキャプチャーするとこの loopback0 を介してやり取りされるようです。loopback0はループバックデバイスなのかというとそうでもなく、eth1 を loopback0 に名称変更したもののようです。
```
hv_netvsc fc232fd7-4443-4791-a539-deef713ec4d4 loopback0: renamed from eth1
```
そして hv_netvsc とあるので Hyper-V仮想スイッチに接続されたデバイス = eth0と同じく普通のネットワークアダプタです。そんなところにlocalhostのトラフィックが流れる？ようです。つまり、ループバックというよりはloopback0の向こう側に127.0.0.1を名乗るWindows君が隠れているようです。

### Dockerだけ問題が出る？

loopback0を介して 127.0.0.1 <---> 127.0.0.1 の通信が成立しているのですが、Dockerの場合にはどうなっているのか確認します。

```
Windows--->            --->WSL(loopback0)--->         --->WSL(br-xxxx)--->
127.0.0.1 -> 127.0.0.1  127.0.0.1 -> 127.0.0.1      127.0.0.1 -> 172.x.x.2
```

確かにダメですね。コンテナに送信元が127.0.0.1としてパケットが届いています。これではホストであるLinux(WSL)へ返答できません。

ローカルホストフォワーディングの場合はどうかというと

```
Windows--->                 --->WSL(lo)--->         --->WSL(br-xxxx)--->
127.0.0.1 -> 127.0.0.1  127.0.0.1 -> 127.0.0.1     172.x.x.1 -> 172.x.x.2
```

こちらはコンテナに届く際に172.x.x.1に変換されていますね。正確には変換されているわけではなく、旧来は172.x.x.1を起点とした通信が発生しています。

### 2種類のコンテナ通信

Dockerコンテナへの通信方法は2種類用意されています。一つはiptables(nftables)を駆使して宛先IPアドレス(やポート)を変換して仮想ネットワークに接続されたコンテナまでルーティングする方法です。もう一つはdocker-proxyプロセスが指定されたポート番号で一旦受け取って、再度docker-proxyを起点としてコンテナと通信する方法です。

通常はnftablesを使った経路操作でコンテナまでパケットを導くのですが、127.0.0.1 <--> 127.0.0.1 のような通信ではルーティングされず、nftablesに書かれたNATのルールも通りません。この為、localhostを介しての通信はdocker-proxyプロセスに送り届けられます。にもかかわらずミラーモードの場合にはNAT変換されてコンテナまでルーティングされています。

### 127.0.0.1をルーティングしてしまう？

このルーティング制御はカーネルパラメタを使って変更できます。確認すると、以下二つの値が変更されていました。

```
net.ipv4.conf.eth0.route_localnet = 1
net.ipv4.conf.loopback0.route_localnet = 1
```
これが変なパケットをルーティングしてしまった原因です。

Q. この状況を避けるには？
A. route_localnet = 0 にする

不正解

この設定は loopback0を介して 127.0.0.1 でやり取りする目的を達成するために設定したものでしょうから、これを否定するということはそもそもWindowsと通信できなくなるはずです。

正解は docker-proxy に働いてもらう。です。

### docker-proxyを機能させる

これが、先の回避策となるのですが、daemon.json にて "iptables" : false を記載すると、nftablesにルールが生成されなくなります。つまり、コンテナとのやり取りはすべて docker-proxy プロセスが受け取るので一般的なlocalhostからの通信と同じ状態に戻ります。

ただ、そもそもの状態だと

127.0.0.1 宛てのパケットはルーティングされませんでした
　　　　　　　　　　↓
ルーティングされないということはPREROUTINGというチェーンを通ることはなかったはず
　　　　　　　　　　↓
PREROUTINGチェーンを処理させなければ万事ok?

PREROUTINGを処理させない条件はインタフェースが loopback0 の場合というのでよさそうです。この場合にaccept(=チェーンを完了する=以降のルールを処理しない)ルールを先頭に入れておけばよさそうです。

daemon.jsonへの変更ではDocker-ceのみが対象ですが、nftablesで対応しておけば同様の問題を抱えるツールがあった場合にも対処できそうです。ただし、挿入したルールよりも前にルールが書かれるようなケースでは別途対応が必要です。

このnftablesのルールをWSL自身があらかじめ設定しておいてくれれば多くの問題は露見しないのではないかと思っています。賛同が集まれば、、、、

## 更なる非互換の沼へ

これまでのローカルホストフォワーディングは127.0.0.1でWindowsからWSLへアクセスできるという機能だったのでこれが出来れば概ねokです。しかし、PREROUTINGチェーンを回避するというのは所詮付け焼刃なのでボロは出ます。

先のissueにもあったように他のホストからは通信できています。まだ出ていないパターンは

　　　　　**WindowsからWindowsのIPアドレスへ通信する**

です。Windowsにも192.168.1.30などIPアドレスが付いています。ミラーモードでは当然このパターンも対応しています。しかし、Dockerコンテナへの通信ではlocalhost同様にダメです。この時どういう流れになっているかというと、

```
Windows--->                       --->WSL(eth0)--->              --->WSL(br-xxxx)--->
192.168.1.30 -> 192.168.1.30  192.168.1.30 -> 192.168.1.30   192.168.1.30 -> 172.x.x.2
```
往路は問題なさそうです。復路は？
```
Windows<---                       <---WSL(eth0)<---              <---WSL(br-xxxx)<---
------------ <- ------------  ------------ <- ------------   192.168.1.30 <-- 172.x.x.2
```
WSL内のコンテナネットワークからWSL Linux本体に戻ってきたところで途絶します。正確にはWSL Linuxからコンテナへ向けてリセットパケットが送出されます。つまり、不正なパケット(自身がネゴシエーションしたわけじゃないのに返答を送ってくる)として処理されています。これはコンテナに接しているインタフェースから自身のIPアドレス向けにパケットが戻ってくるのでそこで処理されていると思われます。ミラーモードの構造では、その後eth0へ送り出し->その先にある192.168.1.30=Windowsへと送り届けないといけません。

こうなってくると潔くNATでの制御をあきらめてdocker-proxyのみで運用するのが正解のような気もします。


### その他にも
ミラーモードはその名の通りWindowsのネットワークスタックの情報をWSL側へ射影します。この為、WSLでVPN接続するとインタフェースは生えるものの経路情報が消されてしまいます。これはここまで書いたDockerの挙動に比べれば当然なのでWindowsでVPN張ろうよとあきらめが、、、つくかどうかは人それぞれかもしれません。いずれにせよ普通ではないネットワークなのでいろいろ制限はありそうです。

特にDocker-ceで問題となった部分はloopback0の向こう側に127.0.0.1というノードが**他にいる**。eth0の向こう側に自身と同じIPアドレスのノードが**他にいる**。というイレギュラー仕様なので罠は多そうです。逆に言うと担当者の多大なる努力の痕跡が見受けられます。

### 制限はあるものの、、、

これまで(これからも？)標準のNATモードでは様々弊害があったのに対し、ミラーモードは大きな助けになると考えています。しかし、Windowsネットワークスタックと情報を共有するという踏み込んだ仕様のため制限が存在するのも事実です。最低限、DockerでのWindowsからのlocalhost接続に関しては箱出しのインストールで解決する必要があると思っています。DockerがWSL用に特殊な処理を行うのは望ましくないと考えると、先のnftablesの設定でlocalhostアクセスだけでも救っておくのが最善と考えています。

その先は、そもそも他ホストからアクセスするためにはWindowsのファイヤーウオール設定が必須などある程度操作知識が必要になります。であれば逆に、設定すれば大丈夫というのが許される世界に入ってくるので対応方法も様々考えられます。それ以上を望むのであれば、ブリッジモードにすればWindowsとは別系のネットワークとして活用できる(&ローカルホストフォワーディングも使える)ので非互換に苦しむことも無いでしょう。

個人的には、持ち歩いてネットワーク接続が切り替わるノートPC等はミラーモード、据え置きのデスクトップはブリッジモードが最適かなと考えています。が、それも人それぞれ。

## オマケ
loopback0からのパケットをPREROUTINGから離脱させるサービスです。systemdを利用している場合にご利用ください。

```:/etc/systemd/system/loopback0.service
[Unit]
Wants=network-pre.target
Before=network-pre.target shutdown.target

[Service]
User=root
ExecStart=/bin/sh -ec "\
  nft add chain ip nat PREROUTING '{ type nat hook prerouting priority dstnat; policy accept; }';\
  nft insert rule ip nat PREROUTING iif loopback0 counter accept comment mirrored\
"
ExecStop=/bin/sh -ec '\
  n=$(nft -a list chain ip nat PREROUTING | sed -En "s/^.*comment \\"mirrored\\" # handle ([0-9]+)$/\\1/p");\
  [ -n "$n" ] && nft delete rule ip nat PREROUTING handle $n\
'
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
```

このファイルを/etc/systemd/system 以下に置いて以下のコマンドを実行すれば起動時にルールが生成されます。
```
$ systemctl --now enable loopback0
```