0%

cgo 优化被合并主干之后,很高兴的写了篇流水账,倾诉了一番心酸史,也感谢各位大佬的转发,收获了写公众号以来最多的围观

然而打脸来得也真快,就在昨儿,愚人节的早上,被 revert 了 …

好在 Cherry 大佬还有意继续推进,搞了个 CL 481061 ,Michael 大佬也给了 +2 approve,希望能合回去,赶上 1.21 这个版本。

挖坑小能手

我们看看 Cherry 大佬最新这个 CL 的 commit log 中的描述:

1
2
3
4
5
6
7
8
CL 392854, by doujiang24 <doujiang24@gmail.com>, speed up C to Go
calls by binding the M to the C thread. See below for its
description.
CL 479255 is a followup fix for a small bug in ARM assembly code.
CL 479915 is another followup fix to address C to Go calls after
the C code uses some stack, but that CL is also buggy.
CL 481057, by Michael Knyszek, is a followup fix for a memory leak
bug of CL 479915.

最初我搞的 CL 392854 被合并之后,大佬们已经帮忙搞了三个 bugfix 了,真是惭愧…

最开始的 CL 479255,是一个低级 bug,arm 的汇编少复制了一行,没啥好说的

morestack on g0

CL 479915 则是费了一番功夫才搞明白…

Cherry 大佬的描述在这个 issue 59294

g0 是干啥的

要讲清楚这个 bug,得先介绍一下 g0

每个 M 都有一个 g0,来处理一些特殊的事情,比如扩栈的时候,newstack 函数就运行在 g0 上。

如下 morestack 的代码:

1
2
3
4
5
6
7
// Call newstack on m->g0's stack.
MOVQ m_g0(BX), BX
MOVQ BX, g(CX)
MOVQ (g_sched+gobuf_sp)(BX), SP
MOVQ (g_sched+gobuf_bp)(BX), BP
CALL runtime·newstack(SB)
CALL runtime·abort(SB) // crash if newstack returns

这里是先切换到 g0 栈,再运行的 newstack,结合 g 的数据结构,核心就是这行伪代码

1
SP = g0.sched.sp

问题

但是,在 cgo 启用的时候,g0 并不会像普通的 g 一样,拥有自己的 stack 空间,而是会复用 C 线程的 stack 空间。

我们可以看 needmg0 栈的处理,g0 栈就是当前 C 栈顶往下的 32 kb 地址空间

1
2
3
gp.stack.hi = getcallersp() + 1024 // 这个 1024 没搞懂是为啥
gp.stack.lo = getcallersp() - 32*1024
gp.stackguard0 = gp.stack.lo + _StackGuard

优化之前,每次 c 调用 go 都会执行 needm,也就是 g0 的栈会根据当前 c 栈来动态计算;

但是,优化之后,并不是每次 c 调用 go 都会执行 needm 了,也就是 g0 栈固定在第一次进入 go 时计算的栈空间了。

也就是 stackguard0 是固定的了,如果后续 c 调用 go 的时候,c 栈比第一次高很多,这可能就会导致,runtime 在栈检查的时候认为 g0 栈 overflow 了;而 g0 的栈是不能扩的,也就会抛 morestack on g0 的异常。

栈检查

这里稍微解释下 Go 栈检查的逻辑,比如 amd64 上,我们经常会看到这样的指令:

1
2
3
4
5
6
000000000008b100 <runtime.main.func1>:
8b100: 49 3b 66 10 cmp rsp,QWORD PTR [r14+0x10]
8b104: 76 2d jbe 8b133 <runtime.main.func1+0x33>
...
8b133: e8 e8 8a 02 00 call b3c20 <runtime.morestack_noctxt.abi0>
8b138: eb c6 jmp 8b100 <runtime.main.func1>

核心是这样的伪代码:

1
if rsp <= g.stack.stackguard0; { runtime.morestack_noctx() }

也就是拿栈顶 rspstackguard0 对比,小于 stackguard0,则需要扩栈。

对应上面问题的场景,也就是 stackguard0 是固定的,但是 rsp 则是每次不一样的。

解决办法

Cherry 大佬在 issue 里说的解法有两种:

  1. 每次 c 调用的时候,重新为 g0 计算栈空间,这样就跟优化之前保持一致的行为。
  2. g0 的栈顶地址,设置为 c 的栈顶;本质上就是把 g0 的栈空间搞到最大,这样就不容易达到扩栈的条件

大佬在 CL 479915 选择了第二种解法,patch 都搞到 13 个版本才合并,我一路看下来,最大的感觉是,系统兼容性真难搞。

不过,这里还有一个小隐患,也就是栈顶是直接拉满够用了,但是栈底实际上还是不对的,比如 morestack 切栈的时候,会不会切到 c 栈使用了的空间,把 c 栈写坏了呢?

目前看下来,即使在复用 M 的时候,也会有这段逻辑,也就是会将真实的 rsp 存入 g0.sched.sp,所以看起来貌似还好。不过这个还是比较的 tricky,会不会有其他的坑,其实也说不太好。

1
2
3
4
MOVQ	m_g0(BX), SI
MOVQ (g_sched+gobuf_sp)(SI), AX
MOVQ AX, 0(SP)
MOVQ SP, (g_sched+gobuf_sp)(SI)

又来 revert

而然在 CL 479915 合并之后第二天,Cherry 大佬就来了个 revert CL ,直接把 cgo 优化全 revert 了。原因是新的修复,发现了更多了 breakage。

不过是啥错误,也没有细说,不过,Michael 大佬提了另外一个 CL 481057 ,看起来是内存泄漏导致了 sanitizers 异常。

好在呢,Cherry 大佬又提了个 CL 481061 ,打算重新把 cgo 优化合进来,虽然 Michael 大佬已经给了 +2 approve,但是还没合。

比较让人担心的是,不清楚 google 内部的测试,是否还有其他异常,希望顺利吧 …

最后

其实,之前对于 g0 的理解是不到位的,以为是分配了单独的栈空间,实际上开启 cgo 时 g0 是复用的 c 线程栈。

果然,没理解到位的,终将是要付出代价的。辛苦了大佬们帮忙填坑,哈哈

这是第二次 CL 被 revert 了,真心不好玩,不过,确实是自己没搞好,也挺感谢有这些测试,也很感叹工程化能力真牛,respect!

