實現etcd服務註冊與發現

slowquery發表於2022-08-25

轉載自:實現etcd服務註冊與發現

0.1、目錄結構

.
├── api
│   └── main.go
├── common
│   └── common.go
├── docker-compose.yml
├── etcd
│   └── Dockerfile
├── go.mod
├── go.sum
├── rpc
│   ├── courseware
│   │   ├── courseware.pb.go
│   │   └── courseware_grpc.pb.go
│   ├── courseware.proto
│   └── main.go
└── server
    ├── service_discovery.go
    └── service_registration.go

1、docker-compose部署一個3節點的叢集

專案根目錄下建立etcd目錄,並在目錄下新增Dockerfile檔案

FROM bitnami/etcd:latest

LABEL maintainer="liuyuede123 <liufutianoppo@163.com>"

專案根目錄下新增docker-compose.yml

version: '3.5'
# 網路配置
networks:
  backend:
    driver: bridge

# 服務容器配置
services:
  etcd1:                                  # 自定義容器名稱
    build:
      context: etcd                    # 指定構建使用的 Dockerfile 檔案
    environment:
      - TZ=Asia/Shanghai
      - ALLOW_NONE_AUTHENTICATION=yes
      - ETCD_NAME=etcd1
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd1:2380
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_ADVERTISE_CLIENT_URLS=http://etcd1:2379
      - ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster
      - ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      - ETCD_INITIAL_CLUSTER_STATE=new
    ports:                               # 設定埠對映
      - "12379:2379"
      - "12380:2380"
    networks:
      - backend
    restart: always

  etcd2: # 自定義容器名稱
    build:
      context: etcd                    # 指定構建使用的 Dockerfile 檔案
    environment:
      - TZ=Asia/Shanghai
      - ALLOW_NONE_AUTHENTICATION=yes
      - ETCD_NAME=etcd2
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd2:2380
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_ADVERTISE_CLIENT_URLS=http://etcd2:2379
      - ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster
      - ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      - ETCD_INITIAL_CLUSTER_STATE=new
    ports: # 設定埠對映
      - "22379:2379"
      - "22380:2380"
    networks:
      - backend
    restart: always

  etcd3: # 自定義容器名稱
    build:
      context: etcd                    # 指定構建使用的 Dockerfile 檔案
    environment:
      - TZ=Asia/Shanghai
      - ALLOW_NONE_AUTHENTICATION=yes
      - ETCD_NAME=etcd3
      - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd3:2380
      - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380
      - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379
      - ETCD_ADVERTISE_CLIENT_URLS=http://etcd3:2379
      - ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster
      - ETCD_INITIAL_CLUSTER=etcd1=http://etcd1:2380,etcd2=http://etcd2:2380,etcd3=http://etcd3:2380
      - ETCD_INITIAL_CLUSTER_STATE=new
    ports: # 設定埠對映
      - "32379:2379"
      - "32380:2380"
    networks:
      - backend
    restart: always

相關引數概念:

  1. ETCD_INITIAL_ADVERTISE_PEER_URLS:該成員節點在整個叢集中的通訊地址列表,這個地址用來傳輸叢集資料的地址。因此這個地址必須是可以連線叢集中所有的成員的。
  2. ETCD_LISTEN_PEER_URLS:該節點與其他節點通訊時所監聽的地址列表,多個地址使用逗號隔開,其格式可以劃分為scheme://IP:PORT,這裡的scheme可以是http、https
  3. ETCD_LISTEN_CLIENT_URLS:該節點與客戶端通訊時監聽的地址列表
  4. ETCD_ADVERTISE_CLIENT_URLS:廣播給叢集中其他成員自己的客戶端地址列表
  5. ETCD_INITIAL_CLUSTER_TOKEN:初始化叢集token
  6. ETCD_INITIAL_CLUSTER:配置叢集內部所有成員地址,其格式為:ETCD_NAME=ETCD_INITIAL_ADVERTISE_PEER_URLS,如果有多個使用逗號隔開
  7. ETCD_INITIAL_CLUSTER_STATE:初始化叢集狀態,new表示新建

啟動叢集

