0%

本地限流因为使用简单,执行高效,可以算是最常用的了

在 Envoy 里,local rate limit 可以用于不同的 Envoy Filter 扩展阶段,从而实现不同行为的限流:

  1. HTTP Filter,限制 HTTP 请求频率
  2. Network Filter,限制 TCP 建连频率
  3. Listener Filter,限制TLS 握手频率

配置用法

以常用的 HTTP localrate 为例来感受一下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http_filters:
- name: envoy.filters.http.local_ratelimit
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: http_local_rate_limiter
token_bucket:
max_tokens: 1000
tokens_per_fill: 1000
fill_interval: 2s
status: 429
response_headers_to_add:
- append_action: OVERWRITE_IF_EXISTS_OR_ADD
header:
key: x-local-rate-limit
value: 'true'

Envoy local ratelimit 采用令牌桶算法,限流的核心配置是这三个:

  1. max_tokens 桶的最大 token 容量,也是初始的默认值
  2. tokens_per_fill 每个周期增加的 token 数量
  3. fill_interval 填充 token 的时间周期

那么,上面配置的效果就是:

  1. 一个最大容量为 1000 的桶,每 2s 往桶里加 1000 个 token
  2. 每来一个请求,消耗桶里一个 token,如果桶里没有 token 了,就会拦截请求

对于拦截请求的响应,还可以配置:

  1. 响应状态码,默认是 429
  2. 和响应的请求头

细粒度限流

上面的示例是一个粗粒度的策略,针对这个 listener 端口上的所有请求。

如果想实现一个细粒度的,比如域名级,或者接口级的呢?

也是可以做到的,ratelimit 也支持 route 级别的配置:

1
2
3
4
5
6
7
8
9
10
11
routes:
- match: {prefix: "/path/with/rate/limit"}
route: {cluster: service_protected_by_rate_limit}
typed_per_filter_config:
envoy.filters.http.local_ratelimit:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: http_local_rate_limiter
token_bucket:
max_tokens: 10000
tokens_per_fill: 1000
fill_interval: 1s

如果是同一个路由内,还想实现更细粒度限流呢?

Envoy 还有路由级别的 descriptors,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- match: {prefix: "/foo"}
route:
cluster: service_protected_by_rate_limit
rate_limits:
- actions: # any actions in here
- request_headers:
header_name: x-envoy-downstream-service-cluster
descriptor_key: client_cluster
typed_per_filter_config:
envoy.filters.http.local_ratelimit:
"@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
stat_prefix: test
token_bucket:
max_tokens: 1000
tokens_per_fill: 1000
fill_interval: 60s
descriptors:
- entries:
- key: client_cluster
value: foo
token_bucket:
max_tokens: 10
tokens_per_fill: 10
fill_interval: 60s

可以根据请求中的特征(上面示例是一个请求 header),实现路由内,不同的限流配置

实现机制

从配置方式理解,Envoy 的实现也不需要很复杂

  1. 在配置解析阶段,配置中的每一个 token_bucket,就会生成一个 RateLimitTokenBucket 桶实例

  2. 在请求处理阶段,通过 route/descriptors 匹配到 token_bucket 之后,就从对应的桶里取值即可

在 1.31.0 之前,是每个桶都有一个定时器,每 fill_interval 给桶里注入 tokens_per_fill 的令牌

在 1.31.0 里,做了个优化,不再依赖定时器

原因是,定时器运行在 main thread,而 main thread 在 Envoy 里面还有一个重要职责就是加载来自控制面的配置,当配置量很大的时候,可能会长时间的阻塞这些 timer,就影响限流了

与 NGINX 对比

在 NGINX 里,与 Envoy local rate limit 最接近的就是 limit_req

首先是限流算法上的区别,Envoy 是令牌桶算法,而 NGINX 是漏桶算法

漏桶算法可以实现流量整形,比如上面示例的 1000/2s 在 NGINX 会被处理为 500/s,也就是每 2ms 放行一个请求,从而让请求更均匀的转发给后端应用

而令牌桶算法的限制值,更接近人类的理解,X 时间放行 Y 个请求

动态限流 key

不过,在我看来,最大的区别还是,NGINX 可以指定动态限流 key,从而实现每个 IP 限制 10/s 这样的效果,而 Envoy 却做不到。

从实现机制上来说,NGINX 是基于 shared memory zone 来实现的,可以灵活的开辟大内存来实现很多 key 的存储,同时,基于 lru 的淘汰算法,也可以保证内存可控

shared memory zone 是 NGINX 内部的一个通用的底层组件,而 Envoy 还没有这类基建

从使用场景上来说,NGINX 主要用于南北向接入网关,防 DDOS/cc 攻击需求强烈,而基于 IP 的限流策略则是最常用的防攻击策略

而 Envoy 多用于东西向场景,流量来源多是可信的,限流策略主要是用于保护后端服务不被打爆,所以对于这种动态 key 的诉求相对没那么强烈

集群限流

Envoy 的 local ratelimit 虽然是本地限流,但是也可以配置为集群级别的限流效果

1.31.0 新增了 local_cluster_rate_limit 的配置,可以让 token 的限流值,按照 Envoy 集群数量来平均分配,并且随着 Envoy 实例数量自动调整

当前,这里有个假设前提是,流量在 Envoy 节点上是均匀的

NGINX 的 shared memory zone 是单机内共享的,不过商业版里,也增加了一个跨机同步的机制,这样也可以实现集群级别的限流

最后

个人观点,简单点评几句:

Envoy 的本地限流,好的是应用场景多,HTTP 请求,TCP 连接,TLS 握手 都可以用

NGINX 呢,优势是动态限流 key,用来防 cc,应对公网不可信的流量,确实很香

嗯,至少目前如此,后面 Envoy 加上动态限流 key,也不是太大的问题

上一篇 HTTP json 转 gRPC 中,我们提到了一起 Golang runtime panic,这篇就稍微展开介绍的

报错

单测的时候,运行倒是正常,跑压测的时候,很快就会出现 Golang runtime panic

比如:

1
runtime: marked free object in span 0x7fef96df8658, elemsize=24 freeindex=83 (bad use of unsafe.Pointer? try -d=checkptr)

这个是 Golang 的 GC 在 sweep 阶段,发现有一个内存块,标记被使用,也就是有一个指针指向这个内存块,但是,这个内存块同时也是被标记 free 的,不应该被使用的

简单说,就是有内存指针指向了 free 内存块,是个非法指针

原因

虽然 panic 的时候,会把所有 goroutine 的调用栈,都全部打印出来

但是,由于 panic 发生在 sweep 阶段,显然已经不是非法指针写入的时候了,调用栈并没有太多分析价值了

不过,好在我们是 demo 演示场景,代码本身比较简单,经过一番折腾,主要是 review 代码,发现坑在 copyHeaderMapToGo 中的这段:

1
2
3
4
5
len = value.length();
go_strs[i].n = len;
go_strs[i].p = go_buf;
memcpy(go_buf, value.data(), len); // NOLINT(safe-memcpy)
go_buf += len;

这里是将 C++ 侧保存的 header map,copy 到 Golang 提前准备好的内存中

其中涉及到两个操作:

  1. 为 Golang string 的 p 指针和 n 长度赋值

  2. 将字符串内容 copy 到 go_buf 内存块中

问题就出现在第一步,因为 value 有可能为空,那么 go_buf 有可能就已经是非法的了,超出了预定的总长度

刚好,我们压测的这个 grpc 场景,Golang 的 grpc server 总是会返回

1
2
grpc-status: 0
grpc-messages

grpc-message 这个 trailer 的 value 总是为空,所以,压测场景很容易就 panic 了

知道原因,修复方案也简单,当 len 为 0 时,我们直接跳过即可

这是已经合并的 PR: https://github.com/envoyproxy/envoy/pull/35930

骚操作

在 C 里面,修改 Golang 指针的值,是 Golang 不建议的玩法,可以说是实锤的骚操作

不过,我们这么玩也是理论上安全的,只是需要非常小心,玩不好就踩坑了

至于,为什么我们选择这种骚操作,主要还是在保证内存安全的前提下,还提供不错的性能,具体可以看之前的 Envoy Go 扩展之内存安全

最开始实现的时候,只考虑了 Golang string 的使用场景,因为长度为 0,那么指针地址就算是非法,也是会被忽略的

但是,到了 GC 环节,GC 是不看类型的,只看指针引用,所以就悲剧了

彩蛋

顺便在 copyHeaderMapToGo 中还发现了一处优化点:

1
auto value = std::string(header.value().getStringView());

这里的 std::string 是没有必要的,因为反正都要 copy 到 Golang 里面,C++ 侧再 copy 一次是没必要的了

于是,又有了这个 PR,简单场景压测了一把,Golang 的 header 初始化,可以有 10%+ 的提升,~400ns

当然,还有一些 Golang 侧的优化空间,后面有空再继续搞了

尽管 json 解析比较费 CPU,但是,由于 HTTP + json 简单好上手,适配成本低,深受大家喜欢,尤其是对外的场景,HTTP + json 可以说是首选。

但是,有些内部的微服务场景,因为调用方也是可控的内部服务,所以,gRPC 以及类似 RPC 协议,在内部场景被广泛的使用。

把内部的 gRPC/RPC 服务,通过 HTTP + json 暴露给外部用户,加上对应的认证鉴权,限流策略,则是 API 网关的一大应用场景。

演示场景

本文将演示基于 Envoy Golang 扩展,来实现这一协议转换需求:

  1. 简单模式,转为普通的 json

  2. 服务端推流模式,转为 SSE + json

这里演示的 grpc 服务,直接使用 github 上的开源项目 golang grpc demo

刚好也两个都有:

1
2
3
4
5
6
7
8
9
service Demo {
// 简单模式。一个请求,一个响应。
//客户端发送一个请求,包含两个数字,服务端是返回两个数字的和
rpc Add (TwoNum) returns (Response) {}

//服务端流模式,客户端发送一个请求,服务端返回多次。
//请求一次,返回三次,分别是两数子和、两数之积、两数之差
rpc GetStream (TwoNum) returns (stream Response) {}
}

上代码

因为主要是工程实践,也没有太多原理好介绍,咱们直接看代码就可以

这里是完整的代码仓库:envoy-http-to-grpc-demo

接下来,针对两种场景的代码实现,做个简单的介绍

简单模式

gRPC 与普通的 http 请求区别也不大,从针对请求的处理,我们可以很清晰的看到这些差异

针对 header 的处理,简单两行就可以搞定:

1
2
header.Set("content-type", "application/grpc")
header.Del("content-length")

body 的处理需要多几行,因为 body 有一套 grpc message 的编码方式

不过,核心也就是把 json 转成 pb 编码,期中 demo.TwoNum 就来自 golang grpc demo 中使用 proto 生成的 Golang struct。

然后再套上 grpc message 的 header,因为没有 content-length,每个 grpc message 都有 4 byte 来申明长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
jsonBuf := buffer.Bytes()
data := &demo.TwoNum{}
if err := json.Unmarshal(jsonBuf, data); err != nil {
return f.badRequest(err)
}

grpcHeader := make([]byte, 5, 10)
pbBuf := proto.NewBuffer(grpcHeader)
if err := pbBuf.Marshal(data); err != nil {
return f.badRequest(err)
}

grpcBuf := pbBuf.Bytes()
size := len(grpcBuf) - 5

setGrpcHeader(grpcBuf, size)
buffer.Set(grpcBuf)

针对响应的处理,则是逆向的,这里不做表述

服务端推流模式

流式处理对于每个 grpc message 的处理逻辑是一样的,唯一不同的是,需要从数据流中切分出一个个 grpc message 出来

不过,好在 grpc message header 里有 4 byte 的长度,要实现分包也简单

核心就是先从 header 里读长度,长度够了就可以切片,解码了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (f *filter) transCoder() ([]byte, error) {
if len(f.remainBuf) < 5 {
return nil, nil
}
size := int(f.remainBuf[1])<<24 | int(f.remainBuf[2])<<16 | int(f.remainBuf[3])<<8 | int(f.remainBuf[4])
if len(f.remainBuf) < size+5 {
return nil, nil
}
grpcBuf := f.remainBuf[5 : size+5]
f.remainBuf = f.remainBuf[size+5:]
data := &demo.Response{}
err := proto.Unmarshal(grpcBuf, data)
if err != nil {
return nil, err
}
jsonBuf, err := json.Marshal(data)
if err != nil {
return nil, err
}
return jsonBuf, nil
}

然后,再将 json 封装为 SSE 流式返回

1
msg := fmt.Sprintf("event: message\\ndata: %s\n\n", jsonBuf)

测试体验

  1. 简单模式,实现加法
1
2
curl -X POST localhost:10000/demo.Demo/Add -d '{"x":100,"y":10}'
{"result":110}
  1. 流式 SSE 输出
1
2
3
4
5
6
curl -X POST localhost:10000/demo.Demo/GetStream -d '{"x":100,"y":10}'
event: message\ndata: {"result":110}

event: message\ndata: {"result":1000}

event: message\ndata: {"result":90}

Envoy json to grpc

Envoy 有个 json to grpc 的 C++ filter

也可以实现 json 转 grpc,直接从 proto 生成 pb,然后在 Envoy 配置里指定 pb 就可以了

为啥还想着用 Golang 再撸一个呢

