0%

去年 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 的灵活性。

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

年中折腾过 istio,写了一篇 《白话 Istio》 ,简单介绍了 Istio 是一个配置组装工厂,从 k8s 等上游拉取 CRD 等配置,向下游 Envoy 供应 xDS 配置资源。

最近因为要了解增量实现机制,又翻了代码,这篇尝试从代码层面,简单记录一下的。

两句话

  1. 每个客户端流,一个主循环,等待两个 channel,一个 deltaReqChan,处理来自客户端的请求,一个 pushChannel 处理上游配置变更事件
  2. 本文只是介绍基本的工作流,更多的复杂度在于,上游配置资源,到下游 xDS 资源的转换关系,这里并没有做具体介绍

入口

首先 istio 对 Envoy 提供了一组 GRPC service

比如这个 service/method

envoy.service.discovery.v3.AggregatedDiscoveryService/DeltaAggregatedResources

对应于 Envoy 中的 Incremental ADS,增量聚合模式,实现入口在 pilot/pkg/xds/delta.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
33
34
35
36
37
func (s *DiscoveryServer) StreamDeltas(stream DeltaDiscoveryStream) error {
// 鉴权
ids, err := s.authenticate(ctx)

// 新建 conn 对象
con := newDeltaConnection(peerAddr, stream)

// 单独协程读请求,读到一个请求,就写入 deltaReqChan
go s.receiveDelta(con, ids)

// 主循环,每个 stream
for {
select {
// 处理来自客户端 (envoy)的请求
case req, ok := <-con.deltaReqChan:
if ok {
if err := s.processDeltaRequest(req, con); err != nil {
return err
}
} else {
// Remote side closed connection or error processing the request.
return <-con.errorChan
}

// 处理来自上游配置变更的推送任务
case pushEv := <-con.pushChannel:
err := s.pushConnectionDelta(con, pushEv)
pushEv.done()
if err != nil {
return err
}

case <-con.stop:
return nil
}
}
}

每个 stream 一个主循环,就干两件事:

  1. 处理请求,来自于 read 协程
  2. 处理推送任务,来自上游配置变更事件

处理请求

一次处理一个请求,在主循环中执行

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
func (s *DiscoveryServer) processDeltaRequest(req *discovery.DeltaDiscoveryRequest, con *Connection) error {
// 客户端(envoy)回复了 ack,可以将 ack 状态上报
if s.StatusReporter != nil {
s.StatusReporter.RegisterEvent(con.conID, req.TypeUrl, req.ResponseNonce)
}

// 是否需要回复,比如只是 NACK,则没必要回复
shouldRespond := s.shouldRespondDelta(con, req)
if !shouldRespond {
return nil
}

request := &model.PushRequest{
Full: true,
Push: con.proxy.LastPushContext,
Reason: []model.TriggerReason{model.ProxyRequest},
Start: con.proxy.LastPushTime,
Delta: model.ResourceDelta{
Subscribed: sets.New(req.ResourceNamesSubscribe...),
Unsubscribed: sets.New(req.ResourceNamesUnsubscribe...),
},
}

// 响应请求
return s.pushDeltaXds(con, con.Watched(req.TypeUrl), request)
}

响应请求

根据 TypeURL 找对应的资源生成器,生成资源之后,封装成 DeltaDiscoveryResponse 后,发送给 Envoy

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
func (s *DiscoveryServer) pushDeltaXds(con *Connection,
w *model.WatchedResource, req *model.PushRequest,
) error {
// 根据资源类型找到生成器,比如:CDS 是 CdsGenerator,EDS 是 EdsGenerator
gen := s.findGenerator(w.TypeUrl, con)

// 根据生成器类型生成资源
switch g := gen.(type) {
case model.XdsDeltaResourceGenerator:
res, deletedRes, logdata, usedDelta, err = g.GenerateDeltas(con.proxy, req, w)
// 除了 CDS 和 EDS,目前都还没有 delta 的实现
case model.XdsResourceGenerator:
res, logdata, err = g.Generate(con.proxy, w, req)
}

// 构造 DeltaDiscoveryResponse 发送给客户端
resp := &discovery.DeltaDiscoveryResponse{
ControlPlane: ControlPlane(),
TypeUrl: w.TypeUrl,
// TODO: send different version for incremental eds
SystemVersionInfo: req.Push.PushVersion,
Nonce: nonce(req.Push.LedgerVersion),
Resources: res,
}
// 这里还会记录 NonceSent
if err := con.sendDelta(resp); err != nil {
return err
}
return nil
}

