WSL2ミラーモードでDocker Engineを使う

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

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

ということで、この記事に書いた内容はすでに昔話です。安心してミラーモードをご利用ください。
2024.09.22
:::

WSL2にミラーモードが投入されてからもうじき1年を迎えようとしています。しかし、未だに Docker Engineでの通信に問題を抱えています[^1]。4月には「対応を検討している」「副作用を検証している」とやっと進展がありました。6月に入り「Dockerと話し合っているのでもう少しまってくれ」お、いよいよだな。そして、moby(Docker)にissueがたてられました。そこでは、「Docker Desktopでは発生せず Linux の Docker Engine だけの問題なので**修正するものは何もありません**」え？話し合ってるんじゃなかったの？
まぁ、そりゃそうだよね。だってこれ完全に WSL の非互換問題だもん。

[^1]: ここでいうDockerはDocker Desktopではなく、linuxパッケージでインストールするものを指します。


ということで、この問題はしばらく解決しそうにありません。Docker Desktopを使えば解決するのでしょうが、Dcoker のWindows連携アプリ自体が問題を起こすことも多かったので個人的には遠慮したいところです。そして何よりこの問題は以下のファイルを /etc/systemd/system/network-mirrored.service に置いて
```
$ sudo systemctl --now enable network-mirrored
```
とすれば解決できるのです。ただし、副作用が無いとは言えないので完璧とは言い切れません。今のところ使っている人たちには満足いただけているようです。

https://gist.github.com/shigenobuokamoto/b565d468541fc8be7d7d76a0434496a0

たいしたことはしていなくて、ほんの数行の話なのですが「何をしているのかわからない」とよく言われます。でも、不思議なことにこれで機能するんですよね。

この記事ではこの小技の仕組みを記録しておこうと思います。

## ミラーモードでの課題をおさらい

課題の前にミラーモードについてですが、ミラーモードはこれまであった WSL2ネットワークへの不満を解消する意欲的な機能です。

標準のネットワークモードは以下のようにWindowsホストの裏側に NAT を介して隠れたような構成になっています。

```
LAN ------ Windows Host ---(NAT)--- Linux
```

この為、LinuxからLANあるいはその先のインターネットへアクセスできるものの逆方向のアクセスはできません。また、このNATはIPv4しか通さないので IPv6 での通信もできません。
※いずれも工夫すれば対応可能ですが、一旦横に置いておきます。

ミラーモードを使うといままで出来なかったことが可能になります。
- IPv6で通信可能
- Windows Hostより外側(LANやインターネット)から Linux インスタンスにアクセス可能
- 経路情報がWindows準拠なのでVPN経路との齟齬が起こりにくい


実はミラーモードも構成としてはNATモードと変わらないのですが、Microsoft技術者の工夫で「Windows と同じ IPアドレスで Linux が稼働しているような」、こんなイメージになっています。

```
LAN ------ Windows Host 
           Linux
```

これはあくまでも表面的なイメージで Windowsへ届いたパケットを中間ネットワークを介してLinuxにばれないように送り届ける工夫がなされています。

そう、表層的にWindowsと同じIPアドレスを使うように装っていますが実際には違うので凝った使い方をすると非互換に遭遇することがあります。

現在私が把握している課題は
- Docker ネットワークへ Windows Hostからアクセスできない(本テーマ)
- Linuxインスタンスからの通信に使うソースポートが 300個に制限されている(Microsoftで対応を検討中)
- VLANが使えない(多分無理)

GitHubのissuesを見ているとDockerの問題とNetworkManager等を動かしてネットワーク設定を壊してしまう[^2]2点以外は大きな話題になっていないので、ここさえ解決できればミラーモードが標準のネットワークに昇格できるんじゃないかと思っています。

[^2]: これはストアで配布するWSL用ディストロが対処するのが妥当な気がします。

## 処理から逆引きする形でミラーモードの構造に迫ってみる

先に上げた課題はいずれも芸の凝った実装の弊害ですが、Dockerでの問題を解決する小技を通してその構造に迫ってみます。

### スクリプトを広げてみる

gist に置いてあるのは systemdサービスですが、根幹は以下の部分です。

```
  echo "\
  add chain   ip nat WSLPREROUTING { type nat hook prerouting priority dstnat - 1; policy accept; };\
  insert rule ip nat WSLPREROUTING iif loopback0  ip daddr 127.0.0.1 counter dnat to 127.0.0.1 comment mirrored;\
  "|nft -f -\
```