有两个原因:

  1. Golang 有更强的可定制能力

    比如我们这里把服务端推流转成了 HTTP SSE 协议,如果要在 C++ 里写,开发成本至少要高上一个数量级

  2. 后续可以将协议转换再抽象一层

    一来可以像 Envoy 的 json to grpc 一样,直接配置 pb 即可,简单场景代码都可以不需要写了

    另外,也可以支持 HTTP 转基于 TCP 的各种 RPC 协议,这块中国电子云的杜鑫老哥已经搞了一个 提案,PR 也在路上了

意外发现

原本开发实现还挺快的,差不多两个小时就搞定了,不过,到了压测的时候,发现了一个 Golang runtime panic,说是 GC 的时候发现有指针指向了 free 的内存对象。

还好,经过一番折腾,是 Envoy Golang 的一个 bug,header value 如果是空字符串,就有可能踩到这个坑,又搞了 PR 来修复

另外,还有一个功能 bug:trailer 里面设置 header 没有生效,以及一个优化点

细节就不展开了,后面有空的话,单独写一篇来介绍

限流是网关代理的核心能力之一,Nginx 和 Envoy 都有多种限流机制,来应对不同场景的限流需求

准备挖一个新坑,系列性的介绍 Envoy 的各种限流策略,大致按照这个思路:

  1. 功能介绍
  2. 核心代码实现
  3. 与 Nginx 对比

本文作为开头,先水一篇,来点概览性的介绍 ^_^

限制对象

从限制的对象来说,限流分为三种:

  1. 连接,包括:

    1. 新建连接频率

    2. TLS 握手频率

    3. 并发连接数量

  2. 请求

    一般是请求速率

  3. 带宽

    包括单连接级,和某个范围聚合的

限流生效范围

从限流策略的生效范围来说,又分为两种

  1. 本地限流

    也就是网关代理的单机级别,因为不需要远程通讯,执行效率最高,是很常见的一种方式,不过依赖流量在各个网关实例上相对平均

  2. 全局限流

    跨越网关单机,全局级的限流,因为依赖远程通讯,执行效率会低一些,但是限流更准确

限流用途

限流有两类用途:

  1. 保护上游后端

    因为上游后端的服务能力是有上限的,保证转发给后端的请求量,在后端的服务能力之内,从而保证服务的稳定,不至于产生过载/雪崩,导致服务不可用

  2. 防 CC 攻击

    在对公网服务的网关中,很常规的保护机制,因为公网的流量可以认为是不授信的,而且恶意请求也是经常会发生的事情

Envoy vs Nginx

Nginx 开源已经 20 年了,Envoy 也开源 8 年了,年头都不算小了

我的个人观点,各有优劣势,也都有不够完善的地方,具体的细节,后面我们慢慢分析

在我看来,很大的一个区别是:

Nginx 作为成熟的南北向网关,在防 CC 攻击这块是更成熟的,Envoy 虽然也有用于南北向网关,不过,更多是在东西向网关,在防 CC 攻击这块,是明显更弱的

比如,防 CC 攻击,最常见的的策略是,针对每个 IP 限流,Envoy 还是不支持的(严谨的说,是官方还没有,第三方插件还是可以搞的)。因为在内网调用中,异常的调用来源,都是内部服务,可以追溯到应用 owner 来背锅的

如果是来自公网的异常调用,是很难找到攻击者来背锅的,这就必须依赖网关来做好基础防护了

好久没折腾 cgo,上一篇已经是去年了,cgo 内存优化无缘 golang 1.22 中提到,golang 1.23 会合并回来

眼看 golang 1.23 即将 freeze,于是提了个 PR,想着开启内存优化

还有 bug

很不幸的是,rsc 说之前 boringcrypto 使用了这个优化,导致了一个 CI 失败,需要先修复了

好吧,原来上一篇里有个乌龙,上次我们说,被 revert 的原因是,#cgo 指令的向后兼容性的问题

实际上并不只是这一个原因,而是,确实还有个 bug …

仔细看了那个 issue,是在 arm64 机器上,并且开启 boringcrypto 特性的时候,才会偶发出现的错误

心想这不会是个 arm64 上的坑吧,难道又要挨个翻 arm64 的指令了…

于是,在阿里云上搞了个 arm64 的机器,发现确实有小概率会测试失败

好吧,能复现就是好的开始,虽然是小概率随机

原因

分析过程就不展开了,有点繁琐,咱们直接说原因

首先,我们这个优化,是让编译器,将内存放到栈上,C 直接使用 Goroutine 栈上的地址,来减少 GC 的开销

然后,问题就是,Gorontine 的栈是会移动的,地址变了,导致 C 使用的地址就是非预期的了

copystack

对于栈移动这种场景,之前也是分析过的,应该是没问题的才对的

因为 runtime 移动栈的操作,也就是 copystack 这个函数,是会处理栈上指针的,让新栈上的指针指向新的地址

stackmap

具体的指针调整,涉及的点还比较多,核心的还是每个栈帧的处理,这里就涉及到 stackmap

大致可以这么理解,每个函数的栈空间是固定的,stackmap 就是描述这个栈空间上对象的信息,比如是否为指针

其中,有一个部分就是存了函数的参数信息,到底是一个 pointer 还是 scalar

这次的问题就出在这里,有些代码上看起来是 pointer 的参数,被编译器认为是 scalar

cgo wrapper

还得先回到 cgo 编译器的实现,比如这样一个 Go 调用 C 的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
int pointer3(int *a, int *b, int *c, int d) {
return *a + *b + *c + d;
}
#cgo noescape pointer3
#cgo nocallback pointer3
*/
import "C"

//go:noinline
func testC() {
var a, b, c, d C.int = 1, 2, 3, 4
C.pointer3(&a, &b, &c, d)
}

cgo 编译器会生成这样的 wrapper 函数:

1
2
3
4
5
6
7
//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
_Cgo_no_callback(true)
_cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
_Cgo_no_callback(false)
return
}

重点在于,虽然参数有 4 个,但是函数体中只使用了 p0 这一个。

导致编译器 SSA 推导优化之后,后面 3 个都是 non-alive 的了,也就在 stackmap 中被标记为 scalar 了,从而在 copystack 中,后面几个指针值就没有被正确处理了

修复方案

知道了原因,其实修复也比较简单了,最早想的是直接用 runtime.Keepalive,不过生成的 cgo wrapper 包里不能用 runtime 包

最后是参考 _Cgo_use 搞了 _Cgo_keepalive,本质上也还是欺骗下 golang 编译器,让它认为后面的参数是有用的,也就不会被分析为 non-alive 了

最终效果,就是生成了这样的 wrapper 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
//go:cgo_unsafe_args
func _Cfunc_pointer3(p0 *_Ctype_int, p1 *_Ctype_int, p2 *_Ctype_int, p3 _Ctype_int) (r1 _Ctype_int) {
_Cgo_no_callback(true)
_cgo_runtime_cgocall(_cgo_cab107a710a2_Cfunc_pointer3, uintptr(unsafe.Pointer(&p0)))
_Cgo_no_callback(false)
if _Cgo_always_false {
_Cgo_keepalive(p0)
_Cgo_keepalive(p1)
_Cgo_keepalive(p2)
_Cgo_keepalive(p3)
}
return
}