生成资源

资源有多种,实现也分散了,具体可以看,这两个 interface 的具体实现:

XdsResourceGeneratorXdsDeltaResourceGenerator

主要逻辑就是,选取对应的 CRD 资源,生成 xDS 资源,以 LDS 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (configgen *ConfigGeneratorImpl) BuildListeners(node *model.Proxy,
push *model.PushContext,
) []*listener.Listener {
builder := NewListenerBuilder(node, push)

// Mesh sidecar 和 Gateway 有不同的生成逻辑
switch node.Type {
case model.SidecarProxy:
builder = configgen.buildSidecarListeners(builder)
case model.Router:
// 主要是从 Gateway CRD 生成 LDS 的主要配置
builder = configgen.buildGatewayListeners(builder)
}

// 再把对应的 envoyfilter patch 到 LDS
builder.patchListeners()
return builder.getListeners()
}

订阅资源

讲完请求处理这条链路,再来看推送链路。

这就得先从订阅资源开始了,istio 启动之后,会从 k8s 订阅 EndpointSlicePods 等资源

1
2
3
c.registerHandlers(filteredInformer, "EndpointSlice", out.onEvent, nil)

c.registerHandlers(c.pods.informer, "Pods", c.pods.onEvent, c.pods.labelFilter)

当有资源变更时,会触发 DiscoveryServer.ConfigUpdate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (s *DiscoveryServer) EDSUpdate(shard model.ShardKey, serviceName string, namespace string,
istioEndpoints []*model.IstioEndpoint,
) {
inboundEDSUpdates.Increment()
// Update the endpoint shards
pushType := s.edsCacheUpdate(shard, serviceName, namespace, istioEndpoints)
if pushType == IncrementalPush || pushType == FullPush {
// Trigger a push
s.ConfigUpdate(&model.PushRequest{
Full: pushType == FullPush,
ConfigsUpdated: sets.New(model.ConfigKey{Kind: kind.ServiceEntry, Name: serviceName, Namespace: namespace}),
Reason: []model.TriggerReason{model.EndpointUpdate},
})
}
}

debounce

ConfigUpdate 只是进入 pushChannel 队列,中间会经过 debounce 处理,才会进入真正的任务队列 pushQueue

debounce 的核心是 update 事件的合并:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for {
select {
case <-freeCh:
free = true
pushWorker()
case r := <-ch:
lastConfigUpdateTime = time.Now()
if debouncedEvents == 0 {
timeChan = time.After(opts.debounceAfter)
startDebounce = lastConfigUpdateTime
}
debouncedEvents++

// 核心是 Merge
req = req.Merge(r)
case <-timeChan:
if free {
pushWorker()
}
case <-stopCh:
return
}
}

聚合后的事件,会为每个客户端连接,都生成一个任务,塞入全局的 pushQueue

1
2
3
for _, p := range s.AllClients() {
s.pushQueue.Enqueue(p, req)
}

推送

另外,再有一个单独的协程,从 pushQueue 消费,来完成推送

主要逻辑就是,喂给入口主循环等待的推送任务队列 pushChannel

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
func doSendPushes(stopCh <-chan struct{}, semaphore chan struct{}, queue *PushQueue) {
for {
select {
case <-stopCh:
return
default:
// 从 pushQueue 获取任务
client, push, shuttingdown := queue.Dequeue()
if shuttingdown {
return
}

doneFunc := func() {
queue.MarkDone(client)
<-semaphore
}

go func() {
pushEv := &Event{
pushRequest: push,
done: doneFunc,
}

select {
// 这里的 pushChannel 就是入口主循环等待的推送任务队列
case client.pushChannel <- pushEv:
return
case <-closed: // grpc stream was closed
doneFunc()
log.Infof("Client closed connection %v", client.conID)
}
}()
}
}
}

