通过负载均衡分发请求
负载均衡是一个复杂的主题,且有许多实现方式。gRPC 默认提供客户端负载均衡。这种方式比 “旁路” 或 “代理” 负载均衡少见,因为它需要客户端 “知道” 所有服务器的地址,并且在客户端实现复杂的逻辑,但它的优势在于可以直接与服务器通信,从而实现低延迟的通信。如果你想了解如何根据你的使用场景选择合适的负载均衡方式,可以参考这篇文档: gRPC负载均衡。
为了展示客户端负载均衡的强大功能,我们将在 Kubernetes 上部署三个服务器实例,并让客户端在它们之间平衡负载。我事先创建了 Docker 镜像,这样我们就不必在这里重复构建过程。如果你有兴趣查看 Docker 文件,可以在服务器和客户端文件夹中找到,它们有详细的文档。此外,我已将这些镜像上传到 Docker Hub,方便我们直接拉取: Docker Hub镜像。
在部署服务器和客户端之前,让我们看看在代码层面上需要做哪些更改。在服务器端,我们将简单地打印每个接收到的请求。这可以通过一个拦截器来实现,代码如下(在 server/interceptors.go
文件中):
func unaryLogInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
log.Println(info.FullMethod, "called")
return handler(ctx, req)
}
func streamLogInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Println(info.FullMethod, "called")
return handler(srv, ss)
}
这段代码只是打印了调用了哪个方法,然后继续执行。在此之后,这些拦截器需要注册到一个拦截器链中。因为我们已经有了身份验证拦截器,而 gRPC 只接受一个 grpc.UnaryInterceptor
和一个 grpc.StreamInterceptor
的调用。所以我们现在可以像下面这样将两个相同类型(单次调用或流式调用)的拦截器合并,在 server/main.go
中:
opts := []grpc.ServerOption{
// ...
grpc.ChainUnaryInterceptor(unaryAuthInterceptor, unaryLogInterceptor),
grpc.ChainStreamInterceptor(streamAuthInterceptor, streamLogInterceptor),
}
这就是服务器端的所有内容。现在,让我们关注客户端。我们将使用 grpc.WithDefaultServiceConfig
函数添加一个 DialOption
。这个函数接受一个 JSON 字符串作为参数,表示服务及其方法的全局客户端配置。如果你有兴趣深入了解配置,可以查看以下文档: https://github.com/grpc/grpc/blob/master/doc/service_config.md。
对于我们来说,配置将非常简单;我们只需声明客户端应使用 round_robin
负载均衡策略。默认策略是 pick_first
,这意味着客户端将尝试连接所有可用的地址(由 DNS
解析),一旦可以连接到一个,它将把所有请求发送到那个地址。round_robin
则不同,它将尝试连接所有可用的地址,然后按顺序将请求转发给每个服务器。
为了设置 round_robin
负载均衡,我们只需要在 client/main.go
中添加一个 DialOption
,如下所示:
opts := []grpc.DialOption{
// ...
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin": {}}]}`),
}
最后需要注意的是,负载均衡只在使用 DNS 方案时有效。这意味着我们需要改变运行客户端的方式。之前,我们是这样运行客户端的:
$ go run ./client 0.0.0.0:50051
现在,我们需要在地址前面加上 dns:///
方案,如下所示:
$ go run ./client dns:///$HOSTNAME:50051
现在,我们准备好部署应用了。首先,部署服务器时,我们需要一个无头服务(headless service)。这通过将 ClusterIP
设置为 None
来完成,这样客户端可以通过 DNS
找到所有服务器实例。每个服务器实例都会有自己的 DNS A 记录,表示该实例的 IP。除此之外,我们还需要将端口 50051 映射到服务器,并使选择器等于 todo-server
,以便所有具有该选择器的 Pod
都可以被暴露。
目前,在 k8s/server.yaml
文件中,服务配置如下:
apiVersion: v1
kind: Service
metadata:
name: todo-server
spec:
clusterIP: None
ports:
- name: grpc
port: 50051
selector:
app: todo-server
接下来,我们将创建一个包含三个实例的 Deployment。我们会确保这些 Deployment 拥有正确的标签,以便服务能够找到它们,并且我们将端口 50051 暴露出来。
我们可以在服务配置后添加以下内容:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: todo-server
labels:
app: todo-server
spec:
replicas: 3
selector:
matchLabels:
app: todo-server
template:
metadata:
labels:
app: todo-server
spec:
containers:
- name: todo-server
image: clementjean/grpc-go-packt-book:server
ports:
- name: grpc
containerPort: 50051
现在,我们可以使用以下命令部署服务器实例:
$ kubectl apply -f k8s/server.yaml
几秒钟后,我们可以使用以下命令查看 Pods 状态(名称可能不同):
$ kubectl get pods
NAME READY STATUS
todo-server-85cf594fb6-tkqm9 1/1 Running
todo-server-85cf594fb6-vff6q 1/1 Running
todo-server-85cf594fb6-w4s6l 1/1 Running
接下来,我们需要为客户端创建一个 Pod。通常,如果客户端不是微服务,我们不需要在 Kubernetes 中部署它。然而,由于我们的客户端是一个简单的 Go 应用,部署它为容器来与服务器实例通信会更加方便。
在 k8s/client.yaml
中,我们有以下简单的 Pod 配置:
apiVersion: v1
kind: Pod
metadata:
name: todo-client
spec:
containers:
- name: todo-client
image: clementjean/grpc-go-packt-book:client
restartPolicy: Never
我们可以使用以下命令来运行客户端:
$ kubectl apply -f k8s/client.yaml
几秒钟后,我们应该得到类似的输出(或者是错误,而不是完成)。
$ kubectl get pods
NAME READY STATUS
todo-client 0/1 Completed
最后,我们最关心的事情是查看负载均衡的实际效果。为此,我们将获取每个服务器的名称,并使用 kubectl logs
命令查看它们的日志:
$ kubectl logs todo-server-85cf594fb6-tkqm9
listening at 0.0.0.0:50051
/todo.v2.TodoService/UpdateTasks called
/todo.v2.TodoService/ListTasks called
$ kubectl logs todo-server-85cf594fb6-vff6q
listening at 0.0.0.0:50051
/todo.v2.TodoService/DeleteTasks called
$ kubectl logs todo-server-85cf594fb6-w4s6l
listening at 0.0.0.0:50051
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/ListTasks called
/todo.v2.TodoService/ListTasks called
现在,您可能会看到不同的结果,但应该能够看到负载已分布到不同的实例上。需要注意的另一点是,由于我们没有使用真正的数据库,todo-client
的日志可能不正确。这是因为我们可能在服务器 1 上有一个任务,并且请求列出服务器 2 上的任务,而服务器 2 并不知道我们想要的任务。在生产环境中,我们会使用真实的数据库,这种情况就不会发生了。
总结一下,我们看到默认的负载均衡策略是 pick_first
,它会尝试按顺序连接所有可用地址,直到找到一个可达的地址并将所有请求发送到该地址。接着,我们使用了 round_robin
负载均衡策略,它将请求依次发送到每个服务器。最后,我们看到,在 gRPC 代码中设置客户端负载均衡是非常简单的。其余的配置主要是一些 DevOps 工作。