是的,多了一些实际上不会执行的 _Cgo_keepalive 的函数调用

shrinkstack

上面分析的是扩栈时的问题,那会不会这种情况呢:

从 Go 进入 C 之后,执行 C 代码的时候,Go runtime 来了个 GC,对 Goroutine 进行缩栈操作呢?

答案是不会的,这个倒是最早在 提案 里就有讨论过的,这种场景下,Goroutine 不会执行 shrinkstack,所以也是安全的

最后

PR 是修复了一版,也请崔老师 trybot 跑了 CI 了,应该问题不大了

不过,Go 1.23 是赶不上了,估计也只能等 Go 1.24 了

不得不说,还是得多谢在 boringcrypto 中尝鲜这个特性的老哥,要不然这个 bug 确实不太好发现

可以想象一下,在 Envoy 的运行过程中,偶发的 panic,比起纯 Go 的测试环境,那查起来是要酸爽很多的了

最近 Envoy Go 扩展有一个比较大的改动:支持全双工流式处理(原来只支持半双工流式处理),趁此做些简单的介绍。

先搞清几个基本概念

什么是流式处理

与流式相对应的就是全缓冲

比如下载一个大文件,全缓冲是完整收到整个文件之后,才会转发给客户端

而流式则是收到一部分,也立即转发多少给客户端

在 nginx 里,默认是全缓冲,proxy_buffering off 就是开启流式处理

envoy 里底层默认是流式处理,除非有 filter 插件不支持流式,必须全缓冲处理

什么是异步处理

流式处理确实也算网关标配了,不过流式加异步就不是了

还是下载一个大文件,我们可以在网关上实现 gzip 压缩,减少网络带宽。

这个 gzip 压缩也是流式处理,但是并不要求异步,因为 gzip 是纯 CPU 计算任务,且 gzip 也有流式的支持。

但是,如果想给 AI 大模型 的流式响应,加上实时的安全审查。

此时,则需要将流式的内容,实时发送给远程审查服务,审查服务返回通过之后,才发给客户端。

这个安全审查,则是需要异步处理了。

这时 Nginx 底层的流式处理机制就玩不转了,因为 nginx 不支持 body 处理阶段挂起请求。
(当然,利用 lua cososcket 来替换 proxy 还是可以玩的)

Envoy 这块的底层机制要灵活一些,body 也支持异步处理,可以随时挂起请求。

Envoy Go 提供了流式的异步处理机制,我们可以使用 golang 的网络库,调用远程的服务,从而轻松的实现上面的安全审查,这是本次修复之前,也能支持的。

什么是全双工

对于普通的 http 请求,可以当做为半双工,也就是,客户端发出请求之后,等待响应之后,才会继续发送请求。
(http pipeline 有点不一样,但是对于 web server 来说,还是一个个来处理的,本质上差异也不大)

但是,对于 websocket,HTTP connnect,GRPC bidirectional 等就不一样了,这类协议里有像 tcp 那样的全双工通信机制,随时都能双向发送数据。

这次修复的问题,就是全双工才会遇到的问题,也是之前没有考虑到位的点。

原因

之前在介绍 并发安全 的时候,提到我们有设计一个所有权机制来解决并发问题,这里的所有权依赖的是一个请求处理的状态机。

而之前没有考虑到全双工的场景,这里的状态机在 c 和 Go 的交互期间,只能有一个存在,也就是,要么在处理请求,要么在处理响应。

所以,在全双工场景,状态机就玩不转了。

修复方式,也比较简单,也就是 c 和 Go 之间,使用两个状态机,请求和响应分别一个,这样就不会冲突了。

只是,知易行难,改动量还是有点大的 …

加上最近内部业务压力有点大,断断续续搞了两个月才搞定 …

演示

这里我们以 websocket 为例,来体验一下 Envoy Golang 对于全双工流式的处理。

这里的后端服务是 gorilla/websocket 的 echo 服务,也就是简单的将客户端发送的数据,原样的返回,这是后端的演示效果:

我们在浏览器端,发送了 foobar,收到的也是 foobar

websocket

流式修改内容

我们在中间加入一个 Envoy 的代理,并且开启我们的 Golang 插件示例

我们在 Golang 插件里,演示一下动态修改 websocket 的数据内容,在客户端发给 server 的数据里,加上 Hello, 前缀,在 server 返回给 client 的数据,加上 , World 后缀

如下图所示,我们在浏览器里发送 foobar,收到的就是 Hello, foo, WorldHello, bar, World

streaming-update

我们概览下 DecodeData 的核心代码:

因为 DecodeData 收到的数据,是原始数据,需要自己解析 websocket frame 的封包协议;也就是代码里的 readFrame,我们参考 gorilla/websocket 做了些小调整,就实现了一个简单的 frame 协议处理

1
2
3
4
5
6
7
8
9
10
11
12
13
bytes := data.Bytes()
f.reqBuffer = append(f.reqBuffer, bytes...)
var fr *frame
f.reqBuffer, fr = readFrame(f.reqBuffer)
if fr == nil {
// already cache into Golang side
data.Reset()
return api.Continue
}

newData := append([]byte("Hello, "), fr.GetData()...)
fr.SetData(newData)
data.Set(fr.Bytes())

调用远程检查服务

这个示例里,我们只是针对上行数据,也就是客户端发给服务端的数据,将每个包先发给一个远程的服务,示例里是 httpbin.org,如果返回了 200,我们将内容修改为 Authorized,否则就修改为 Unauthorized

所以,也就有了如下的演示效果:

streaming-async

同样,概览下 DecodeData 的核心代码:

这里的主要区别是,我们启动了一个新的启程,来做异步的任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bytes := data.Bytes()
f.reqBuffer = append(f.reqBuffer, bytes...)
var fr *frame
f.reqBuffer, fr = readFrame(f.reqBuffer)
if fr == nil {
// already cache into Golang side
data.Reset()
return api.Continue
}
go func() {
bytes := fr.GetData()
ok := checkData(bytes)
if !ok {
bytes = []byte("Unauthorized")
} else {
bytes = []byte("Authorized")
}
fr.SetData(bytes)
data.Set(fr.Bytes())
f.callback.DecoderFilterCallbacks().Continue(api.Continue)
}()
return api.Running

再看看 checkData 的实现,因为 Envoy Go 支持全功能的 Golang,我们可以直接使用 Golang 的网络库来发请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Just a demo to request a remote server
func checkData(data []byte) bool {
req, err := http.NewRequest("POST", "https://httpbin.org/post", bytes.NewBuffer(data))
if err != nil {
api.LogDebugf("new request error: %v", err)
return false
}

httpc := &http.Client{}
resp, err := httpc.Do(req)
if err != nil {
api.LogDebugf("query request error: %v", err)
return false
}
resp.Body.Close()

return resp.StatusCode == 200
}

