gRPC-LoadBalancer
gRPC Load Balancer (with etcd)
引子
两个月之前,我们组想脱离公司全局的 Nginx 代理(毕竟 Nginx 的 TCP 代理用来做 gRPC 的负载均衡有很多问题),使用 gRPC 自带的负载均衡,调研了一圈开源的实现,有用consul的, 有用旧版本API实现的(方法将要被弃用),不得已,只能自己翻文档实现。
弊端
且说一下 Nginx 作为 gRPC 服务的负载均衡的问题,由于 Nginx 作为中间件,gRPC 的 client 是不知道后端有多少服务的,它只认 Nginx 的地址,所有的服务发现和负载均衡由Nginx统一处理, 默认的负载方式是轮询。
连接池
初始阶段,client 建立N多链接到 Nginx(N的数量取决于 pool 的大小,也就是人工实现一个连接池, 其实根本没必要,但为了让流量均衡的发送到后端所有服务上必须要这样),但这个N的数字其实很有讲究, 如果N小于后端服务的数量,那么后端真实server收到的请求是不均匀的,在仅有一个 client,10个 server 的情况下,如果 client 建立5条连接到 Nginx,然后 Nginx 分别转发给后端的每一个 server, 那么就会有5个 server 收不到连接,白干。如果增加 client的数量,2x5=10,后端的10个 server 都收到了请求,才均匀。
但,这个pool大小的设定实在玄学,client的数量也会变更,鬼知道哪些server收到的请求多,哪些 server收到的请求少?
又,gRPC 是建立在 TCP 上的,每条连接可处理的请求能到 1w+ qps 以上,client 端建立几十几百个 连接到Nginx,Nginx 又转发这些到后端的N个 server 实例,看起来实在很傻逼。
异常
server扩容
后端server总要扩容的,不管是大促还是活动,谁也不能保证不会增加server数量。但是在Nginx代理的情况下, 如果后端新增了 backend,由于是TCP代理,已有的连接不能掐断,但client端的连接池已经初始完毕, 不会继续建立连接到Nginx,这时候新上的server就收不到一丁点流量,直到新连接的建立。
当然,你可以说,我们可以在client的连接池上动手脚,比如动态增加conn的数量,这样不就有流量到新的 server上去了吗;我们还可以定时关闭client已建立的连接,然后重新连接,这样nginx代理就均衡了。 可我只觉得这是脱裤子放屁,多此一举,没事儿找事儿,强行创造KPI。
server重启
同样的事情还会发生在server重启上。
如果后端有5个server,在某一时间重启了4个,重启耗时10s。在这10s内发生了很多有意思的事情。
- Nginx 发现到 backends 的连接断开
- 此时client无感知,client 到 Nginx 的连接还在
- client发送请求,发现超时,因为 Nginx 到 backends 的连接断了
- client如果做了错误处理,那么会重新建立连接到 Nginx,这时候流量转发到唯一存活的server上
- Nginx 如果做了错误处理,一则主动断开与client的连接,此时client重连;二则移除这个backend, 但没卵用,因为client还在凉着;三则转发流量到存活的server上(很高级,不知道能不能行, 估计plus可以,猜测)
- 不管怎么处理,所有流量都发到唯一的server上去了
10s过后,4个service启动完毕,然而发现自己很闲,因为再也没有流量过来了。
除非,除非我们的 Nginx 大师更新完 backends 列表或者发现 service 重新 online 之后, 自动将正在进行的TCP连接迁移到了新的 service 上去,毕竟这在技术上是可以实现的,就是有点难而已。 难度系数大概10.0吧。但我还是觉得这样做实在是没事儿找事儿,没有问题主动创造问题。
想尝试的可以用这个nginx配置和这些命令:
user nginx;
worker_processes 1;
events {
worker_connections 1024;
}
stream {
upstream xxx {
server 172.17.0.1:50001;
server 172.17.0.1:50002;
}
server {
listen 80;
proxy_pass xxx;
}
}
docker run -dti -p 8080:80 nginx
nc -lkp 50001 -v -s 172.17.0.1
nc -lkp 50002 -v -s 172.17.0.1
nc localhost 8080 -v
不要忘了开防火墙,不然nginx连不通本机。
网络抖动
同理,如果 Nginx 到 service instance 的网络抖动了,也相当于一次重启了,问题同同上。
gRPC LB
基于此类问题(明显是人为创造出来的问题,用 Nginx 代理有状态的TCP服务), 我们必须选择 gRPC 框架中的LB。
但是开源方案又不能满足需求:1. etcd做服务发现。2.新版API。 所以自己动手丰衣足食。由于是在公司写的,开源出来难免说不过去,所以就只能把思路放在这里,作为备忘录。
首先
https://grpc.io/blog/loadbalancing
https://github.com/grpc/grpc/blob/master/doc/load-balancing.md
然后
分清几个角色
Balancer:
https://github.com/grpc/grpc-go/blob/master/balancer/balancer.go
balancer.Builder
:build 一个 Balancerbalancer.Picker
:从连接池中选择一个 SubConn 返回,作为真正 request 发送的载体balancer.Balancer
:做的事情就很多了,好在提供了一个Basic可以用,所以不用写代码就能实现。 作用包括管理SubConn,收集连接的状态,处理gRPC请求。
Resolver:
https://github.com/grpc/grpc-go/blob/master/resolver/resolver.go
resolver.Builder
: build 一个 resolverresolver.Resolver
:给 connection 赋予后端 gRPC server 的 Address( 被调用ResolveNow方法,传递 Address 给 ClientConnNewAddress(addresses []Address)
)
由于grpc自带了一个RoundRobin
的Balancer
和一个BasicBuilder
,所以我们只需要实现resolver.Builder
和resolver.Resolver
grpc.Dial
的时候发生了什么
个人感觉dial的源码比较复杂,里面有很多 go 出去的 watcher,回调也很麻烦, 所以在这里的阐述可能有一些缺漏,还请大家批评指正。
大体上分为几个步骤:
- 解析target: target 根据naming的协议,
以这种格式存在:
scheme://authentication/endpoint
,所以如果传入了一个这样的targetetcd://user:pass/a_fake_service_discovery_key
, 就表示要用etcd
的resolver builder
,认证口令为user:pass
,endpoint为a_fake_service_discovery_key
。 如果只传入一个地址,比如localhost:5001
,那在parse target的时候,就会返回一个默认的scheme, 也就是grpc默认的passthrough
builder,透传这个地址给 connection。 - 根据解析出来的scheme选择对应的
resolver.Builder
,所有的 builder 都需要注册给 resolver 这个包,这也就是为什么resolver.Builder
要有一个Scheme() string
的方法,在resolver包里有一个全局变量,存着所有注册的builder, 所以 builder 的 scheme 不能相同,而且resolver.Register(b Builder)
不保证线程安全, 因为里面是用一个没锁的map存储的,名字相同,最后注册的会生效。 (同样的逻辑还适用于balancer.Register
,不同的是 balancer 通过Name() string
区分) - 找到 scheme 对应的 builder 之后,grpc 就会调用这个 builder 的
Build(target Target, cc ClientConn, opts BuildOption) (Resolver, error)
方法,将 target, 创建出来的ClientConn
以及BuildOption
传给这个builder。要注意的是,这里的cc是最重要的,代表了即将要返回的*grpc.ClientConn
,这个ClientConn
里面有一堆SubConn
,代表着到后端server的真实连接。 Build
方法里,要对ClientConn
进行处理,也就是创建一个Resolver
,并解析Target
,然后将解析出来的地址变成 一堆resolver.Address
,通过ClientConn.NewAddress([]Address)
传递给进来的ClientConn
- 通过上一个步骤,
ClientConn
会创建一堆SubConn
,在这个函数里可以找到相关逻辑func (bw *balancerWrapper) lbWatcher()
,里面的channel和通知错综复杂,剪不断理还乱。
通过上述过程,一个 ClientConn 就建好了,可以准备被封装成需要的 Client 来发送请求了,发送请求的过程就比较简单了,无非就是选择哪一个 SubConn 进行通信,也就是业务真正落到了谁的头上。这个逻辑是由 balancer.Picker
完成的,最简单的轮询:
func (p *rrPicker) Pick(ctx context.Context, opts balancer.PickOptions)
(balancer.SubConn, func(balancer.DoneInfo), error) {
p.mu.Lock()
sc := p.subConns[p.next]
p.next = (p.next + 1) % len(p.subConns)
p.mu.Unlock()
return sc, nil, nil
}
一开始感觉这个代码写的不够好,里面有锁,看上去会影响性能,但实际上,我们的业务,或者大部分业务根本达不到这个锁的瓶颈, 对性能影响的担心是多余的。
P.S. 刚才翻了一下代码,发现事情并没这么简单,里面包含了Invoke,SendMsg,Receive等一系列复杂的逻辑和错误处理,只是grpc
帮我们处理好了而已,但就本文而言,到达 Picker
已经算是万里长征第一步了,之后的过程不会在此讨论。
最后
通过以上流程的梳理,就可以完成一个简单的 Resolver
和 Builder
,于是我就扩展了一下原来的 grpc-echo
,变成了这个样子:
https://github.com/wrfly/grpc-echo
如果要做一个生产级别可用的库,要考虑的事情还有很多:
- etcd 的 namespace 的划分和认证
- 通过 etcd 服务发现,append 新的地址
- 如何避免 prefix 重复
- 使用 lease to keepalive key
- 如何通过 watch 的方式主动变更 Resolver 名下的 ClientConn (有个坑,ClientConn Close 的时候,会附带 Close 其关联的 Resolver)
- 如何通过一个 Resolver 管理多个 ClientConn
- 如果 etcd 中的 backends 连不上了怎么办
- 如何对 backends 进行健康检查
- 需不需要调整 grpc 的
Backoff
策略 - 重启发现 etcd 连不上了怎么办(etcd被打挂了,但是实际的backends还在)
- 如何处理 Resolver 中发生的事件,online,offline,health check,new conn等 (日志?事件?通知?)
- 如果全部的backends都连不上了怎么办?如何容灾?
- 需不需要调整负载策略,将RR换成根据权重或者负载大小动态调整流量
总而言之,不是一件简单的事情。
Envoy
envoy 原生就支持 gRPC 的负载均衡,我自己虽然没测试过LB,但也搞过它对于 gRPC 的代理。 说不定下一篇文章就会讨论和实践 envoy 的 gRPC LB,并跟本篇文章进行对比。