增量机制

最后说下我了解到的增量实现:

  1. 目前的增量,是指单个资源粒度的单独更新
    相对于原来按照资源类型,把所有资源全量更新的方式,是增量
    单个资源的局部更新,还没有的

  2. 目前 Istio 实现的增量,还仅局限于单个连接内的增量
    如果断连后重连,还是要走一遍全量推送的
    虽然 Envoy 是支持了跨连接的增量支持

  3. 跨连接的增量是通过每个资源的版本号来的
    从 xDS 协议设计上,每个资源都有版本号,Envoy 也确实会在重连时,将现有资源的版本号传给 Istio
    只是 Istio 并没有处理版本号这块细节,发给 Envoy 的版本号只是默认的空字符串

个人不太成熟的观点,不对的地方,欢迎指正。

1
2
3
4
5
1. IaaS 是对硬件资源的抽象
提供类似"水电煤"这样的,计算服务

2. PaaS 是对应用全生命周期管控的抽象
提供综合商场中开个小店这样的,更上层的平台服务

云计算

首先,云原生的前一个阶段,是云计算。

IaaS 层的云计算,是对计算硬件资源的抽象,使得计算的硬件资源,可以像 “水电煤” 一样,随时可以获取。

具体来说,我们可以非常方便的,在阿里云购买虚拟机,而不用自己去采购硬件,担心网络等问题。

就像,我们用电,只需要接入电网,不需要去直面发电厂,是一样的道理。

这是抽象的价值,平台的价值。不过,相对而言,IaaS 还是比较浅层的抽象。

云原生

以我个人的理解,目前以 k8s 为代表的云原生这一套,则是 PaaS 层的抽象。

有了硬件计算资源之后,要把企业的线上生产系统,部署运行起来,则需要一套部署运维平台。

在微服务的时代,企业的生产系统,是由一连串的微服务应用组成的。自然的,运维部署平台,服务的对象就是这些微服务。

k8s 就是这么一个平台,为平台的用户,微服务应用的 owner,提供了一套抽象能力,来部署运维自己的应用。

具体来说,微服务应用的 owner,只需要通过一套标准的申明,比如,需要多少 CPU,内存,对外提供什么服务,流量怎么进来,就能让自己服务运行起来。同时,还能享受平台提供的管控能力。

这就有点像,我们去商场开一个小店,商场除了提供基础的物理店铺环境,还会提供更多配套的平台服务,小店主可以像商场提需求,商场可以来安排准备好。

抽象模型

云原生之所以能火,我认为有很重要的两点,第一个就是抽象模型。

本质上而言,云原生就是一套面向应用 owner 的抽象模型,这一套模型,能够比较好的满足应用 owner 的需求,发布,管控,可视化报表,等等,一条龙服务。

能解决问题,能为应用 owner 提供好的服务,才有需求,以 k8s 为代表的云原生基础设施,才有了生存的基础。

开放标准

没有云原生概念之前,其实大家也在往这个平台方向努力,做类似这样的事情。毕竟抽象,做平台,才能发挥更大的价值,一直都是大家的梦想。

这一波云原生,则是以相对开放的方式,来共建一套标准。也就是,大家别各玩各的了,一起玩,搞一套标准,这样才更有生命力。

虽然,参与方也不是活雷锋,是为了自己厂商的利益,但是这样权衡出来的标准,确实是更符合大众利益的,尤其是上层用户,应用 owner 的利益。

协作关系

云原生带来了更深层次的,协作关系的变化,新的协作关系可以更好的解放生产力。

以往,应用方,通常也是企业的核心业务,营收部门,要上线一个应用,需要自己去对接不同的,IT 基础设施团队。