完整的示例代码可见:https://github.com/doujiang24/envoy-go-websocket-example

最后

网关的流式处理确实也不是什么新鲜的事了,大文件处理场景就已经是刚需了。

只是在 AI 大模型 的生成速度比较慢,又多了一个刚需的场景。

最后,支持全功能的 Golang 语言,优势还是很明显的,现成有的 Golang 库,都能直接使用,没有 tinygo 的限制,哈哈

前两天,在 envoy-wasm Slack 群里看到,有个 Google 大佬 Yan,又在提把 Rust 搞到 Envoy 里来

这次他提到了 Google 的 crubit,这个 C++ Rust 双向互操作工具,看着还蛮有意思的样子,简单记录下的

用 Rust 开发 Envoy

我印象中,这个在 Envoy 社区其实一直有讨论,包括 Envoy 的创始大佬 Matt 也是 Rust 的粉丝

有两个方向,一个是用 Rust 来写 Envoy 核心,另一个则是写扩展

前者是很激进的,我还没看到有人在尝试,后者则相对容易一些,至少可以看到 snowp 大佬尝试的 PR:用 Rust 来写了个 echo 的 demo,https://github.com/envoyproxy/envoy/pull/25409

哈哈,有点类似于,当年春哥给 Nginx 写的 echo-nginx-module:https://github.com/openresty/echo-nginx-module

不过,这个尝试已经弃坑了,Rust 与 Envoy 这种大型 C++ 交互,并不那么简单

Why not Wasm

Rust 可以编译为 Wasm,并且 Wasm 在 Envoy 也已经有集成,为什么还有人想折腾 Rust 原生扩展呢

群里大佬们也说了不少,结合我的理解聊聊:

  1. Wasm 需要拷贝内存

    Wasm 也有内存安全机制,字节码读写 VM 内的线性内存地址,所以拷贝是少不了的,现实的实现中,一次读取可能会有多次拷贝

  2. API 能力有限

    必须 proxy-wasm-cpp-sdk 包一层 API,Wasm 才能调用,封装链路很长

  3. Rust 语言特性支持不完善

    比如异步函数支持就不够好,需要 Wasm Vm 对异步运行时有支持

Crubit 解决什么

Crubit:C++/Rust 双向互操作工具,这是来自官方的定义:https://github.com/google/crubit

具体来说,包括两个点:

  1. 函数互相调用

    C++ 和 Rust 之间的函数可以互相调用

  2. 内存互相访问

    C++ 和 Rust 之间的数据结构,可以互相访问

理想的期望效果就是,Rust 可以像现在 C++ 一样方便来开发扩展

对比来说,可以简化的是:

  1. 不需要包一层 FFI API 来做跨语言调用,这是现有的非 C++ 扩展都面临的问题
  2. 不需要手写内存 Struct 的映射,甚至可以直接跨语言读写内存

工作机制

简单说,就是分析源码,自动生成 FFI Wrapper 代码

以 struct 为例,从这个 C++ struct:

1
2
3
4
struct Position {
int x;
int y;
};

会自动生成如下的 Rust struct:

1
2
3
4
pub struct Position {
pub x: ::core::ffi::c_int,
pub y: ::core::ffi::c_int,
}

这样,写 Rust 代码的时候,就可以像 Rust 一样来操作 C++ Struct 了,还是挺方便的

尤其是,像 Envoy 这种大型 C++ 工程,有很多的 Struct 嵌套,手写是个不小的工程,且维护成本也不小的

前景如何

首先 Crubit 这个项目目前还是初始 “MVP” 版本,看文档貌似还有不少的限制

并且,Yan 大佬也还只是一个想法,也还没有玩起来,估计任重而道远

如果真搞成了的话,应该用起来会方便很多了,估计会有新扩展用 Rust 来实现了

并且,如果走出去了这一步,以后没准越来越多的 Envoy 核心代码,也会由 Rust 来写了

那就让我们拭目以待吧~

还有硬骨头

不过,即使搞定了 C++ 和 Rust 的互相操作,还有一个硬骨头在前面等着,也就是异步调度

因为,Envoy 有一套基于单线程的异步并发模型,Rust 也有自己的异步抽象,这两个如何顺利的配合呢

简单说,前面解决的都是 Rust 的同步函数在 Envoy 上运行的问题

现在要解决的是,Rust 的异步函数如何在 Envoy 上运行的问题

tokio 可以么

直接用 tokio 这种异步运行时可以么?答案是不太好搞

tokio 是一个多线程的运行时,有自己的调度机制,我们可以简单类比为 Golang 的 runtime 调度

一个异步函数,有可能会被调度到不同的线程上来执行,这个就会打破 Envoy 的单线程并发模型的约定

(当然,理论上也是可以解决,每个 Envoy 线程,绑定一个单线程的 tokio 运行时)

并且,tokio 需要有自己的主循环来触发 epoll_wait,这个与 Envoy 自己的 epoll_wait 会有冲突的

除非将两者合并,或者 Envoy 自己撸一个 Rust 的异步运行时

这其中的工作量嘛,想想都头大

跟 MoE 有啥区别

哈哈,我的主业是搞 MoE 的,自然是需要拿出对比一番的

MoE 是把 Golang 嵌入到了 Envoy,跟这里聊的 Rust 嵌入到 Envoy 是同一个领域的不同方案

首先,我们碰到的问题是一样的

  1. 跨语言的函数调用,内存操作,需要一些 Wrapper 胶水代码
  2. Envoy 有单线程并发模型约定

但是,解题思路不太一样

互操作

MoE 是老老实实自己写 Wrapper 代码,因为定性是扩展开发,其实需要用的 API 并不会太多,总量也比较有限

Rust 方案,其实后续有进入 Envoy Core 代码的可能性,所以这块期望会更高

如果有了更通用的方案,以后 Envoy Core 中的 C++ 代码,慢慢被 Rust 替换掉,也未可知

单线程约束

MoE 比较取巧的绕过了单线程的约束,保留 Goroutine 自由的被调度到其他线程,也就是不需要 Golang runtime 加额外的限制

这样我们可以支持标准的 Golang runtime,现有的 Golang 库直接拿过来就可以用,而不需要改造

但是,我们自己写的 Wrapper 框架代码,又会同时保证 Envoy 的单线程并发约定,所以,也不会有并发问题

而 Rust 的异步机制,并不像 Golang 的 Goroutine 这种完善的重封装,本质上有点类似的 Lua 的协作式协程

甚至,异步运行时都交给第三方库来实现了(好歹 Lua 还是内置提供了 resume 和 yield 这样的调度 API)

所以,Rust 是有机会像 OpenResty 一样,将语言的异步调度和宿主的事件循环结合起来的

