Z.S.K.'s Records

grpc gateway loadbalance on kubernetes

由于grpc天生的长连接特性, 如果要在kubernetes中使用grpc, 想要实现负载均衡问题需要考虑长连接问题.

由于本人对grpc了解的不是很深入,没法说的太多,看官们勿喷.

http/2长连接

详情大家可参考这篇文章在K8S上使用gRPC做负载均衡

上述文章中也提出了可有多种方式处理这种场景.

今天要跟大家说一说,使用kubernetes的headless 给grpc做负载均衡引出的问题.因为这种方式最简单且最直接.

需要很简单: 客户端通过grpc调用服务端, 请求可以负载均衡

很简单的demo, 一个服务端, 一个客户端

客户端

客户端逻辑很简单, 主要代码如下:

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
func lbconn() *grpc.ClientConn {
//progress-service-svc.just-4-test.svc.cluster.local:50051 服务端服务名
//使用dns进行resolv
conn, err := grpc.Dial("dns:///progress-service-svc.just-4-test.svc.cluster.local:50051",
grpc.WithInsecure(),
grpc.WithBalancerName(roundrobin.Name),
)
if err != nil {
fmt.Printf("did not connect: %v \n", err)
return nil
}
return conn
}

func main() {
fmt.Println("begin client")
conn := lbconn()
c := pb.NewHelloClient(conn)
for i := 0; i < 1000000; i++ {
call(c, i)
time.Sleep(time.Second * 5)
}

func call(){
// 略
}

}

开始只有一个实例,启动日志:

从这里也可以看到,grpc启动的时候,会直接通过服务名拿到服务端pod的ip列表

下面是多个服务端实例的情况

服务端

服务端主要代码:

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 *grpcServer) Hi(ctx context.Context, in *pb.HiRequest) (*pb.HiResponse, error) {
fmt.Println("service got request!", in)
return &pb.HiResponse{FromWho: in.ToWho, Message: fmt.Sprintf(" %v receive message : ", id) + in.Message}, nil
}

func main() {
rand.Seed(time.Now().UnixNano())
id = rand.Intn(10000)
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterHelloServer(s, &grpcServer{})
go func() {
cf := &gg.GatewayConfig{
Fn: pb.RegisterHelloHandlerFromEndpoint,
GrpcPort: ":50051",
}
if err := gg.RegisterGRPCGateway(cf); err != nil {
fmt.Println("grpc gateway closed:", err)
}
}()
fmt.Println("start...")
s.Serve(lis)
}

服务端启动日志:

ClusterIP

服务端使用常规的ClusterIP的svc代理出来,这种情况我们发现, 客户端所有的请求都打在一个实例上, 服务端新增或者减少对客户端都没有影响(当然, 如果减少的刚好是连接的那个实例,那所有的实例都会切换到另一实例上), 至始至终都是一个服务端实例在响应.没有达到效果.

因为ClusterIP暴露出来的是一个ip, grpc通过这个ip只能转到某一个实例上进行响应, 所以客户端一起与这个特定的实例建立长连接.

可参考这里

Headless

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apiVersion: v1
kind: Service
metadata:
name: progress-service-svc
namespace: just-4-test
spec:
ports:
- name: 50051tcp50051
port: 50051
protocol: TCP
targetPort: 50051
selector:
workload.user.cattle.io/workloadselector: deployment-just-4-test-process-service
sessionAffinity: None
type: ClusterIP
clusterIP: None
status:
loadBalancer: {}

问题

从测试的情况来看:

如果服务实例从2个scale up到5个, 所有的请求还是直接路由到之前的2个, 新增的3个没有请求进来.

如果是从2个scale down到1个,那客户端可以感应到,请求会自动地切换剩余的实例,这个很好理解, grpc维持的长连接具有探活能力.

Scale up这种情况grpc客户端无法更新服务端列表,原因估计是grpc启动的时候通过服务名 –> dns –> headless 直接就拿到了服务端pod的实例ip列表,维持在类似连接池中, 后面所有的请求都直接从连接池中做路由,而不是每次都通过服务名实时地查询dns记录, (实时查询的话那一定是会得到新增实例的.), 这也让grpc更高效

所以,在kubernetes中对grpc的服务,需要做特殊的处理, 如果说服务上线就能确定服务端实例个数, 后续实例数都不会变化,那可以直接使用headless这种方式,但谁也不能保证实例数永远不变或者不会发生重启等情况.

因此, 可以在客户端侧实现watch endpoints机制来保证,服务端实例发生变化时,客户端会即时地拿到最新的实例列表,从而确保了请求的负载均衡, github上有直接的解决方案, 大家可以参考这里kuberesolve

客户端实现创建k8s client即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var conns []*grpc.ClientConn
func resolverconn() *grpc.ClientConn {
client, err := kuberesolver.NewInClusterK8sClient()
if err != nil {
fmt.Println("errrr", err)
return nil
}
resolver.Register(kuberesolver.NewBuilder(client, "kubernetes"))
// USAGE:
// if schema is 'kubernetes' then grpc will use kuberesolver to resolve addresses
cc, err := grpc.Dial("kubernetes:///progress-service-svc:50051", grpc.WithInsecure())
if err != nil {
fmt.Printf("did not connect: %v \n", err)
return nil
}
return cc
}

使用这种watch方式,我们同时测试了,服务端用headless或者clusterip的svc效果都是一样的.

至此,虽然grpc也是基于http协议,但是因为其长连接的特性, 在kubernetes中确实与http会有所不同.

当然, 把watch ep逻辑集成在client侧还是有点侵入性的,应该有更好的办法能实现, 目前没有找到, 如果有了解的同学欢迎告知.

参考文章:

转载请注明出处https://izsk.me


Z.S.K. wechat
Scan Me To Read on Phone
I know you won't do this,but what if you did?