0%

年中折腾过 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 就可以了。

最后

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

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

经过持续厚脸皮的 ping,Michael 大佬终于开始 review 那个 cgo 优化的 PR 了。只不过,大佬不喜欢 copy 几百行汇编的方案,希望用 hack 的方式。

好吧,这么大个改动也不早说,谁让咱朝中无人呢,只能这么任人摆布了。

这两天居家隔离,正好也就搞下了。虽然搞得头大,不过也对 cgo 的认识更深了一些,趁着热乎,简单记录一下的。

之前从运行时,调度策略等方面分享过 cgo,今儿就从编译器视角,来介绍下 cgo 的。

简单说

常规编译 Go 代码,就是编译 + 链接,这两步。

cgo 的主要区别是:

  1. 多了预编译这一步,用来插入一些包裹代码
  2. 链接的时候,需要链接 C 和 Go 的两种产物

三步走

预编译

将原始的 Go 预编译,生成中间的 Go + C 代码

比如,这么一个 Go 函数

1
2
3
4
//export AddFromGo
func AddFromGo(a int64, b int64) int64 {
return a + b
}

会编译为两份,其中,Go 代码:

1
2
3
4
5
6
7
8
9
10
//go:cgo_export_dynamic AddFromGo
//go:linkname _cgoexp_83bc3e2136d8_AddFromGo _cgoexp_83bc3e2136d8_AddFromGo
//go:cgo_export_static _cgoexp_83bc3e2136d8_AddFromGo
func _cgoexp_83bc3e2136d8_AddFromGo(a *struct {
p0 int64
p1 int64
r0 int64
}) {
a.r0 = AddFromGo(a.p0, a.p1)
}

这里的 Go 函数,接受的参数是一个结构体指针,结构体中存了真实的函数参数。

以及 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
extern void _cgoexp_83bc3e2136d8_AddFromGo(void *);

CGO_NO_SANITIZE_THREAD
GoInt64 AddFromGo(GoInt64 a, GoInt64 b)
{
size_t _cgo_ctxt = _cgo_wait_runtime_init_done();
typedef struct {
GoInt64 p0;
GoInt64 p1;
GoInt64 r0;
} __attribute__((__packed__, __gcc_struct__)) _cgo_argtype;
static _cgo_argtype _cgo_zero;
_cgo_argtype _cgo_a = _cgo_zero;
_cgo_a.p0 = a;
_cgo_a.p1 = b;
_cgo_tsan_release();
crosscall2(_cgoexp_83bc3e2136d8_AddFromGo, &_cgo_a, 24, _cgo_ctxt);
_cgo_tsan_acquire();
_cgo_release_context(_cgo_ctxt);
return _cgo_a.r0;
}

这里的 AddFromGo 就变成了一个标准的 C 函数了,可以被其他的 C 无缝调用了。
同时,也可以看到,上面 Go 函数需要的结构体,是如何封装的了。

这里的 crosscall2,咱们就不展开了,感兴趣可以翻看 以前的分享

编译

这个环节很普通,就是常规编译器的套路

C 代码用 gcc/clang 编译,Go 用 go compiler 编译。

链接

本质上,这里的链接也是常规套路,只是链接的来源,有 C 和 Go 的两种目标产物。

此时,有个小问题是,这两个目标产物,需要如何链接。

这里就需要用到第一步预编译中,产生的编译指令了。

比如,Go 中这两行

1
2
//go:linkname _cgoexp_83bc3e2136d8_AddFromGo _cgoexp_83bc3e2136d8_AddFromGo
//go:cgo_export_static _cgoexp_83bc3e2136d8_AddFromGo

其作用是,导出 _cgoexp_83bc3e2136d8_AddFromGo 这个 Go 函数

然后在 C 里面,刚好通过 extern 指定引入了这个函数。

1
extern void _cgoexp_83bc3e2136d8_AddFromGo(void *);

这样就完成了 C 和 Go 之间的配置。