从去年 3 月 15 日第一次提交,到昨天被合并,预计下一个 1.21 版本可以发布,这个 cgo 优化 搞了一年多,经历了各种波折坎坷,从一个 600 多行的补丁,折腾到了 200 多行,最后到合并的时候,又成了 700 多行,还是挺不容易的。

尤其是,这周是 Go team 的 Quiet Week,一般是不处理外部事务的,但是 Cherry 大佬还是一直在 review,也是挺让我感动的,respect

按照崔老师的话说,这波是逆风局了,因为这个优化可能并不是 Google 内部需要的,或者 Go team 所看重的,能走到现在也是受了很多人的帮助,这里记录下过程,踩过的坑,也对给予过帮助的人,表达谢意

起因

前年底加入 MOSN 团队,开始搞 MoE 以来,就一直有在折腾 cgo,因为 MoE 是重度依赖 cgo,并且是业界少见的,有点把 Go 当做嵌入式,这么频繁的跟 C 宿主交互的玩法。

去年写过这篇文章,详细介绍过这个优化 ,感兴趣的可以去仔细了解。

简单来说,就是 c 调 Go 的时候,需要在 C 线程上伪装 Go runtime 所需要的 GMP 环境,每次从 C 进入 Go,会用 needm 来获取 M 和 g,从 Go 返回 C,会用 dropm 来释放 M 和 g。然而,needmdropm 很重的操作,这个优化也很简单,就是复用,只在 C 线程退出的时候,才释放。

PreBindExtraM C API

起初,对这块机制也不是那么清楚,就搞了个 新增 API 来主动开启优化的提案 ,也就是 C 可以主动调用 PreBindExtraM 来提前绑定 M,然后这个 M 就一直不会被释放了。

经过 ian 和 aclements 两位大佬的提醒,原来注释里 rsc 大佬提了一个 TODO,从 Go 返回 C 的时候,可以不释放 M,但是前提是,需要用 pthread_key_create 注册一个 destructor,在线程退出的时候,可以释放 M,否则,M 就可能会泄漏了。

除了像 Windows 这种,不支持注册 pthread destructor 的系统,都启用优化,也就是第一次获取到 M 之后,就不再释放,直到 C 线程退出。

全部 CPU 体系跑通

接下来的主要工作,主要是通过 destructor,在线程退出的时候,释放 M 了。

因为 destructor 是 os 触发的,使用的是 C function call ABI,但是 dropm 是在一个 Go 函数,自然是 Go function Call ABI,这两者的寄存器使用是两套约定。

所以,参考了 C 调用 Go 会用到的 crosscall2 的实现,引入了 cgodropm 函数,将 C 里面的 callee-save, Go 里面的 caller-save 全部压栈,再调用 dropm。

在 ian 的帮助下,先是跑通了 amd64,然后又跑通了十来个 CPU 体系结构。此时已经是 600 多行的补丁了。

虽然,大部分的汇编是从 crosscall2 抄过来,不过也踩了一些坑,其中印象最深的是 arm64,stack pointer 必须是 16 byte 对齐的,否则会抛 bus error 异常了。

复用 crosscall2

然后是 Michael 大佬来了,他坚持不想要这个大段汇编的 cgodropm,改为复用 crosscall2 + hack cgocallback

这… 几百行的汇编,也没办法,大佬坚持要改,只好重新改了。改完之后,又瘦身到 200 多行的补丁了。

不过,这里依然有一个问题,crosscall2 只是导出给外部 link 的 C 程序使用,并没有导出给 runtime 的 C 程序使用,在这里又开始折腾了好久。

为此研究了一番,Go 和 C 之间的符号导出机制,link 的工作机制。

折腾了一番,搞了个 cgo_crosscall2 的 wrapper 函数,导出给 runtime 的 C 程序使用,但是大佬不认。

runtime C 直接调用 crosscall2

这时 Cherry 大佬出来了,坚持认为应该在 runtime C 代码里面直接调用 crosscall2,如果有问题的话,就是 compiler 或者 link 哪里有问题。

好吧,只提了要求,但是也没有太具体的指导,还是只能自己折腾。

这回是研究了 compiler 的流程,cgo compiler 编译出来的中间结果,会通过 gcc ld 来会判断是否依赖外部符号,原来的 runtime C 是不会引用外部函数的,但是 crosscall2 是在汇编里实现的,在 runtime C 里加了 crosscall2 的调用,这下就捅了马蜂窝了,gcc ld 认为是依赖外部符号的,发生了连锁反应,后面的编译测试都有问题了。

经过了一通折腾,最后在 Cherry 大佬的提议下,搞了一个小 hack,在 cgo compiler 的时候,临时搞个假的 crosscall2 欺骗 gcc ld。

然而,Cherry 大佬最后又反水了,不想这么搞了,因为会在 compiler 流程里,做一些 hardcode 的 hack …

函数指针变量

这时候,Cherry 大佬,希望让我试下,通过汇编将 crosscall2 的函数地址,写入到 C 里面的函数指针变量。

这… 不是跟上一版差不多么?然而,大佬坚持,而且也表达了歉意,那也没辙了,继续折腾。

此时,Go 摸得也差不多,并且大佬也给出了比较具体的建议,所以搞起来,也还算比较顺利。

这一版里,Go 不需要获取 crosscall2 的值,改为提供一个汇编函数,将 crosscall2 写入一个 C 函数指针变量,确实看起来也更干净了。

虽然也费了不少劲,但是还算比较顺利,又通过了测试了。并且 Cherry 和 Ian 两位大佬都给了 approve。

Slow trybots

看起来比较接近了,这次热心网友 thepudds 提议要不要跑一下 slow trybots,也就是一些比较少见的测试环境,包括一些听都没听过的操作系统,CPU 架构。

说到 thepudds,也挺让我感动的,从 PR 一开始就有参与讨论,可前期并没有参与,但是到了后期,我提了 patch,基本很快就帮我 run trybots。

Go 并不是默认就会跑测试,而是需要有权限的人来触发,如果要等 Go team 的人来确认,又是多了一天的往返,所以,有段时间我是请崔老师帮忙,thepudds 热心之后,崔老师也没那么烦我了,哈哈

说到 slow trybots,去年提过一个补丁,合并之后,就是因为 slow trybots 失败了,直接被 revert 了。

有了这种惨痛教训,我也挺希望跑跑 slow trybots,实在不想再被 revert 了。

macOS m1

好吧,果然发现了一些失败,第一个就是 macOS m1。并不是 arm64 都有问题,只有 macOS m1 才能复现…

