7. gRPC 基于 endpoints 做客户端负载均衡

0. 简介

3. Service 中我们可以知道,Service 负载均衡是在第四层,而 gRPC 是基于 HTTP/2 协议的长连接,第四层的基于连接的负载均衡对其不起作用,所以我们要建立在OSI模型第七层 的,基于调用的负载均衡。

k8s 的负载均衡区分为客户端负载均衡服务端负载均衡

客户端的负载均衡一般离不开对连接地址列表的 get 和对整个资源的 watch,即在客户端维持着对多个服务端的连接,然后采用不同的选择策略,譬如 pick_first 或者 round_robin,从列表中选择连接进行访问, 我们在上一家公司的时候,就是采用的基于 ETCD 的客户端负载均衡的方案。

而服务端的负载均衡方案一般而言需要增加一个第三方组件,例如 istioenvoy,client请求中间组件,由中间组件再去请求后端 Pod。

1. k8s endpoints

k8s endpoints 定义是网络端点的列表,通常由 Service 引用,以定义可以将流量发送到哪些 Pod,其提供的是 Service 中的所有的 Pod IP。

bash 复制代码
 ~  kubectl get endpoints
NAME             ENDPOINTS                         AGE
custom           10.244.2.2:9090,10.244.3.2:9090   21d
echo             10.244.1.2:9090,10.244.2.3:9090   21d
kubernetes       172.18.0.5:6443                   21d
two-containers   10.244.3.7:80                     15d

需要注意的是,k8s endpointSlice 是 v1.21 之后 k8s 提供的一种为 endpoints 进行可扩缩和可拓展的替代方案,感兴趣的同学可以研究下,我的集群比较小,所以和 endpoints 几乎没有变化。

bash 复制代码
 ~  kubectl get endpointslice
NAME                   ADDRESSTYPE   PORTS   ENDPOINTS               AGE
custom-44wjg           IPv4          9090    10.244.2.2,10.244.3.2   21d
echo-v7phx             IPv4          9090    10.244.2.3,10.244.1.2   21d
kubernetes             IPv4          6443    172.18.0.5              21d
two-containers-fhqx2   IPv4          80      10.244.3.7              15d

因为 endpoints 提供了:

  • 可以通过list接口获取该 Service 的所有 Pod 信息
  • 可以通过watch接口监听该 Service 的所有 Pod 的增删变化

两点能力,从而可以利用这些能力构建 gRPC 的客户端负载均衡方案。

2. 负载均衡方案 ------ kuberesolver

很幸运现在在 Github 上已经有了这个解决方案了:kuberesolver。这里,我们参照 grpc-examples 中的 k8s-endpoint 方案,来走一遍这个流程。

2.1 验证

我们直接参照 grpc-examples 中的 k8s-endpoint 方案,首先验证一下方案的可行性。

首先编译镜像,并且构建 server 端 Deployment 等:

bash 复制代码
make build-server-image # 构建grpc server镜像
make build-client-image # 构建grpc client镜像
make create-server-deployment # 构建grpc server deployment,即创建grpc server pods

因为我的环境是 kind 搭建的,所以还需要把构建的镜像加载到集群 nodes 中:

bash 复制代码
kind load docker-image greeter_client:1.0 greeter_client:1.0 --name multi
kind load docker-image greeter_server1:1.0 greeter_server1:1.0 --name multi

开启 Client 端的 Deployment(需要屏蔽deployment-client.yml中的serviceAccountName: endpoints-reader):

bash 复制代码
make create-server-service # 创建clusterIP service
make create-client-endpoint-deployment # 创建grpc client deployment

这个时候查看日志会发现:

bash 复制代码
~ make client-endpoint-log
kubectl get pods -n grpc-lb-example | grep greeter-client-endpoint | awk  '{print $1}' | xargs -I{} kubectl logs -f  {} -n  grpc-lb-example greeter-client-endpoint
conn target: kubernetes:///grpc-lb-example-greeter-server-svc.grpc-lb-example:50051
2024/01/14 03:00:21 ERROR: kuberesolver: watching ended with error='invalid response code 403 for service grpc-lb-example-greeter-server-svc in namespace grpc-lb-example', will reconnect again
2024/01/14 03:00:22 ERROR: kuberesolver: watching ended with error='invalid response code 403 for service grpc-lb-example-greeter-server-svc in namespace grpc-lb-example', will reconnect again
2024/01/14 03:00:23 ERROR: kuberesolver: watching ended with error='invalid response code 403 for service grpc-lb-example-greeter-server-svc in namespace grpc-lb-example', will reconnect again

原因是 k8s 开启了 RBAC 鉴权,所以需要构建ServiceAccountRoleRoleBinding3 个实例,这里依次执行:

bash 复制代码
make create-clusterrole # 创建cluster role
make create-serviceaccount # 创建 service account
make clusterrolebinding # 创建 cluster role binding