docker-compose up -d
Creating network "etcd_backend" with driver "bridge"
Creating etcd_etcd1_1 ... done
Creating etcd_etcd2_1 ... done
Creating etcd_etcd3_1 ... done

測試叢集可用性

# 登入其中一個節點
docker exec -it 5f97bf0b446f6e6514576fc1eb46c2f60d2c2b3e3f3ee3b1ad6219414fa915c8 /bin/sh
# 寫入一個鍵值
etcdctl put name "liuyuede"
OK
# 檢視
etcdctl get name
name
liuyuede

# 登入另外倆個節點
docker exec -it a6ccc9b6e5cc81ee7c779e2b9e7235cd6d814e92fbc66b7e4846798acff8ee2a /bin/sh
etcdctl get name
name
liuyuede

docker exec -it 6817fa89e3e9e422628e0049910b672df389c62d41bf2349a0f77e22c99e5270 /bin/sh
etcdctl get name
name
liuyuede

etcd叢集採用的是raft協議,一般至少為倆個叢集,只有一個master,如果刪除到只剩一個節點當前節點也不能提供服務

檢視叢集情況

etcdctl --endpoints=http://0.0.0.0:12379,0.0.0.0:22379,0.0.0.0:32379 endpoint status --write-out=table

+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
|       ENDPOINT       |        ID        | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| http://0.0.0.0:12379 | ade526d28b1f92f7 |   3.5.4 |   20 kB |      true |      false |         3 |         13 |                 13 |        |
|        0.0.0.0:22379 | d282ac2ce600c1ce |   3.5.4 |   20 kB |     false |      false |         3 |         13 |                 13 |        |
|        0.0.0.0:32379 | bd388e7810915853 |   3.5.4 |   20 kB |     false |      false |         3 |         13 |                 13 |        |
+----------------------+------------------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+

2、增加服務註冊功能

服務註冊的流程

  1. 向etcd新增一個包含rpc服務資訊的鍵值對,並設定租約(比如5秒過期)
  2. 利用保活函式KeepAlive不斷續約
package server

import (
    "context"
    "encoding/json"
    "errors"
    clientv3 "go.etcd.io/etcd/client/v3"
    "time"
)

type ServiceInfo struct {
    Name string
    Ip   string
}

type Service struct {
    ServiceInfo ServiceInfo
    stop        chan error
    leaseId     clientv3.LeaseID
    client      *clientv3.Client
}

func NewService(serviceInfo ServiceInfo, endpoints []string) (service *Service, err error) {
    client, err := clientv3.New(clientv3.Config{
        Endpoints:   endpoints,
        DialTimeout: time.Second * 10,
    })
    if err != nil {
        return nil, err
    }

    service = &Service{
        ServiceInfo: serviceInfo,
        client:      client,
    }
    return
}

func (s *Service) Start(ctx context.Context) (err error) {
    alive, err := s.KeepAlive(ctx)
    if err != nil {
        return
    }

    for {
        select {
        case err = <-s.stop: // 服務端關閉返回錯誤
            return err
        case <-s.client.Ctx().Done(): // etcd關閉
            return errors.New("server closed")
        case _, ok := <-alive:
            if !ok { // 保活通道關閉
                return s.revoke(ctx)
            }
        }
    }
}

func (s *Service) KeepAlive(ctx context.Context) (<-chan *clientv3.LeaseKeepAliveResponse, error) {
    info := s.ServiceInfo
    key := s.getKey()
    val, _ := json.Marshal(info)

    // 建立租約
    leaseResp, err := s.client.Grant(ctx, 5)
    if err != nil {
        return nil, err
    }

    // 寫入etcd
    _, err = s.client.Put(ctx, key, string(val), clientv3.WithLease(leaseResp.ID))
    if err != nil {
        return nil, err
    }

    s.leaseId = leaseResp.ID
    return s.client.KeepAlive(ctx, leaseResp.ID)
}

// 取消租約
func (s *Service) revoke(ctx context.Context) error {
    _, err := s.client.Revoke(ctx, s.leaseId)
    return err
}

func (s *Service) getKey() string {
    return s.ServiceInfo.Name + "/" + s.ServiceInfo.Ip
}

3、增加服務發現