好在跟公司 IT 临时借了个 m1 …

最后定位是,macOS m1 上,对于 TLS 变量的顺序不太一样,看起来是先清理了 Go 侧的 TLS,然后才调用的 pthread 注册的 destructor …

好吧,又是一通改,那就先把 m 存到 C 侧的 TLS,不再依赖 os 的 clean 顺序了。

AIX ppc64

还有另一个失败,则是完全没听过的 AIX ppc64 环境,这是 IBM 搞的操作系统和 CPU。

只有 AIX 这个系统还有点文档,仔细查阅之后,也没有发现有啥差异 … ppc64 的机器,那是别想搞到手的了 …

就在一筹莫展之际,无意间发现 master 分支上,有一个 ppc64 相关的变更,一看是 ibm 的邮箱,并且还有 +2 的 approve 权限。

尝试发了个邮件,请求帮助,很幸运大佬回复了,在大佬的指引和帮助下,这个兼容性也修复了。

简单来说,AIX 使用的 function call 还是 ELF v1 版本,function call 并不是直接 call 函数地址,而是搞一个 function descriptor。

合并

搞完这些兼容性问题,就是上周了。

等了几天,Cherry 大佬还没回应,这周又 ping 了一次,thepudds 提醒这周是 Quiet Week,心想,好吧,再等一周吧。

很惊喜的是,Cherry 大佬居然出来了,又 review 几轮,基本每天一往返,非常高效。

就在昨天,就这么被合并了,哈哈,真心不容易。

并且,合并之后,还有一个 arm 的 slow trybots 失败了,大佬也很给力,没有 revert,直接帮忙给修了,欧力给!

早上起来发现补丁被合并之后,激动得立马请 chatgpt 写了一段彩虹屁,好好夸了一番,哈哈。

感受

最大的一个感受,be nice!

整体过程中,大佬们大多是不太积极的,不过也可以理解,大佬也是 Google 的打工人,有自己的工作,这种菜鸟提的 patch,谁知道你会不会弃坑呢。

好在从一开始,崔老师就给我打过预防针,这个会很难,有一定的心理预期,所以过程中,肯定有不爽的时候,尤其是各种 ping,发邮件都没有回应的时候,然而,keep nice 我想我还是做到了,哈哈。

好在最后大佬们看到我的耐心,也开始变得积极了,信任也是这么一点点积累出来的。

最后

哈哈,流水账记录了不少,虽然走了不少弯路,但是其中踩过的坑,都是成长,记录下来,也是希望能有一些借鉴意义。

总体来说,这个优化其实也不难,为了搞一个简单的优化,把 compiler,link,runtime 研究了一遍。

有点像,当年搞一个 LuaJIT 的小 bug,把 LuaJIT 的 c code,byte code,IR code,assembly code 研究了一遍。

虽然比较折腾,不过还是蛮有意思的,相信后面还可以给 cgo 搞更多的优化

如果你也重度依赖 cgo 欢迎一起交流~

上一篇我们体验了用 Istio 做控制面,给 Go 扩展推送配置,这次我们来体验一下,在 Go 扩展的异步模式下,对 goroutine 等全部 Go 特性的支持。

异步模式

之前,我们实现了一个简单的 basic auth,但是,那个实现是同步的,也就是说,Go 扩展会阻塞,直到 basic auth 验证完成,才会返回给 Envoy。

因为 basic auth 是一个非常简单的场景,用户名密码已经解析在 Go 内存中了,整个过程只是纯 CPU 计算,所以,这种同步的实现方式是没问题的。

但是,如果我们要实现一个更复杂的需求,比如,我们要将用户名密码,调用远程接口查询,涉及网络操作,这个时候,同步的实现方式就不太合适了。因为,同步模式下,如果我们要等待远程接口返回,那么,Go 扩展就会阻塞,Envoy 也就无法处理其他请求了。

所以,我们需要一种异步模式:

  1. 我们在 Go 扩展中,启动一个 goroutine,然后立即返回给 Envoy,当前正在处理的请求会被挂起,Envoy 则可以继续处理其他请求。
  2. goroutine 在后台异步执行,当 goroutine 中的任务完成之后,再回调通知 Envoy,挂起的请求可以继续处理了。

注意:虽然 goroutine 是异步执行,但是 goroutine 中的代码,与同步模式下的代码,几乎是一样的,并不需要特别的处理。

为什么需要

为什么需要支持 Goroutine 等全部 Go 的特性呢?

有两方面的原因:

  1. 有了 full feature supported Go,我们可以实现很非常强大,复杂的扩展
  2. 可以非常方便的集成现有的 Go 世界的代码,享受 Go 生态的红利
    如果不支持全部的 Go 特性,那么在集成现有 Go 代码的时候,会有诸多限制,导致需要重写大量的代码,这样,就享受不到 Go 生态的红利了。

实现

接下来,我们还是通过一个示例来体验,这次我们实现 basic auth 的远程校验版本,关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.StatusType {
go func() {
// verify 中的代码,可以不需要感知是否异步
// 同时,verify 中是可以使用全部的 Go 特性,比如,http.Post
if ok, msg := f.verify(header); !ok {
f.callbacks.SendLocalReply(401, msg, map[string]string{}, 0, "bad-request")
return
}
// 这里是唯一的 API 区别,异步回调,通知 Envoy,可以继续处理当前请求了
f.callbacks.Continue(api.Continue)
}()
// Running 表示 Go 还在处理中,Envoy 会挂起当前请求,继续处理其他请求
return api.Running
}

再来看 verify 的代码,重点是,我们可以在这里使用全部的 Go 特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 这里使用了 http.Post
func checkRemote(config *config, username, password string) bool {
body := fmt.Sprintf(`{"username": "%s", "password": "%s"}`, username, password)
remoteAddr := "http://" + config.host + ":" + strconv.Itoa(int(config.port)) + "/check"
resp, err := http.Post(remoteAddr, "application/json", strings.NewReader(body))
if err != nil {
fmt.Printf("check error: %v\n", err)
return false
}
if resp.StatusCode != 200 {
return false
}
return true
}

// 这里操作 header 这个 interface,与同步模式完全一样
func (f *filter) verify(header api.RequestHeaderMap) (bool, string) {
auth, ok := header.Get("authorization")
if !ok {
return false, "no Authorization"
}
username, password, ok := parseBasicAuth(auth)
if !ok {
return false, "invalid Authorization format"
}
fmt.Printf("got username: %v, password: %v\n", username, password)

if ok := checkRemote(f.config, username, password); !ok {
return false, "invalid username or password"
}
return true, ""
}

