WebAssembly 和 Sockets: WasmEdge 上的 PHP 開發伺服器

語言: CN / TW / HK

本文作者是 Wasm Labs @ VMware OCTO 的 Asen Alexandrov,原文連結:http://wasmlabs.dev/articles/php-dev-server-on-wasm/ 。翻譯與傳播均獲得授權。本文中的我或我們,均指代 Asen 或 VMware。

WebAssembly:無需容器的 Docker使用 Docker + WasmEdge 執行 WordPress 這兩篇文章介紹了 VMware 使用 Docker 執行基於 WasmEdge 的 worldpress,這篇文章我們介紹這背後的技術細節,如何用 WasmEdge Sockert 實現在 WasmEdge 裡執行 PHP 伺服器。


VMware 在開發 Wasm Language Runtime 專案的過程中, 在 WASI 之上開發了服務端 WebAssembly PHP 構建並持續對其進行擴充套件。 正如在初始工作大綱中解釋的那樣,由於 WASI 仍然不完整,我們無法移植使用了服務端 socket 的程式碼。

然而,其他 WebAssembly 執行時,如 WasmEdge 已經超越了當前的 WASI 標準,並使用提供缺失 socket 支援的 API 方法對其進行了擴充套件。 我們決定利用 WasmEdge 併為 wasm32-wasi 提供一個改進的 PHP 版本,它現在包括了 PHP 開發伺服器。

本文探討了我們在這項工作的過程中遇到的一些挑戰。 希望我們吸取的教訓能夠幫到其他人在 WASI 和現有應用程式上的工作。 除其他主題外,本文將涵蓋:

  • WASI 和 WasmEdge 的 socket 支援
  • 將帶有 socket 的 C 應用程式移植到 WASI
  • WASI 中的檔案描述符 (fd-s)
  • call_indirect 指令

有 Wasm/WASI 的服務端的 sockets

Berkeley Sockets API 提供了廣泛使用的流程來實現 TCP 伺服器。 建立 socket fd(檔案描述符)後,我們將其 bind 到一個埠,開始 listen 傳入連線並在它們到達時 accept 它們。 後者為已建立的連線提供了一個新的 fd,我們可以使用它來 send 或者 recv資料。

WASI 的 wasi_snapshot_preview1 版本遵循 Berkeley Sockets 方法,但委員會正在花時間和想辦法完善 Socket API 到最好的版本。 因此,對於 socketbindlisten 部分,我們還沒有類似 WASI 的東西。

符合預期的行為是 Wasm 執行時將提供預先開啟所需 socket 的功能,並允許 Wasm 應用程式 accept 它們的連線。 一方面,此行為與預開啟主機資料夾的方法一致,後者只增加安全性。 另一方面,它要求應用程式做額外的工作來找出他們將接受連線的 fd,例如通過 sd_listen_fds

接受預先開啟的 socket 的支援由 Wasmtime 等執行時實現。 這是用 C 編寫的伺服器應用程式的工作原理。

  1. Wasmtime 在執行 Wasm 應用程式時獲得一個額外的 --tcplisten HOST:PORT 引數
  2. Wasmtime 開始監聽 HOST:PORT 並將其檔案描述符編號傳遞給 Wasm 應用程式
  3. 伺服器程式碼使用 sd_listen_fds 獲取監聽檔案描述符
  4. 伺服器程式碼呼叫 accept,由 wasi-libc 實現並從WASI 轉換成 sock_accept
  5. wasmtime 實現 wasi_snapshot_preview1 其中包括 sock_accept

這種方法確實允許使用 WASI 實現伺服器端應用程式。 然而,這意味著不能將現有的程式碼庫按原樣移植到 WASI。 必須付出額外的努力才能用 sd_listen_fds 替換 socketbindlisten 呼叫。 它要求將部分應用程式邏輯轉移到底層的 Wasm 執行時。 這已經導致了一個已知問題,即把預開啟資料夾的 fd-ss 與預開啟監聽 socket 混淆。

記得本文前面列出的內容,我們決定採用不同的方法將 PHP 伺服器移植到 wasm32-wasi 。

帶有 WasmEdge 的服務端 socket