たったこれだけでなおるの？という物量です。

まずはこの処理自体を見ていきましょう。nftコマンドは nftables(iptablesの後継のパケットフィルタ)にルールを設定するコマンドです。
ルールを展開して書くと、

```
ip nat WSLPREROUTING {
  type nat hook prerouting priority dstnat - 1; policy accept;
  iif loopback0  ip daddr 127.0.0.1 counter dnat to 127.0.0.1;
}
```

こんな感じです。

`ip nat WSLPREROUTING` これは WSLPREROUTING という名前のチェーンを IPv4 の NAT として定義します。

チェーンの中身は以下の二つ

`prerouting`フックにチェーンを追加します。優先度は dstnat よりも一つ前。ポリシーとしては許可、つまりルールに引っかからなければ素通し(=無かったことと同意)させます。dstnat というのは iptables の PREROUTINGチェーンの優先度です。つまり、Dockerが定義するPREROUTINGチェーンのルールを処理する前にWSLPREROUTINGが処理されます。

もう一つはこのチェーンでのルールです。
loopback0 というインタフェース(ミラーモードで使用されるWindowsホストとの通信用インタフェース)から 127.0.0.1 宛てのパケットを受け取ったら 127.0.0.1 へ NATする。

。。。。。それだけ？

いや、127.0.0.1 宛てのパケットを 127.0.0.1 へNATするなんて意味わからんし。。。。。と言われました。。。

でも解決するんです。


## いったい何がおこるのか？

Windowsホストからの通信は loopback0 を介して Linuxインスタンスへ送られます。

loopback0 これは 127.0.0.1(Windows)と通信するものですが、「いやまて、127.0.0.1 は loだろ」。まぁ、そのとおりです。でも lo で完結してしまうと 127.0.0.1 <-> 127.0.0.1 の通信で Windowsとやり取りは出来ません。そこでミラーモードでは loopback0 というインタフェース(eth0同様普通のネットワークアダプタ)を介して通信します。

ここで困るのが Docker のような Linuxインスタンス内の仮想インタフェースです。
通信を覗くとこのようになってコンテナから Windows へパケットを戻すことができません。

```
    Windows Host                  Linux                   コンテナ
                                               NATする
127.0.0.1 -> 127.0.0.1   127.0.0.1 -> 127.0.0.1      127.0.0.1 -> 172.17.0.3
                                                     127.0.0.1 <- 172.17.0.3
                                            ??? 自身のループバックへ戻してしまう？
```

そこで先ほどの nftables です。127.0.0.1 宛てのパケットを 127.0.0.1 宛てに NATをするとそこで NAT処理が終了します。つまり、PREROUTING に書かれた Docker の NAT が働きません。

```
    Windows Host                  Linux                   コンテナ
                                               NATなし
127.0.0.1 -> 127.0.0.1   127.0.0.1 -> 127.0.0.1
```                                            

NATしないのは良しとしてそれでどうやって通信する？

Docker には nftables での NAT 以外にユーザーランドプロキシという仕組みが用意されています。docker-proxy プロセスが指定のポートで listen しているので、このように docker-proxy を使用することでコンテナと通信可能になります。


```
    Windows Host                  Linux                   コンテナ
                                               NATなし
127.0.0.1 -> 127.0.0.1   127.0.0.1 -> 127.0.0.1
                                     docker-proxy
                                          172.17.0.1 -> 172.17.0.3
                                          172.17.0.1 <- 172.17.0.3
127.0.0.1 <- 127.0.0.1   127.0.0.1 <- 127.0.0.1
```                                            

nftablesに仕込まれた処理の意味は 127.0.0.1 -> 127.0.0.1 にNATすること自体ではなく、NATの処理を終了させるのが目的ということです。return や accept アクションを指定するとチェーン内の処理は中断できますが次のチェーン(このケースではPREROUTING)が処理されてしまいます。NATを完了させないとhookに登録された後続のチェーンをキャンセルできないようです。

PREROUTINGチェーン内でDockerが用意するルールの前に終了させることも可能(初期のスクリプトはそうしていた)ですが 処理順序や他の機能でPREROUTINGに変更が加わる場合の影響を考えると別チェーンの方が独立性は高いと考えて現在の処理にしています。

## なにか違和感が、、、

ありますか？実は通常のDockerの状態だと外部からの通信に対しては NAT 処理、127.0.0.1 からの通信に対しては docker-proxy が使用されます。しかし、ミラーモードの場合 127.0.0.1 からの通信に関わらず NAT 処理されています。変ですよね？

