gRPC服务发现&负载均衡

构建高可用、高性能的通信服务,通常采用服务注册与发现、负载均衡和容错处理等机制实现。根据负载均衡实现所在的位置不同,通常可分为以下三种解决方案:

1、集中式LB(Proxy Model)

在服务消费者和服务提供者之间有一个独立的LB,通常是专门的硬件设备如 F5,或者基于软件如 LVS,HAproxy等实现。LB上有所有服务的地址映射表,通常由运维配置注册,当服务消费方调用某个目标服务时,它向LB发起请求,由LB以某种策略,比如轮询(Round-Robin)做负载均衡后将请求转发到目标服务。LB一般具备健康检查能力,能自动摘除不健康的服务实例。 该方案主要问题:

  1. 单点问题,所有服务调用流量都经过LB,当服务数量和调用量大的时候,LB容易成为瓶颈,且一旦LB发生故障影响整个系统;

  2. 服务消费方、提供方之间增加了一级,有一定性能开销。

2、进程内LB(Balancing-aware Client)

针对第一个方案的不足,此方案将LB的功能集成到服务消费方进程里,也被称为软负载或者客户端负载方案。服务提供方启动时,首先将服务地址注册到服务注册表,同时定期报心跳到服务注册表以表明服务的存活状态,相当于健康检查,服务消费方要访问某个服务时,它通过内置的LB组件向服务注册表查询,同时缓存并定期刷新目标服务地址列表,然后以某种负载均衡策略选择一个目标服务地址,最后向目标服务发起请求。LB和服务发现能力被分散到每一个服务消费者的进程内部,同时服务消费方和服务提供方之间是直接调用,没有额外开销,性能比较好。该方案主要问题:

  1. 开发成本,该方案将服务调用方集成到客户端的进程里头,如果有多种不同的语言栈,就要配合开发多种不同的客户端,有一定的研发和维护成本;

  2. 另外生产环境中,后续如果要对客户库进行升级,势必要求服务调用方修改代码并重新发布,升级较复杂。

3、独立 LB 进程(External Load Balancing Service)

该方案是针对第二种方案的不足而提出的一种折中方案,原理和第二种方案基本类似。
不同之处是将LB和服务发现功能从进程内移出来,变成主机上的一个独立进程。主机上的一个或者多个服务要访问目标服务时,他们都通过同一主机上的独立LB进程做服务发现和负载均衡。该方案也是一种分布式方案没有单点问题,一个LB进程挂了只影响该主机上的服务调用方,服务调用方和LB之间是进程内调用性能好,同时该方案还简化了服务调用方,不需要为不同语言开发客户库,LB的升级不需要服务调用方改代码。 
该方案主要问题:部署较复杂,环节多,出错调试排查问题不方便。

gRPC服务发现及负载均衡实现

gRPC开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的gRPC代码API中已提供了命名解析和负载均衡接口供扩展。

其基本实现原理:

  1. 服务启动后gRPC客户端向命名服务器发出名称解析请求,名称将解析为一个或多个IP地址,每个IP地址标示它是服务器地址还是负载均衡器地址,以及标示要使用那个客户端负载均衡策略或服务配置。

  2. 客户端实例化负载均衡策略,如果解析返回的地址是负载均衡器地址,则客户端将使用grpclb策略,否则客户端使用服务配置请求的负载均衡策略。

  3. 负载均衡策略为每个服务器地址创建一个子通道(channel)。

  4. 当有rpc请求时,负载均衡策略决定那个子通道即grpc服务器将接收请求,当可用服务器为空时客户端的请求将被阻塞。

根据gRPC官方提供的设计思路,基于进程内LB方案(即第2个案,阿里开源的服务框架 Dubbo 也是采用类似机制),结合分布式一致的组件(如Zookeeper、Consul、Etcd),可找到gRPC服务发现和负载均衡的可行解决方案。接下来以GO语言为例,简单介绍下基于Etcd3的关键代码实现:

1)命名解析实现:resolver.go

  1. package etcdv3
  2. import (
  3. "errors"
  4. "fmt"
  5. "strings"
  6. etcd3 "github.com/coreos/etcd/clientv3"
  7. "google.golang.org/grpc/naming"
  8. )
  9. // resolver is the implementaion of grpc.naming.Resolver
  10. type resolver struct {
  11. serviceName string // service name to resolve
  12. }
  13. // NewResolver return resolver with service name
  14. func NewResolver(serviceName string) *resolver {
  15. return &resolver{serviceName: serviceName}
  16. }
  17. // Resolve to resolve the service from etcd, target is the dial address of etcd
  18. // target example: "http://127.0.0.1:2379,http://127.0.0.1:12379,http://127.0.0.1:22379"
  19. func (re *resolver) Resolve(target string) (naming.Watcher, error) {
  20. if re.serviceName == "" {
  21. return nil, errors.New( "grpclb: no service name provided")
  22. }
  23. // generate etcd client
  24. client, err := etcd3.New(etcd3.Config{
  25. Endpoints: strings.Split(target, ","),
  26. })
  27. if err != nil {
  28. return nil, fmt.Errorf( "grpclb: creat etcd3 client failed: %s", err.Error())
  29. }
  30. // Return watcher
  31. return &watcher{re: re, client: *client}, nil
  32. }

2)服务发现实现:watcher.go

  1. package etcdv3
  2. import (
  3. "fmt"
  4. etcd3 "github.com/coreos/etcd/clientv3"
  5. "golang.org/x/net/context"
  6. "google.golang.org/grpc/naming"
  7. "github.com/coreos/etcd/mvcc/mvccpb"
  8. )
  9. // watcher is the implementaion of grpc.naming.Watcher
  10. type watcher struct {
  11. re *resolver // re: Etcd Resolver
  12. client etcd3.Client
  13. isInitialized bool
  14. }
  15. // Close do nothing
  16. func (w *watcher) Close() {
  17. }
  18. // Next to return the updates
  19. func (w *watcher) Next() ([]*naming.Update, error) {
  20. // prefix is the etcd prefix/value to watch
  21. prefix := fmt.Sprintf( "/%s/%s/", Prefix, w.re.serviceName)
  22. // check if is initialized
  23. if !w.isInitialized {
  24. // query addresses from etcd
  25. resp, err := w.client.Get(context.Background(), prefix, etcd3.WithPrefix())
  26. w.isInitialized = true
  27. if err == nil {
  28. addrs := extractAddrs(resp)
  29. //if not empty, return the updates or watcher new dir
  30. if l := len(addrs); l != 0 {
  31. updates := make([]*naming.Update, l)
  32. for i := range addrs {
  33. updates[i] = &naming.Update{Op: naming.Add, Addr: addrs[i]}
  34. }
  35. return updates, nil
  36. }
  37. }
  38. }
  39. // generate etcd Watcher
  40. rch := w.client.Watch(context.Background(), prefix, etcd3.WithPrefix())
  41. for wresp := range rch {
  42. for _, ev := range wresp.Events {
  43. switch ev.Type {
  44. case mvccpb.PUT:
  45. return []*naming.Update{{Op: naming.Add, Addr: string(ev.Kv.Value)}}, nil
  46. case mvccpb.DELETE:
  47. return []*naming.Update{{Op: naming.Delete, Addr: string(ev.Kv.Value)}}, nil
  48. }
  49. }
  50. }
  51. return nil, nil
  52. }
  53. func extractAddrs(resp *etcd3.GetResponse) []string {
  54. addrs := [] string{}
  55. if resp == nil || resp.Kvs == nil {
  56. return addrs
  57. }
  58. for i := range resp.Kvs {
  59. if v := resp.Kvs[i].Value; v != nil {
  60. addrs = append(addrs, string(v))
  61. }
  62. }
  63. return addrs
  64. }

