gRPC负载均衡

kubernetes service 负载均衡算法 kubernetes grpc 负载均衡_grpc 轮训算法

之前已经学习了gRPC通过命名解析的方式获取后端集群的node信息,包含服务个数,服务IP、服务端口等关键信息,图中1的部分。

今天我们来学习下如何使用算法,从服务列表中选择一个合适的节点进行RPC调用,图中3的部分。

应用于生产环境的负载均衡算法比较复杂,往往需要考虑不同节点之间硬件的差异导致节点能够提供服务的能力。如2核4G的机器(节点A)能够处理100个并发;4核8G的机器(节点B)能够处理200个并发,当这两个节点组成一个集群时,如果将请求均匀的分摊在这两个节点,显然是不合适的。常见的负载均衡算法有

  • 轮训
  • 随机
  • 加权轮训
  • 加权随机
  • 最小连接数等。

gRPC内置两种负载均衡算法pick_first、round_robin,个人感觉gRPC负载算法的能力比较薄弱,这两种内置的算法也难以满足不同的业务场景,好在自定义gRPC负载算法比较简单。接下来开始学习之旅吧。

负载策略算法

服务端代码

首先,大概的讲解下服务端的代码,该代码适用于后续所有的不同负载策略的客户端。

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"sync"
	"google.golang.org/grpc"
	pb "google.golang.org/grpc/examples/features/proto/echo"
)

//服务端占用端口
var (
	addrs = []string{":50051", ":50052", ":50053", ":50054", ":50055"}
)
type ecServer struct {
	pb.UnimplementedEchoServer
	addr string
}

func (s *ecServer) UnaryEcho(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
	return &pb.EchoResponse{Message: fmt.Sprintf("%s (from %s)", req.Message, s.addr)}, nil
}

func startServer(addr string) {
	lis, err := net.Listen("tcp", addr)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterEchoServer(s, &ecServer{addr: addr})
	log.Printf("serving on %s\n", addr)
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

func main() {
	var wg sync.WaitGroup
	// 启动不同的goroutine 占用不同的端口,模拟服务端集群
	for _, addr := range addrs {
		wg.Add(1)
		go func(addr string) {
			defer wg.Done()
			startServer(addr)
		}(addr)
	}
	wg.Wait()
}

pick_first

gRPC客户端默认的负载策略,pick_first 策略尝试连接到第一个地址,如果第一个处于活跃状态,则将其用于所有的RPC;如果它失败,则尝试下一个地址进行调用,以此类推。

摘取客户端调用的核心代码

func main(){
  pickfirstConn, err := grpc.Dial(
		fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
  .....
}

测试结果 如下,可以分析出客户端一直选取第一个节点进行RPC调用:

--- calling helloworld.Greeter/SayHello with pick_first ---
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)

round_robin

round_robin 翻译为轮训调度算法。round_robin连接到它看到的所有地址,并按顺序依次向每个后端发送一个RPC。例如,假设后端有3个服务节点,第一个RPC将被发送到后端1,第二个RPC将发送到后端2,第三个RPC将再次被发送到后台3,第四次RPC又从后端1开始,依次类推,周而复始。

摘取客户端调用的核心代码

func main() {

	pickfirstConn, err := grpc.Dial(
		fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
		grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
  ......
}

测试结果如下,可以得出,每一次调用都会轮训选取服务节点进行RPC调用:

--- calling helloworld.Greeter/SayHello with pick_first ---
this is examples/load_balancing (from :50054)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50052)
this is examples/load_balancing (from :50053)
this is examples/load_balancing (from :50054)
this is examples/load_balancing (from :50055)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50052)
this is examples/load_balancing (from :50053)
this is examples/load_balancing (from :50054)
// 这段输出有一个小问题,不知道有童鞋发现没有,为什么第一次输出不是从 50051端口开始呀,留个悬念,后面看源码就知道答案了

源码解析

目前为止,已经学习了gRPC内置的两种负载均衡算法,我们以round_robin算法作为研究对象,分析下他的实现过程。gRPC定义了两个非常核心的接口,用于构建负载均衡算法、及将算法加入到gRPC框架中。它们分别是Picker接口、PickerBuilder接口

Picker 接口

// Picker接口被用于RPC调用之间connection的选取,即选择哪一个connection进行数据发送;
// 服务端程序状态发生变化时 内部状态会自动更新
type Picker interface {
	// Pick 方法是实现不同算法的 场所
	Pick(info PickInfo) (PickResult, error)
}

round_robin 算法