这里的示例,是从 Go 函数导出给 C,如果是 Go 引用 C 函数,则会用到 cgo_import_static 这样的编译指令。

有啥用呢

整个编译过程,其实也没多少信息量,主要是知道了,Go 提供的这些 编译指令

可以通过这些编译指令,来更底层的控制 C 和 Go 之间的符号链接。 某些时候,可以更方便做 C 和 G 之间的交互了。

比如,C 里面搞个全局变量,Go 里面通过 cgo_import_import/dynamic 就可以链接到 C 变量的地址,从而直接读写 C 变量了。
(这个理论上 go 已经能做到了,go 内部自己就有这么用的,只是没有暴露对外,加了一些使用上的限制)

当然,如果是作为普通 cgo 的使用者,这些个编译指令,hack 的玩法,通常是用不着的。

以后,有需要再试试的。

对于 http filter,Envoy 提供了一大堆状态码,虽然每个都有不少的注释,但是依旧很头大,傻傻搞不清楚。

本文记录一下自己的理解,如有错误,欢迎指正~

http filter 是什么

Envoy 提供的 http 层的扩展机制,开发者可以通过实现 Envoy 约定的接口,在 Envoy 的处理流程中注入逻辑

比如,这两个请求阶段的接口:

1
2
Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool end_stream)
Http::FilterDataStatus decodeData(Buffer::Instance& data, bool end_stream)

如果想修改请求头,那就在 decodeHeaders 中修改 headers,如果想修改请求的 body,那就在 decodeData 中修改 data

本文所关注的状态码,就是这些函数的返回值,比如示例中的 FilterHeadersStatusFilterDataStatus

Envoy 为 filter 提供了啥

  1. 流式

    这个从上面的从 API 形式就可以看出来,核心是,http header 以及 http body 的每个 data 块,是一个个处理的。

  2. 异步

    当一个 filter 在处理过程中,如果需要等待外部响应,也可以先反馈给 Envoy 某种状态,等 filter ready 之后再继续。

  3. 并发

    这个其实很自然,因为有流式,所以,从逻辑上来说,不同的 filter 之间是存在并发的。

    但是,又容易被忽视,因为 Envoy 的工作模式中,具体到某个 http 请求是工作在单线程上的,所以容易有误解。

所以,Envoy 内部有一个 filter manager 模块,目的是用于管理 filter 的运行,也可以简单理解为,一个复杂的状态机。

状态码

交代完背景,我们具体看看状态码,以及 filter 和 filter manager 的状态机运转关系

Header 状态码

以 header 的状态码为例,咱们先挨个解读一下,找找感觉

  1. Continue

    这个最简单,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理了

  2. StopIteration

    表示 header 还不能继续交给下一个 filter 来处理

  3. ContinueAndDontEndStream

    表示 header 可以继续交给下一个 fitler 处理,但是下一个 filter 收到的 end_stream = false,也就是标记请求还未结束;以便当前 fitler 再增加 body。

  4. StopAllIterationAndBuffer

    表示 header 不能继续交给下一个 filter,并且当前 filter 也不能收到 body data。

    意思是,请求中的 body data 先 filter manager 缓存起来,如果缓存大小超过了 buffer limit(一个配置值),那就直接返回 413 了。

  5. StopAllIterationAndWatermark
    同上,区别是,当缓存超过了 limit,filter manager 就会启动流控,也就是暂停从连接上读数据了。

过了一遍之后,有这么几个关键点

  1. StopIteration,只是先不交给下一个 filter 处理,但是并不停止从连接读数据,继续触发 body data 的处理
  2. header 阶段,也可以对 body data 进行写操作,可想而知,也就是 add 操作了。
  3. filter manager 有一个 data 的缓冲区,帮 filter 临时缓冲数据

data 状态码

