cover_image

APISIX插件体系:外部插件实践

魏伟 SQB Blog 2024年02月23日 06:30

APISIX 插件体系:外部插件实践

unsetunset背景unsetunset

apisix[1] 是一个动态、实时、高性能的云原生网关,以 openresty[2] 作为技术基础,可以作为业务的流量入口,提供了动态路由、动态上游、动态证书、A/B 测试、灰度发布(金丝雀发布)、蓝绿部署、限速、防攻击、收集指标、监控报警、可观测、服务治理等功能。

apisix 的一大亮点是其丰富灵活的插件模块: apisix 提供了丰富的内置lua插件,涵盖了认证鉴权、安全、可观测性、流量管理、多协议接入等多个领域,无需用户自己动手实现,即插即用; 同时 apisix 对 lua[3] 插件支持热更新和热插拔,无需重启 apisix 实例。apisix 也支持用户根据自己的需求开发自己的插件,用户可以使用 lua 语言为每一个进入 apisix 的请求添加自定义逻辑;对于不熟悉 lua 语言的用户,apisix 也支持用户使用其它语言开发插件。

unsetunsetAPISIX 多进程模型unsetunset

在介绍 apisix 插件前,我们先了解一下 apisix 的多进程模型,知道 apisix 如何处理请求。

图片
  • master: 负责读取和解析 nginx 配置文件,启动和监控 worker 进程
  • worker: 负责处理请求,多个 worker 之间相互独立,拥有各自的事件循环
  • luajit: openresty 嵌入到 nginx 中的 lua 实例,使得开发者可以使用 lua 语言为 nginx 开发模块扩展其功能。对每个请求会创建一个 lua 协程,实现了不同请求之前的隔离

unsetunsetAPISIX 请求生命周期unsetunset

openresty 为每个进入了 luajit 实例的请求定义了生命周期,所有请求都会在这样一个生命周期中流转,直至请求完成。

请求的生命周期如下:

图片

apisix 插件提供的扩展点对应上述 openresty 的执行阶段,开发者可以在这些阶段中编写自定义逻辑。

图片

注意 check_schema 和 befoer_proxy 阶段是 apisix 为插件添加的方法,并不与 openresty 的阶段对应。

unsetunsetAPISIX 插件体系unsetunset

lua 插件

apisix 内置插件使用 lua 编写,可以参考插件开发[4]

外部插件

除了 lua 插件,apisix 还支持用户使用非lua语言编写插件,官方提供了两种方式:

  • External plugin
  • WASM plugin

External Plugin

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 开头,根据插件的执行位置,提供了三个插件:

  • ext-plugin-pre-req: 在 apisix 开始执行 lua 插件前执行
  • ext-plugin-post-req: 在 apisix 执行完 lua 插件后执行
  • ext-plugin-post-resp:在 apisix 获取到上游的响应,执行 lua 插件前执行

可以看出该方式实际上就是启动了一个其他语言编写的 web 服务器,直接使用 unix socket 通信,rpc 协议发送请求。相比远程调用其他语言服务,无需经过网络。

该方法虽然实现了用非 lua 语言为 apisix 编写插件,但是局限性较大:

  1. Unix socket 通信和 rpc 发送请求带来了额外的性能开销
  2. 如果希望同时处理请求和响应,需要绑定两个 ext-plugin-* 插件,即 请求 RequestFilter 需要绑定 ext-plugin-pre-req 或 ext-plugin-post-req,响应 RequestFilter 绑定 ext-plugin-post-resp。
  3. 如果是将 apisix 以容器的形式部署在 k8s 集群上,完全可以直接编写一个普通的 web 服务和 apisix 部署在同一个 POD 中 ,让 apisix 也通过 unix socket 与该 web 服务进行通信,在该 web 服务中编写插件逻辑。

WASM plugin

apisix 同样支持使用 WebAssembly 技术。可以使用其它语言编写的 apisix 的插件,编译成 WASM 格式,作为插件运行。该方式目前支持 C/C++/Go/Rust 等语言。

apisix 的 WASM 插件需要遵循 proxy-wasm 规范,并使用对应的 SDK 进行编写,默认将 wasmtime 嵌入 NGINX,就像嵌入 luajit 引擎一样。

如下图所示,所有 WASM 插件共享一个 WASM VM,每个插件都拥有一个独立的 VMContext,但不同的路由会有各自不同的 PluginContext。

图片

缺点:

  1. WASM 没有协程,任何 WASM 的逻辑,需要从头到尾执行,在调用过程中会阻塞 nginx。如果在 WASM 中也执行一些同步操作,那么请求响应时间将会非常得差
  2. 不能使用原生 Go 编译套件,需要使用 tinygo 的实现
    • 与原生 Go 相比,部分功能不受支持:如 cgo 以及一些 runtime 包中的实现功能如 runtimemetrics 等。这会导致无法正常编译具有一定规模和复杂性的项目
    • 从 Go 1.21 开始实验性地支持编译到 wasip1 目标,但仍然无法实现 ABI 的 export:详见 https://github.com/golang/go/issues/42372 ;
  3. apisix 提供的WASM Nginx 模块[5]并没有完整实现所有 WASM Proxy 约定的 ABI 接口

FFI

注: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(12)

因此,只要支持编译成动态链接库的语言,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 指针。对于这类情况,可以考虑以下两种方式来处理:

  1. 可以利用 C 语言的数据结构,将复杂的数据结构定义在 C 代码中,然后在 Go 中使用这些 C 数据结构
  2. 可以通过将数据序列化为 C 可处理的格式(如 JSON 或 Protocol Buffers),在 Go 和 C 之间传递这些格式化后的数据

需要注意的是,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]),其主要思路为:

  1. 将其他语言编译成动态链接库,通过 luajit 加载
  2. patch openresty,防止 lua 调用非 lua 语言阻塞。增加了一个队列,所有调用请求进入这个队列,由非 lua 语言的运行时拉取请求。返回响应则直接将事件塞入 nginx 的事件循环,更为高效!
图片

第一步与我们的思路相同,都是通过 FFI 调用。而第二步则更进一步,解决了 openresty 调用其他语言阻塞的问题。通过这样的方式,我们可以在 apisix 使用 FFI 高效得调用其他语言,编写一个普通的 lua 插件,只需要引入对应的包即可!

当然使用 FFI 的方式也存在着问题:

  1. 如果 FFI 引用的动态链接库崩溃,可能会对整体系统产生一些影响,具体取决于系统的容错性和设计。因此我们需要更为谨慎得设计和使用 FFI,引入更广和更深的测试用例来覆盖 FFI 的使用场景。
  2. 使用 lua-resty-ffi 意味着需要 patch openresty,这同样引入了一些稳定性问题,毕竟该特性不受 openresty 支持,如果出现问题将较难排查。

unsetunset结语unsetunset

本文介绍了 apisix 进程模型和请求的生命周期,简要介绍了为 apisix 编写外部插件的几种方式以及各自的优劣。在 apisix 外部插件的实践中,最重要的是选择合适的技术手段抹平 lua 语言与其他语言之间的差异,从而获得最佳的性能。

unsetunset关于作者unsetunset

魏伟,来自技术平台部

unsetunset相关文章unsetunset

参考资料
[1]

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


全部文章 · 目录
上一篇智慧门店商品系统演化之路下一篇零售主商品MVC到DDD升级实践

微信扫一扫
关注该公众号

继续滑动看下一个
SQB Blog
向上滑动看下一个