最终效果

MoE 一大亮点就是支持原生的 Golang,现有的 Golang 库直接拿来就可以用,而不需要改造

Rust 方案,如果真搞成的话,普世性会比 OpenResty 嵌入 Lua 的效果更好一些

因为 OpenResty 中 Lua 非阻塞库,需要依赖 Nginx 的 event loop 重写一次,比如网络库需要基于 cosocket 重写

而 Rust 的异步运行时,如果与 Envoy 的事件循环结合起来了话,应该现有的异步实现,也可以跑起来的

或许也不需要改造?估计还得取决于具体的实现

不过,虽然我对 Rust 并不熟,不过依然可以笃定这个坑小不了,没那么快到的

最后

还是很期待 Rust 进入 Envoy 的,虽然如果成了的话,会多了一个 Rust 扩展机制,跟 Golang 扩展机制竞争

不过,以我对 Rust 和 Golang 浅显的理解,这两个发展的路线并不太相同,Rust 更偏向于系统编程,Golang 更偏向于业务编程

也就是 Rust 更适合做 Envoy 核心,Golang 更适合做 Envoy 扩展

当然啦,纯粹个人 YY,欢迎大家技术交流~

一句话省流版:API spec 管理方式 + Consumer 类业务网关能力

说来惭愧,作为一个从事网关十来年的老炮,对于 API gateway 的认知却很迷糊,一直不得其要领

初次结缘

关于 API gateway 最初的印象,还是 2015 年的 OpenResty Con,来自 Adobe 张帅的一个分享。他们实现了一个统一的 API 管理平台,把来自内部多个团队的对客 API,给统一管理起来了

当时的大致印象是,哦,一个基于 OpenResty 的网关,用 Lua 来实现认证鉴权之类。但是,对于他提及的 API gateway 却并没有什么认知,只是停留在 OpenResty 数据面的实现机制

妥妥的局限在一个 OpenResty 数据面开发人员的思维,汗…

算是认识

对于 API gateway 作为一个产品的认知,始于 2019 年,那会在春哥公司,搞 OpenResty Edge,有个客户点名想要 Azure APIM 那样的 API 网关

于是,适用体验了一番 Azure APIM,当时两个体会:

  1. 基础能力也没啥特别的,也就是网关标准的那些能力,OpenResty Edge 都能支持
  2. 主要区别是转发策略的管理方式不同,基于现有的底层能力,包一层皮也是可以实现的

现在看来,其实还是没看懂,局限在接入网关的思路,并且还是开发人员底色。完全没有意识到,管理方式不同,对用户意味着什么

很可惜,后来考虑到 ROI,这个客户没有继续下去,我对 API gateway 的认知也就停留在这里了

用户视角

直到去年,因为项目上线,我们一个服务需要对客提供接口,需要经过统一的 API 网关

于是,作为用户,使用了内部 API 网关之后,给了我很强的冲击,第一次完整的从用户视角,从产品的角度来思考 API gateway

这才有了今天这篇文章,也就是,以我入坑接入网关太深的视角,谈谈 API gateway 到底有什么差异

从我的视角(误区)来看,主要是两个差异

1. 管理方式

表面上看,产品提供给用户的管理方式不同,实际上对应的是,用户群体的不同

  1. 接入网关,更多的还是系统运维的视角,更加全局一些
  2. API gateway,侧重的是应用开发者的视角

作为应用开发者,最直观的概念还得是 API(路由这种概念,本质上来自网关自身的实现)

在应用开发者的工作流里,一直是围绕着 API 进行的,设计评审,质量验收,安全验收,都是基于 API 进行的

并且,对于 API 的描述,业界也有了一些通用的标准,比如 OpenAPI Specification

那么开发完成之后的发布环节,最自然的也还是继续使用 API 这个概念,通过 API spec,就把 API 发布出去了,这个体验才自然

另外,对于域名,证书什么的,网关最好直接托管了,用户可以不需要操心。对用户来说,有的用,符合公司统一管控规则就行,具体是什么,其实并不太关心

体验了完整的应用开发流程,当了一回用户之后,管理方式的不同,对用户意味着什么,给我的冲击是最大的

本质上来说,软件架构发展,分工细化的演进结果。服务之间通讯是基于 API 的,不同角色之间沟通也是基于 API 的,网关没道理不是基于 API 的

2. 业务网关

上面是基于产品对客,最直观的管理方式,接下来是网关产品能力的了

接入网关侧重于流量接入,更多承载的是公司级的统一管控策略,看重是性能,稳定性

API gateway 侧重于业务网关,为业务服务的角色,承载的业务级别的通用能力

Consumer

举一个常见的例子,API 发布之后,就会有人来调用,对调用方就需要进行认证鉴权

以接入网关的思路,提供一个认证鉴权的插件能力,已经算到头了

API gateway 则是更近一步,抽象了 Consumer 的概念来进行管理

本质上来说,一个 Consumer 就是一个认证鉴权后的身份 ID,初步看起来也没啥差异。但是,我们还可以基于 Consumer 来进行不同的配置,比如根据 Consumer 的等级,配置不同的限流值

对于业务系统来说,已经算是通用的逻辑了,就可以放到 API gateway 上来承载,但是,对于全站级别的接入网关而言,或许就算不上那么通用了

业务插件能力

除了 Consumer 这种绝大部分 API gateway 都会抽象出来的产品能力,还有很多垂类的业务场景的产品能力

比如,眼下很火的 AI 大模型,对客提供的也是 API,那么,API gateway 也是可以承载一些通用的插件能力的

例如:

  1. 统一的 API 协议,屏蔽各家大模型提供商的接口差异
  2. 统一的 token 二次管理,调用方一个 token 调遍所有大模型
  3. 以及,各种调用 metrics
  4. 甚至,token 的计量

这种对于接入网关来说,这种属于业务逻辑了,太偏业务了,但是对于 API gateway 这种业务网关来说,那就很合适了

极端点说,有两个业务方需要的通用能力,就可以放到 API 网关来承载…

MoE 硬广时间

由此可见,对于 API gateway 来说,插件扩展能力,会是一个刚需,易用且强大的扩展能力,将是 API gateway 的核心卖点之一

嗯,就这么丝滑,到了 MoE 硬广时间了

MoE 将 Golang 嵌入了 Envoy,我们可以通过 Golang 来实现网关插件,这是研发性能和性能的双赢组合

还不了解的,可以看看去年的几篇旧闻,感兴趣的欢迎技术交流~

今年,我们除了继续完善优化,还会继续往上走,提供一个更高阶的产品出来,让我们拭目以待 😄

最后

其实 API gateway 也很好理解,就是一个以 API 为核心的业务网关,就像它的名字那么简单

上面掰扯这么多的差异分析,基本来自我多年作为网关 developer 的偏见…

看不见、看不起、看不懂、不知道现在补上,来不来得及,哈哈~