再看 data 的状态码,

  1. Continue

    跟 header 类似,表示当前 filter 已经处理完毕,可以继续交给下一个 filter 处理了。

    只是,如果 header 之前返回的是 StopIteration,且尚未交给下一个 fitler,那么,此时 header 也会被交给下一个 fitler 处理。

  2. StopIterationAndBuffer

    表示当前 data 不能继续交给下一个 filter,由 fitler manager 缓存起来。

    并且,与 header 类似,如果达到 buffer limit,直接返回 413。

  3. StopIterationAndWatermark

    同上,只是达到 buffer limit,只是触发流控。

  4. StopIterationNoBuffer

    表示当前 data 不能继续交给下一个 filter,但是,fitler manager 也不需要缓存 data。

这里也有几个点:

  1. 如果 data 要被交给下一个 filter 处理了,header 是肯定也会被交给下一个 fitler 处理了。

    我们可以把 header 理解为,一个带有特殊语义的首个 data 块,无论怎么流式处理,数据的顺序,是必须要保证的。

  2. data 阶段多了一个 NoBuffer 的状态,这又是什么目的呢?

关键解读

有几个容易误解的地方,咱们展开聊聊

异步

为了支持 filter 的异步,fitler 可以返回 Stop 语义的状态码,这样 filter manager 不会继续后续的 filter,但是:

并不意味着整个 filter manager 都停止了,当前 filter 以及之前的 filter,还是会接受到当前请求上的数据

并发

每个 filter 接受到的数据,在处理期间,是独享的,没有并发风险,但是 filter manager 的缓冲区,只有一个,这个是有并发风险的。

因为 filter 是逻辑上并发的,但是 filter manager 只有一个,所以是存在逻辑并发风险的。

举个例子:

filter A => filter B => filter C,这样一个处理链表

先来了第一个 data 块:filter A 和 B 反馈 continue,filter C 返回 StopIterationAndBuffer,此时 buffer 中是 data 1,

再来第二个 data 块,filter A 返回了 StopIterationAndBuffer,此时 buffer 中是 data 1 和 2 了,

然后 filter A 通知 filter manager 恢复处理,那么此时,filter B 看到的数据,就是 data 1 和 2 了。

显然,这并不符合预期。

也就是说,对于每个 filter 而言,如果在 data continue 之后,再返回 StopIterationAndBuffer 的话,就可能有缓冲区并发风险。

简单的理解,如果要复用 filter manager 的缓冲区,每个 filter 只有首次异步的机会。

如果需要随时异步,那怎么办呢?

解决方案,也就是 StopIterationNoBuffer,filter 自己搞缓冲,也就不存在并发风险了。至于副作用嘛,主要就是自己多实现一些代码。

理论上来说,StopIterationNoBuffer 是最灵活的,不过也是更麻烦的,效率也会略差一点的。

个人体会

Envoy 的状态设计,为大部分常见的场景,提供了比较方便易用的设计,但是相对复杂的场景,就需要自己多实现一些逻辑了。

只是这么搞了之后,对于新人的上手门槛就高了,如果不搞懂这些状态,闭着眼睛用,也是容易踩坑的。

最后

一个小问题,MOE 作为把 MOSN(Go)塞进 Envoy 的方案,此时的开发语言已经是 Go 这种天然异步的,MOE 又提供的是什么样的开发体验呢?

MOE 近期会开源第一版,等开源之后,后面可以聊聊~

没有意外,也没有惊喜

弃坑 NGINX 的,CF 不是第一个,也不会是最后一个。

有点看头的是,用 rust 重新撸了个 Pingora 来替换,不过,披露出来的信息量也不大,没啥惊喜。

不意外

作为一款开源软件,其生命力来源于:

  1. 开发者
  2. 用户

开发者,是生产者,可以发展完善软件;用户,是消费者,不断拓展开源软件的应用场景,实现开发者的价值。二者互相成就的过程,就是开源软件的生命力。

近几年,业界各种诟病 NGINX 的声音越来越多,而 NGINX 选择性的忽略了这些声音(也不影响其继续闷头赚钱)。

作为开发者之一,我能感受到的是,有那么一批开发者,以肉眼可见的速度,一个个用脚投票,逃离了 NGINX 这个坑。包括,我也是其中一员。