服務發現流程

  1. 實現grpc中resolver.Builder介面的Build方法
  2. 透過etcdclient獲取並監聽grpc服務(是否有新增或者刪除)
  3. 更新到resolver.State,State 包含與 ClientConn 相關的當前 Resolver 狀態,包括grpc的地址resolver.Address
package server

import (
    "context"
    "encoding/json"
    "fmt"
    "go.etcd.io/etcd/api/v3/mvccpb"
    clientv3 "go.etcd.io/etcd/client/v3"
    "google.golang.org/grpc/resolver"
)

type Discovery struct {
    endpoints  []string
    service    string
    client     *clientv3.Client
    clientConn resolver.ClientConn
}

func NewDiscovery(endpoints []string, service string) resolver.Builder {
    return &Discovery{
        endpoints: endpoints,
        service:   service,
    }
}

func (d *Discovery) ResolveNow(rn resolver.ResolveNowOptions) {

}

func (d *Discovery) Close() {

}

func (d *Discovery) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    var err error
    d.client, err = clientv3.New(clientv3.Config{
        Endpoints: d.endpoints,
    })
    if err != nil {
        return nil, err
    }

    d.clientConn = cc

    go d.watch(d.service)

    return d, nil
}

func (d *Discovery) Scheme() string {
    return "etcd"
}

func (d *Discovery) watch(service string) {
    addrM := make(map[string]resolver.Address)
    state := resolver.State{}

    update := func() {
        addrList := make([]resolver.Address, 0, len(addrM))
        for _, address := range addrM {
            addrList = append(addrList, address)
        }
        state.Addresses = addrList
        err := d.clientConn.UpdateState(state)
        if err != nil {
            fmt.Println("更新地址出錯:", err)
        }
    }
    resp, err := d.client.Get(context.Background(), service, clientv3.WithPrefix())
    if err != nil {
        fmt.Println("獲取地址出錯:", err)
    } else {
        for i, kv := range resp.Kvs {
            info := &ServiceInfo{}
            err = json.Unmarshal(kv.Value, info)
            if err != nil {
                fmt.Println("解析value失敗:", err)
            }
            addrM[string(resp.Kvs[i].Key)] = resolver.Address{
                Addr:       info.Ip,
                ServerName: info.Name,
            }
        }
    }

    update()

    dch := d.client.Watch(context.Background(), service, clientv3.WithPrefix(), clientv3.WithPrevKV())
    for response := range dch {
        for _, event := range response.Events {
            switch event.Type {
            case mvccpb.PUT:
                info := &ServiceInfo{}
                err = json.Unmarshal(event.Kv.Value, info)
                if err != nil {
                    fmt.Println("監聽時解析value報錯:", err)
                } else {
                    addrM[string(event.Kv.Key)] = resolver.Address{Addr: info.Ip}
                }
                fmt.Println(string(event.Kv.Key))
            case mvccpb.DELETE:
                delete(addrM, string(event.Kv.Key))
                fmt.Println(string(event.Kv.Key))
            }
        }
        update()
    }
}

4、grpc課件服務

common引數

package common

const CoursewareRpc = "rpc.courseware"

var Endpoints = []string{"127.0.0.1:12379", "127.0.0.1:22379", "127.0.0.1:32379"}

生成課件服務grpc

syntax = "proto3";

package rpc;
option go_package = "./courseware";

message GetRequest {
  uint64 Id = 1;
}
message GetResponse {
  uint64 Id = 1;
  string Code = 2;
  string Name = 3;
  uint64 Type = 4;
}


service Courseware {
  rpc Get(GetRequest) returns(GetResponse);
}
protoc --go_out=./ --go-grpc_out=./ courseware.proto

課件服務入口

package main

import (
    "context"
    "fmt"
    "go-demo/etcd/common"
    "go-demo/etcd/rpc/courseware"
    "go-demo/etcd/server"
    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"
    "net"
    "os"
    "strings"
    "time"
)

var Port string

type service struct {
    courseware.UnsafeCoursewareServer
}

