0%

之前简单了解过 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 就可以了。

最后

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

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

经过持续厚脸皮的 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 呢?或许还没有到期望的顶峰。

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

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