3)服务注册实现:register.go

  1. package etcdv3
  2. import (
  3. "fmt"
  4. "log"
  5. "strings"
  6. "time"
  7. etcd3 "github.com/coreos/etcd/clientv3"
  8. "golang.org/x/net/context"
  9. "github.com/coreos/etcd/etcdserver/api/v3rpc/rpctypes"
  10. )
  11. // Prefix should start and end with no slash
  12. var Prefix = "etcd3_naming"
  13. var client etcd3.Client
  14. var serviceKey string
  15. var stopSignal = make( chan bool, 1)
  16. // Register
  17. func Register(name string, host string, port int, target string, interval time.Duration, ttl int) error {
  18. serviceValue := fmt.Sprintf( "%s:%d", host, port)
  19. serviceKey = fmt.Sprintf( "/%s/%s/%s", Prefix, name, serviceValue)
  20. // get endpoints for register dial address
  21. var err error
  22. client, err := etcd3.New(etcd3.Config{
  23. Endpoints: strings.Split(target, ","),
  24. })
  25. if err != nil {
  26. return fmt.Errorf( "grpclb: create etcd3 client failed: %v", err)
  27. }
  28. go func() {
  29. // invoke self-register with ticker
  30. ticker := time.NewTicker(interval)
  31. for {
  32. // minimum lease TTL is ttl-second
  33. resp, _ := client.Grant(context.TODO(), int64(ttl))
  34. // should get first, if not exist, set it
  35. _, err := client.Get(context.Background(), serviceKey)
  36. if err != nil {
  37. if err == rpctypes.ErrKeyNotFound {
  38. if _, err := client.Put(context.TODO(), serviceKey, serviceValue, etcd3.WithLease(resp.ID)); err != nil {
  39. log.Printf( "grpclb: set service '%s' with ttl to etcd3 failed: %s", name, err.Error())
  40. }
  41. } else {
  42. log.Printf( "grpclb: service '%s' connect to etcd3 failed: %s", name, err.Error())
  43. }
  44. } else {
  45. // refresh set to true for not notifying the watcher
  46. if _, err := client.Put(context.Background(), serviceKey, serviceValue, etcd3.WithLease(resp.ID)); err != nil {
  47. log.Printf( "grpclb: refresh service '%s' with ttl to etcd3 failed: %s", name, err.Error())
  48. }
  49. }
  50. select {
  51. case <-stopSignal:
  52. return
  53. case <-ticker.C:
  54. }
  55. }
  56. }()
  57. return nil
  58. }
  59. // UnRegister delete registered service from etcd
  60. func UnRegister() error {
  61. stopSignal <- true
  62. stopSignal = make( chan bool, 1) // just a hack to avoid multi UnRegister deadlock
  63. var err error;
  64. if _, err := client.Delete(context.Background(), serviceKey); err != nil {
  65. log.Printf( "grpclb: deregister '%s' failed: %s", serviceKey, err.Error())
  66. } else {
  67. log.Printf( "grpclb: deregister '%s' ok.", serviceKey)
  68. }
  69. return err
  70. }

4)接口描述文件:helloworld.proto

  1. syntax = "proto3";
  2. option java_multiple_files = true;
  3. option java_package = "com.midea.jr.test.grpc";
  4. option java_outer_classname = "HelloWorldProto";
  5. option objc_class_prefix = "HLW";
  6. package helloworld;
  7. // The greeting service definition.
  8. service Greeter {
  9. // Sends a greeting
  10. rpc SayHello (HelloRequest) returns (HelloReply) {
  11. }
  12. }
  13. // The request message containing the user's name.
  14. message HelloRequest {
  15. string name = 1;
  16. }
  17. // The response message containing the greetings
  18. message HelloReply {
  19. string message = 1;
  20. }