デフォルト設定のLinuxでは 127.0.0.1 への通信はルーティングされません。ルーティングされないので PREROUTINGチェーンも通りません。通らないので NATされないはず、、なのですが、ミラーモードでは127.0.0.1 が lo あるいは loopback0 の向こうだったりするのでルーティングが必要になります。
このような事情からミラーモードの場合には以下のカーネルパラメタが設定されます。

```
net.ipv4.conf.loopback0.route_localnet = 1
```

このおかげでミラーモードが成り立つのですが、先の Docker のように想定外の挙動になってしまうこともあります。

## というか全部ユーザーランドプロキシじゃだめ？
そういう解決方法もあります。というか、この問題を最初に認識した際、真っ先に思いついたのはnftablesの生成を止めて全てユーザーランドプロキシで対応する方法です。これでも問題ないのですが、一部ユーザーランドプロキシは遅いから嫌だという意見もありました。WSL上のDockerに外部から恒常的にアクセスして使っているんでしょうか？まぁ、そういう一部の意見はさほど重要でないとしてもこの対応を行うためには /etc/docker/daemon.json にDockerの設定を記述する必要があります。つまり、Docker側の対応なので**ユーザーがその情報を理解したうえで設定を変更する必要**があります。

当 systemd サービスの処理としては、カーネルパラメタが変更されているにもかかわらず loopback0 から 127.0.0.1 が流入するケースにおいてはルーティングをキャンセルしているわけです。ある意味強引なことをしているので弊害もあるかもなーと思いつつ、
 - デフォルトだとルーティングはされない
 - キャンセルされるのは loopback0 から 127.0.0.1 宛てのパケットが流入した時のみ
 - 他の用途で lo 以外(しかも loopback0 名指し)から 127.0.0.1 宛てのパケットが飛んでくることなんてあるのか？
 - そもそも loopback0 は WSL専用に作られるものなのでWSL以外の用途でこれを使うことはないだろう
 - dockerがuserland-proxyを停止した場合には問題があるが、Windowsホスト -> 127.0.0.1 -> docker network へのNATルールは別の理由で動かないのでこれ自体が問題とはなりえない

つまり、WindowsホストからWSL固有の通信のみLinuxのデフォルト状態に戻しているだけと考えると、これといってマイナスポイントは思いつきません。そうなると、同等のものがWSL標準になる、あるいは /init に組み込まれればユーザーが意識する必要はなくなるので利便性は高まります。



## いろいろ深いミラーモード

でも 127.0.0.1 のルーティングって何をしているの？という疑問など説明を聞いても納得というよりは更なる疑問が発生すると思います。

そう、127.0.0.1 での Windows / Linux(WSL)間の通信はルーティングされているのです。Windowsでの処理はブラックボックスですが、Linux -> Windows についてはルーティングテーブルで確認できます。

```
$ ip route show table 127
127.0.0.1 via 169.254.73.152 dev loopback0 proto kernel src 127.0.0.1 onlink
```

Linuxが処理できない 127.0.0.1 宛てのパケットは loopback0 を介して 169.254.73.152 へと送られます。169.254.73.152 で受け取ったパケットをWindowsプロセスが中継することで、Windows内では 127.0.0.1 <-> 127.0.0.1 として動作しているようです。

その 169.254.73.152 は何処から出てきたんだ？これは \/init にハードコードされています。

さて、こうなるとミラーモードは決して Windows と並列にネットワーク接続されているわけではなく、

NATモードのこの図と同様に仮想ネットワークに繋がっているものを
```
LAN ------ Windows Host ---(NAT)--- Linux
```

こんなふうに見せかけることが改めて実感できます。

```
LAN ------ Windows Host 
           Linux
```
この仕組みを理解していれば MACVLANが使えなくてもそりゃだめだろなって思いますよね。でも、知らないと使えるかもって期待しちゃいます。

ミラーモードの使用感としては通常の NATモードより違和感は少ない優れたモードだと思っています。ただ、実現するために凝り過ぎた仕組みが満載されているため、ネットワークに詳しい人ほど躓きポイントが多い仕組みかもしれません。

そして、その仕様は非公開、更にWindows側での処理はブラックボックスなのでなかなかに謎めいた機能です。

ただ、最後にもう一言。

おそらく、ミラーモードは一般用途のWSLではベストな選択です(Dockerの問題さえ解決できれば)。