gRPC負載均衡(自定義負載均衡策略--etcd 實現)

背景

在工作學習中使用gRPC的地方比較多,通常我們都使用的是自帶的負載均衡算法,但是在某些場景下我們需要對服務的版本進行控制
比如 [app V2 只能去鏈接 user V3],在這樣的情況下就只能選自定義負載均衡策略

目標

實現基于版本(version)的grpc負載均衡器,了解過程后可自己實現更多的負載均衡功能

  • 注冊中心
    • Etcd Lease 是一種檢測客戶端存活狀況的機制。 群集授予具有生存時間的租約。 如果etcd 群集在給定的TTL 時間內未收到keepAlive,則租約到期。 為了將租約綁定到鍵值存儲中,每個key 最多可以附加一個租約
  • 服務注冊 (注冊服務)
    • 定時把本地服務(APP)地址,版本等信息注冊到服務器
  • 服務發現 (客戶端發起服務解析請求(APP))
    • 查詢注冊中心(APP)下有那些服務
    • 并向所有的服務建立HTTP2長鏈接
    • 通過Etcd watch 監聽服務(APP),通過變化更新鏈接
  • 負載均衡 (客戶端發起請求(APP))
    • 負載均衡選擇合適的服務(APP HTTP2長鏈接)
    • 發起調用

服務注冊 (注冊服務)

源碼 register.go

func NewRegister(opt ...RegisterOptions) (*Register, error) {
    s := &Register{
        opts: newOptions(opt...),
    }
    var ctx, cancel = context.WithTimeout(context.Background(), time.Duration(s.opts.RegisterTtl)*time.Second)
    defer cancel()
    data, err := json.Marshal(s.opts)
    if err != nil {
        return nil, err
    }
    etcdCli, err := clientv3.New(s.opts.EtcdConf)
    if err != nil {
        return nil, err
    }
    s.etcdCli = etcdCli
    //申請租約
    resp, err := etcdCli.Grant(ctx, s.opts.RegisterTtl)
    if err != nil {
        return s, err
    }
    s.name = fmt.Sprintf("%s/%s", s.opts.Node.Path, s.opts.Node.Id)
    //注冊節點
    _, err = etcdCli.Put(ctx, s.name, string(data), clientv3.WithLease(resp.ID))
    if err != nil {
        return s, err
    }
    //續約租約
    s.keepAliveChan, err = etcdCli.KeepAlive(context.Background(), resp.ID)
    if err != nil {
        return s, err
    }
    return s, nil
}

在etcd里面我們可以看到如下信息
APP v1版本服務在節點的key /hwholiday/srv/app/app-beb3cb56-eb61-11eb-858d-2cf05dc7c711

{
    "node": {
        "name": "app",
        "path": "/hwholiday/srv/app",
        "id": "app-beb3cb56-eb61-11eb-858d-2cf05dc7c711",
        "version": "v1",
        "address": "172.12.12.188:8089"
    }
}

APP v2版本服務在節點的key /hwholiday/srv/app/app-beb3cb56-eb61-11eb-858d-2cf05dc7c711

{
    "node": {
        "name": "app",
        "path": "/hwholiday/srv/app",
        "id": "app-19980562-eb63-11eb-99c0-2cf05dc7c711",
        "version": "v2",
        "address": "172.12.12.188:8088"
    },
}

服務發現 (客戶端發起服務解析請求(APP))

源碼 discovery.go
實現 grpc內的 resolver.Builder 接口(Builder 創建一個解析器,用于監視名稱解析更新)

func NewDiscovery(opt ...ClientOptions) resolver.Builder {
    s := &Discovery{
        opts: newOptions(opt...),
    }
    etcdCli, err := clientv3.New(s.opts.EtcdConf)
    if err != nil {
        panic(err)
    }
    s.etcdCli = etcdCli
    return s
}

// Build 當調用`grpc.Dial()`時執行
func (d *Discovery) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    d.cc = cc
    res, err := d.etcdCli.Get(context.Background(), d.opts.SrvName, clientv3.WithPrefix())
    if err != nil {
        return nil, err
    }
    for _, v := range res.Kvs {
        if err = d.AddNode(v.Key, v.Value); err != nil {
            log.Println(err)
            continue
        }
    }
    go func(dd *Discovery) {
        dd.watcher()
    }(d)
    return d, err
}

//根據官方的建議我們把從注冊中心拿到的服務信息儲存到Attributes中
// Attributes contains arbitrary data about the resolver intended for
// consumption by the load balancing policy.
// 屬性包含有關供負載平衡策略使用的解析器的任意數據。
//Attributes *attributes.Attributes
func (d *Discovery) AddNode(key, val []byte) error {
    var data = new(register.Options)
    err := json.Unmarshal(val, data)
    if err != nil {
        return err
    }
    addr := resolver.Address{Addr: data.Node.Address}
    addr = SetNodeInfo(addr, data)
    d.Node.Store(string(key), addr)
    return d.cc.UpdateState(resolver.State{Addresses: d.GetAddress()})
}

