diff --git a/content/zh/blog/posts/moe-extend-envoy-using-golang-6/index.md b/content/zh/blog/posts/moe-extend-envoy-using-golang-6/index.md new file mode 100644 index 00000000..6cd25f88 --- /dev/null +++ b/content/zh/blog/posts/moe-extend-envoy-using-golang-6/index.md @@ -0,0 +1,91 @@ +--- +title: "MoE 系列[六] - Envoy Go 扩展之并发安全" +linkTitle: "MoE 系列[六] - Envoy Go 扩展之并发安全" +date: 2023-04-16 +author: "[doujiang24](https://github.com/doujiang24)" +description: "这篇聊的并发安全,则是专注在并发场景下的内存安全,相对来说会复杂一些" +aliases: "/zh/blog/posts/moe-extend-envoy-using-golang-6" +--- + +前一篇介绍了 Envoy Go 扩展的内存安全,相对来说,还是比较好理解的,主要是 Envoy C++ 和 Go GC 都有自己一套的内存对象的生命周期管理。 + +这篇聊的并发安全,则是专注在并发场景下的内存安全,相对来说会复杂一些。 + +## 并发的原因 + +首先,为什么会有并发呢? + +本质上因为 Go 有自己的抢占式的协程调度,这是 Go 比较重的部分,也是与 Lua 这类嵌入式语言区别很大的点。 + +细节的话,这里就不展开了,感兴趣的可以看这篇 [cgo 实现机制 - 从 c 调用 go](https://uncledou.site/2022/go-cgo-c-to-go/) + +这里简单交代一下的,因为 c 调用 go,入口的 Go 函数的运行环境是,Goroutine 运行在 Envoy worker 线程上,但是这个时候,如果发生了网络调用这种可能导致 Goroutine 挂起的,则会导致 Envoy worker 线程被挂起。 + +所以,解决思路就是像 [Go 扩展的异步模式](https://uncledou.site/2023/moe-extend-envoy-using-golang-4/) 中的示例一样,新起一个 Goroutine,它会运行在普通的 go 线程上。 + +那么此时,对于同一个请求,则会同时有 Envoy worker 线程和 Go 线程,两个线程并发在处理这个请求,这个就是并发的来源。 + +但是,我们并不希望用户操心这些细节,而是在底层提供并发安全的 API,把复杂度留在 Envoy Go 扩展的底层实现里。 + +## 并发安全的实现 + +接下来,我们就针对 Goroutine 运行在普通的 Go 线程上,这个并发场景,来聊一聊如何实现并发安全的。 + +对于 Goroutine 运行在 Envoy 线程上,因为并不存在并发冲突,这里不做介绍。 + +### 写 header 操作 + +我们先聊一个简单的,比如在 Go 里面通过 `header.Set` 写一个请求头。 + +核心思路是,是通过 `dispatcher.post`,将写操作当做一个事件派发给 Envoy worker 线程来执行,这样就避免了并发冲突。 + +### 读 header 操作 + +读 header 则要复杂不少,因为写不需要返回值,可以异步执行,读就不行了,必须得到返回值。 + +为此,我们根据 Envoy 流式的处理套路,设计了一个类似于所有权的机制。 + +Envoy 的流式处理,可以看这篇 [搞懂 http filter 状态码](https://uncledou.site/2022/envoy-filter-status/) + +简单来说,我们可以这么理解,当进入 `decodeHeaders` 的时候,header 所有权就交给 Envoy Go 的 c++ 侧了,然后,当通过 cgo 进入 Go 之后,我们会通过一个简单的状态机,标记所有权在 Go 了。 + +通过这套设计/约定,就可以安全的读取 header 了,本质上,还是属于规避并发冲突。 + +为什么不通过锁来解决呢?因为 Envoy 并没有对于 header 的锁机制,c++ 侧完全不会有并发冲突。 + +### 读写 data 操作 + +有了这套所有权机制,data 操作就要简单很多了。 + +因为 header 只有一份,并发冲突域很大,需要考虑 Go 代码与 c++ 侧的其他 filter 的竞争。 + +data 则是流式处理,我们在 c++ 侧设计了两个 buffer 对象,一个用于接受 filter manager 的流式数据,一个用于缓存交给 Go 侧的数据。 + +这样的话,交给 Go 来处理的数据,Go 代码拥有完整的所有权,不需要考虑 Go 代码与 C++ 侧其他 filter 的竞争,可以安全的读写,也没有并发冲突。 + +### 请求生命周期 + +另外一个很大的并发冲突,则关乎请求的生命周期,比如 Envoy 随时都有可能提前销毁请求,此时 Goroutine 还在 go thread 上继续执行,并且随时可能读写请求数据。 + +处理的思路是: + +1. 并没有有效的办法,能够立即 kill goroutine,所以,我们允许 goroutine 可能在请求被销毁之后继续执行 +2. 但是,goroutine 如果读写请求数据,goroutine 会被终止,panic + recover(recover 细节我们下一篇会介绍)。 + +那么,我们要做的就是,所有的 API 都检查当前操作的请求是否合法,这里有两个关键: + +1. 每请求有一个内存对象,这个对象只会由 Go 来销毁,并不会在请求结束时,被 Envoy 销毁,但是这个内存对象中保存了一个 weakPtr,可以获取 C++ filter 的状态。 + + 通过这个机制,Go 可以安全的获取 C++ 侧的 filter,判断请求是否还在。 + +2. 同时,我们还会在 `onDestroy`,也就是 C++ filter 被销毁的 hook 点;以及 Go thread 读写请求数据,这两个位置都加锁处理,以解决这两个之间的并发冲突。 + +## 最后 + +对于并发冲突,其实最简单的就是,通过加锁来竞争所有权,但是 Envoy 在这块的底层设计并没有锁,因为它根本不需要锁。 + +所以,基于 Envoy 的处理模型,我们设计了一套类似所有权的机制,来避免并发冲突。 + +所有权的概念也受到了 Rust 的启发,只是两者工作的层次不一样,Rust 是更底层的语言层面,可以作用于语言层面,我们这里则是更上层的概念,特定于 Envoy 的处理模型,也只能作用于这一个小场景。 + +但是某种程度上,解决的问题,以及其中部分思想是一样的。 diff --git a/content/zh/blog/posts/moe-extend-envoy-using-golang-7/index.md b/content/zh/blog/posts/moe-extend-envoy-using-golang-7/index.md new file mode 100644 index 00000000..d6543c09 --- /dev/null +++ b/content/zh/blog/posts/moe-extend-envoy-using-golang-7/index.md @@ -0,0 +1,56 @@ +--- +title: "MoE 系列[七] - Envoy Go 扩展之沙箱安全" +linkTitle: "MoE 系列[七] - Envoy Go 扩展之沙箱安全" +date: 2023-05-07 +author: "[doujiang24](https://github.com/doujiang24)" +description: "今天来到了安全性的最后一篇,沙箱安全,也是相对来说,最简单的一篇" +aliases: "/zh/blog/posts/moe-extend-envoy-using-golang-7" +--- + +前两篇介绍了内存安全和并发安全,今天来到了安全性的最后一篇,沙箱安全,也是相对来说,最简单的一篇。 + +## 沙箱安全 + +所谓的沙箱安全,是为了保护 Envoy,这个宿主程序的安全,也就是说,扩展的 Go 代码运行在一个沙箱环境中,即使 Go 代码跑飞了,也不会把 Envoy 搞挂。 + +具体到一个场景,也就是当我们使用 Golang 来扩展 Envoy 的时候,不用担心自己的 Go 代码写的不好,而把整个 Envoy 进程搞挂了。 + +那么目前 Envoy Go 扩展的沙箱安全做到了什么程度呢? + +简单来说,目前只做到了比较浅层次的沙箱安全,不过,也是实用性比较高的一层。 + +严格来说,Envoy Go 扩展加载的是可执行的机器指令,是直接交给 cpu 来运行的,并不像 Wasm 或者 Lua 一样由虚拟机来解释执行,所以,理论上来说,也没办法做到绝对的沙箱安全。 + +## 实现机制 + +目前实现的沙箱安全机制,依赖的是 Go runtime 的 `recover` 机制。 + +具体来说,Go 扩展底层框架会自动的,或者(代码里显示启动的协程)依赖人工显示的,通过 `defer` 注入我们的恢复机制,所以,当 Go 代码发生了奔溃的时候,则会执行我们注入的恢复策略,此时的处理策略是,使用 `500` 错误码结束当前请求,而不会影响其他请求的执行。 + +但是这里有一个不太完美的点,有一些异常是 `recover` 也不能恢复的,比如这几个: + +``` +Concurrent map writes +Out of memory +Stack memory exhaustion +Attempting to launch a nil function as a goroutine +All goroutines are asleep - deadlock +``` + +好在这几个异常,都是不太容易出现的,唯一一个值得担心的是 `Concurrent map writes`,不熟悉 Go 的话,还是比较容易踩这个坑的。 + +所以,在写 Go 扩展的时候,我们建议还是小心一些,写得不好的话,还是有可能会把 Envoy 搞挂的。 + +当然,这个也不是一个很高的要求,毕竟这是 Gopher 写 Go 代码的很常见的基本要求。 + +好在大多常见的异常,都是可以 `recover` 恢复的,这也就是为什么现在的机制,还是比较有实用性。 + +## 未来 + +那么,对于 `recover` 恢复不了的,也是有解决的思路: + +比如 `recover` 恢复不了 `Concurrent map writes`,是因为 runtime 认为 `map` 已经被写坏了,不可逆了。 + +那如果我们放弃整个 `runtime`,重新加载 so 来重建 `runtime` 呢?那影响面也会小很多,至少 Envoy 还是安全的,不过实现起来还是比较的麻烦。 + +眼下比较浅的安全机制,也足够解决大多数的问题了,嗯。 \ No newline at end of file