然后取消注释deployment-client.yml中的serviceAccountName: endpoints-reader,再次执行:

bash 复制代码
make create-client-endpoint-deployment # 创建grpc client deployment

最后查看日志,可以发现确实是在轮询各个不同的Pod。

bash 复制代码
~ make client-endpoint-log
kubectl get pods -n grpc-lb-example | grep greeter-client-endpoint | awk  '{print $1}' | xargs -I{} kubectl logs -f  {} -n  grpc-lb-example greeter-client-endpoint
conn target: kubernetes:///grpc-lb-example-greeter-server-svc.grpc-lb-example:50051
2024/01/14 03:13:48 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-mbqjr
2024/01/14 03:13:49 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-8txlt
2024/01/14 03:13:50 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-z486g
2024/01/14 03:13:51 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-mbqjr
2024/01/14 03:13:52 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-8txlt
2024/01/14 03:13:53 Greeting: Hello world, reply from server pod: grpc-lb-example-greeter-server-svc-55bdbc7659-z486g

2.2 kuberesolver 代码简析

gRPC 提供了 reolver.go 中的 Builder 接口,用于在客户端构建解析器,从而获取可用的地址列表。

go 复制代码
// Builder creates a resolver that will be used to watch name resolution updates.
type Builder interface {
	// Build creates a new resolver for the given target.
	//
	// gRPC dial calls Build synchronously, and fails if the returned error is
	// not nil.
	Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
	// Scheme returns the scheme supported by this resolver.  Scheme is defined
	// at https://github.com/grpc/grpc/blob/master/doc/naming.md.  The returned
	// string should not contain uppercase characters, as they will not match
	// the parsed target's scheme as defined in RFC 3986.
	Scheme() string
}

而在 kuberesolver 中就是构建了 kubeBuilder 对象实现了这个功能:

go 复制代码
// Build creates a new resolver for the given target.
//
// gRPC dial calls Build synchronously, and fails if the returned error is
// not nil.
func (b *kubeBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    if b.k8sClient == nil {
       if cl, err := NewInClusterK8sClient(); err == nil {
          b.k8sClient = cl
       } else {
          return nil, err
       }
    }
    ti, err := parseResolverTarget(target)
    if err != nil {
       return nil, err
    }
    if ti.serviceNamespace == "" {
       ti.serviceNamespace = getCurrentNamespaceOrDefault()
    }
    ctx, cancel := context.WithCancel(context.Background())
    r := &kResolver{
       target:    ti,
       ctx:       ctx,
       cancel:    cancel,
       cc:        cc,
       rn:        make(chan struct{}, 1),
       k8sClient: b.k8sClient,
       t:         time.NewTimer(defaultFreq),
       freq:      defaultFreq,

       endpoints: endpointsForTarget.WithLabelValues(ti.String()),
       addresses: addressesForTarget.WithLabelValues(ti.String()),
    }
    go until(func() {
       r.wg.Add(1)
       err := r.watch()
       if err != nil && err != io.EOF {
          grpclog.Errorf("kuberesolver: watching ended with error='%v', will reconnect again", err)
       }
    }, time.Second, ctx.Done())
    return r, nil
}

// Scheme returns the scheme supported by this resolver.
// Scheme is defined at https://github.com/grpc/grpc/blob/master/doc/naming.md.
func (b *kubeBuilder) Scheme() string {
    return b.schema
}

grpc.Dail 或者 grpc.DailContext 函数每次调用的时候,都会走到以上的 Build 函数,重点是在后面针对 kResolver 对象起的 watch 协程,穿插一点注意事项:

这里需要每次调用都 new 一个新的 kResolver 对象:

  1. 如果将 kResolver 对象中一些有关连接的信息放在 kubeBuilder 对象中,就犯了大错了,那是因为在客户端使用 kuberesolver.RegisterInCluster() 方式进行客户端的 resolver 注册的,这个注册方式是将解析器注册到一个全局名为 m 的哈希表中,如果有并发请求的时候,会使得连接信息错乱,所以必须每次请求都 new 一个新的 kResolver 对象。
  2. 或者直接采用一次请求一次注册的方式,即使用 grpc.WithResolvers(kuberesolver.NewBuilder(nil, "kubernetes"))
    的方式,使其作为 grpc.DialOption 来使用,这样的注册就是一个局部注册,且优先级高于全局注册。

watch 函数如下,这里起了一个循环,主要做的事情有:

  • 定时调用 kResolver.resolve()
  • 在外部调用 resolver.ResolveNow() 的时候调用 kResolver.resolve()
  • 接收 endpoints 对象的变更消息,发生变更时调用 kResolver.handle()