負載均衡 (客戶端發起請求(APP))

源碼 version_balancer.go

  • gRPC提供了PickerBuilder和Picker接口讓我們實現自己的負載均衡策略
//PickerBuilder 創建 balancer.Picker。
type PickerBuilder interface {
    //Build 返回一個選擇器,gRPC 將使用它來選擇一個 SubConn。
    Build(info PickerBuildInfo) balancer.Picker
}
//gRPC 使用 Picker 來選擇一個 SubConn 來發送 RPC。
//每次平衡器的內部狀態發生變化時,它都會從它的快照中生成一個新的選擇器。 
//gRPC 使用的選擇器可以通過 ClientConn.UpdateState() 更新。
type Picker interface {
    //選擇合適的子鏈接發送請求
    Pick(info PickInfo) (PickResult, error)
}
  • 從上面得知我們可以干事的地方在Build方法或者Pick方法(調用gRPC方法時先執行Build再執行Pick)
    • Build(info PickerBuildInfo) balancer.Picker
      info里面有服務的鏈接,和鏈接對應的剛剛通過AddNode方法存入的服務信息
      這里我們可以基于grpc-client層面來做負載,比如(加權隨機負載)
    • Pick(info PickInfo) (PickResult, error)
      info里面有調用的方法名和 context.Context
      通過context.Context我們可以獲得這個來獲取發起請求的時候填入的參數,這樣我們可以很靈活的針對每個方法進行不同的負載
      這里我們可以基于grpc-client-api層面來做負載
func (*rrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
    if len(info.ReadySCs) == 0 {
        return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
    }
    var scs = make(map[balancer.SubConn]*register.Options, len(info.ReadySCs))
    for conn, addr := range info.ReadySCs {
        nodeInfo := GetNodeInfo(addr.Address)
        if nodeInfo != nil {
            scs[conn] = nodeInfo
        }
    }
    if len(scs) == 0 {
        return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
    }
    return &rrPicker{
        node: scs,
    }
}
func (p *rrPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
    p.mu.Lock()
    defer p.mu.Unlock()
    version := info.Ctx.Value("version")
    var subConns []balancer.SubConn
    for conn, node := range p.node {
        if version != "" {
            if node.Node.Version == version.(string) {
                subConns = append(subConns, conn)
            }
        }
    }
    if len(subConns) == 0 {
        return balancer.PickResult{}, errors.New("no match found conn")
    }
    index := rand.Intn(len(subConns))
    sc := subConns[index]
    return balancer.PickResult{SubConn: sc}, nil
}

客戶的使用我們定義的 version 負載均衡策略

    r := discovery.NewDiscovery(
        discovery.SetName("hwholiday.srv.app"),
        discovery.SetEtcdConf(clientv3.Config{
            Endpoints:   []string{"172.12.12.165:2379"},
            DialTimeout: time.Second * 5,
        }))
    resolver.Register(r)
    // 連接服務器
    conn, err := grpc.Dial(
        "hwholiday.srv.app", //沒有使用這個參數
        grpc.WithDefaultServiceConfig(fmt.Sprintf(`{"LoadBalancingPolicy": "%s"}`, "version")),
        grpc.WithInsecure(),
    )
    if err != nil {
        log.Fatalf("net.Connect err: %v", err)
    }
    defer conn.Close()
    // 調用服務
        apiClient := api.NewApiClient(conn)

    ctx := context.WithValue(context.Background(), "version", "v1")
    _, err = apiClient.ApiTest(ctx, &api.Request{Input: "v1v1v1v1v1"})
    if err != nil {
        fmt.Println(err)
    }

運行效果

測試源碼

  • 運行APP服務v1,調用grpc-client 使用 v1

    • APP打印
    • 啟動成功 === > 0.0.0.0:8089
    • input:"v1v1v1v1v1"
    • grpc-client打印
    • === RUN TestClient
    • v1v1v1v1v1v1v1v1v1v1
  • 運行APP服務v1,調用grpc-client 使用 v2

    • APP打印
    • 啟動成功 === > 0.0.0.0:8089
    • grpc-client打印
    • === RUN TestClient
    • rpc error: code = Unavailable desc = no match found conn

總結

詳情介紹地址
源碼地址: https://github.com/hwholiday/learning_tools/tree/master/etcd
通過學習我們可以實現基于version的負載策略,這里只是提供一種思路怎么去實現可能我的這個例子不太適合這個,但是提供了一種思路,歡迎一起討論

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容