WasmEdge 團隊決定在 WASI 標準化之前實現 socket 支援。 在其 0.8.2 版中,他們擴充套件了標準的 wasi_snapshot_preview1,其方法與帶有 Berkeley socket的典型應用流程相同。 這使我們能夠編譯使用 socket 的現有程式碼並在 WasmEdge 上執行這些程式。

當然,雖然這種方法使現有程式碼的處理變得更容易,但如果必須使用另一個 WebAssembly 執行時,它可能會導致相容性問題。 由於其他執行時不支援新增的方法(例如 sock_bind、sock_listen 等),為 WasmEdge 構建的二進位制檔案無法在其他執行時上執行。 如果這些方法的某個版本被新增到 WASI 標準中,它可能不一定與 WasmEdge 定義的相匹配。 這意味著即使在功能標準化之後,也會有一段時間不相容。 一個典型的例子sock_accept 方法,它首先由 WasmEdge 提出,後來被標準化,但簽名略有不同。

功能標準化後也會有一段時間不相容

用這種方式,必須使用一個額外的層來將 wasi_snapshot_preview1 socket 方法包在符合 POSIX 標準的介面後面。 WasmEdge 團隊提供了一個 Rust SDK,它極大地幫助了針對 WasmEdge socket API 編寫新軟體的開發者。目前的一些關於基於 POSIX 的 C SDK 的工作正在進行當中,目前處於原型階段。當 WASI 獲得完整的 socket 支援時,可以預期 wasmedge_wasi_socket_c 提供的方法將由 wasi-libc 實現,從而現有程式碼可以繼續有效使用。

下圖顯示了採用這種方法的方式。

  1. WasmEdge 像往常一樣被呼叫。 HOST:PORT 引數被傳遞給 Wasm 應用程式以便在它認為合適的情況下進行解釋和處理。
  2. 應用像往常一樣呼叫 bindlisten、``accept 。但是相比 wasi-libc, 他們是由一個 wasmedge-socket-c-SDK 模組呼叫的,該模組將之轉換為 sock_bindsock_listensock_accept
  3. WasmEdge 實現 wasi_snapshot_preview1 但使用其非標準的 sock_* 方法集對其進行了擴充套件。

Wasm Language Runtimes 中, 我們正在構建現有的程式碼庫以在 WASI 上執行,例如傳統的程式語言直譯器,如 Python 和 PHP。 因此,我們選擇遵循第二種方法。 我們獲得的主要好處是靈活性。 如果將來 WASI 發生變化,我們只需在轉換 WASI 和 POSIX 之間呼叫的 SDK 上進行要求的相應更改。

讓伺服器使用 WasmEdge 進行監聽

我們要應對的第一個挑戰是做一個簡單的用 C 寫的 bindlistenaccept 伺服器,使其在 WasmEdge 上工作。我們從 hangedfish/wasmedge_wasi_socket_c 的程式碼,以及 hangedfish/httpclient_wasmedge_socket 中隨附的例子開始。

雖然 wasmedge_wasi_socket_c 對於客戶端 sockets 執行良好,但事實證明對服務端的 sockets 而言有些問題。具體而言我們發現了以下問題:

  • 在 POSIX 標準和 WasmEdge 的 wasi_snapshot_preview1 定義的型別之間轉換網路地址
  • 正確處理記憶體所有權

在我們有了一個執行的伺服器,能夠證明我們可以編譯基於 socket 的 C 程式碼並在 WasmEdge 上執行之後,我們開始為 PHP 開發伺服器做這件事。

賦能 PHP 開發伺服器

事實證明,這項任務並不容易。 我們遇到了下面概述的幾個不同問題。

socket_accept 的簽名

首先,我們必須重新啟用一些與網路相關的程式碼,這些程式碼之前在 __wasi__ 構建。我們新增打補丁的 wasmedge_wasi_socket_c 作為 PHP 程式碼的一部分,include 和 link 優先順序高於 wasi-libc。在 include 時我們的 netdb.h 將遮住來自 wasi-libc的那個。 在 link 時我們將得到來自 wasmedge_wasi_socket_c 的定義,而不是在 wasi-libc 中的(如果有重疊的話)。

