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内发生了很多有意思的事情。

  1. Nginx 发现到 backends 的连接断开
  2. 此时client无感知,client 到 Nginx 的连接还在
  3. client发送请求,发现超时,因为 Nginx 到 backends 的连接断了
  4. client如果做了错误处理,那么会重新建立连接到 Nginx,这时候流量转发到唯一存活的server上
  5. Nginx 如果做了错误处理,一则主动断开与client的连接,此时client重连;二则移除这个backend, 但没卵用,因为client还在凉着;三则转发流量到存活的server上(很高级,不知道能不能行, 估计plus可以,猜测)
  6. 不管怎么处理,所有流量都发到唯一的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 一个 Balancer
  • balancer.Picker:从连接池中选择一个 SubConn 返回,作为真正 request 发送的载体
  • balancer.Balancer:做的事情就很多了,好在提供了一个Basic可以用,所以不用写代码就能实现。 作用包括管理SubConn,收集连接的状态,处理gRPC请求。

Resolver:

https://github.com/grpc/grpc-go/blob/master/resolver/resolver.go

  • resolver.Builder: build 一个 resolver
  • resolver.Resolver:给 connection 赋予后端 gRPC server 的 Address( 被调用ResolveNow方法,传递 Address 给 ClientConn NewAddress(addresses []Address)

由于grpc自带了一个RoundRobinBalancer和一个BasicBuilder,所以我们只需要实现resolver.Builderresolver.Resolver

grpc.Dial的时候发生了什么

个人感觉dial的源码比较复杂,里面有很多 go 出去的 watcher,回调也很麻烦, 所以在这里的阐述可能有一些缺漏,还请大家批评指正。

大体上分为几个步骤:

  • 解析target: target 根据naming的协议, 以这种格式存在: scheme://authentication/endpoint,所以如果传入了一个这样的target etcd://user:pass/a_fake_service_discovery_key, 就表示要用 etcdresolver 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 已经算是万里长征第一步了,之后的过程不会在此讨论。

最后

通过以上流程的梳理,就可以完成一个简单的 ResolverBuilder,于是我就扩展了一下原来的 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,并跟本篇文章进行对比。

comments powered by Disqus