尽管,从统计数据看,NGINX 的市场份额还在涨,用户量还是很健康,只是在开发者眼中,NGINX 已经不再诱人。

作为 CF 这种,同时具备开发者和用户双重角色的,选择弃坑 NGINX,在我看来一点也不意外。毕竟 CF 定位的是科技创新,不是求稳的网络运营商,相对来说,开发者的声音会被更多的考虑。

没惊喜

尽管 CF 的博客,有说明弃坑的理由,以及用 Rust 重新撸的好处,不过,在我看来,没多少信息量。

这些个槽点,优势,算不上新闻。在我看来,也就表达一个意思,我 CF 真的干了,他们说的是对的,没骗人。

这些年 Rust 很吸人眼球,不少的开发者跃跃欲试。一个新的语言,涌入了一批批开发者,很自然的就会出现一阵风潮,把现有的轮子重新撸一遍,用 Rust 撸一个 NGINX 的替代品,早晚的事。

我更关心这两个:

  1. 替换的完成度
    原文只说了,1 trillion requests a day,看起来是个挺大的数字,说明并不是玩具,是真实的生产了哟
    但是,原有的 NGINX 还剩多少呢,这个没有看到。
  2. 投入了多少人力
    这个应该更加机密一些,一般都不会公开说。如果有了解内幕的,可以私下透露~

像 CF 这种基于 NGINX 深耕多年的庞大系统,要完整替换,肯定不是一朝一夕搞得完的。

个人不负责任的猜测,应该还只是部分相对简单场景的尝试,还是有大量的老 NGINX 在运行的。

原来在 NGINX 上写下的一大坨 c 和 Lua 代码,要用 Rust 全部重写?想想都觉得很酸爽。

还是蛮期待 CF 的后续,看看他们是如何完成老代码的迁移,真的全部用 Rust 重写?

两层架构

现代的网关,已经很复杂了,在转发之上,承载了非常多的流量管控能力,而且随着 Service Mesh 愈演愈烈,网关上承载的逻辑,越来越贴近业务语义。

尽管在云原生浪潮中,网关承载的复杂流量管控,也开始标准化, 一个 yaml 配置走天下。

然而,标准化配置只能覆盖大部分常见的场景,真实的情况下,还充斥着大量的定制化需求。

这就依赖网关软件能提供方便的定制能力,也就是说,除了网关自身的基础实现,还需要一套方便的机制,能够方便的实现定制需求。

NGINX 世界里,标准的 c module 扩展方式成本很高,于是有了 OpenResty 通过 Lua 来完成定制需求。Envoy 世界里,标准的 c++ filter 开发门槛也是很高,于是也提供了 Lua,ext_proc,Wasm 等多种扩展方式。

CF 的 Pingora 怎么搞呢?绑死在 Rust 这一颗树上?

我在干啥

MOE,MOSN on Envoy,MOSN 的新一代架构。

包含两层含义:

  1. 基于 Envoy 提供 Go 语言扩展能力
  2. 复用 MOSN 成熟的扩展机制/能力

为什么搞 MOE?

有机会的话,也可以写个长篇大论,简单说,就是看好 Envoy 和 Go。

能搞成么?

所谓的行业趋势,只是大家一个个用脚投票走出来的,走着看呗。

MOE 正在开源筹备中,很快会有第一版,欢迎感兴趣的朋友,一起来玩。

最后

借用一个图,我的理解,NGINX 已经到了高峰期,而 rust 呢?或许还没有到期望的顶峰。

开源世界,如同自然世界,也需要多样性,百花齐放好过一枝独秀。

最后,放个梗,张华考上了大学,李萍进了技校,我当了工人,我们都有美好的前途。

一直在调侃,程序猿的归宿是跑滴滴、送外卖。我内心还是比较喜欢送外卖的,哈哈,至少可以多运动一下的,偶尔爬爬楼也挺好的。只是,没想到这么快就实现了 …