go 复制代码
func (k *kResolver) watch() error {
    defer k.wg.Done()
    // watch endpoints lists existing endpoints at start
    sw, err := watchEndpoints(k.k8sClient, k.target.serviceNamespace, k.target.serviceName)
    if err != nil {
       return err
    }
    for {
       select {
       case <-k.ctx.Done():
          return nil
       case <-k.t.C:
          k.resolve()
       case <-k.rn:
          k.resolve()
       case up, hasMore := <-sw.ResultChan():
          if hasMore {
             k.handle(up.Object)
          } else {
             return nil
          }
       }
    }
}

所以重点就是看看 kResolver.resolve() 函数和 kResolver.resolve() 函数具体在做什么?

kResolver.resolve()

go 复制代码
func (k *kResolver) resolve() {
    e, err := getEndpoints(k.k8sClient, k.target.serviceNamespace, k.target.serviceName)
    if err == nil {
       k.handle(e)
    } else {
       grpclog.Errorf("kuberesolver: lookup endpoints failed: %v", err)
    }
    // Next lookup should happen after an interval defined by k.freq.
    k.t.Reset(k.freq)
}

其做的事情很简单,即通过 getEndpoints 从 k8s 集群获取 endpoints 列表,然后使用 k.resolve() 函数进行处理。

kResolver.handle()

go 复制代码
func (k *kResolver) handle(e Endpoints) {
    result, _ := k.makeAddresses(e)
    // k.cc.NewServiceConfig(sc)
    if len(result) > 0 {
       k.cc.NewAddress(result)
    }

    k.endpoints.Set(float64(len(e.Subsets)))
    k.addresses.Set(float64(len(result)))
}

其做的事情也很简单,就是将获取到的 endpoints 构建成 []resolver.Address 形式,然后通过 ClientConn.NewAddress 函数进行整个连接池的地址更新,值得注意的是,该函数已经被弃用,建议使用 ClientConn.UpdateState 函数进行替代。

kResolver.watchEndpoints() watch endpoints 的实现机制

这里需要注意一下 kResolver.watchEndpoints() 函数的使用:

go 复制代码
func watchEndpoints(client K8sClient, namespace, targetName string) (watchInterface, error) {
    u, err := url.Parse(fmt.Sprintf("%s/api/v1/watch/namespaces/%s/endpoints/%s",
       client.Host(), namespace, targetName))
    if err != nil {
       return nil, err
    }
    req, err := client.GetRequest(u.String())
    if err != nil {
       return nil, err
    }
    resp, err := client.Do(req)
    if err != nil {
       return nil, err
    }
    if resp.StatusCode != http.StatusOK {
       defer resp.Body.Close()
       return nil, fmt.Errorf("invalid response code %d for service %s in namespace %s", resp.StatusCode, targetName, namespace)
    }
    return newStreamWatcher(resp.Body), nil
}

本质很简单,就是通过监控 endpoints 的变化,然后发生变化时解析变化,将结果通过 channel 传递给前面的 watch 协程处理。

不过我们一般理解的 HTTP 的调用都是短连接的方式,怎么通过监控一个返回的 HTTP Response.Body 做到监控的呢?答案就是 Chunked transfer encoding,apiserver 通过在返回 http header 中配置 Transfer-Encoding: chunked 实现分块编码传输。apiserver 把需要传递的数据按照 chunked 块的方法流式写入。而客户端收到 apiserver 经过 chunked 编码的数据流后, 同样按照 http chunked 的方式进行解码.

3. 小结

其实总结起来也很简单,gRPC 提供了一整套客户端负载均衡的接口,其本质都是获取 并且监控 能访问的地址列表,然后根据这些列表中的地址进行各种不同的均衡策略,pick_first 或者 round_robin等,这里在 k8s 环境中基于 endpoints 实现的负载均衡和基于 etcd 服务注册的方式没有本质区别。

相关推荐
木鱼时刻21 小时前
容器与 Kubernetes 基本概念与架构
容器·架构·kubernetes
chuanauc1 天前
Kubernets K8s 学习
java·学习·kubernetes
庸子2 天前
基于Jenkins和Kubernetes构建DevOps自动化运维管理平台
运维·kubernetes·jenkins
李白你好2 天前
高级运维!Kubernetes(K8S)常用命令的整理集合
运维·容器·kubernetes
Connie14512 天前
k8s多集群管理中的联邦和舰队如何理解?
云原生·容器·kubernetes
伤不起bb2 天前
Kubernetes 服务发布基础
云原生·容器·kubernetes
别骂我h2 天前
Kubernetes服务发布基础
云原生·容器·kubernetes
weixin_399380692 天前
k8s一键部署tongweb企业版7049m6(by why+lqw)
java·linux·运维·服务器·云原生·容器·kubernetes
斯普信专业组3 天前
K8s环境下基于Nginx WebDAV与TLS/SSL的文件上传下载部署指南
nginx·kubernetes·ssl
&如歌的行板&3 天前
如何在postman中动态请求k8s中的pod ip(基于nacos)
云原生·容器·kubernetes