又是一年结束了,照例来总结总结~

输出是为了更多的思考

算上这篇,2023 一共写了 26 篇文章,很好的完成了计划:大致一个月两篇,有想法就多写写,不想写就歇着,这点我还是挺满意的

因为要输出,平常就会多思考,往深度了想,并且,文章算是一种相对系统性的表达,写作的过程也会让思考更加系统。有时候,写的过程中,还能发现一些理解错误

通常写一篇文章,也得花上个好几个小时,这种思考的深度还是有一些的

哈哈,当然也不是啥精雕细琢的,多是当前所做所想的一些总结而已

我给自己的定位是,主要是从自我总结的角度,来把事情讲清楚,要正确到位,并没有一定要让尽量多的人读懂

哈哈,当然也会尽量写得更清晰一些,只是主题内容本身多是一些技术细节,本来受众也不会多的

目前还没有打算写一些相对普世的科普文章(今年的 MoE 系列可能也算),或许明年会有一些尝试,比如,Envoy 的科普介绍

哈哈,本来今年有这个想法的,只能说太卷了,忙不过来了,哈哈~

效果

哈哈,好在各位看官捧场,今年写的文章还是有一些阅读量,尤其是几篇关于 cgo 的文章,估计是被平台推荐了

年中还舔着脸开过一阵打赏,收到了一批土豪老板的馈赠,搞得我都不太好意思了

后来开了文末广告,也能有一丢丢收入,几杯奶茶钱,主打一个体验生活了,哈哈

最让我看重的是,能吸引有一些朋友来交流,做技术还是蛮孤独的,碰上个同道中人,要懂得珍惜,哈哈~

QCon 广州

今年上半年还参与了一次 QCon 分享,终于是线下的了,能见到真人了,还是有点激动,厚着脸皮蹭了几个饭局,哈哈

QCon 这种输出要求会更高一些,准备自然也会更充分一些

对我来说,一方面是要更加体系化,另外更重要的是,让听众也能有所收获,也不能太随着自己性子来了

哈哈,我自己总体感觉也就还凑合,主办方还给发了个明星讲师,我也是受宠若惊 …

上下半场

哈哈,不吹水了,总结下今年的工作先

以半年为界,今年的上下两个半场,是肉眼可见的的状态不一样了

上半场 - 继续打野

上半年整体是去年的延续,玩玩新东西,搞搞开源,不亦乐乎

虽然年初也有定下今年要内部落地目标,但是呢,现实是推进并不太顺利

大家的精力比较分散,背着更重要的事情要忙,我呢,能推多少算多少,主要产出还是打野~

经过去年的体验把玩,今年打野感觉也更顺畅了一些,目的性更强了,推进力也更强了~

今年开源算是整个几个大活,不过,基本都发生在上半年~

从 github 的统计数据看,上半年的密集程度明显更高~

github-2023

Envoy Go

数了一下,2023 一共给 Envoy 提了近 50 个 PR,主要集中在上半年,基本把 Envoy Go 给怼到了成熟稳定的状态。

也有幸吸引了一些社区玩家,甚至他们还能帮忙发现一些 bug,让我深感幸运的同时,也觉得有些愧疚。好在都能给快速修复,也给足大家信心

除了更成熟稳定,也解决了原来依赖 cgocheck=0,这个使用上的容易踩的坑。这个说实话,多少有点设计上的失误,主要还是对 cgo 了解的不够深入的情况下,对性能的过于执着 …

持续的迭代改进,Envoy 官方也对 Golang 扩展有了更多的认可

下半年我们先是申请了 extension maintainer,官方也是爽快的答应了

不过,发现 extension maintainer 用处比较有限,再申请 maintainer 的时候,Matt 大佬说,还要再多玩一玩其他的模块先,哈哈~

CGO

今年对 cgo 的研究更深入了一些,两个 cgo 优化怼进了 golang 主干

其中 CPU 的优化,去年已经怼了大半年了,今年也是想一鼓作气怼到底的

不过,我感觉,官网对 cgo 并不是很重视的,期间有一段时间感觉很简单。好在后面 iant 和 Cherry Mui 两位大佬都很给力,respect~

明年希望有空搞一搞 extra P 的优化,这个算是 cgo 头上的一朵乌云。

下半场 - 一卷到底

到了下半年,主要是转到内部落地的项目,这次更方面条件合适,机会难得,不搞则以,搞就必须搞成

对我而言,打野快两年了,也该搞点事情了,内部沟通的时候,我也是表了决心的。如果放在战场,那就立下军令状的了,哈哈~

于是,下半场就开始卷起来了,嗯,卷飞了的那种,在广州办公区,我已经算是卷的那一批了(不过,算不上卷王,总还有人比你更卷,哈哈)

好在卷归卷,落地目标也算达成了,我觉得也是一次不错的体验,也有比较多的感悟,挑两个感触比较深的说说~

拿结果

推内部项目是目的性很强的,一切为了拿结果

哈哈,下半年最大的变化估计就是摇人了,在大公司里干活,遇到问题能摇对人,摇得动人,已经是生存的核心竞争力了~

当然还有,各种拉通对齐也是少不了的,每个人都有自己的目标结果,甚至还有一些屁股问题,要推动别人干活,也是不容易的~

规模化作战

当然,以上并没有揶揄的意思,大公司的协作机制就是不一样的

经过下半年一番折腾,也算是比较有深度的体验了,这种人挨人的规模化作战方式

以我浅薄的理解,大公司的好处就是人多,可以规模化作战,此时人与人之间的协作距离就很近了,这种就免不了一定的摩擦

拍脑袋的数据,如果 5 个人的团队,能发挥 4 个人的战力值,也就是人效比 0.8,应该也是不错的了

相对而言,创业公司就比如特种兵,每个人的空间通常都比较大,但是呢,打法肯定是不一样的了

AI

以 ChatGPT 为代表的 AI,确实一直都在持续给我们带来震撼

今年我也一直有在关注了解 AI,不过一直也没有躬身入局进去玩一玩

对我最大的变化就是,搜索引擎用得少了,公司内网的 GPT 反倒是首选的了(感谢公司提供的 GPT,最近还给升级到了 GPT 4.0 Turbo)

之前也写过一篇文章,我内心是愿意相信 AGI 的,但是嘛,眼下而言,我觉得:

  1. AI 好玩的应该是创新应用,基于大模型的能力,给我们的生活带来更美好的体验
  2. 我还是先搞好网关这个老本行吧,把网关搞好,来支撑 AI 创新应用,也算是为 AI 添砖加瓦了

出去走走

今年疫情算是彻底放开了,一家人也算是顺顺利利过来了

虽然上周家里娃赶上甲流,居家呆了差不多一周,不过,小孩好得也挺快的,倒是我感觉快被传染了,还好即时蹭了小孩的药吃了,哈哈~