今儿在家里聊天,本来是聊去哪儿玩,突然就聊起了外卖众包,一激灵,我也心动了,提议我也搞一个玩玩 …

白天去了个博物馆,被要求 72 小时核酸,愣是没进去。晚上没啥事了,又想起了外卖众包这茬,立马就注册了美团外卖众包 …

注册

注册还挺快的,扫脸实名制之后,立马就审核通过了,估计是机器自动通过的。

然后是在线学习,也就是看几段视频,再加一些简单的考试题,新手骑士就可以上路了。众包要求就这么低,也不用穿啥服装,嘿嘿。

第一单

因为我的小电驴,没啥地方装东西了,所以选单比较谨慎,生怕碰上东西太多,或者有啥汤水的。也不敢让系统自动派单,怕给我推太多,完不成。

第一单,选了个小龙虾外卖,离我家 1 公里多点。 取餐还挺顺利的,到那之后,直接从门口的桌子上,按照取餐号码拿就行了,很明显这个是主营外卖的店。

不过,送餐就比较惨了,在一个城中村的深处,七拐八绕的,路上人还挺多,环境不太好。好在导航还算靠谱,总归是送到了。

之后,就干脆跑出了这个村,再开始接单了。

战况

今晚战况:

  1. 跑单两小时,11 公里,完成 7 单,预计收入 39.2 RMB
  2. 基本都是接一单,送一单的模式。
  3. 只有一次,是刚接单的时候,系统就推送了另外两个顺路的单,就顺手接了,也算挑战一下。当时手里并发有 3 个单,这也算我的高光时刻了,哈哈。

外卖战况

算账

考虑到我是新人,时薪 20 RMB,也还算凑合了。

不过,值得一提的是,我跑单是 8 - 10 点,应该是高峰期。而且还有新人优先派单名额,也就是系统会优先推送一些近的单给我,50 秒内,我有优先选择权。

期间在路上,碰到一个大哥,聊了一会的。

他已经跑了 44 单了,说今儿算少的,平常这个时候(9点多)已经 50 单了,
而且他说,他是在饿了么搞兼职的,一单 9 块钱,好像是跟月单量挂钩的,一个月搞一万多 RMB 还是可以的。

大哥还给我看了他的排名,他是他们站里的第二名。我问大哥,这么厉害,有没有啥诀窍,大哥说,就是熬着,早上 7 点上线,晚上搞到 10 点,中午 2 点之后,能休息一阵。

这么算下来,大哥每天工作 10 来个小时,时薪可以到 40-50 RMB,也还是不错滴~

最后

一些简单的感想:

  1. 除去新鲜感之外,其实跑外卖不好玩,我感觉骑行环境不太好,尤其是要穿一些小巷子
  2. 要想提高收入,就得提高并发,多接几个比较顺路的单,不过这就真考验骑手的计算能力了,也不能并发太多,倒是超时了(这是真人工智能)
  3. 不知道系统自动派单的时候,这方面做得咋样,感觉这里面要考虑得还是挺多的,感觉骑手的调度比滴滴车的调度应该更复杂一些。
  4. 目前骑手比较依赖手机,感觉这个对于骑行中的人来说,安全挑战还是比较大的,毕竟眼睛是要多看路的。或许这种时候,可以考虑搞个手表,利用震动这种触感,来给骑手一些提示,比如到了某个地方。

近一年有开始折腾 Envoy 了,在 bazel 这块踩了一些坑,总结记录下的,主要是个人的体感,不一定准确,欢迎批评指正。

bazel 是个啥

编译构建工具,跟常用的 Makefile 是类似的。

只是,bazel 更复杂,上手门槛更高,以我的个人的体验来看,主要是为了提升表达能力,可编程能力更强。

Makefile 通常是用于简单的描述,偶尔会搞个函数啥的;但是在 bazel 里,会看到大面积的自定义函数。

为啥 Envoy 需要 bazel