另外,我们还需要实现一个简单的 HTTP 服务,用来校验用户名密码,这里就不展开了,用户名密码还是 foo:bar

完整的代码,请移步 github

测试

老规矩,启动之后,我们使用 curl 来测试一下:

1
2
3
4
5
6
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 401 Unauthorized

# valid foo:bar
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 200 OK

依旧符合预期。

总结

在同步模式下,Go 代码中常规的异步非阻塞,也会变成阻塞执行,这是因为 Go 和 Envoy 是两套事件循环体系。

而通过异步模式,Go 可以在后台异步执行,不会阻塞 Envoy 的事件循环,这样,就可以用上全部的 Go 特性了。

由于 Envoy Go 暴露的是底层的 API,所以实现 Go 扩展的时候,需要关心同步和异步的区别。

当然,这对于普通的扩展开发而言,并不是一个友好的设计,只所有这么设计,更多是为了极致性能的考量。

大多数场景下,其实并不需要到这么极致,所以,我们会在更上层提供一种,默认异步的模式,这样,Go 扩展的开发者,就不需要关心同步和异步的区别了。

欢迎感兴趣的持续关注~

上一篇我们用 Go 扩展实现了 basic auth,体验了 Go 扩展从 Envoy 接受配置。

只所以这么设计,是想复用 Envoy 原有的 xDS 配置推送通道,这不,今天我们就来体验一番,云原生的配置变更。

前提准备

这次我们需要一套 k8s 环境,如果你手头没有,推荐使用 kind 安装一套。具体安装方式,这里就不展开了。

安装 Istio

我们直接安装最新版的 Istio:

1
2
3
4
5
6
7
8
9
10
# 下载最新版的 istioctl
$ export ISTIO_VERSION=1.18.0-alpha.0
$ curl -L https://istio.io/downloadIstio | sh -

# 将 istioctl 加入 PATH
$ cd istio-$ISTIO_VERSION/
$ export PATH=$PATH:$(pwd)/bin

# 安装,包括 istiod 和 ingressgateway
$ istioctl install

是的,由于 Go 扩展已经贡献给了上游官方,Istiod(pilot)和 ingressgateway 都已经默认开启了 Go 扩展,并不需要重新编译。

Istio 配置 Ingress

我们先用 Istio 完成标准的 Ingress 场景配置,具体可以看 istio 的官方文档

配置好了之后,简单测试一下:

1
2
3
4
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 200 OK
server: istio-envoy
date: Fri, 10 Mar 2023 15:49:37 GMT

基本的 Ingress 已经跑起来了。

挂载 Golang so

之前我们介绍过,Go 扩展是单独编译为 so 文件的,所以,我们需要把 so 文件,挂载到 ingressgateway 中。

这里我们把上次 basic auth 编译出来的 libgolang.so,通过本地文件挂载进来。简单点搞,直接 edit deployment 加了这些配置:

1
2
3
4
5
6
7
8
9
10
11
12
# 申明一个 hostPath 的 volume
volumes:
- name: golang-so-basic-auth
hostPath:
path: /data/golang-so/example-basic-auth/libgolang.so
type: File

# 挂载进来
volumeMounts:
- mountPath: /etc/golang/basic-auth.so
name: golang-so-basic-auth
readOnly: true

开启 Basic auth 认证

Istio 提供了 EnvoyFilter CRD,所以,用 Istio 来配置 Go 扩展也比较的方便,apply 这段配置,basic auth 就开启了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: golang-filter
namespace: istio-system
spec:
configPatches:
# The first patch adds the lua filter to the listener/http connection manager
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: "envoy.filters.network.http_connection_manager"
subFilter:
name: "envoy.filters.http.router"
patch:
operation: INSERT_BEFORE
value: # golang filter specification
name: envoy.filters.http.golang
typed_config:
"@type": "type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config"
library_id: example
library_path: /etc/golang/basic-auth.so
plugin_name: basic-auth
plugin_config:
"@type": "type.googleapis.com/xds.type.v3.TypedStruct"
type_url: typexx
value:
username: foo
password: bar

虽然有点长,但是,也很明显,配置的用户名密码还是:foo:bar

测试

我们测试一下:

1
2
3
4
5
6
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200"
HTTP/1.1 401 Unauthorized

# valid foo:bar
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 200 OK

符合预期。

接下来,我们改一下 EnvoyFilter 中的密码,重新 apply,再测试一下:

1
2
3
# foo:bar not match the new password
$ curl -s -I -HHost:httpbin.example.com "http://$INGRESS_HOST:$INGRESS_PORT/status/200" -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 401 Unauthorized

此时的 Envoy 并不需要重启,新的配置就立即生效了,云原生的体验就是这么溜~

总结

因为 Go 扩展可以利用 Envoy 原有的 xDS 来接受配置,所以,从 Istio 推送配置也变得很顺利。

不过呢,Istio 提供的 EnvoyFilter CRD 在使用上,其实并不是那么方便 & 自然,后面我们找机会试试 EnvoyGateway,看看 k8s Gateway API 的体验咋样。

至此,我们已经体验了整个 Envoy Go 的开发 & 使用流程,在云原生时代,人均 Golang 的背景下,相信可以很好的完成网关场景的各种定制需求。

下一篇,我们将介绍,如何在 Go 扩展中使用异步协程。这意味着,我们可以使用的是一个全功能的 Go 语言,而不是像 Go Wasm 那样,只能用阉割版的。

敬请期待 ~

去年 10 月搞的提案,最近终于被接受了,第一个被接受的提案,写篇文章记录下的,嘿嘿

故事

去年在搞 MoE,MOSN on Envoy 新架构,折腾了不少 cgo。在分析 cgo 实现的时候,发现了一个 GC 相关的优化点,于是就搞了个提案。

10 月份提交的,一直没动静。今年 2 月开始活跃了,经过简单几轮讨论,就被接受了,整体还算比较顺利的。

之所以会比较顺利,我估计也是搭上了 Go team 的便车,看起来是他们想优化一下 escape analysis,这个提案刚好可以 match,给 escape analysis 更多的信息。

场景

我们知道 C 函数只有一个返回值,Go 调用 C 的时候,如果需要多个返回值,那么 C 函数如何设计呢?

