解決 WSL 2 裡的 Java 服務無法從外部連接的問題
前言
WSL (Windows Subsystem Linux) 是 Windows 10 的新功能,讓 Windows 使用者可以在 Windows 上執行 Linux 的指令與程式。WSL 有 1 代與 2 代,1 代是"模擬" Linux kernel,而 2 代是跑真正的 Linux kernel。詳細介紹與說明可在網路上找到,在此不再贅述。WSL 2 雖然有很多好處 (最重要的其中一項是可跑真正的 Docker Engine),但也產生出 WSL 1 沒有的問題。這篇文章就是說明一個關於 Java 的問題以及 Github 上的高手提出的解決方法。
問題
Linux 功能強大,開源工具又多,許多系統管理或自動化操作都是用 Linux shell script 寫的。有 WSL 2 可以在 Windows 上以 Linux 的指令來管理系統或服務真的是一大福音。但目前因為不明原因,在 WSL 2 上執行的 Java 服務無法從外部連上 (又或是倒過來,WSL 2 上的任何服務,如果用 Java 來連接會連不上)。這個問題只會出現在 WSL 2,純 Windows 或純 Linux 的環境都不會有這樣的問題。
很多產品或專案會用 Java 開發,又或是使用 Java 開發的服務像是 Tomcat 或 H2。如果想要用 WSL 2 裡啟動 Tomcat 等服務,從 WSL 2 外連不上就失去意義了。以下提供了幾種解決方法。
解決方法
1. 不要堅持用 Linux 而用純 Windows 的方式啟動服務
這是最簡單的作法。打開 cmd.exe 或是 PowerShell 就能做到。這樣的解決方法在整個環境的系統都是 Windows 的話就沒問題,不過很多服務或伺服器還是以 Linux 為主流,如果能以 Linux 的方式管理會更為方便。另外,有些工具 (例如 MockServer) 只提供 Linux 版的 Docker image,以純 Windows 的方式無形中也會縮限擴充性。
2. 降級成 WSL 1
如果沒有使用 Docker 的需求,這個方法也很容易。首先,打開 PowerShell 視窗。(需以系統管理者權限開啟,如下圖)
然後在 PowerShell 中執行以下指令即可,如下所示:
PS C:\> wsl -l -v
NAME STATE VERSION
* Ubuntu Running 2
PS C:\> wsl --set-version Ubuntu 1
指令說明:
* wsl -l -v (注意: -l 是 -L 的小寫,不是 123 的 1): 列出目前安裝的 Linux 以及各是用哪個 WSL 的版本。
* wsl --set-version <name> <version>: 設定 Linux 要用哪個 WSL 的版本啟動。
回到 WSL 1 確實可以解決這個問題,但如果要在 Linux 啟動 Docker container 的話,在 WSL 1 裡會失敗,因為 WSL 1 只是"模擬" Linux,如果要啟動 Docker container 也得換成 Docker 的模擬器才能執行,反而會衍生出更多問題。
3. 在 WSL 2 執行 Windows 的 java.exe
這個做法也很簡單。在 WSL 2 是可以直接執行 Windows 上的所有工具的,只要 cmd.exe 能執行的都可以。當然也包括 cmd.exe 本身,這意味著可以在 WSL 2 執行 Windows 的批次檔 (batch file,通常副檔名為 .bat)。
以下就是示範在 WSL 2 裡執行 cmd.exe 執行 Tomcat 的 startup.bat:
prompt$ $ which cmd.exe
/mnt/c/WINDOWS/system32/cmd.exe
prompt$
prompt$ $ cmd.exe /?
Starts a new instance of the Windows command interpreter CMD [/A | /U] [/Q] [/D] [/E:ON | /E:OFF] [/F:ON | /F:OFF] [/V:ON | /V:OFF]
[[/S] [/C | /K] string] /C Carries out the command specified by string and then terminates
/K Carries out the command specified by string but remains
/S Modifies the treatment of string after /C or /K (see below)
... (以下省略)
prompt$ cmd.exe /c tomcat\\startup.bat
由於 cmd.exe 在 PATH 裡,所以打 "which" 就可以找得到 (註: WSL 2 的 /mnt/c 就是 Windows 的 C 槽)。用 cmd.exe 執行的話,要注意的是檔案路徑的 separator。Linux 是斜線 "/",而 Windows 是倒斜線 "\"。在 WSL 2 要執行 cmd.exe 的批次檔須寫成 Windows 的路徑,但對 Linux 來說,"\" 是 escape 某個 字元的意思,所以要寫成 "\\"。
這樣啟動後便可以讓 WSL 2 外面的應用程式連接得上。那麼,如果想以 Docker container 啟動呢? 請看以下執行結果:
prompt$ which wincurl
/usr/local/bin/wincurl
prompt$ ls -l /usr/local/bin/wincurl
lrwxrwxrwx 1 root root 52 May 4 13:11 /usr/local/bin/wincurl -> /mnt/c/curl-7.76.1-win64/bin/curl.exe
prompt$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b558029d214b mockserver/mockserver "java -Dfile.encodin…" 12 minutes ago Up 12 minutes 0.0.0.0:1080->1080/tcp zealous_driscoll
prompt$
prompt$ curl -v -X PUT "http://localhost:1080/mockserver/retrieve?type=ACTIVE_EXPECTATIONS"
* Trying ::1:1080...
* Trying 127.0.0.1:1080...
* Connected to localhost (127.0.0.1) port 1080 (#0)
> PUT /mockserver/retrieve?type=ACTIVE_EXPECTATIONS HTTP/1.1
> Host: localhost:1080
> User-Agent: curl/7.76.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< version: 5.11.2
< connection: keep-alive
< content-type: application/json; charset=utf-8
< content-length: 2
<
[]* Connection #0 to host localhost left intact
prompt$ wincurl -v -X PUT "http://localhost:1090/mockserver/retrieve?type=ACTIVE_EXPECTATIONS"
* Trying ::1:1090...
* Trying 127.0.0.1:1090...
* connect to ::1 port 1090 failed: Connection refused
* connect to 127.0.0.1 port 1090 failed: Connection refused
* Failed to connect to localhost port 1090: Connection refused
* Closing connection 0
curl: (7) Failed to connect to localhost port 1090: Connection refused
prompt$
上面的輸出是分別用 Linux 跟 Windows 版的 curl 對 MockServer 的 Docker container 送 HTTP request。最一開始的兩個指令是說明 wincurl 是哪裡來的。先下載了 Windows 版本的 curl.exe 放在 C 槽的某個文件夾裡,然後再在 /usr/local/bin 建立一個 symlink,這樣不論在 Linux 的哪個文件夾裡都能用。
從上面的範例可以看出,以 Docker 啟動的 MockServer container,可以讓 WSL 2 的 Linux 版 curl 連上,但無法用 Windows 版的 curl.exe 連上。因為對 WSL 2 來說,雖然可以使用 curl.exe,但任何 .exe 程式 (包括 curl.exe) 都是屬於 WSL 2 以外的程式,所以連不上 WSL 2 裡面的 Java 服務。
如果需要用 Docker 啟動 Java 的服務,那就只剩下下面的方法,用 TCP forwarding。
4. 設定 Windows 防火牆規則做 TCP forwarding
這個解決方法的出處是這篇 Github 上的討論,設定 Windows 防火牆規則做 TCP forwarding 就能解決,並提供了可執行的 PowerShell script 做自動設定。懂得用 Windows Firewall 的人也可以從 UI 設定,不過討論裡的說明有提到,WSL 2 的 Linux 每次啟動的 IP 都會換,如果只是需要用 localhost (127.0.0.1) 連接,那用 UI 設定一次便沒問題。但倘若是要啟動某個服務 (例如 Tomcat) 好讓網內的其他系統可以連接,那麼最好是照著討論裡的方法做,也就是設定 Task Manager,每次系統管理員啟動/登入 Windows 時執行解決方法提供的 PowerShell script。
將提供的 PowerShell script 複製後存成一個副檔名為 ".ps1" 的檔案 (下面的範例是存成 run.ps1)。另外有兩個地方需要修改:
1. 將 $ports=@(80,443,10000,3000,5000); 裡面的 port 刪除或替換或新增,換成欲啟動服務的 port。例如 MockServer 預設 port 是 1080。
2. 將 $addr='0.0.0.0'; 的 0.0.0.0 換成 Windows 的 IP。如果只是想以 localhost 連接,則改成 127.0.0.1。
修改後,要執行此 PowerShell script 也需要以系統管理員權限開啟 PowerShell 視窗才行,因為更動防火牆設定需要系統管理員權限。執行結果如下:
PS C:\> .\run.ps1
Remove-NetFireWallRule : No MSFT_NetFirewallRule objects found with property 'DisplayName' equal to 'WSL 2 Firewall
Unlock'. Verify the value of the property and retry.
At line:1 char:1
+ Remove-NetFireWallRule -DisplayName 'WSL 2 Firewall Unlock'
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : ObjectNotFound: (WSL 2 Firewall Unlock:String) [Remove-NetFirewallRule], CimJobException
+ FullyQualifiedErrorId : CmdletizationQuery_NotFound_DisplayName,Remove-NetFirewallRule
Name : {b04c87c1-516e-4046-aa9c-edf4ef4f7ad5}
DisplayName : WSL 2 Firewall Unlock
Description :
DisplayGroup :
Group :
Enabled : True
Profile : Any
Platform : {}
Direction : Outbound
Action : Allow
EdgeTraversalPolicy : Block
LooseSourceMapping : False
LocalOnlyMapping : False
Owner :
PrimaryStatus : OK
Status : The rule was parsed successfully from the store. (65536)
EnforcementStatus : NotApplicable
PolicyStoreSource : PersistentStore
PolicyStoreSourceType : Local
Name : {a16642b0-1928-44c0-bc96-519e8bea0e9b}
DisplayName : WSL 2 Firewall Unlock
Description :
DisplayGroup :
Group :
Enabled : True
Profile : Any
Platform : {}
Direction : Inbound
Action : Allow
EdgeTraversalPolicy : Block
LooseSourceMapping : False
LocalOnlyMapping : False
Owner :
PrimaryStatus : OK
Status : The rule was parsed successfully from the store. (65536)
EnforcementStatus : NotApplicable
PolicyStoreSource : PersistentStore
PolicyStoreSourceType : Local
PS C:\>
初次執行會看到一開始有發生錯誤,這是因為這個 script 一開始會試圖刪除這個 script 預定建立的叫做 "WSL 2 Firewall Unlock" 的防火牆規則,而因為初次執行時這個規則並不存在,所以試圖刪除會報錯是正常的。之後再執行就因為規則存在而不再報錯。
執行完這個 script,再來試驗一次 Windows 版的 curl.exe 看看結果:
prompt$ wincurl -v -X PUT "http://localhost:1080/mockserver/retrieve?type=ACTIVE_EXPECTATIONS"
* Trying ::1:1080...
* Trying 127.0.0.1:1080...
* Connected to localhost (127.0.0.1) port 1080 (#0)
> PUT /mockserver/retrieve?type=ACTIVE_EXPECTATIONS HTTP/1.1
> Host: localhost:1080
> User-Agent: curl/7.76.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< version: 5.11.2
< connection: keep-alive
< content-type: application/json; charset=utf-8
< content-length: 2
<
[]* Connection #0 to host localhost left intact
prompt$
可以看到,因為新增了 TCP forwarding,Windows 的 curl.exe 執行結果便與 Linux 的 curl 無異,表示該服務已可從 WSL 2 外面連接得上了。
Reference
* 安裝 WSL: Windows 10 上適用於 Linux 的 Windows 子系統安裝指南
* 問題的討論串: WSL2 port forwarding is not working if accessed from java
* 解決方法: WSL 2 TCP Network Forwarding