然而,這暴露了已經提到的 WASI 標準和 WasmEdge 方法之間 sock_accept 的不相容性。 結果,由於簽名不匹配,甚至 php-cgi 構建目標也停止在 Wasmtime 上工作。 反之亦然,如果我們刪除 wasmedge_wasi_socket_c 並僅使用 wasi-libc 構建 php-cgi(沒有伺服器端網路),它將無法在 WasmEdge 上執行。

譯者注:WasmEdge 在 0.12 版本將支援現有的不完善的 wasi socket 提案,這樣只使用 wasi-libc 構建的 php-cgi 就既能在 WasmEdge,也能在 Wasmtime 執行。

[error]     Mismatched function type. Expected: FuncType {params{i32 , i32 , i32} returns{i32}} ,
                Got: FuncType {params{i32 , i32} returns{i32}}
[error]     When linking module: "wasi_snapshot_preview1" , function name: "sock_accept"

這個問題迫使我們修改構建,以便我們僅在明確為 WasmEdge 構建時新增 wasmedge_wasi_socket_c 程式碼。 對我們來說,這是一個寶貴的教訓:WASI 是一項正在進行的工作。 如果你想跨不同的執行時工作,你應該為每個執行時的定製構建或補丁做好準備。

WASI fd-s 是隨機的

獲取服務端 socket PHP 程式碼來構建並開始收聽,這感覺很棒。 但是,它仍然無法正常工作。 外部除錯顯示 TCP 連線已被伺服器接受,但仍然沒有任何反應。 於是經過艱苦的除錯,我們發現了一個奇特的事情。

事實證明,PHP 程式碼正使用select method 來找出應該 act 哪個當前開啟的 fd-s 。 因此,在 bind-ing 和 listen-ing 之後,它只會在已接收到客戶端連線的 socket 上呼叫 accept。 這是一種正常的方式,但它有個重大警告。 一方面,POSIX 文件清楚地說明了一件事。

警告:select() 只能監視檔案描述符編號小於 FD_SETSIZE (1024)——對許多現代應用程式來說,這是一個不合理的低限——這個限制不會改變。

在設計 WASI 時,人們有意識地努力消除非明確宣告就式生成 fd 數字的機會。 因此 path_open 文件稱:

返回的檔案描述符不保證是當前未開啟的最小編號的檔案描述符; 它是隨機的,以防止應用程式依賴於對索引做出假設,因為這在多執行緒上下文中容易出錯。來源: WASI 文件

當 WasmEdge 團隊擴充套件 wasi_snapshot_preview1 時,path_open 是唯一返回新 fd-s 的方法。 因此,他們理所當然地決定採用相同的方法。

所以我們有隨機範圍從 3 到 2^31 的 socket fd-s 並且每次執行都不同。 但是,由於 PHP 程式碼使用的是 select,因此它會存在於 socket fd-s 小於 FD_SETSIZE 的世界中。 這包括對於大的 fd 數字不會產生作用的程式碼——這些 fd-s 將被忽略並“消失”,在應用程式流程中沒有蹤跡:

# define PHP_SAFE_FD_SET(fd, set) do { if (fd < FD_SETSIZE) FD_SET(fd, set); } while(0)

此外,我們有一段程式碼通過迴圈遍歷 0 直到 max_fd(所有開啟的 fd-s 中的最大數量)並檢查 fd 狀態來處理 fd-s,而不是僅檢查已知的 fd-s。 如果檔案描述符真的很小並且在關閉後被重用,這種方法就足夠快了。 然而,對於 [3,2^32) 中的隨機數,當 max_fd 恰好很大時,會迴圈遍歷數十億次無用的迭代。 這使得 PHP 伺服器在某些情況下會變得無用。

為了解決這個問題,我們首先測試了 WasmEdge 的本地構建,其中 fd-s 的隨機生成器被限制為 FD_SETSIZE。 這很有效,我們與 WasmEdge 團隊討論過,因為這些巨大的 *fd-s,*把使用 say select的現有應用程式移植到 WASI 可能會變得非常困難。 因此,他們建立了一個 issue 從而使生成器可配置,以便在必要時可以限制 fd 的最大數量。

