有经验的程序员都知道,技术选型是一个 trade-off 的过程。当你选择玫瑰时,小心花朵下面的尖刺。进一步想,如果我们早已知晓鲜花底下的不怀好意的锋芒,就能在摘花时借助剪刀,避免赤手空拳地冒险。这也是本文的主题:应用 Envoy Golang filter 过程中的挑战以及如何应对它们。

先用简单一句话介绍 Envoy Golang filter。它在 Envoy 中运行 Go 编译出来的 so 库,支持编写在原生 Go runtime 里运行的 Go 代码来控制 Envoy 的行为。

Envoy Golang filter 有一些值得尝试的优点:

  • 无缝衔接各种 Go SDK,尤其适合在复杂的公司内部环境中对接各种系统。
  • 原生的 Go 能力支持,比如自由地使用 goroutine 和 reflect。
  • 支持大部分 Go toolchain 能力,比如通过 pprof 来做 profile,还有借助 PGO 来优化代码。

一如暖阳下的蔷薇,Golang filter 也不免有着阴影下的尖刺。接下来让我们看看应用 Envoy Golang filter 过程中的挑战,以及更为重要的:如何应对这些挑战。

首先,Go 并非为了嵌入宿主而设计的语言。

虽然 Go 支持编译成 so 库,供宿主来加载使用。但搞笑的是,它并不支持卸载。因为 Go 在设计时没有考虑到制定释放任意 goroutine 等资源的机制,所以除非用户手动保证 Go 代码在卸载时能够把资源都释放干净,否则难免会有泄露的问题。事实上,Go 官方没有费心去支持 dlclose:https://github.com/golang/go/issues/11100,也不承诺同时运行多个 Go so 的安全性:https://github.com/golang/go/issues/65050

解决方法?不要把 Golang filter 当作一种插件,而是认为它是一套常驻的应用代码。你可以认为 Golang filter 就是在一个 Envoy 上运行的底座,支撑一个 Go 写的应用。

其次,为了编写安全的代码,我们也需要注意 Go 的一些局限性。其中一个便是关于 panic recovering。

不像某些可以搞个全局 try catch 来捕获异常的语言,Go 的 panic 并不能通过全局的 recover 来捕获。比如在 goroutine 里的 panic,必须在该 goroutine 里使用 recover 才能生效。另外还存在着 unrecoverable 的 panic。一如字面上的意义,unrecoverable 的 panic 是没有办法 recover 的,一旦发生,程序会以状态码 2 退出。比如 out of memory,goroutine deadlock 之类的就是 unrecoverable panic。有一种 unrecoverable panic 较为容易出现在用户代码中:“racing map writes”。不加锁并发写 builtin map 会导致这种无法拯救的异常。所以务必仅执行可靠的 Go 代码。

另外,Go 虽然让编写并发代码更简单了,却没有让编写并发代码更安全。任何足够复杂的 Go 项目,都伴随着和 data race 的长期斗争。同样的,将 Go 跑在 Envoy 里,也免不了这种宿命般的永恒争斗。后续的篇幅都将围绕着并发而展开。

CGO 调用涉及到 Go GMP 调度的逻辑,这里细节就不展开了。简单来说,C 调用 Go 是在同一个线程里,Go 调用 C 不一定在同一个线程里。这里存在一个不匹配:Envoy 处理请求时只会使用单个线程,而 Go 则是多线程。让我们分情况讨论,先看看 Envoy 调用 Go 的情况。

Envoy 调用 Go 是在同一个线程里。在 GMP 调度中,当 G(协程)上的代码进行阻塞操作时,所在的 M(线程)会阻塞,P 会用来执行别的 M。但由于此时 Envoy 的 worker thread 就是这里被阻塞的 M,所以直接在 Go 代码里进行阻塞操作会阻塞到 Envoy 正常的请求处理,除非用户额外创建一个 goroutine,在这个 goroutine (本质是别的 thread)上做阻塞操作。这一点和一般的 Go 业务代码不一样,毕竟没人在发起 HTTP 请求时额外包装一层 goroutine。为了解决这个问题,我们在 HTNN 里默认将每个诸如请求头处理的 Envoy 调用 Go 的操作,用 goroutine 包装起来,让用户能够安全地按照之前开发 Go 业务代码的习惯开发功能。

这里插播一下广告,介绍下 HTNN。HTNN 是蚂蚁集团在 Envoy Golang filter 上封装的一层 Go 开发框架。Envoy Golang filter 更多提供一种原子能力,而 HTNN 则构建在这种能力之上,提供更友好的框架支撑。

可惜,并非所有的 Envoy 调用 Go 的操作都能用 goroutine 包装起来。所有能够被包装的操作有一个共同点:他们都是可以返回一个表示 yield 的状态码,然后在未来的某个时间 resume。只有这种操作,才能在 goroutine wrapper 执行完后重新调度。像是 access log handler 就不支持 resume,所以没办法包装成非阻塞的接口。好在我们还是可以起一个 goroutine 异步来执行一些操作,但这种异步操作不能直接使用 Envoy Go 的接口,因为这时候底层的请求对象可能已无法访问。