协作关系是,应用方将业务系统,跑的这些基础设施之上,比如,对于网关团队而言,常见的操作,就是接入一个新应用的流量。

云原生这一波后,这些 IT 基础设施被打包为 PaaS 平台,转为向应用方提供服务。

这一转变,意味着,应用方在 PaaS 上,可以使用底层的基础服务,从接入方变用户,以前可能需要去求人解决问题,现在有了甲方大爷的底气了。

而底层基础服务团队,则从底层服务的运营方,变成了服务的提供方,得做好服务甲方大爷的心里准备了。

不过,既然是提供服务了,价值也更好说了,不会被简单粗暴的,当做纯粹的成本部门来对待了。

这里面,我觉得很重要的一点是,做平台收租可以,但是需要把服务做好,不能像以往一样,让应用方来各种配合自己的不合理设计了。

最后

随着生产力的提升,社会分工细致化之后,需要更合适的抽象模型,更好的平台,更合适的协作关系。

虽然,这只是 IT 团队的一个协作关系的升级,但是,对于整个社会而言,又何尝不需要有这种升级呢?

平台方,天然就具备垄断性,至少得有服务意识,别把自己当大爷,随意的强奸用户,不是么?

至于,怎么才能让平台有服务意识呢?我觉得,得让用户有得选,服务不好,还可以换

上一篇 Envoy bazel 学习 & 踩坑 中记录了,bazel 一个很重要的功能就是管理依赖项。我们只需要申明依赖项的来源,bazel 会自动去获取。

听上去是挺美好的,然而现实中的网络环境确实残酷的。这不,最近就踩坑了。

前两天编译 Envoy 的时候,开启了 WASM,就需要下载 v8 那个依赖项。那个依赖项死活下载不了,在开发机上,每次下载完成的长度都少一大截,通不过验证。

不过,在本地浏览器中下载的文件是正常的,就想着 hack 一下他的缓存,然而这种内部实现,是没有文档的。折腾了一番,搞定了,记录一笔的。

bazel 的依赖项缓存,是按照 sha 来的,比如如下这个例子:

1
2
3
4
5
6
7
8
boringssl = dict(
project_name = "BoringSSL",
project_url = "https://github.com/google/boringssl",
version = "098695591f3a2665fccef83a3732ecfc99acdcdd",
sha256 = "e141448cf6f686b6e9695f6b6459293fd602c8d51efe118a83106752cf7e1280",
strip_prefix = "boringssl-{version}",
urls = ["https://github.com/google/boringssl/archive/{version}.tar.gz"],
),

首先缓存目录是:~/.cache/bazel/_bazel_doujiang24/cache/repos/v1/content_addressable/,其中的 doujiang24 每个环境会不一样。

然后${version}.tar.gz 文件会缓存在 sha256/$sha256/file$sha256 也就是上述配置中申明的。

bazel 也就是通过类似的方式来查找,校验缓存的。

1
2
$ sha256sum sha256/e141448cf6f686b6e9695f6b6459293fd602c8d51efe118a83106752cf7e1280/file
e141448cf6f686b6e9695f6b6459293fd602c8d51efe118a83106752cf7e1280 sha256/e141448cf6f686b6e9695f6b6459293fd602c8d51efe118a83106752cf7e1280/file

需要 hack 的时候,通过写入对应的 file 文件即可。

好吧,这两天朋友圈被两篇文章卷飞了

一是 Envoy 的 Matt 大佬的 采访文章 ,预测 Nginx 在市场上的寿命只剩 10 到 15 年了(大佬可真敢说…)

二是阿里云基于 Envoy 开源 Higress 的文章 ,又拿 Tengine/Nginx 来对比。

仿佛一夜间,Envoy 就要逆袭 Nginx 了,朋友圈里好不热闹,搞 Envoy 的招呼赶紧上车,不搞 Envoy 的直呼太卷了,甚至还有 “去 Nginx” 的论调 …

以上,是朋友圈中摘选的一些情绪化的东西。

外行看热闹,内行看门道,咱作为网关领域的一线开发,怎么看呢?