今年安排了两次家庭出游,一次北京,终于带家里老人坐了飞机,去了北京兜了一圈,也算是完成了老人家的心愿

对我而言,去哪里玩倒不是那么重要,主要是能陪着他们走一走,对我这种常年在外的,这种专程陪伴也是难得

还有十一去了趟潮汕,算是休闲游,扔掉工作,丢带烦恼,享受岁月静好,哈哈~

希望明年也可以继续走起~

最后

啰啰嗦嗦写得有点多了,时间也不早了,就这么多吧,哈哈~

今年整体感觉还是不错的,虽然也有一些遗憾,不过该做的基本都做到了

明年,希望工作上,整体节奏把握得更好一些,更从容一些,不用卷得那么辛苦,也可以顺畅推进

当然,今年下半年开源搞得相对少了些,明年还是要继续玩起的。这不,这周六就要去 Gopher Meetup 深圳站吹水了,哈哈,欢迎面基约起~

生活上嘛,希望顺顺利利的吧,最好能降低点体重,哈哈~

发现年终总结还是工作居多,或许这就是打工人吧,哈哈~

看起来有点标题党的嫌疑,用了「下半场」这么个烂大街的词。

但是,从我个人的经历来看,又是一个非常贴切的描述。

个人经历

那么,就先说说我的个人经历

Nginx 老炮

我以前是搞 OpenResty/Nginx 的,玩了十来年,算是个老炮玩家。

最早接触 Nginx,是 2010 在淘宝实习,很荣幸就在春哥所在的量子统计团队。

不过,工作上跟春哥直接接触不多,好在,那会春哥很喜欢搞分享,听过春哥很多分享,也知道春哥在搞 ngx_lua module。

此后十年,算是亲历了 Nginx 的崛起,从给 PHP 当 webserver,到统一的网关接入,从 CDN 到数据中心,Nginx 已经是网关的主流方案。

而我个人,也在春哥的 OpenResty 社区,一路打怪升级,从开源迷弟,走到老司机,有幸成为了 OpenResty 的核心开发者。

哈哈,春哥是我的贵人,对我帮助非常大,这里暂且不表,以后有机会再单独

Envoy extension maintainer

差不多两年前,加入了蚂蚁的 MOSN 团队,主要搞 MoE 架构,也就是 MOSN on Envoy。

在 Envoy 里面,我们主要是搞 Golang filter 扩展,将 Golang 嵌入 Envoy,支持用 Golang 来写 Envoy 扩展。

在大家的通力协作下,我们也混了个 Envoy 的 extension maintainer。

最近几年,随着微服务的发展,Service Mesh 的兴起,内网的东西向流量,也开始被网关代理管理起来了。

作为后起之秀的 Envoy,也借势成为了东西向网络代理的首选。

下半场

为什么说是下半场了呢

现状

经过多年的赛跑,Envoy 在东西向已经站住了脚跟,在南北向虽然也有建树,但是王者还是 Nginx。

从我个人的体感来看,Envoy 和 Nginx 现在就是一个对象相持阶段。

像接入层这种关键性的基础设施,稳定是第一重要的因素,而从 Nginx 的各种宣发文章中,以及老用户的顾虑中,也可以看到这是 Nginx 的主要卖点之一。

这注定是一场攻坚战,要想决胜也不是一朝一夕之功。

所以,我觉得是下半场了,已经不是跑马圈地的阶段了,而是攻坚战了。

k8s Gateway API

作为下半场,我觉得有两个看点,其一就是,k8s Gateway API。

在 k8s 体系中,承担南北接入流量的是 Ingress,而 Ingress 的数据面实现,主流还是 NGINX Ingress Controller。

Ingress 确实由于早期设计的不合理,给了大家掀桌子,重新洗牌的机会。

在去年,我们内部有过一次关于 k8s Gateway API 的严肃讨论,那时我们注意到 k8s Gateway API 的玩家已经聚集了主要的网关玩家,包括 Nginx 和 Envoy 两大阵营,以及其他多路玩家。

让这些人排排坐起,把事情推进下去,k8s Gateway API 能做成也是必然的事。

而能让这些人排排坐起的主要动因,就是大家对重新洗牌的共同诉求。大家感兴趣的话,可以看看出力多的几家,那就是掀桌子的主力,哈哈。

对于 Envoy 而言,这也是进一步抢夺网关市场的机会,这将是重头戏。

东西南北融合

按照现在的主流选择,东西向用 sidecar,南北向用集中式网关。

而 sidecar 这种部署形态,并不太适合网络作为基础设置的定位。又催生出了 istio ambient mesh 这种架构,其中 waypoint 这个组件,也是以 Deployment 的形式部署了。

在蚂蚁,我们也早在 ambient mesh 之前,就在推动 NodeSentry 这种 Node 化的部署架构,说明大家面临的问题是一样的,sidecarless 也是人心所向。

除了数据面的部署形态的部分趋同,还有控制面的资源定义,k8s Gateway API 原本是为了南北向设计的,但是,以 linkerd 为代表的 Mesh 用户,希望 k8s Gateway API 也可以兼容 Mesh 场景,于是就有了 GAMMA,Gateway API for Mesh Management and Administration。这也将某种程度的,驱动东西南北的融合。

随着技术实现上的融合,使得业务上的融合也变得可能,我相信后续业务上的融合点,也会变得多起来。

做点什么

作为网关领域的从业者,我们注定要躬身入局的,那我们选择做点什么呢?

虽然,在云原生这一波浪潮中,网络作为基础设施,也是被标准化,资源化的重点。数据面的实现,并不是业务关心的第一要素。

但是,从技术的角度看,网关是从数据面为基础向上发展的,所以,我们第一阶段重心投入在数据面。

也就是我们今年在推动 Envoy Golang 扩展,这将很大程度的提升 Envoy 的可扩展能力,这是未来 Envoy 能否成为王者的重要因素。

因为,当资源标准化之后,对于标准的能力,大家都是标配了,能否具备高效的扩展方式,来解决长尾的定制扩展需求,将是未来网关选型的重要因素之一。

在 Envoy 数据面上立住脚之后,我们也在向上发展,投入控制面,做产品。

相信不久之后,大家就可以看到我们在控制面,产品层的产出了

未来

Nginx 和 Envoy 也只是目前网关市场的两个头部玩家,至少在开源圈子里是这样的。

以后会不会有冒出个新的头部玩家,也未可知。不过网关这种基础设施的变更周期也没那么快,没有足够的驱动因素,也很难达成掀桌子的共识。

不管后续又来了哪个玩家,上面这些发展趋势,我估计是很难撼动的了,游戏规则已经基本清晰,接下来就看刺刀怎么拼了。

至于未来谁是云原生时代的王者,作为一个用脚投票了的从业人员,我觉得依然有必要,保持开放的心态。

最终的王者,没准会是 AI,哈哈