首先,我们希望 Go 和 C 之间的交互足够简单,每次调用都是独立的。比如,我们并不希望在 C 侧申请内存,然后给 Go 返回内存指针。因为这样的话,我们还需要显式释放内存。

那么,最好的办法,就是按照 C 的套路玩,将 Go 对象内存指针,通过参数传给 C,在 C 里给 Go 的内存对象赋值。比如,在 Envoy Go 扩展里,我们就是这么玩的,可以看这个简单的示例:

1
2
3
4
5
6
7
8
9
func (c *httpCApiImpl) HttpGetIntegerValue(r unsafe.Pointer, id int) (uint64, bool) {
var value uint64
res := C.envoyGoFilterHttpGetIntegerValue(r, C.int(id), unsafe.Pointer(&value))
if res == C.CAPIValueNotFound {
return 0, false
}
handleCApiStatus(res)
return value, true
}

这里,我们将一个 uint64 内存对象的地址传给 C,是不是看起来比较清爽了呢。

问题

单从 Go 代码来看,value 是可以放在 stack 上的,但是,由于有了 cgo 调用,目前的实现,会将 value escape 到 heap 上,这会加重 GC 负载。

尤其在 MoE 这种网关场景中,这种代码是跑在请求处理的热路径上的,并且 Go 代码中可能会比较频繁的进行这类调用,也就是有很多这类 GC 对象 escape 到 heap 中,因此造成的 GC 开销,应该也是不小的。

实现

为了确保总是会 escape,cgo compiler 会生成的这样的 Go 包裹代码:

1
2
3
4
5
6
7
func _Cgo_use(interface{})

func _Cfunc_xxx(xxx) {
if _Cgo_always_false {
_Cgo_use(p0)
}
}

目的是,欺骗 escape analysis,如果 p0 是指针,则总是会逃逸。

并且,由于 _Cgo_always_false 总是为假,在编译优化阶段,这个分支又被优化掉了。

原因

为什么有了 cgo 调用,就需要 escape 呢?

Go stack

这里需要先简单介绍一下 Go stack:

目前版本里,Go stack 大小是可变的,而且是连续内存,当生长/缩小的时候,会重新申请内存段,将老的内存从 old stack copy 到 new stack。

并且 copy 过程中会进行比较复杂的栈上指针映射转换,也就是说,stack 上也可以有指针变量指向 stack 上的地址。

但是,如果指针变量已经传给 C,那是没有办法做指针映射转换了,也就是栈发生移动的时候,C 拿到的地址就是非法的了。

时机

那什么情况下,会出现呢?

最开始想到的是,在进入 C 之后,如果 Go 发生了 GC,可能会触发缩栈,但是后来仔细看代码,进入 C 之后,缩栈是被禁掉了的。

另外一个是,不太常用的场景,C 如果又回调了 Go,那么在 Go 里面是可以伸缩栈了,如果在回到 C,此时 C 中的地址就是非法的了。

解决办法

增加 annotation,让编译器感知不需要 escape,不生成 _Cgo_use 就可以了。

一开始我提的是 go:cgo_unsafe_stack_pointer,Go team 觉得不好,最后是 rsc 大佬提的:#cgo noescape/nocallback

感兴趣的话,可以看 proposal 的讨论:
https://github.com/golang/go/issues/56378

最后

提案是接受了,实现还是有些工作量的,主要是这个 annotation 是放在 C code 里的,目前 Go 对于 C 代码并没有 parser。
剩下的部分,应该是比较简单的了。

后面有空在搞了。

上一篇我们用一个简单的示例,体验了用 Golang 扩展 Envoy 的极速上手。

这次我们再通过一个示例,来体验 Golang 扩展的一个强大的特性:从 Envoy 接收配置。

Basic Auth

我们还是从一个小示例来体验,这次我们实现标准的 basic auth 的认证,与上一次示例不同的是,这次认证的用户密码信息,需要从 Envoy 传给 Go,不能在 Go 代码中写死了。

完整的代码可以看 example-basic-auth,下面我们展开介绍一番。

获取配置

为了更加灵活,在设计上,Envoy 传给 Go 的配置是 Protobuf 的 Any 类型,也就是说,配置内容对于 Envoy 是透明的,我们在 Go 侧注册一个解析器,来完成这个 Any 配置的解析。

如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func init() {
// 注册 parser
http.RegisterHttpFilterConfigParser(&parser{})
}

func (p *parser) Parse(any *anypb.Any) interface{} {
configStruct := &xds.TypedStruct{}
if err := any.UnmarshalTo(configStruct); err != nil {
panic(err)
}

v := configStruct.Value
conf := &config{}
if username, ok := v.AsMap()["username"].(string); ok {
conf.username = username
}
if password, ok := v.AsMap()["password"].(string); ok {
conf.password = password
}
return conf
}

这里为了方便,Any 中的类型是 Envoy 定义的 TypedStruct 类型,这样我们可以直接使用现成的 Go pb 库。

值得一提的是,这个配置解析,只有在首次加载的时候需要执行,后续在 Go 使用的是解析后的配置,所以,我们解析到一个 Go map 可以拥有更好的运行时性能。

同时,由于 Envoy 的配置,也是有层级关系的,比如 http-filter, virtual host, router, virtual clusters 这四级,我们也支持这四个层级同时有配置,在 Go 侧来组织 merge。

当然,这个只有在 Go 侧有复杂的 filter 组织逻辑的时候用得上,后面我们在 MOSN 的上层封装的时候,可以看到这种用法,这里暂时不做展开介绍。

认证

具体的 Basic Auth 认证逻辑,我们可以参考 Go 标准 net/http 库中的 BasicAuth 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func (f *filter) verify(header api.RequestHeaderMap) (bool, string) {
auth, ok := header.Get("authorization")
if !ok {
return false, "no Authorization"
}
username, password, ok := parseBasicAuth(auth)
if !ok {
return false, "invalid Authorization format"
}
if f.config.username == username && f.config.password == password {
return true, ""
}
return false, "invalid username or password"
}

这里面的 parseBasicAuth 就是从 net/http 库中的实现,是不是很方便呢。

配置

简单起见,这次我们使用本地文件的配置方式。如下是关键的配置:

1
2
3
4
5
6
7
8
9
10
11
12
http_filters:
- name: envoy.filters.http.golang
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config
library_id: example
library_path: /etc/envoy/libgolang.so
plugin_name: basic-auth
plugin_config:
"@type": type.googleapis.com/xds.type.v3.TypedStruct
value:
username: "foo"
password: "bar"