然而,我們現在需要一個解決方案。 我們還希望它在當下和未來都能為 WasmEdge 工作。 所以我們處理這個問題的最終方法是:

  1. 調整 PHP_SAFE_FD_* 系列巨集,從而它們不會檢查是否 fd < FD_SETSIZE
  2. 調整所有迴圈遍歷 fd 數字 0max_fd的地方,使其只迴圈遍歷“已知”的 fd-s

修正一個 "call_indirect - mismatched function type"

我們讓 PHP 伺服器開始工作並開始處理請求。 它適用於小型 PHP 指令碼。 然而,一旦我們開始嘗試使用 WordPress,我們就開始看到一個奇怪的錯誤。

[error] execution failed: indirect call type mismatch, Code: 0x8c
[error]     In instruction: call_indirect (0x11) , Bytecode offset: 0x001df4f1 , Args: [2164]
[error]     Mismatched function type. Expected: FuncType {params{i32} returns{}} ,
                Got: FuncType {params{i32} returns{i32}}
[error]     When executing function name: "_start"

讓我們將此與 C 語言中眾所周知的函式指標概念進行比較來討論。call_indirect 指令等同於嘗試從函式指標呼叫函式。 所以它的引數相當於一個函式指標。 在我們的例子中,引數 2164 表示這是 WebAssembly 模組的函式表中的 2164-s 函式。 現在錯誤本身相當於說有一個指向 void(i32) 的函式指標,但它被分配了一個函式的地址,該函式的簽名實際上是 i32(i32)

為了解決這個問題,我們使用了 wabtwasm2wat 來檢視 funcref 部分。 在那裡我們發現索引為 2164 的函式是 zend_list_free。 從那裡開始,通過一些程式碼搜尋,我們發現了一個案例,其中這個函式 int ZEND_FASTCALL zend_list_free(zend_resourceres) 被分配給一個函式指標 typedef void (ZEND_FASTCALLzend_rc_dtor_func_t)(zend_refcounted *p);。 這能在 C 中工作,因為函式指標忽略了返回值。 然而,在 WebAssembly 中,這是一種型別不匹配。

由於這只是一個地方,我們無法更改函式指標型別,因此我們使用的修復方法是將不匹配的函式包在具有預期簽名的函式中。

static void zend_list_free_void_wrapper(zend_resource *res) {
    zend_list_free(res);
}

有興趣瞭解更多有關 call_indirectfuncref 部分,以及如何 debug 該問題的,這是一篇CoinEx Chain lab 寫的特別棒的文章,文中有很多手把手案例。

嘗試一下!

注:寫作本文時,我們的程式碼更改正在進行中。 此部分將在未來更新。 如果想在 WebAssembly 中試用 PHP 開發伺服器,可以從 http://github.com/vmware-labs/webassembly-language-runtimes/tree 上 WebAssembly 語言執行時的 php-server-wasmedge 分支構建並執行.

  1. 安裝 WasmEdge
  2. 安裝前期準備軟體或者使用 ghcr.io/vmware-labs/wasi-builder:16 容器映象
  3. export WASMLABS_RUNTIME=wasmedge
  4. 執行 ./wl-make.sh php/php-7.4.32 獲取二進位制碼
  5. 要提供示例 docroot,請使用 wasmedge --dir /:/ build-output/php/php-7.4.32/bin/php -S 0.0.0.0:8080 -t images/php/docroot/

目前進展

應對上述所有挑戰,我們設法構建了一個能夠執行 WordPress 的穩定、可工作的 PHP 伺服器。 然而,這是一項正在進行的工作。 我們在這裡和那裡抄了近路,預計未來 WASI 或 WasmEdge 也會有更改,需要我們重新審視、修補原始 PHP 程式碼的方式。

正如已經提到的,我們在嘗試在 POSIX 地址型別與 WASI 和 WasmEdge 的擴充套件中定義的地址型別之間進行轉換時遇到了問題。 因此我們決定讓伺服器始終監聽 0.0.0.0。 如果需要在特定 IPv4 地址上執行此程式碼或使用 IPv6,則需要修改修補程式碼。

我們想感謝 Michael Yuan 和 WasmEdge 團隊在開發過程中提供的幫助。 WebAssembly 生態處於早期階段,很高興能與其他專案合作,共同推動 Wasm 向前發展。 希望你喜歡這篇文章,同時,期待你的意見和建議!