Envoy 是个 C++ 项目,C/C++ 这种比较老的语言,没有内置依赖包管理器这种先进特性(相比较而言,Go 语言在这块就先进了许多)。

我的理解,对于 Envoy 来说,bazel 一个主要的作用就是,补齐了依赖包管理。如果仅仅是编译,Makefile 之类的简单工具,应该也够用了。

几个概念

有几个常用的基本概念,先了解一下的

WORKSPACE

在项目的根目录,会有一个 WORKSPACE 文件,用于描述整个项目的,最主要是描述了依赖库,比较类似于 Go 语言中的 go.modgo.sum

比如 Envoy 的 WORKSPACE 文件中,有这样的描述:

1
2
load("//bazel:repositories.bzl", "envoy_dependencies")
envoy_dependencies()

其中,具体依赖库的详细信息,在 bazel/repository_locations.bzl 文件里。
比如下面这个示例:

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"],
),

描述的是依赖库 boringssl,是不是很像 go.modgo.sum

另外,在这里还可以频繁看到 @envoy @envoy_api 这种,这里也表示的一个依赖库的作用域。

BUILD

BUILD 文件会有很多个,用于描述一个目标的编译过程,类似于 Makefile 中描述一个目标的构建过程。

比如,这样子的:

1
2
3
4
5
6
7
8
9
10
envoy_cc_library(
name = "lua_filter_lib",
srcs = ["lua_filter.cc"],
hdrs = ["lua_filter.h"],
deps = [
":wrappers_lib",
"//envoy/http:codes_interface",
"@envoy_api//envoy/extensions/filters/http/lua/v3:pkg_cc_proto",
],
)

.bazelrc

这也是在项目根目录下的,用于描述 bazel 的默认配置。

比如,这个:

1
build:linux --copt=-Wno-deprecated-declarations

可以指定一些编译参数之类的。

执行

有了上面这些描述信息之后,最终要执行编译构建的命令就简单许多了

比如:

1
bazel build envoy

这里的构建目标 envoy,来自项目根目录下的 BUILD 文件。

也可以是这样子的:

1
bazel build //source/exe:envoy

此时的构建目标 //source/exe:envoy,来自项目根目录下的 source/exe/BUILD 文件了。

踩过的坑

除了上面这些一手体感,还有一些坑,也记录下的

变更编译器版本

有一次,想换个高版本的 gcc,但是修改了 PATH 后重新构建,始终不生效。

原来是,bazel 是增量编译的,所以会使用上一次编译时使用编译器,以保证整个项目是使用的同一个编译器。

所以,如果想更换编译器,需要清空下缓存:

1
bazel clean --expunge

split DWARF

较新版的 Envoy,启用了 -gsplit-dwarf 这个特性,也就是将调试符号放到独立的 .dwo 文件里了,以减少生成的二进制文件大小。

不过呢,这个特性还比较新,可能会有一些坑,至少在我的环境下(gdb 11,这个版本也不低了),就有 .dwo 读取错误。
比如这样子的:

1
DW_FORM_strp pointing outside of .debug_str section

所以,干脆关掉这个特性,就一切正常了(主要是 bt full 这类查看局部变量的功能)。

具体操作是,注释掉 .bazelrc 中的这一行:

1
# build:linux --features=per_object_debug_info

最后

记录下,我这边可以完整工作的环境:

  1. g++ 11
  2. gdb 11

在这个环境下,gdb 是可以完整工作的,局部变量都可以看。

之前在一个比较老的版本上,工作是不太顺利的。

最近我们发现 go 编译为 so 之后,内存占用涨了好多,初步分析下来,是动态符号导致的,感觉不太符合常识
趁着娃还在外面放假,正好学习学习~

hello world

1
2
3
int main() {
printf("hello world\n")
}

在最简单的 hello world 中,printf 最终也是来自 libc.so 这个动态链接库
通过 objdump,我们可以找到这一行:

1
400638:  call   400520 <printf@plt>