这里我们配置了用户名密码:foo:bar

预告一下,下一篇我们会体验通过 Istio 来推送配置,体会一番动态更新配置的全流程。

测试

编译,运行,跟上篇一样,我们还是使用 Envoy 官方提供的镜像即可。

跑起来之后,我们测试一下:

1
2
3
4
5
6
7
8
9
10
$ curl -s -I 'http://localhost:10000/'
HTTP/1.1 401 Unauthorized

# invalid username:password
$ curl -s -I 'http://localhost:10000/' -H 'Authorization: basic invalid'
HTTP/1.1 401 Unauthorized

# valid foo:bar
$ curl -s -I 'http://localhost:10000/' -H 'Authorization: basic Zm9vOmJhcg=='
HTTP/1.1 200 OK

是不是很简单呢,一个标准的 basic-auth 扩展就完成了。

总结

Envoy 是面向云原生的架构设计,提供了配置动态变更的机制,Go 扩展可以从 Envoy 接受配置,也就意味着 Go 扩展也可以很好的利用这套机制。

Go 扩展的开发者,不需要关心配置的动态更新,只需要解析配置即可,非常的方便~

下一篇我们会介绍,配合 Istio 来动态更新用户名密码,体验一番云原生的配置变更体验。

后续还有更多 Golang 扩展的特性介绍,原理解析,以及,更上层的 MOSN 集成体验,欢迎持续关注。

背景

MoE,MOSN on Envoy 是 MOSN 团队提出的技术架构,经过近两年的发展,在蚂蚁内部已经得到了很好的验证;并且去年我们也将底层的 Envoy Go 七层扩展贡献了 Envoy 官方,MOSN 也初步支持了使用 Envoy 作为网络底座的能力。

准备写一系列的文章,逐一介绍这里面的技术,本文是开篇,重点介绍 MoE 中的基础技术,Envoy Go 扩展。

FAQ

开始前,先回答几个基本的问题:

  1. MoE 与 Envoy Go 扩展的区别

    MoE 是技术架构,Envoy Go 扩展是连接 MOSN 和 Envoy 的基础技术

  2. Envoy Go 扩展,与用 Go 来编译 Wasm 有什么区别

    Envoy Go 支持 Go 语言的所有特性,包括 Goroutine,Go Wasm 则只能使用少量的 Go 语言特性,尤其是没有 Goroutine 的支持

  3. Go 是静态链接到 Envoy 么?

    不是的,Go 扩展编译成为 so,Envoy 动态加载 so,不需要重新编译 Envoy

  4. Envoy Go 支持流式处理么?

    支持的。

    由于 Go 扩展提供的是底层的 API,非常的灵活,使用上相对会稍微复杂一些;如果只想简单的使用,可以使用 MOSN 的 filter,后面我们也会介绍。

需求

我们先实现一个小需求,来实际体会一下:

对请求需要进行验签,大致是从 URI 上的某些参数,以及私钥计算一个 token,然后和 header 中的 token 进行对比,对不上就返回 403。

很简单的需求,仅仅作为示例,主要是体验一下过程。

代码实现

完整的代码可以看 envoy-go-filter-example 这个仓库

这里摘录最核心的两个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const secretKey = "secret"

func verify(header api.RequestHeaderMap) (bool, string) {
token, ok := header.Get("token")
if ok {
return false, "missing token"
}

path, _ := header.Get(":path")
hash := md5.Sum([]byte(path + secretKey))
if hex.EncodeToString(hash[:]) != token {
return false, "invalid token"
}
return true, ""
}

func (f *filter) DecodeHeaders(header api.RequestHeaderMap, endStream bool) api.StatusType {
if ok, msg := verify(header); !ok {
f.callbacks.SendLocalReply(403, msg, map[string]string{}, 0, "bad-request")
return api.LocalReply
}
return api.Continue
}

DecodeHeaders 是扩展 filter 必须实现的方法,我们就是在这个阶段对请求 header 进行校验。

verfiy 是校验函数,这里的 RequestHeaderMap 是 Go 扩展提供的 interface,我们可以通过它来读写 header,其他都是常见的 Go 代码写法。

编译

编译很简单,与常见的 Go 编译一样,这里我们使用 Golang 官方的 docker 镜像来编译:

1
2
3
4
docker run --rm -v `pwd`:/go/src/go-filter -w /go/src/go-filter \
-e GOPROXY=https://goproxy.cn \
golang:1.19 \
go build -v -o libgolang.so -buildmode=c-shared .

Go 编译还是很快的,只需要几秒钟,当前目录下,就会产生一个 libgolang.so 的文件。

反观 Envoy 的编译速度,一次全量编译,动辄几十分钟,上小时的,这幸福感提升了不止一个档次。

运行

我们可以使用 Envoy 官方提供的镜像来运行,如下示例:

1
2
3
4
5
docker run --rm -v `pwd`/envoy.yaml:/etc/envoy/envoy.yaml \
-v `pwd`/libgolang.so:/etc/envoy/libgolang.so \
-p 10000:10000 \
envoyproxy/envoy:contrib-dev \
envoy -c /etc/envoy/envoy.yaml

只需要把上一步编译的 libgolang.soenvoy.yaml 挂载进去就可以了。

值得一提的是,我们需要在 envoy.yaml 配置中启用 Go 扩展,具体是这段配置:

1
2
3
4
5
6
7
http_filters:
- name: envoy.filters.http.golang
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.golang.v3alpha.Config
library_id: example
library_path: /etc/envoy/libgolang.so
plugin_name: example-1

跑起来之后,我们测试一下:

1
2
3
4
5
$ curl 'http://localhost:10000/'
missing token

$ curl -s 'http://localhost:10000/' -H 'token: c64319d06364528120a9f96af62ea83d' -I
HTTP/1.1 200 OK

符合期望,是不是很简单呢

后续

什么?这个示例太简单?

是的,这里主要是体验下开发流程,下篇我们再介绍更高级的玩法:

Go 接受来自 Envoy 侧的配置,异步 Goroutine,以及与 Istio 配合的用法。

之前简单了解过 bazel ,前一阵给 Envoy CI 加 Go 代码风格的检查,又折腾了一番 bazel 的扩展规则,把当时的笔记扩展记录下来,以后没准还需要折腾。

背景

背景是这样的,Envoy CI 原来使用 python 脚本来做代码风格检查,但是那一套规则,对于 Go 并不匹配,所以想改用 gofmt 来检查 Go 代码。

