通过负载均衡分发请求

负载均衡是一个复杂的主题,且有许多实现方式。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 工作。