正好,前几天在组内有一个这方面的小分享,拿出来说道说道

个人背景

我以前一直是搞 OpenResty/Nginx 的,去年底加入了蚂蚁开始搞 MOSN,MOE 这个方向,也差不多一年了。

这一年中,有小半时间在搞 Envoy 相关的,对 Envoy 也开始有了一些了解。

后面的分享,就是一个已经上车 Envoy 的 Nginx 老炮,谈谈从 Nginx 到 Envoy 有哪些变化,仅供参考。

大环境

  1. 时代变了,云原生时代来了,对很多基础软件都带来了冲击。谁能顺应潮流,与时俱进,就能赢得未来。
  2. Nginx 基本盘还在,在 Web server 市场,Nginx 依然独占鳌头。只是对于开发者而言,已经不再那么诱人。
  3. Envoy 已经在开始蚕食 Nginx 的强项市场,Ingress,API gateway。这一波中,云厂商在打头阵,冲在一线。一线大厂,由于历史包袱,也在积极储备,逐步汰换。
  4. 东西南北,统一架构的趋势,愈演愈烈。

控制面

从 Nginx 到 Envoy 最大的一个变化,就是抽象了独立的控制面。

Nginx 诞生于云原生之前的时代,是面向系统运维的架构。那个时代,应用发布是很低频的操作,由系统运维来把关操作,是很合理的。

而 Envoy 诞生的云原生时代里,动态变更配置,已经是常态化的基础需求,独立的控制面,是更合理的选择。

并且,有了独立控制面之后,网关就可以脱离数据面,进行更上层的产品演进。比如,Istio,Envoy Gateway 这种就有了独立的发展空间。

扩展机制

回到 Envoy 数据面,另外一个很大的变化是,扩展机制。

Nginx 的底座是 Http/TCP server,可以在预定的 hook 点注册 handler。

Envoy 的底座则更加底层,只是 Filter 扩展的管理器。底座与协议无关,只负责管理运行一个个的 Filter,由 Filter 来完成所有的事情,包括协议解析。

同时四层 TCP 和七层 HTTP 使用的是同一套架构,HTTP 的 filter manager,本身也只是一个 TCP 层的一个复杂的 Filter。

这种灵活的可扩展性,使得 Envoy 可以适用于更多不同的场景。

Matt 大佬采访中确实也提到了这一点,Envoy 的核心技术理念就是围绕可扩展性展开的,希望提供的是一个可扩展的底层工具。

上手门槛

Envoy 确实上手门槛有点高。

以我个人的体验来看,有这么几个方面:

  1. 由于上述的一些架构变化,软件的整体复杂性本身,确实是要更高了一些
  2. Envoy 有非常深的 Google/硅谷 C++ 全家桶的痕迹,构建工程就很复杂,初次 bazel 编译,按小时算的
  3. 从 C 到 C++ 的学习成本

这些门槛,对于一个专门从事网关的开发人员来说,也并不是太大的难事,硬着头皮,有一两个月,也可以啃下来。

有解么?

但是,对于简单的网关用户来说,这个门槛,其实是非常高的。并且,跨过这个门槛之后,C++ 的研发效率,也确实比较低。

所以,这也就是,我们正在搞的 MOE 架构的发展空间,通过把 Go 语言嵌入到 Envoy,为用户提供了,使用 Go 语言来开发 Envoy Filter 的机制。

这么下来,上手门槛就非常低了,同时上手之后,Go 语言的研发效率,也是公认的高。

不信?MOE 的底层,envoy-go-extension 已经开源了 ,欢迎来体验用 Go 来开发 Envoy Filter

后续,我们还会提供更上层的 MOSN 侧的封装,到时候,整个 MOSN 都可以跑在 Envoy 上,大家直接开发 MOSN filter 就可以了。

最后

随着云原生的发展,对于网络提出更高的要求,多租,多云,身份认证,这些都是网关软件的发展机会。

未来谁会发展得更好?尚未可知。每个开发者,会用脚投出最真实的一票。