其实最简单的方式是,在 Envoy CI 的 base docker image 中安装 golang 的包,但是他们觉得 golang 包太大了…

那么此时,就需要通过 bazel 来安装 golang,并且把 gofmt 暴露出来。

rules_go

按照 phlax 的提示,rules_go 已经引入了 gofmt,接下来就看,怎么用上了。

经过一通翻 rules_go 的代码,大概搞明白了 bazel 扩展规则的基本套路。

我的思路是,就是通过自定义规则,来产生 gofmt 文件,类似于编译 Envoy,然后,在检查脚本里就可以用上 gofmt 了。

最终效果是,在 tools/code_format/BUILD 里调用自定义规则:

1
2
3
gen_gofmt(
name = "gofmt",
)

通过 build 来生成 gofmt

1
bazel build //tools/code_format:gofmt

rule

自定义规则,分为两部分,首先是上层的定义:rule

rule 可以理解为函数申明,可以有入参,通常是编译的源文件,如下示例中没有;还有返回值,也就是产出物。

rule 本身,比较简单,只是申明了基本信息,包括 rule implementation,还有对入参的定义。

如下示例,是基于 rules_go 的自定义的 go rule 规则 ,有几个点:

  1. 申明了 gen_gofmt 这个 rule,实现在 _gen_gofmt_impl
  2. 依赖了 rules_go 中的 go toolchain,toolchain 会提供一个执行环境,比如通常的 rule_cc 是提供的是 C 编译器的执行环境
  3. 入参处理,是在 attrs 中完成,示例并没有入参,只是将 go_context_data 放进上下文中
1
2
3
4
5
6
7
8
9
gen_gofmt = rule(
implementation = _gen_gofmt_impl,
attrs = {
"_go_context_data": attr.label(
default = "@io_bazel_rules_go//:go_context_data",
),
},
toolchains = ["@io_bazel_rules_go//go:toolchain"],
)

rule implementation

另一部分就是,rule implementation,也就是用 skylark(类 python 的语言)函数来描述具体的生产过程。

如下示例,有几个点:

  1. go_contextrules_go 提供的函数,将 toolchain 中的内容暴露出来
  2. action 是 bazel 内置的,用来申明执行什么任务,可以多个叠加,这里就只是简单的执行 cp 命令
  3. 返回值也就是产出物
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def _gen_gofmt_impl(ctx):
name = ctx.label.name
out = ctx.actions.declare_file(name)

go = go_context(ctx)
sdk = go.sdk
gofmt = sdk.root_file.dirname + "/bin/gofmt"

ctx.actions.run(
executable = "cp",
arguments = [gofmt, out.path],
outputs = [out],
inputs = sdk.tools,
)

return [DefaultInfo(
files = depset([out]),
)]

体会

bazel 确实很灵活,可以支持很复杂的扩展,但是上手门槛也高。

如果,有一个现成的示例可以抄,那么使用门槛也不高,简单 copy & paste,了解一些上层的概念也够用了。

但是,如果简单模式走不通,那就比较麻烦了,得把 bazel 的底层逻辑搞懂了,这就是门槛了。

我觉得,最主要的还是,通识性不够强,不够普世,bazel 自己撸的一套方案,用得人少,门槛就搞了。

结局

最后,phlax 发现可以这样来执行 gofmtbazel run @go_sdk//:bin/gofmt,自定义规则就可以删掉了,汗 …

还是通识性不够强,用的人少,走的弯路就多了。

ChatGPT

其实,这种笔记也挺无聊的,让我们问问 ChatGPT 能做到什么程度呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
我:请写一篇如何实现 bazel 自定义规则的介绍文章,大约 200 字

AI:Bazel 自定义规则是使用 Skylark 语言编写的,它们通过 BUILD 文件被 Bazel 调用。如果您想实现 Bazel 自定义规则,您需要执行以下步骤:

创建一个 Skylark 脚本:首先,您需要创建一个 Skylark 脚本,该脚本将包含您的自定义规则的实现。

实现自定义规则:接下来,您需要实现自定义规则。在 Skylark 脚本中,您可以使用内置函数或编写自己的函数,以实现所需的构建逻辑。

在 BUILD 文件中使用自定义规则:最后,您需要在您的 BUILD 文件中使用自定义规则。您可以通过调用 Skylark 脚本中实现的自定义规则函数来实现这一点。

请注意,如果您希望 Bazel 可以找到和使用您的自定义规则,您需要遵循 Bazel 的标准文件结构和命名规范。在您的自定义规则生效前,请确保进行了充分的测试,以确保它们按预期工作。

我:请提供一个 bazel 自定义规则的最小化示例

AI:下面是一个使用 Skylark 语言实现 Bazel 自定义规则的最简单示例:

# custom_rule.bzl
def custom_rule_impl(ctx):
output = ctx.outputs.out
ctx.actions.run(
outputs=[output],
inputs=ctx.files.srcs,
executable="echo",
arguments=["Hello, Bazel!", ">", output.path]
)

custom_rule = rule(
implementation = custom_rule_impl,
attrs = {
"srcs": attr.label_list(allow_files=True),
"out": attr.output(),
},
)

以下是如何在 BUILD 文件中使用这个自定义规则的示例:
# BUILD
load("//path/to/custom_rule.bzl", "custom_rule")

custom_rule(
name = "hello",
srcs = ["input.txt"],
out = "output.txt",
)

在此示例中,我们实现了一个简单的 custom_rule,该规则通过调用 echo 命令将消息 "Hello, Bazel!" 写入输出文件。在 BUILD 文件中,我们加载了 custom_rule.bzl,并调用了 custom_rule 以使用该规则。

这两个问题,我给 90 分,不过后面还有更细节的问题,比如,如何使用 rules_go 中的 gofmt,就回答错了。

或许,ChatGPT 还需要进化,又或者需要更多我这样的无聊笔记投喂训练,才能更深的理解?

去年底换了新工作,加入蚂蚁搞 MOSN,开始了新征程。

虽然,大形势还很严峻,也不知道未来会怎么样,但是,这一年下来,还是很充实的,成长了不少,学了不少新东西,也有了更多的思考。

充电状态

之前在鸥锐的创业环境下,需要操心的事情多,能量输出一直是比较大的,整体属于耗电状态。

来蚂蚁当螺丝钉之后,操心的事情少了,可以更专注在技术领域,整体属于充电状态。