func (s *service) Get(ctx context.Context, req *courseware.GetRequest) (res *courseware.GetResponse, err error) {
    fmt.Println("獲取課件詳情 port:", Port, " time:", time.Now())
    return &courseware.GetResponse{
        Id:   1,
        Code: "HD4544",
        Name: "多媒體課件",
        Type: 4,
    }, nil
}
func main() {
    args := os.Args[1:]
    if len(args) == 0 {
        panic("缺少port引數:port=8400")
    }
    for _, arg := range args {
        ports := strings.Split(arg, "=")
        if len(ports) < 2 || ports[0] != "port" {
            panic("port引數格式錯誤:port=8400")
        }
        Port = ports[1]
    }
    listen, err := net.Listen("tcp", ":"+Port)
    if err != nil {
        fmt.Println("failed to listen", err)
        return
    }
    s := grpc.NewServer()
    courseware.RegisterCoursewareServer(s, &service{})

    reflection.Register(s)

  // 註冊到etcd
    newService, err := server.NewService(server.ServiceInfo{
        Name: common.CoursewareRpc,
        Ip:   "127.0.0.1:" + Port,
    }, common.Endpoints)
    if err != nil {
        fmt.Println("新增到etcd失敗:", err)
        return
    }

    go func() {
        err = newService.Start(context.Background())
        if err != nil {
            fmt.Println("開啟服務註冊失敗:", err)
        }
    }()

    if err = s.Serve(listen); err != nil {
        fmt.Println("開啟rpc服務失敗:", err)
    }
}

5、api服務

package main

import (
    "context"
    "fmt"
    "go-demo/etcd/common"
    "go-demo/etcd/rpc/courseware"
    "go-demo/etcd/server"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    "google.golang.org/grpc/resolver"
    "time"
)

func main() {
    d := server.NewDiscovery(common.Endpoints, common.CoursewareRpc)
    resolver.Register(d)

    for {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    // 透過etcd註冊中心和grpc服務建立連線
        conn, err := grpc.DialContext(ctx,
            fmt.Sprintf(d.Scheme()+":///"+common.CoursewareRpc),
            grpc.WithTransportCredentials(insecure.NewCredentials()),
            grpc.WithDefaultServiceConfig(`{"loadBalancingConfig": [{"round_robin":{}}]}`),
            grpc.WithBlock(),
        )
        if err != nil {
            fmt.Println("和rpc建立連線失敗:", err)
            return
        }

        client := courseware.NewCoursewareClient(conn)
        get, err := client.Get(ctx, &courseware.GetRequest{Id: 1})
        if err != nil {
            fmt.Println("獲取課件失敗:", err)
            return
        }

        fmt.Println(get)

        time.Sleep(3 * time.Second)
        cancel()
    }

}

6、測試

開啟3個服務,可以看到客戶端透過負載均衡隨機到一個服務請求

go run main.go port=8400
獲取課件詳情 port: 8400  time: 2022-08-25 18:47:43.784942 +0800 CST m=+78.228450885
獲取課件詳情 port: 8400  time: 2022-08-25 18:47:52.925858 +0800 CST m=+87.369721731
獲取課件詳情 port: 8400  time: 2022-08-25 18:48:02.001177 +0800 CST m=+96.445393312
獲取課件詳情 port: 8400  time: 2022-08-25 18:48:05.060066 +0800 CST m=+99.504401028
獲取課件詳情 port: 8400  time: 2022-08-25 18:48:14.154148 +0800 CST m=+108.598836458
go run main.go port=8500
獲取課件詳情 port: 8500  time: 2022-08-25 18:47:46.832479 +0800 CST m=+62.822399701
獲取課件詳情 port: 8500  time: 2022-08-25 18:47:49.844536 +0800 CST m=+65.834573960
獲取課件詳情 port: 8500  time: 2022-08-25 18:47:55.955638 +0800 CST m=+71.945912584
獲取課件詳情 port: 8500  time: 2022-08-25 18:48:17.168293 +0800 CST m=+93.159391485
獲取課件詳情 port: 8500  time: 2022-08-25 18:48:20.182787 +0800 CST m=+96.174002796
go run main.go port=8600
獲取課件詳情 port: 8600  time: 2022-08-25 18:47:58.968283 +0800 CST m=+1.317052360
獲取課件詳情 port: 8600  time: 2022-08-25 18:48:08.106493 +0800 CST m=+10.455617422
獲取課件詳情 port: 8600  time: 2022-08-25 18:48:11.125212 +0800 CST m=+13.474453269
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章