这里的 printf@plt,表示 printf 这个函数是依赖的外部函数,是要动态寻址的。

为什么需要函数寻址

不同于静态链接,函数地址在链接期(执行前),就可以确定下来了。
然而,动态链接库的地址,是在程序执行的时候,加载 so 文件时才能确定的。那么,要调用动态库中的函数,是没有办法提前知道的地址的,所以需要一套机制来寻找函数的地址。

具体而言,分为两种寻址:

  1. so 中导出的函数地址
  2. so 内部调用非导出的函数地址

简而言之

第一种,是通过函数名来寻址的,相当于在主程序里调用了 dlsym(x, "printf") 来寻址,然后 dlsym 会在 so 文件里找 printf 的地址
第二种,是通过偏移量来寻址的,虽然绝对地址不固定,但是 so 文件内部,两个函数之间的偏移量是固定的。

缓存加速

通过字符串来查找,想想也知道是比较低效的,那有什么办法提速呢?原理也简单,就是加缓存。
具体而言呢,是通过可执行文件中的两个段的配合,其中 .plt 可执行,.got.plt 可写,来实现缓存的效果。

还是从这一行 call 指令开始

1
400638:  call   400520 <printf@plt>

400520 来自 .plt 段,而且 .plt 是可执行的
继续用 objdump 可以看指令:

1
2
3
400520:   jmp    QWORD PTR [rip+0x200afa]  # 601020 <printf@GLIBC_2.2.5>
400526: push 0x0
40052b: jmp 400500 <.plt>

这里有两个 jmp
第一个 jmp 的地址来自 601020,而这个 601020 来自 .got.plt 段,.got.plt 是可写的
首次执行的时候,601020 里存的就是 400526,此时意味着慢路径,需要动态查找。
当查到地址之后,会修改 601020 中的值,这样后续就可以直接一个 jmp 就完成寻址了,不需要再按照字符串查找了。

查找逻辑

至于慢路径查找,最终会调用到 _dl_lookup_symbol_x,大体而言是这么个逻辑:

  1. 先在当前可执行文件中,通过 0x0 这个偏移量,找到函数名,也就是 printf
  2. 然后再从 so 文件中,根据 printf 来查找函数地址

核心会用到两个段的数据(上面的 1 和 2 两步都会用到这两个段,只是对应来自两个不同的文件)

  1. .dynsym 用来存符号,也就是 Elf_Sym 这个结构,这个结构体里存了函数偏移地址,名称偏移地址等
  2. .dynstr 用来存字符,比如 printf 这个字符串本身就存在这里

使用 nm -D 可以看到类似这样的数据,其中 U 表示 undefined,需要外部寻址的函数

1
2
00000000004004e8 T _init
U printf

内部调用

这个就简单很多了,偏移量是固定的,不用动态查找,直接调用 call 指令就行了。x86 上的 call 执行也有好几个,其中有一个就是按照偏移量来的。
这里有一个有意思的小细节,比如这个例子:

1
2
3
4
5
000000000040061e <main>:
40061e: 48 83 ec 08 sub rsp,0x8
400622: bf 01 00 00 00 mov edi,0x1
400627: e8 e6 ff ff ff call 400612 <add>
40062c: 89 c6 mov esi,eax

call指令的跳转地址是 0x400612,这个是怎么来的呢?
e8 表示按照相对地址寻址,然后就有了这么结果:0x40062c + 0xffffffe6 = 0x400612

平常用 objdumpgdb 看到 call 指令的地址,也都是计算后的,不注意的话,会以为都是绝对地址。

总结

  1. 调用动态链接库中的函数,是通过函数名动态查找的
  2. 导出的函数,以及依赖的外部函数,都在 .dysym 里记录了元信息
  3. 函数名字符串,是存在 .dynstr 里的
  4. .plt.got.plt 这一对配合,用于寻址缓存
  5. 内部调用直接用偏移量,call 指令有一种就是按照偏移量来计算的

广告

如果觉得有意思,欢迎关注我的公众号~

微信公众号