type rrPicker struct {
	// subConns 变量保存算法中不同节点连接,这里需要注意
  // 定义的服务端连接端口顺序为:{":50051", ":50052", ":50053", ":50054", ":50055"}
  // subConns数组存储的顺序并不一定等于上面数组定义的顺序
	subConns []balancer.SubConn
  // 计数器
	next     uint32
}
func (p *rrPicker) Pick(balancer.PickInfo) (balancer.PickResult, error) {
  //获取连接个数
	subConnsLen := uint32(len(p.subConns))
  // 计数器累加
	nextIndex := atomic.AddUint32(&p.next, 1)
  // 根据计数器、连接数取模,获取下标,得到服务节点
	sc := p.subConns[nextIndex%subConnsLen]
	return balancer.PickResult{SubConn: sc}, nil
}

PickerBuilder 接口

//构建round_robin是算法
func (*rrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
	logger.Infof("roundrobinPicker: Build called with info: %v", info)
	if len(info.ReadySCs) == 0 {
		return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
	}
  //获取连接
	scs := make([]balancer.SubConn, 0, len(info.ReadySCs))
	for sc := range info.ReadySCs {
		scs = append(scs, sc)
	}
  //关联算法 并返回
	return &rrPicker{
		subConns: scs,
		// 初始化的值为随机下标,因此回答了上面 控制台输出的问题
		next: uint32(grpcrand.Intn(len(scs))),
	}
}

系统加载

上面已经对算法进行实现,同时通过Build方法也将连接对象跟算法进行了绑定,还剩下一个很关键的问题,即gRPC如何加载算法到框架中,让客户端进行调用?

// 常量定义算法名称
const Name = "round_robin"

var logger = grpclog.Component("roundrobin")

// newBuilder creates a new roundrobin balancer builder.
func newBuilder() balancer.Builder {
  //将算法名称 与 算法Build 进行关联
	return base.NewBalancerBuilder(Name, &rrPickerBuilder{}, base.Config{HealthCheck: true})
}

// 利用init函数进行算法注册
func init() {
	balancer.Register(newBuilder())
}

至此,基于gRPC round_robin的负载均衡算法已经梳理完毕,相信聪明的你已经学会了。

自定义负载均衡算法

现在我们按照round_robin 的算法模板实现自定义随机的负载均衡算法,随机选择一个服务端的通信节点进行RPC调用:

随机算法

在examples/features/load_balancing下,新建random/random.go文件,代码如下:

package random

import (
	"fmt"
	"google.golang.org/grpc/balancer"
	"google.golang.org/grpc/balancer/base"
	"google.golang.org/grpc/grpclog"
	"math/rand"
)

// 算法名称
const Name = "random"

var logger = grpclog.Component("random")

// 创建随机算法 builder
func newBuilder() balancer.Builder {
	return base.NewBalancerBuilder(Name, &rrPickerBuilder{}, base.Config{HealthCheck: true})
}

//注册算法
func init() {
	balancer.Register(newBuilder())
}

type rrPickerBuilder struct{}

func (*rrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
	logger.Infof("random Picker: Build called with info: %v", info)
	if len(info.ReadySCs) == 0 {
		return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
	}
	scs := make([]balancer.SubConn, 0, len(info.ReadySCs))
	for sc := range info.ReadySCs {
		scs = append(scs, sc)
	}
	return &rrPicker{
		subConns: scs,
	}
}

type rrPicker struct {
	//connection 连接数组
	subConns []balancer.SubConn
}

func (p *rrPicker) Pick(balancer.PickInfo) (balancer.PickResult, error) {
	subConnsLen := len(p.subConns)
	fmt.Println(" subConnsLen : ", subConnsLen)
	nextIndex := rand.Intn(subConnsLen)

	sc := p.subConns[nextIndex]
	return balancer.PickResult{SubConn: sc}, nil
}

客户端调用

摘取部分核心代码

// 导入依赖
import _ "google.golang.org/grpc/examples/features/load_balancing/random"

var addrs = []string{"localhost:50051", "localhost:50052", "localhost:50053", "localhost:50054", "localhost:50055"}

func main(){
  pickfirstConn, err := grpc.Dial(
		fmt.Sprintf("%s:///%s", exampleScheme, exampleServiceName),
		grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"random":{}}]}`),
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
  .....
}

测试结果

--- calling helloworld.Greeter/SayHello with pick_first ---
this is examples/load_balancing (from :50053)
this is examples/load_balancing (from :50053)
this is examples/load_balancing (from :50053)
this is examples/load_balancing (from :50055)
this is examples/load_balancing (from :50052)
this is examples/load_balancing (from :50054)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50051)
this is examples/load_balancing (from :50052)
this is examples/load_balancing (from :50051)

从运行结果可以看出,已经通过随机算法达到了随机选择后端服务节点,进行RPC通信的目的。

完整的代码地址 在这里