本文作者是 Wasm Labs @ VMware OCTO 的 Asen Alexandrov,原文链接:https://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 到最好的版本。 因此,对于 socket
、bind
和 listen
部分,我们还没有类似 WASI 的东西。
符合预期的行为是 Wasm 运行时将提供预先打开所需 socket 的功能,并允许 Wasm 应用程序 accept
它们的连接。 一方面,此行为与预打开主机文件夹的方法一致,后者只增加安全性。 另一方面,它要求应用程序做额外的工作来找出他们将接受连接的 fd,例如通过 sd_listen_fds
。
接受预先打开的 socket 的支持由 Wasmtime 等运行时实现。 这是用 C 编写的服务器应用程序的工作原理。
- Wasmtime 在运行 Wasm 应用程序时获得一个额外的
--tcplisten HOST:PORT
参数 - Wasmtime 开始监听
HOST:PORT
并将其文件描述符编号传递给 Wasm 应用程序 - 服务器代码使用
sd_listen_fds
获取监听文件描述符 - 服务器代码调用
accept
,由 wasi-libc 实现并从WASI 转换成sock_accept
- wasmtime 实现
wasi_snapshot_preview1
其中包括sock_accept
这种方法确实允许使用 WASI 实现服务器端应用程序。 然而,这意味着不能将现有的代码库按原样移植到 WASI。 必须付出额外的努力才能用 sd_listen_fds
替换 socket
、bind
、listen
调用。 它要求将部分应用程序逻辑转移到底层的 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 实现,从而现有代码可以继续有效使用。
下图显示了采用这种方法的方式。
- WasmEdge 像往常一样被调用。
HOST:PORT
参数被传递给 Wasm 应用程序以便在它认为合适的情况下进行解释和处理。 - 应用像往常一样调用
bind
、listen、``accept
。但是相比 wasi-libc, 他们是由一个 wasmedge-socket-c-SDK 模块调用的,该模块将之转换为sock_bind
、sock_listen
、sock_accept
。 - WasmEdge 实现
wasi_snapshot_preview1
但使用其非标准的sock_*
方法集对其进行了扩展。
Wasm Language Runtimes 中, 我们正在构建现有的代码库以在 WASI 上运行,例如传统的编程语言解释器,如 Python 和 PHP。 因此,我们选择遵循第二种方法。 我们获得的主要好处是灵活性。 如果将来 WASI 发生变化,我们只需在转换 WASI 和 POSIX 之间调用的 SDK 上进行要求的相应更改。
让服务器使用 WasmEdge 进行监听
我们要应对的第一个挑战是做一个简单的用 C 写的 bind
、listen
、 accept
服务器,使其在 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 工作。 所以我们处理这个问题的最终方法是:
- 调整
PHP_SAFE_FD_*
系列宏,从而它们不会检查是否fd < FD_SETSIZE
- 调整所有循环遍历 fd 数字
0
到max_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)
。
为了解决这个问题,我们使用了 wabt 的 wasm2wat
来查看 funcref
部分。 在那里我们发现索引为 2164 的函数是 zend_list_free
。 从那里开始,通过一些代码搜索,我们发现了一个案例,其中这个函数 int ZEND_FASTCALL zend_list_free(zend_resource
res)
被分配给一个函数指针 typedef void (ZEND_FASTCALL
zend_rc_dtor_func_t)(zend_refcounted *p);
。 这能在 C 中工作,因为函数指针忽略了返回值。 然而,在 WebAssembly 中,这是一种类型不匹配。
由于这只是一个地方,我们无法更改函数指针类型,因此我们使用的修复方法是将不匹配的函数包在具有预期签名的函数中。
static void zend_list_free_void_wrapper(zend_resource *res) {
zend_list_free(res);
}
有兴趣了解更多有关 call_indirect
、 funcref
部分,以及如何 debug 该问题的,这是一篇CoinEx Chain lab 写的特别棒的文章,文中有很多手把手案例。
尝试一下!
注:写作本文时,我们的代码更改正在进行中。 此部分将在未来更新。 如果想在 WebAssembly 中试用 PHP 开发服务器,可以从 https://github.com/vmware-labs/webassembly-language-runtimes/tree 上 WebAssembly 语言运行时的 php-server-wasmedge
分支构建并运行.
- 安装 WasmEdge
- 安装前期准备软件或者使用
ghcr.io/vmware-labs/wasi-builder:16
容器镜像 export WASMLABS_RUNTIME=wasmedge
- 运行
./wl-make.sh php/php-7.4.32
获取二进制码 - 要提供示例 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 向前发展。 希望你喜欢这篇文章,同时,期待你的意见和建议!