5)实现服务端接口:helloworldserver.go

  1. package main
  2. import (
  3. "flag"
  4. "fmt"
  5. "log"
  6. "net"
  7. "os"
  8. "os/signal"
  9. "syscall"
  10. "time"
  11. "golang.org/x/net/context"
  12. "google.golang.org/grpc"
  13. grpclb "com.midea/jr/grpclb/naming/etcd/v3"
  14. "com.midea/jr/grpclb/example/pb"
  15. )
  16. var (
  17. serv = flag.String( "service", "hello_service", "service name")
  18. port = flag.Int( "port", 50001, "listening port")
  19. reg = flag.String( "reg", "http://127.0.0.1:2379", "register etcd address")
  20. )
  21. func main() {
  22. flag.Parse()
  23. lis, err := net.Listen( "tcp", fmt.Sprintf( "0.0.0.0:%d", *port))
  24. if err != nil {
  25. panic(err)
  26. }
  27. err = grpclb.Register(*serv, "127.0.0.1", *port, *reg, time.Second* 10, 15)
  28. if err != nil {
  29. panic(err)
  30. }
  31. ch := make( chan os.Signal, 1)
  32. signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL, syscall.SIGHUP, syscall.SIGQUIT)
  33. go func() {
  34. s := <-ch
  35. log.Printf( "receive signal '%v'", s)
  36. grpclb.UnRegister()
  37. os.Exit( 1)
  38. }()
  39. log.Printf( "starting hello service at %d", *port)
  40. s := grpc.NewServer()
  41. pb.RegisterGreeterServer(s, &server{})
  42. s.Serve(lis)
  43. }
  44. // server is used to implement helloworld.GreeterServer.
  45. type server struct{}
  46. // SayHello implements helloworld.GreeterServer
  47. func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
  48. fmt.Printf( "%v: Receive is %s\n", time.Now(), in.Name)
  49. return &pb.HelloReply{Message: "Hello " + in.Name}, nil
  50. }

6)实现客户端接口:helloworldclient.go

  1. package main
  2. import (
  3. "flag"
  4. "fmt"
  5. "time"
  6. grpclb "com.midea/jr/grpclb/naming/etcd/v3"
  7. "com.midea/jr/grpclb/example/pb"
  8. "golang.org/x/net/context"
  9. "google.golang.org/grpc"
  10. "strconv"
  11. )
  12. var (
  13. serv = flag.String( "service", "hello_service", "service name")
  14. reg = flag.String( "reg", "http://127.0.0.1:2379", "register etcd address")
  15. )
  16. func main() {
  17. flag.Parse()
  18. r := grpclb.NewResolver(*serv)
  19. b := grpc.RoundRobin(r)
  20. ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
  21. conn, err := grpc.DialContext(ctx, *reg, grpc.WithInsecure(), grpc.WithBalancer(b))
  22. if err != nil {
  23. panic(err)
  24. }
  25. ticker := time.NewTicker( 1 * time.Second)
  26. for t := range ticker.C {
  27. client := pb.NewGreeterClient(conn)
  28. resp, err := client.SayHello(context.Background(), &pb.HelloRequest{Name: "world " + strconv.Itoa(t.Second())})
  29. if err == nil {
  30. fmt.Printf( "%v: Reply is %s\n", t, resp.Message)
  31. }
  32. }
  33. }

7)运行测试

  1. 运行3个服务端S1、S2、S3,1个客户端C,观察各服务端接收的请求数是否相等?

  2. 关闭1个服务端S1,观察请求是否会转移到另外2个服务端?

  3. 重新启动S1服务端,观察另外2个服务端请求是否会平均分配到S1?

  4. 关闭Etcd3服务器,观察客户端与服务端通信是否正常? 关闭通信仍然正常,但新服务端不会注册进来,服务端掉线了也无法摘除掉。

  5. 重新启动Etcd3服务器,服务端上下线可自动恢复正常。

  6. 关闭所有服务端,客户端请求将被阻塞。

猜你喜欢

转载自blog.csdn.net/linglingma9087/article/details/80904216