另外,除了最后疫情几个月,今年大部分时间,基本保持了每周两次运动,身体状态也比之前要好了一些。

新东西

新工作新环境,也玩了不少新东西。

今年基本保持了每月两篇文章的节奏,用来总结记录新的体会。

年中还启用了这个公众号,除了 push 自己更深的学习思考,偶尔还能收到一些朋友的反馈,聊上几句,还是蛮欣慰的。

云原生

今年最大的感悟,就是知道云原生是怎么回事了,也有了比较直接的体感。

以 k8s 为核心的云原生,是 IT 基础设施从 IaaS 到 PaaS 跨时代的演进,也是一众 IT 科技公司正在推进的基础设施革新。

比较偏激的来说,以后的 IT 企业的基础设施,规模做得大的,成为公有云/云厂商,小一点的就是私有云,再小的,估计就只有上云,成为云用户了。

IaaS 层提供的底层的硬件的抽象,用户角色是 IT 企业;PaaS 层提供的是 IT 基础设施的抽象,用户角色已经细化到应用 Owner。

云原生这一波,是更进一步的 IT 基础设施标准化。

比如经典的,Pod 这一系列的抽象,应用的运行,生命周期管控,已经都被涵盖进来了。

很自然的,网关这个基础设施,也在云原生这一波里,被标准化,当做基础设施组件来对上层提供服务了。

MOSN

今年最先入坑的主营项目就是 MOSN。

现有的 MOSN 已经相对成熟,主要是学习工作机制,以及成功经验。

感触最大的是 MOSN 在蚂蚁内部的协作机制,总计十几个团队,上百个协作者,这种工程化落地能力,还是比较震撼的。

Go

搞 MOSN 的另外一个很大的成果,就是 Go 也入门了。

也搞了一些有点深度的东西,比如研究了 cgo,尝试搞了点优化,摘点低垂的果实。

还研究了 Go debug 中的 viewcore 工具,搞出了内存引用关系火焰图。

Envoy

来蚂蚁相中的是 MOE,MOSN on Envoy,这个项目。

对于 Envoy,今年也算入了个门,基本套路搞明白了,也搞了一些 Envoy 的扩展实现。

尤其是 Envoy 宿主 C++ 和外部 Go 的交互方式,算是想清楚了,也还是挺不容易,比较有成就感的事情。

总体来说,MOE 是把 Go 语言用在了嵌入式场景,应该之前很少有人这么玩的,希望明年 MOE 可以为 Envoy 社区带来亮眼的特性,也给 Go 圈带来一些新玩法。

Istio

云原生这一波中,网关组件的演进,是从数据面,到控制面,再到 API 标准的一系列的革新。

Istio 作为控制面,以及先行较主流的 API 标准,也自然入了个门。

相对而言,用 Go 实现的 Istio,从代码上来说,更容易入坑。但是,Istio 作为中间桥梁,对接的组件/概念,实在是很多,所以要搞清楚也不太容易。

最后

写到最后,发现一年的总结也只有工作…

无论如何,2022 自我感觉还不错,也很感谢关心帮助过我的人。

尽管生活不尽如人意,甚至还有很多让人想骂街的,不过我想我还是热爱生活的。

最后,希望 2023 年,MOE 能玩出生态,做出产品,不单内部有更大的落地,也能在开源圈里,和更多小伙伴们玩耍起来。也希望还能继续保持玩一些新东西,有更多的思考。

希望明年可以多出去走走,见见老朋友,也能结交些新朋友。

2023,Go ahead,have fun!

作为一个 Proxy 软件,选择代理的目标上游地址,是最基本的功能。
同时,也需要足够灵活的路由选址策略支持,才能满足各种的业务诉求。

同 Nginx 一样,Envoy 也分为两步:

  1. 先选择 cluster
  2. 再从 cluster 选择 Host

选择 cluster

Envoy 是在 route action 里面完成 cluster 指定的,有这么几种方式:

  1. cluster,直接指定 cluster name

  2. cluster_header,通过读取一个 header 的值来获取 cluster name

  3. weighted_clusters,从一组 cluster 中按照权重选取一个

  4. cluster_specifier_plugin,通过额外的 plugin 来获得 cluster name

    这是 Envoy 提供的一种扩展方式,可以自己实现 plugin 来自定义的选择 cluster

选择 Host

cluster 包含了一批 host,从中选择哪一个,则是由 loadbalancer 来完成了。

Envoy 内置了很多个 balancer,比如常见都有:

  1. 随机
  2. 权重轮询
  3. Maglev 一致性哈希

这些常见的 balancer 侧重于负载均衡,但是还缺少了一些业务语义的灵活支持。 比如:

  1. 就近选择
  2. 灰度,等各种发布策略

所以,Envoy 又提供了 Locality 和 subset 两种筛选机制,这里简单介绍下 subset

Load Balancer Subsets

Subset 是一种从 cluster 中匹配 Host 的很灵活的算法

基本流程是:

  1. Host 可以有打标数据,通常是字符串的 key=>value 对
  2. route 可以指定 key=>value 的过滤条件,来筛选 Host

具体可以看 Envoy 官网上的示例

Subset 匹配条件

之所以,说它很灵活,主要是:

  1. 打标数据可以是任意的 key=>value 对,而且可以多个
  2. 用来匹配的 key=>value 条件,可以是 route 级别指定,还可以由 http filter 来动态设置

最终进行 Subset 匹配的条件,是由 route action 的 metadata,和 http filter 动态设置的 dynamicmetadata 合并而来的。这样的话 http filter 就可以通过设置匹配过滤条件,来灵活的指定筛选效果了

MOE 的优势

Envoy 已经通过动态配置,实现了 cluster/Host 这种元数据的动态更新。

但是,在不同的业务场景下,我们还是需要灵活的自定义的选择策略。对此,MOE 还是提供了两层的灵活控制:

动态选择 cluster

比如:

  1. 容灾场景,需要切换机房
  2. 安全场景,比如发现是攻击请求,引流到蜜罐

这些场景,通过 cluster 内选择 Host,是不太合适的

所以,我们计划提供实现 cluster_specifier_plugin 扩展 ,这样我们就可以,用 Go 代码来实现任意的 cluster 选择策略了。

动态选择 Host

subset 已经是很灵活匹配策略了,MOE 则是计划提供,在 Go 里面动态设置匹配条件 ,以最大程度的释放 Subset 的灵活性。

怎么样,心动了不?如果你有类似的需求,或者对实现感兴趣,欢迎跟我们联系