目录

gRPC 在 Kubernetes 中的负载均衡

背景

新公司服务在往 Kubernetes 上迁移,让我着手处理一个负载不均衡的问题,给到的描述是一个前台服务提供 API,后面挂两个 TensorFlow Model Server,其中后面的俩个服务存在负载不均衡的问题。

考虑到内部服务是基于 kube-proxy 的 L4 负载均衡,TensorFlow Model Server 是基于 gRPC 的,而 gRPC 是基于 HTTP2 的,第一反应是由于 HTTP2 连接复用导致的负载不均衡问题1,这里记录下验证过程和提供一些解决思路。

验证假设

这里部署了一个 grpcbin2 服务用来测试 gRPC 服务的负载均衡:

ghz/grpcbin.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
apiVersion: v1
kind: Service
metadata:
  name: grpcbin
spec:
  clusterIP: None
  ports:
    - port: 9000
      targetPort: 9000
  selector:
    app: grpcbin
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: grpcbin
  labels:
    app: grpcbin
spec:
  replicas: 3
  selector:
    matchLabels:
      app: grpcbin
  template:
    metadata:
      labels:
        app: grpcbin
    spec:
      dnsPolicy: ClusterFirst
      containers:
        - name: app
          image: moul/grpcbin
          ports:
            - containerPort: 9000
          resources:
            requests:
              memory: 50Mi
              cpu: "1"
            limits:
              memory: 50Mi
              cpu: "1"

其中 resource QoS class3 设置为 Guaranteed, 确保 POD 资源是稳定的。

为了方便测试,这里使用 NodePort 类型的 Service 暴露出服务。不影响 kube-proxy 对服务的负载均衡。

测试客户端使用 ghz4,一个用于 gRPC 压力测试的工具,配置如下:

ghz/Dockerfile
1
2
3
4
5
6
FROM ubuntu:bionic
WORKDIR /app
ADD ghz-linux-x86_64.tar.gz .
COPY grpcbin.proto .
RUN chown -Rv root:root .
ENTRYPOINT [ "./ghz" ]
ghz/ghz.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
apiVersion: v1
kind: ConfigMap
metadata:
  name: ghz-config
data:
  config.toml: |-
    host = "grpcbin:9000"

    proto = "grpcbin.proto"
    call = "grpcbin.GRPCBin.DummyUnary"

    insecure = true
    # Number of connections to use.
    # Concurrency is distributed evenly among all the connections.
    # Default is 1.
    connections = 1
    # Keepalive time duration. Only used if present and above 0.
    keepalive = 0
    # Duration of application to send requests.
    # When duration is reached, application stops and exits.
    # If duration is specified, n is ignored. Examples: -z 10s -z 3m.
    duration = "3m"
    # Number of requests to run. Default is 200.
    total = 1000000000
    # Number of requests to run concurrently.
    # Total number of requests cannot be smaller than the concurrency level.
    # Default is 50.
    concurrency = 200
    # Rate limit, in queries per second (QPS). Default is no rate limit.
    qps = 0

    [data]
    f_strings = [
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890",
        "1234567890"
      ]    

---
apiVersion: v1
kind: Pod
metadata:
  name: ghz
  labels:
    app: ghz
spec:
  volumes:
    - name: ghz-config
      configMap:
        name: ghz-config
  containers:
    - name: app
      image: "test/ghz:latest"
      imagePullPolicy: Always
      args: ["--config", "/etc/ghz/config.toml", "--debug", "/var/lib/ghz.json"]
      volumeMounts:
        - mountPath: "/etc/ghz"
          name: ghz-config
  restartPolicy: Never

使用 1 个连接进行测试

1
2
3
4
5
➜ kubectl top pod
NAME                       CPU(cores)   MEMORY(bytes)
grpcbin-7556ccbccb-rp6m9   0m           6Mi
grpcbin-7556ccbccb-v2pgq   861m         10Mi
grpcbin-7556ccbccb-wdpbq   0m           6Mi

运行稳定后可以看到只有 grpcbin-7556ccbccb-v2pgq 这个 POD 有负载。

使用 3 个连接进行测试

1
2
3
4
5
➜ kubectl top pod
NAME                       CPU(cores)   MEMORY(bytes)
grpcbin-7556ccbccb-rp6m9   357m         9Mi
grpcbin-7556ccbccb-v2pgq   518m         10Mi
grpcbin-7556ccbccb-wdpbq   0m           6Mi

