apisix[1] 是一个动态、实时、高性能的云原生网关,以 openresty[2] 作为技术基础,可以作为业务的流量入口,提供了动态路由、动态上游、动态证书、A/B 测试、灰度发布(金丝雀发布)、蓝绿部署、限速、防攻击、收集指标、监控报警、可观测、服务治理等功能。
apisix 的一大亮点是其丰富灵活的插件模块: apisix 提供了丰富的内置lua插件,涵盖了认证鉴权、安全、可观测性、流量管理、多协议接入等多个领域,无需用户自己动手实现,即插即用; 同时 apisix 对 lua[3] 插件支持热更新和热插拔,无需重启 apisix 实例。apisix 也支持用户根据自己的需求开发自己的插件,用户可以使用 lua 语言为每一个进入 apisix 的请求添加自定义逻辑;对于不熟悉 lua 语言的用户,apisix 也支持用户使用其它语言开发插件。
在介绍 apisix 插件前,我们先了解一下 apisix 的多进程模型,知道 apisix 如何处理请求。
openresty 为每个进入了 luajit 实例的请求定义了生命周期,所有请求都会在这样一个生命周期中流转,直至请求完成。
请求的生命周期如下:
apisix 插件提供的扩展点对应上述 openresty 的执行阶段,开发者可以在这些阶段中编写自定义逻辑。
注意 check_schema 和 befoer_proxy 阶段是 apisix 为插件添加的方法,并不与 openresty 的阶段对应。
apisix 内置插件使用 lua 编写,可以参考插件开发[4]。
除了 lua 插件,apisix 还支持用户使用非lua语言编写插件,官方提供了两种方式:
apisix 支持使用 SideCar 的方式运行非 lua 语言插件,该方式目前支持使用 Java/Go/Python/Javascript 编写新的插件。
注: apisix 官方虽然以 SideCar 描述该类插件,但这里提到的 SideCar 跟 istio 里的 SideCar 概念相差很大,该 SideCar 只是一个通过 unix 与 apisix 通信的进程,即 Plugin runner。
下面以 Go 为例:
如图所示, apisix 会启动并监控一个子进程,在子进程中启动相应语言的运行时,apisix 与该子进程使用 rpc 在 unix socket 上进行通信,交给 plugin runner 执行插件逻辑。
开发者可以在 plugin runner 中实现不同的 Request Filter 和 Response Filter,要执行这些 filter,需要将 filter 绑定到 apisix 指定的插件上,这些插件以 ext-plugin 开头,根据插件的执行位置,提供了三个插件:
可以看出该方式实际上就是启动了一个其他语言编写的 web 服务器,直接使用 unix socket 通信,rpc 协议发送请求。相比远程调用其他语言服务,无需经过网络。
该方法虽然实现了用非 lua 语言为 apisix 编写插件,但是局限性较大:
apisix 同样支持使用 WebAssembly 技术。可以使用其它语言编写的 apisix 的插件,编译成 WASM 格式,作为插件运行。该方式目前支持 C/C++/Go/Rust 等语言。
apisix 的 WASM 插件需要遵循 proxy-wasm 规范,并使用对应的 SDK 进行编写,默认将 wasmtime 嵌入 NGINX,就像嵌入 luajit 引擎一样。
如下图所示,所有 WASM 插件共享一个 WASM VM,每个插件都拥有一个独立的 VMContext,但不同的路由会有各自不同的 PluginContext。
缺点:
注:FFI(Foreign Function Interface)并不是 APISIX 提供的编写其他语言的方式,而是由 luajit 支持。
除了使用上面两种官方提供的方法,我们还可以借助 luajit 的 FFI 来调用其他语言。
FFI 是 luajit 提供的与其它语言交互的接口,允许从纯 lua 代码调用外部 C 函数,使用 C 数据结构。其基本使用为:
-- 声明使用 ffi 库
local ffi = require "ffi"
-- 加载动态链接库
local myffi = ffi.load('myffi')
-- 声明动态库中的 C 函数
ffi.cdef[[
int add(int x, int y);
]]
-- 调用 C 函数
local res = myffi.add(1, 2)
因此,只要支持编译成动态链接库的语言,apisix 都可以使用 FFI 将其加载,就可以在 lua 插件中使用其它语言提供的函数!
我们使用 Go 和 Rust 对该方式进行了探索。下面以 Go 和 Rust 为例:
在 Go 语言中,使用 C 包(cgo)提供了与 C 语言进行交互的机制。C 包允许在 Go 中调用 C 函数并使用一些基本的 C 数据类型,例如 C.int、C.double 等。同时,cgo 还提供了便捷的字符串转换函数,如 C.CString 和 C.GoString。
然而,对于较为复杂的数据结构,cgo 在处理上存在一些限制,cgo 传递的指针所指向的 go 内存不能包含任何 go 指针。对于这类情况,可以考虑以下两种方式来处理:
需要注意的是,Go 语言具有垃圾回收机制,与需要手动管理内存的语言有所不同。在传递指针时,需要确保 Go 的垃圾回收器不会在 C 函数中使用的内存被回收。
但是如果我们面对的是这样的场景:由 C 控制一个 Go 对象的创建、销毁及操作,C 并不需要实际存储改对象,只需要通过调用特定方法对其进行管理。这样的场景下所有业务相关均在 C 侧,但实际的对象管理在 Go 一侧。
对于这种情况,cgo 提供了 cgo.Handle[6],可以使用它在 C 和 Go 之间传递 Go 的指针,这样一个被暴露在 C 的指针,其指向的数据块实际仍然被 Go 所管理,且会被分配到堆而不是栈上。这样在 C 侧,我们只需要传回该指针,就可以将操作 Go 内存对象的方法暴露给 C,同时不必担忧指针所指向对象被 Go 的 GC 所移动。其核心思路是在 Go 中创建一个 sync.Map 存储指针和对象。
对于 rust,没有提供 cgo.Handle 这样的方法,同时也不能直接使用 Box 传递指针,因为 Box 虽然将持有对象分配在了堆上,但 Box 本身仍然在栈上,当它 out of scope,其持有对象也被回收。所以可以参考 cgo.Handle 的实现,也通过一个 map 来持有对象。同时,对于复杂结构,我们可以使用社区提供的 binding 库,来映射 lua 类型和 rust 类型,例如 mlua[7],rlua[8],rust-lua-ffi[9]等。其中 mlua 提供 module mode,极大简化了使用 rust 编写 lua 模块的流程。
我们也尝试使用 lua-resty-ffi[10],一个通过 patch openresty 运行其他语言的方法(详见作者 blog[11]),其主要思路为:
第一步与我们的思路相同,都是通过 FFI 调用。而第二步则更进一步,解决了 openresty 调用其他语言阻塞的问题。通过这样的方式,我们可以在 apisix 使用 FFI 高效得调用其他语言,编写一个普通的 lua 插件,只需要引入对应的包即可!
当然使用 FFI 的方式也存在着问题:
本文介绍了 apisix 进程模型和请求的生命周期,简要介绍了为 apisix 编写外部插件的几种方式以及各自的优劣。在 apisix 外部插件的实践中,最重要的是选择合适的技术手段抹平 lua 语言与其他语言之间的差异,从而获得最佳的性能。
魏伟,来自技术平台部
APISIX: https://github.com/apache/apisix
[2]OpenResty: https://openresty.org/cn/
[3]lua: https://www.lua.org/
[4]APISIX 插件开发: https://apisix.apache.org/zh/docs/apisix/plugin-develop/
[5]Run Wasm in OpenResty/Nginx: https://github.com/api7/wasm-nginx-module
[6]A Concurrent-safe Centralized Pointer Managing Facility: https://golang.design/research/cgo-handle/
[7]mlua: https://github.com/khvzak/mlua
[8]rlua: https://github.com/amethyst/rlua
[9]Lua to Rust FFI code generation: https://github.com/distil/rust_lua_ffi
[10]lua-resty-ffi: https://github.com/kingluo/lua-resty-ffi
[11]使用rust为OpenResty/nginx开发GRPC库: https://zhuanlan.zhihu.com/p/586934870
微信扫一扫
关注该公众号