让我们来看看硬币的另一面:Go 调用 Envoy 不一定在同一个线程里。假设我们在 Envoy 调 Go 的那个线程里直接调用 Envoy,那么调用 Envoy 时还在同一个线程里。假设我们起了个 goroutine 来执行 Go 代码,这时候调用 Envoy 就不在同一个线程里。这就引申出一个问题:Envoy 处理请求时只会使用单个线程,所以它的大部分操作都是不会加锁的,但是我们要跨线程操作,又必须加锁,那怎么办呢?

解决办法就是 getDispatcher().post。我们可以 post 一个 callback 到 Envoy worker 线程的 dispatcher。这样就能保证对 worker 线程上某个字段的操作是线程安全的。但如果我们每次都要等到 post 的 callback 执行完成后,才执行下一行 Go 代码,那么耗时会很长。我们希望大多数情况下都不需要等待 callback 执行完毕才继续执行。Go 调 Envoy 的情况可以分为以下几种:

  1. 所需的数据在 Envoy 调 Go 时可以传递,Go 调 Envoy 只是修改:比如 headers。
  2. 需要通过 Go 调 Envoy 来获取或修改:比如 dynamic metadata。
  3. 类似 remoteAddress 这种(至少在 HTTP 层次)上不可变的数据

在第一种情况,我们不需要等待 Go 调 Envoy 执行完毕。因为我们可以先更新 Go 侧的缓存,然后再异步更新 Envoy 侧的真实数据。在第二种情况,修改操作依然能够异步进行,但是获取操作需要等待 posted callback 执行完才能执行下一行 Go 代码。所幸这种情况的获取操作不常发生。至于第三种情况,因为它是不可变的,所以不存在并发问题,无需 post callback。

不管怎么样,Go 跨线程调 Envoy 的性能总比同线程调用打上折扣。而不用 goroutine 运行 Go 代码又不够安全。有没有中庸的办法?HTNN 提供了 NonBlockingPhases 机制,支持插件指定自己在哪些情况下不需要用 goroutine 包装起来。比如如果在处理响应头时只是设置几个新的头,就不需要额外加上一层 goroutine。

对同一个请求,Go 代码和 Envoy 代码可以跑在不同的线程上,这又衍生出一个问题:当 Envoy 处理完这个请求后,由这个请求触发的 goroutine 可以继续执行,并能通过这个请求对应的 Go 对象尝试访问 Envoy 里对应的数据。而这个数据在此时,有可能已经被释放了。这条调用链路是这样的:Go code -> Go request object -> Envoy handler created by Envoy Golang filter -> internal object managed by Envoy event loop。通过注册 GC handler,Golang filter 可以保证 Envoy handler 这一层不会提前释放,但管不了 handler 指向的 C++ object 的生命周期。虽然 Envoy event loop 提供了 DeferredDeletable 的机制,但是尚不能让 filter 无限期占用相关的 C++ object。Envoy Golang filter 现在的解决办法是,在请求结束时设置一个名为 destroyed 的 Envoy handler 标记位。Go 调 Envoy 时看到这个标记位,就会 panic 掉,避免访问已释放的内存。像下面的日志堆栈输出,就是因为客户端提前结束请求,导致 Go 侧处理时发现底下的 Envoy 请求已经被 destroyed 而产生的:

Envoy 社区最近有人在开发 dynamic module 的功能,旨在提供统一的底层接口,给不同语言的 SDK 通过 FFI 调用。之前有人问过,这套新机制会不会替换 Golang filter。看了上面的分析,我想各位读者应该已经明了,除非专门为 Go 量身定制一套接口,否则无法适应这种默认多线程的行为。如果 dynamic module 想要降低开发者门槛,或许可以考虑对接 Javascript。毕竟 Envoy 目前默认都已经把 V8 JS 引擎编译进来了。

还记得在开头我提到 Envoy Golang filter “支持大部分 Go toolchain 能力” 吗?之所以用“大部分”这种严谨的说法,是因为有些工具确实没法用到编译成 so 的 Go 代码上。比如 dlv 只支持 attach 用 Go 实现的进程,所以要想调试 Envoy 里的 Go 代码,只能通过 gdb 来操作。另外 go build -race 无法用在编译成 so 的场景。对此,HTNN 提供了 "mosn.io/htnn/api/plugins/tests/pkg/envoy" package,为所有 Envoy API 提供 mock 接口,推荐开发者在开发阶段就通过 go test -race 跑单元测试提前发现 data race 的问题。

最后,Go 的代码执行覆盖率统计功能没办法在 so 场景下直接使用:https://github.com/golang/go/issues/64371,原因是当 Go 被编译成 so 时,不能自动在进程结束之前将统计数据刷到目标路径。所以 HTNN 在集成测试框架里加入了测试结束时 flush 统计数据的能力。感兴趣的读者可以看看:


spacewander
5.6k 声望1.5k 粉丝

make building blocks that people can understand and use easily, and people will work together to solve the very largest problems.


引用和评论

0 条评论