服务端插件是一个逻辑扩展平台,提供了一个快速托管逻辑的能力。
高性能:相对于RPC调用,没有网络的损耗,性能足够强劲。
高可靠:基于线程隔离,保证互不影响,插件的资源占用或崩溃等问题不直接影响业务。
部署快:不需要发布审核流程, 插件本身逻辑简短,编译速度快,基于版本管理可以快速回退。
易用:可以根据业务需求支持不同语言的插件
相比传统的将服务和逻辑写在一起:减少了服务发布和变更;支持更多的语言实现不同的逻辑;支持热插拔;
相比faas函数计算服务:减少了网络传输的损耗,性能更高。
相比于传统SDK插件模式优点在于:
语言无关,核心业务逻辑只需要实现一遍;
业务接入成本低,通常不需要关心升级,某些场景下甚至可以不需要 IPC 用的轻量级 SDK;
可以按自己的节奏推进版本升级,不必慢慢等待业务自己将其升级到最新版本;
发现线上有版本存在问题,可以直接切换至确认没问题的新/旧版本,而不必拉群通知业务升/降级。
存在一种需要经常变更的公共逻辑
此逻辑对网络延迟敏感
此逻辑变更时希望业务方无感
比如在电商行业中,商品的优惠计算逻辑。不同的商品需要配置不同的优惠计算逻辑,此优惠逻辑经常随着时间发生变更;优惠计算请求频繁对延迟敏感;在变更优惠计算逻辑的同时不希望影响其它服务(商品、支付、购物车)。
综上可以看到插件模式由于实现成本不低,适用业务范围较窄,在使用的时候应谨慎评估ROI。
基于 Go 原生的 plugin 机制(https://golang.org/pkg/plugin/),拥有与本地函数调用同样的性能,但是本身限制较多,比如插件与宿主服务必须使用完全相同的依赖库版本,编译镜像需要一致等等,对于其中的大部分问题 lego 已经提供了相应的解决方案,如果对于插件调用有很高的性能要求并且能够控制宿主服务的编译发布,Go 原生插件是个很好的选择。
Go 原生的插件机制使得插件和宿主的编译依赖紧密耦合,一旦宿主使用插件,则两者都不敢再随意升级环境或依赖,否则将报错。两者将长期限制在老旧的编译环境和库依赖上。其他 Go 插件的限制可以参加资料:medium.com。虽然使用一些 hack 的方式(https://github.com/dearplain/goloader)能够实现加载不同版本的库以实现热更新,但要么方法过于特殊,要么仍然存在依赖耦合。
运行时插件以宿主服务子进程的方式运行,两者之间通过 unix domain socket 方式进行通信,具备良好的隔离性,插件进程异常退出对宿主服务没有影响,调用性能比原生插件低但是远比 RPC 等方式要高,如果不要求极致的调用性能并且希望比较好的隔离性,进程隔离插件比较合适。
将插件编译为 linux 原生的 C 共享库,运行时宿主通过共享库的方式加载进主进程,调用性能稍低于原生插件,但是由于节省了 IO 开销,比进程隔离插件高得多,同时没有 Go 原生插件中对于 Go 版本以及依赖库等严苛的要求,适合不能控制宿主进程编译发布但是对性能有比较高要求的场景。
可以考虑将 Go 插件编译成 C 库,然后宿主进程使用 dlopen 以 cgo 的形式调用库。但是通过 dlopen 方式加载的 go 插件即使执行 dlclose,也无法释放占用的资源,例如执行中的 go routine 会继续执行,虽然可以不再使用旧插件中的函数,但随着热加载更新,插件占用的内存将越来越大,重复代码的执行会同时存在。原生的 go 插件同样存在该问题,热更新时旧插件不会释放资源,需要考虑使用进程隔离等方式解决。
几种插件类型的对比:
插件类型 |
隔离性 |
调用性能 |
主要限制 |
适用场景 |
Go 原生插件 |
低 |
高 |
对宿主 Go 版本有要求 |
能够控制宿主服务的编译与发布,极致性能要求 |
C 共享库插件 |
低 |
较高 |
需要用 C++ 或 Rust 写插件 |
宿主较多,插件偏向纯逻辑,性能要求较高 |
进程隔离插件 |
高 |
中 |
进程间通信的开销 |
无极致性能要求的其他所有场景 |
在 LEGO 平台初期阶段,我们就有调研过关于 WebAssembly 实现方案,对于插件来说,WebAssembly 的理念非常适合用来执行第三方代码,例如隔离性、高性能、多语言支持等,但是当时 WebAssembly 自身并不成熟,所以未将其引入 LEGO。
目前服务框架团队、ByteFaaS 团队开始基于开源的 wasmtime 研发公司内的 WebAssembly 运行时 bruntime,并提供 KiteX 等基础能力的封装,这为 WebAssembly 引入 LEGO 打下了基础,因此目前 LEGO 正在联合服务框架同学进行相应的共建,将 WebAssembly 引入 LEGO 的插件体系,为 LEGO 的插件形式提供更多的可能。
在实现原理上,由于 runtime 是使用 rust 编写,因此 LEGO 侧采用了如下方式来实现:
使用 rust 进一步封装 bruntime,并暴露出异步执行、内存传递的 hostcall 等接口;
插件侧提供了 Go 的 SDK,帮助业务无感衔接 runtime 的 hostcall 和序列化,只需要编写 handler 逻辑;
rust 封装编译为共享库,调用侧使用 cgo 调用,同时为了避免阻塞过多 goroutine,在调用侧 Go SDK 中采用独立协程以异步事件通知方式与共享库交互。
Sidecar 架构模式介绍
计划使用 pb 协议 + cgo 封装 + 进程隔离 的方式来实现插件,以同时满足依赖关系、编译环境、资源隔离、接口管理等需要。结构如图 1 所示。
通过 PB proto 文件管理插件的接口定义,类似 RPC 形式
提供工具,可以从 proto 生成插件框架代码,类似 kitc(可参考 protogen)
插件宿主进程通过 cgo 的方式调用插件
客户端 SDK 负责下载及热更新插件、启动宿主进程等,并通过 unix domain socket 访问插件宿主进程实现插件的调用。
一个插件提供的对外接口主要分为三种。
1. 初始化 Init。插件启动时执行一次:
func Init() error {
// do init...
}
2. 析构 Uninit。插件被卸载时执行一次:
func Uninit() {
// do uninit...
}
3. 实际请求接口。执行具体业务插件逻辑,例如:
func Echo(ctx context, req proto.Message) (proto.Message, error) {
// process and return
}
为了接口调用上函数签名能够相对统一,便于插件宿主使用,因此使用固定的函数参数和返回值格式,入参和出参由 protobuf 消息指定,由插件开发者自行定义。
插件工具可以通过 proto 定义文件一键自动生成以上框架代码,并生成对应的 cgo 封装代码,类似于以下内容,供插件宿主使用:
package main
import "C"
//export Echo
func Echo(ctx *C.char, ctxLen C.int, req *C.char,
reqLen C.int, rsp **C.char, rspLen *C.int) *C.char {
// 1. unmarshal req
// 2. invoke plugin.Echo
// 3. marshal response
return
}
func main() {}
基于以上接口和工具,插件的开发者可以获得和开发 RPC 服务类似的开发体验,不存在太多额外的学习和开发成本。
插件的使用接口方面,统一新增使用 Invoke 接口调用插件逻辑,签名类似于:
func (c *Client) Invoke(ctx context, method string, req proto.Message, rsp proto.Message) error
因此一个基本的插件使用过程示例如下。
cli, err := client.NewClient("test")
if err != nil {
panic(err)
}
// set req...
err := cli.Invoke(ctx, "Echo", req, rsp)
// process rsp...
由于插件宿主代码可以基本固定,客户端按固定方式调用宿主即可,因此不再需要之前进程分离插件那样的复杂维护逻辑,可以实现一句话调用插件,避免目前进程隔离插件使用过于繁复的问题。