运行稳定后可以看到 grpcbin-7556ccbccb-rp6m9grpcbin-7556ccbccb-v2pgq 这两个 POD 负载接近 1:2,即两者连接数分别为 1 和 2,这是因为集群 kube-proxy 使用的模式是 iptables,连接负载策略是随机的。

解释

官方对 kube-proxy 的特点总结很清晰5

The kube proxy:

  • runs on each node # 运行在每个节点
  • proxies UDP, TCP and SCTP # 可以代理UDP、TCP、SCTP协议
  • does not understand HTTP # 但是不能识别HTTP协议
  • provides load balancing # 提供负载均衡
  • is just used to reach services # 只用于到达服务

也就是说:

  • kube-proxy 是一个 L4 负载均衡器,只支持连接级别负载均衡;
  • gRPC 传输协议基于 HTTP2,使用了连接复用特性,客户端一般只会持有一个连接;
  • 因此当连接建立后,后续的请求只会发送到该连接对应的节点。

解决方案

使用客户端负载均衡器

  1. 使用 Headless Service6,通过 DNS 直接返回一组 POD 地址;
  2. gRPC 的客户端代码使用提供的负载均衡功能7,同时使用 DNS Resolver 8实现客户端负载均衡器。

这种方案能够在不改变集群网络架构的情况下支持 gRPC 的负载均衡,但是还是有一些缺陷:

  1. 使用 Headless Service 和 DNS Resolver 会增大 CoreDNS 的压力;
  2. DNS Resolver 会有缓存,对服务的节点变化不够迅速;
  3. 方案有侵入性且适用性差。

验证客户端负载均衡器方案

服务端测试代码及部署配置:

load_balancing/server/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"os"

	"google.golang.org/grpc"
	pb "google.golang.org/grpc/examples/features/proto/echo"
)

type echoServer struct {
	pb.UnimplementedEchoServer
	hostname string
}

func (s *echoServer) UnaryEcho(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
	log.Println("received request:", req.Message)
	return &pb.EchoResponse{Message: fmt.Sprintf("(from %s): reply %s ", s.hostname, req.Message)}, nil
}

func main() {
	hostname, err := os.Hostname()
	if err != nil {
		log.Fatalf("failed to get hostname: %v", err)
	}

	addr := ":9000"
	lis, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	s := grpc.NewServer()
	pb.RegisterEchoServer(s, &echoServer{hostname: hostname})
	log.Printf("serving on %s\n", addr)
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}
load_balancing/server/deploy.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: v1
kind: Service
metadata:
  name: echo-server
spec:
  clusterIP: None # 使用 Headless Service
  ports:
    - port: 9000
      targetPort: 9000
  selector:
    app: echo-server
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-server
  labels:
    app: echo-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: echo-server
  template:
    metadata:
      labels:
        app: echo-server
    spec:
      dnsPolicy: ClusterFirst
      containers:
        - name: app
          image: test/load_balancing_test:latest
          ports:
            - containerPort: 9000
          command: ["./echo_server"]

客户端测试代码及部署配置:

load_balancing/client/main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/balancer/roundrobin"
	ecpb "google.golang.org/grpc/examples/features/proto/echo"
)

func main() {
	target := os.Getenv("ECHO_SERVER")
	if target == ""
		// 使用 dns 解析器
		target = "dns:///echo-server:9000"
	}
	log.Println("target:", target)

	// 建立连接
	ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
	conn, err := grpc.DialContext(
		ctx,
		target,
		grpc.WithBlock(),
		grpc.WithInsecure(),
		// 负载均衡策略
		grpc.WithBalancerName(roundrobin.Name),
	)
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}

	client := ecpb.NewEchoClient(conn)
	// 1 秒请求一次
	messageID := 0
	for range time.Tick(1*time.Second){
		messageID++
		ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
		response, err := client.UnaryEcho(ctx, &ecpb.EchoRequest{Message: fmt.Sprintf("message %d", messageID)})
		if err != nil {
			log.Fatalf("could not send request: %v", err)
		}
		log.Println(response.Message)
	}
}
load_balancing/client/deploy.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: load-balancing-client
  labels:
    app: load-balancing-client
spec:
  replicas: 1
  selector:
    matchLabels:
      app: load-balancing-client
  template:
    metadata:
      labels:
        app: load-balancing-client
    spec:
      dnsPolicy: ClusterFirst
      containers:
        - name: app
          image: test/load_balancing_test:latest
          command: ["./load_balancing_client"]

客户端测试代码使用 DNS 解析服务器地址,并使用 roundrobin 负载均衡策略。

