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 服务注册的方式没有本质区别。

相关推荐
水宝的滚动歌词3 小时前
K8S单节点部署及集群部署
云原生·容器·kubernetes
yohoo菜鸟5 小时前
kubernetes简单入门实战
云原生·容器·kubernetes
狂奔solar8 小时前
分享个好玩的,在k8s上部署web版macos
前端·macos·kubernetes
@东辰13 小时前
【golang-技巧】-自定义k8s-operator-by kubebuilder
开发语言·golang·kubernetes
小安运维日记13 小时前
CKA认证 | Day3 K8s管理应用生命周期(上)
运维·云原生·容器·kubernetes·云计算·k8s
陈小肚13 小时前
k8s 1.28.2 集群部署 docker registry 接入 MinIO 存储
docker·容器·kubernetes
politeboy14 小时前
关于k8s中镜像的服务端口被拒绝的问题
云原生·容器·kubernetes
weixin_4381973815 小时前
K8S创建云主机配置docker仓库
linux·云原生·容器·eureka·kubernetes
皮锤打乌龟21 小时前
(干货)Jenkins使用kubernetes插件连接k8s的认证方式
运维·kubernetes·jenkins
ggaofeng1 天前
通过命令学习k8s
云原生·容器·kubernetes