0. 简介
在 3. Service 中我们可以知道,Service 负载均衡是在第四层,而 gRPC 是基于 HTTP/2 协议的长连接,第四层的基于连接的负载均衡对其不起作用,所以我们要建立在OSI模型第七层 的,基于调用的负载均衡。
k8s 的负载均衡区分为客户端负载均衡 和服务端负载均衡。
客户端的负载均衡一般离不开对连接地址列表的 get
和对整个资源的 watch
,即在客户端维持着对多个服务端的连接,然后采用不同的选择策略,譬如 pick_first
或者 round_robin
,从列表中选择连接进行访问, 我们在上一家公司的时候,就是采用的基于 ETCD 的客户端负载均衡的方案。
而服务端的负载均衡方案一般而言需要增加一个第三方组件,例如 istio 的 envoy,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 鉴权,所以需要构建ServiceAccount
、Role
、RoleBinding
3 个实例,这里依次执行:
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
对象:
- 如果将
kResolver
对象中一些有关连接的信息放在kubeBuilder
对象中,就犯了大错了,那是因为在客户端使用kuberesolver.RegisterInCluster()
方式进行客户端的resolver
注册的,这个注册方式是将解析器注册到一个全局名为m
的哈希表中,如果有并发请求的时候,会使得连接信息错乱,所以必须每次请求都new
一个新的kResolver
对象。- 或者直接采用一次请求一次注册的方式,即使用
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 服务注册的方式没有本质区别。