我们首先测试的是设置 clusterIP: None 的情况(即使用 Headless Service),可以看到客户端输出的日志是符合预期的,请求会轮询服务器连接:

1
2
3
4
5
6
7
2020/05/25 07:12:45 target: dns:///echo-server:9000
2020/05/25 07:12:46 (from echo-server-866cff6884-66kj8): reply message 1
2020/05/25 07:12:47 (from echo-server-866cff6884-967bx): reply message 2
2020/05/25 07:12:48 (from echo-server-866cff6884-xsds5): reply message 3
2020/05/25 07:12:49 (from echo-server-866cff6884-66kj8): reply message 4
2020/05/25 07:12:50 (from echo-server-866cff6884-967bx): reply message 5
2020/05/25 07:12:51 (from echo-server-866cff6884-xsds5): reply message 6

当取消设置 clusterIP: None 后再重新部署客户端,此时客户端只能利用到一个服务器连接,

1
2
3
4
5
6
7
2020/05/25 07:14:02 target: dns:///echo-server:9000
2020/05/25 07:14:03 (from echo-server-6dcb87cb69-ws2gf): reply message 1
2020/05/25 07:14:04 (from echo-server-6dcb87cb69-ws2gf): reply message 2
2020/05/25 07:14:05 (from echo-server-6dcb87cb69-ws2gf): reply message 3
2020/05/25 07:14:06 (from echo-server-6dcb87cb69-ws2gf): reply message 4
2020/05/25 07:14:07 (from echo-server-6dcb87cb69-ws2gf): reply message 5
2020/05/25 07:14:08 (from echo-server-6dcb87cb69-ws2gf): reply message 6

也就是说 gRPC 客户端基于 DNS 解析的负载均衡是需要配合 Headless Service 来使用的。

当然 gRPC 框架自带了其他解析器和负载均衡策略的,这里只是选择了最常见的场景,更复杂的常见是可以通过自定义解析器和负载均衡策略来实现。

另外像 Dubbo 之类的 RPC 框架也是支持配置客户端负载均衡策略的。9

使用服务端负载均衡器

使用 L7 负载均衡器,例如 Linkerd, Envoy。如果条件允许的话,可以使用 istio 之类的服务网格产品。

关于 kube-proxy 负载均衡策略

kube-proxy 支持三种模式10

  • usersapce: 用户空间内的负载均衡,负载均衡支持轮询策略,性能一般;

/grpc-%E5%9C%A8-kubernetes-%E4%B8%AD%E7%9A%84%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1/img/2020-05-26-09-53-36.png

  • iptables: 完全使用 iptables 实现相关功能,只支持随机负载均衡,性能较好;

/grpc-%E5%9C%A8-kubernetes-%E4%B8%AD%E7%9A%84%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1/img/2020-05-26-09-53-51.png

  • ipvs: 基于 ipvs 做负载均衡,使用 iptables 实现 Cluster IP,包过滤,SNAT 或 masquerade11,性能最好,支持较多负载均衡策略,默认是轮询,可通过 --ipvs-scheduler 参数配置。

/grpc-%E5%9C%A8-kubernetes-%E4%B8%AD%E7%9A%84%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1/img/2020-05-26-09-54-05.png

关于是否有必要选择从 iptables 切换到 ipvs 模式,可以参考这两篇文章:

基本结论是:

  • 集群服务超过 1000 个时,使用 ipvs 能获得较明显的性能提升,具体数值取决于集群实际情况,但如果主要使用长连接的服务,使用 ipvs 的效果提升是有限的。
  • ipvs 模式下线规则时候的一个 bug,会导致新连接发送到已经下线的POD内,如果无法接受就需要等官方修复。

如何在已经启动的集群修改 kube-proxy 配置

当然如果确定要将已上线的集群的 kube-proxy 模式设置为 ipvs 的话,这里也提供一些指引。

使用 kubeadm 安装:

使用 Rancher 安装:

Rancher 2.x - 设置集群

使用 RKE 安装:

RKE - How Upgrades Work


  1. gRPC Load Balancing on Kubernetes without Tears ↩︎

  2. grpcbin ↩︎

  3. QoS class ↩︎

  4. ghz ↩︎

  5. Proxies in Kubernetes ↩︎

  6. Headless Service ↩︎

  7. Load Balancing in gRPCgoogle.golang.org/grpc/balancer ↩︎

  8. gRPC Name Resolution ↩︎

  9. Dubbo的负载均衡 ↩︎

  10. VIP 和 Service 代理 ↩︎

  11. When IPVS falls back to IPTABLES ↩︎