12、深入理解gRPC服务发现与负载均衡

深入理解gRPC服务发现与负载均衡

1. 服务发现基础

非分布式日志(如 Log 类型)对服务器信息并不了解。为解决这一问题,我们创建了一个新接口,其唯一方法 GetServers() DistributedLog.GetServers 相匹配。在更新 agent 包中的端到端测试时,我们将 DistributedLog 同时设置为 CommitLog GetServerer ,新的服务器端点会对其进行错误处理。

agent.go 中,更新 setupServer() 方法,使服务器能从 DistributedLog 获取集群服务器信息:

serverConfig := &server.Config{
    CommitLog:    a.log,
    Authorizer:   authorizer,
    GetServerer:  a.log,
}

这样,客户端就可以调用服务器端点来获取集群服务器信息,接下来我们开始构建解析器。

2. 构建gRPC解析器

我们将编写的gRPC解析器会调用 GetServers() 端点,并将信息传递给gRPC,以便选择器知道可以将请求路由到哪些服务器。

操作步骤如下:
1. 创建一个新包来存放解析器和选择器代码:

mkdir internal/loadbalance
  1. internal/loadbalance 目录下创建 resolver.go 文件,代码如下:
package loadbalance
import (
    "context"
    "fmt"
    "sync"
    "go.uber.org/zap"
    "google.golang.org/grpc"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/resolver"
    "google.golang.org/grpc/serviceconfig"
    api "github.com/travisjeffery/proglog/api/v1"
)
type Resolver struct {
    mu            sync.Mutex
    clientConn    resolver.ClientConn
    resolverConn  *grpc.ClientConn
    serviceConfig *serviceconfig.ParseResult
    logger        *zap.Logger
}

Resolver 类型将实现gRPC的 resolver.Builder resolver.Resolver 接口。 clientConn 是用户的客户端连接,gRPC会将其传递给解析器,以便解析器用发现的服务器更新它; resolverConn 是解析器自身与服务器的客户端连接,用于调用 GetServers() 获取服务器信息。

  1. 实现 resolver.Builder 接口:
var _ resolver.Builder = (*Resolver)(nil)
func (r *Resolver) Build(
    target resolver.Target,
    cc resolver.ClientConn,
    opts resolver.BuildOptions,
) (resolver.Resolver, error) {
    r.logger = zap.L().Named("resolver")
    r.clientConn = cc
    var dialOpts []grpc.DialOption
    if opts.DialCreds != nil {
        dialOpts = append(
            dialOpts,
            grpc.WithTransportCredentials(opts.DialCreds),
        )
    }
    r.serviceConfig = r.clientConn.ParseServiceConfig(
        fmt.Sprintf(`{"loadBalancingConfig":[{"%s":{}}]}`, Name),
    )
    var err error
    r.resolverConn, err = grpc.Dial(target.Endpoint, dialOpts...)
    if err != nil {
        return nil, err
    }
    r.ResolveNow(resolver.ResolveNowOptions{})
    return r, nil
}
const Name = "proglog"
func (r *Resolver) Scheme() string {
    return Name
}
func init() {
    resolver.Register(&Resolver{})
}

resolver.Builder 包含两个方法:
- Build() :接收构建解析器所需的数据(如目标地址)和客户端连接,解析器将用发现的服务器更新该连接。它会建立与服务器的客户端连接,以便解析器调用 GetServers() API。
- Scheme() :返回解析器的方案标识符。当调用 grpc.Dial 时,gRPC会从目标地址中解析出方案,并尝试找到匹配的解析器,默认使用其DNS解析器。对于我们的解析器,目标地址应格式化为 proglog://your-service-address

init() 函数中,我们将解析器注册到gRPC,以便gRPC在查找与目标方案匹配的解析器时能够识别它。

  1. 实现 resolver.Resolver 接口:
var _ resolver.Resolver = (*Resolver)(nil)
func (r *Resolver) ResolveNow(resolver.ResolveNowOptions) {
    r.mu.Lock()
    defer r.mu.Unlock()
    client := api.NewLogClient(r.resolverConn)
    ctx := context.Background()
    res, err := client.GetServers(ctx, &api.GetServersRequest{})
    if err != nil {
        r.logger.Error(
            "failed to resolve server",
            zap.Error(err),
        )
        return
    }
    var addrs []resolver.Address
    for _, server := range res.Servers {
        addrs = append(addrs, resolver.Address{
            Addr: server.RpcAddr,
            Attributes: attributes.New(
                "is_leader",
                server.IsLeader,
            ),
        })
    }
    r.clientConn.UpdateState(resolver.State{
        Addresses:     addrs,
        ServiceConfig: r.serviceConfig,
    })
}
func (r *Resolver) Close() {
    if err := r.resolverConn.Close(); err != nil {
        r.logger.Error(
            "failed to close conn",
            zap.Error(err),
        )
    }
}

resolver.Resolver 包含两个方法:
- ResolveNow() :gRPC调用此方法来解析目标、发现服务器,并使用服务器信息更新客户端连接。我们创建一个gRPC客户端调用 GetServers() API获取集群服务器信息,然后用 resolver.Address 切片更新客户端连接状态。
- Close() :关闭解析器,这里关闭在 Build() 中创建的与服务器的连接。

3. 测试解析器

internal/loadbalance 目录下创建 resolver_test.go 文件,代码如下:

package loadbalance_test
import (
    "net"
    "testing"
    "github.com/stretchr/testify/require"
    "google.golang.org/grpc"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/credentials"
    "google.golang.org/grpc/resolver"
    "google.golang.org/grpc/serviceconfig"
    api "github.com/travisjeffery/proglog/api/v1"
    "github.com/travisjeffery/proglog/internal/loadbalance"
    "github.com/travisjeffery/proglog/internal/config"
    "github.com/travisjeffery/proglog/internal/server"
)
func TestResolver(t *testing.T) {
    l, err := net.Listen("tcp", "127.0.0.1:0")
    require.NoError(t, err)
    tlsConfig, err := config.SetupTLSConfig(config.TLSConfig{
        CertFile:      config.ServerCertFile,
        KeyFile:       config.ServerKeyFile,
        CAFile:        config.CAFile,
        Server:        true,
        ServerAddress: "127.0.0.1",
    })
    require.NoError(t, err)
    serverCreds := credentials.NewTLS(tlsConfig)
    srv, err := server.NewGRPCServer(&server.Config{
        GetServerer: &getServers{},
    }, grpc.Creds(serverCreds))
    require.NoError(t, err)
    go srv.Serve(l)
    conn := &clientConn{}
    tlsConfig, err = config.SetupTLSConfig(config.TLSConfig{
        CertFile:      config.RootClientCertFile,
        KeyFile:       config.RootClientKeyFile,
        CAFile:        config.CAFile,
        Server:        false,
        ServerAddress: "127.0.0.1",
    })
    require.NoError(t, err)
    clientCreds := credentials.NewTLS(tlsConfig)
    opts := resolver.BuildOptions{
        DialCreds: clientCreds,
    }
    r := &loadbalance.Resolver{}
    _, err = r.Build(
        resolver.Target{
            Endpoint: l.Addr().String(),
        },
        conn,
        opts,
    )
    require.NoError(t, err)
    wantState := resolver.State{
        Addresses: []resolver.Address{{
            Addr:       "localhost:9001",
            Attributes: attributes.New("is_leader", true),
        }, {
            Addr:       "localhost:9002",
            Attributes: attributes.New("is_leader", false),
        }},
    }
    require.Equal(t, wantState, conn.state)
    conn.state.Addresses = nil
    r.ResolveNow(resolver.ResolveNowOptions{})
    require.Equal(t, wantState, conn.state)
}
type getServers struct{}
func (s *getServers) GetServers() ([]*api.Server, error) {
    return []*api.Server{{
        Id:       "leader",
        RpcAddr:  "localhost:9001",
        IsLeader: true,
    }, {
        Id:       "follower",
        RpcAddr:  "localhost:9002",
    }}, nil
}
type clientConn struct {
    resolver.ClientConn
    state resolver.State
}
func (c *clientConn) UpdateState(state resolver.State) {
    c.state = state
}
func (c *clientConn) ReportError(err error) {}
func (c *clientConn) NewAddress(addrs []resolver.Address) {}
func (c *clientConn) NewServiceConfig(config string) {}
func (c *clientConn) ParseServiceConfig(
    config string,
) *serviceconfig.ParseResult {
    return nil
}

这个测试文件设置了一个服务器,供测试解析器尝试发现服务器。我们使用模拟的 GetServerers 来设置解析器应找到的服务器。

4. 解析器工作流程
graph LR
    A[开始] --> B[构建解析器]
    B --> C[建立与服务器连接]
    C --> D[调用GetServers()获取服务器信息]
    D --> E[更新客户端连接状态]
    E --> F[结束]
5. 解析器相关信息总结
接口 方法 作用
resolver.Builder Build() 接收构建解析器所需数据,建立与服务器连接
resolver.Builder Scheme() 返回解析器的方案标识符
resolver.Resolver ResolveNow() 解析目标、发现服务器并更新客户端连接
resolver.Resolver Close() 关闭解析器与服务器的连接
6. 实现gRPC选择器

在gRPC架构中,选择器负责处理RPC的负载均衡逻辑。它们会从解析器发现的服务器中选择一个来处理每个RPC。选择器可以根据RPC、客户端和服务器的信息进行请求路由。

操作步骤如下:
1. 在 internal/loadbalance 目录下创建 picker.go 文件,代码如下:

package loadbalance
import (
    "strings"
    "sync"
    "sync/atomic"
    "google.golang.org/grpc/balancer"
    "google.golang.org/grpc/balancer/base"
)
var _ base.PickerBuilder = (*Picker)(nil)
type Picker struct {
    mu        sync.RWMutex
    leader    balancer.SubConn
    followers []balancer.SubConn
    current   uint64
}
func (p *Picker) Build(buildInfo base.PickerBuildInfo) balancer.Picker {
    p.mu.Lock()
    defer p.mu.Unlock()
    var followers []balancer.SubConn
    for sc, scInfo := range buildInfo.ReadySCs {
        isLeader := scInfo.Address.Attributes.Value("is_leader").(bool)
        if isLeader {
            p.leader = sc
            continue
        }
        followers = append(followers, sc)
    }
    p.followers = followers
    return p
}

选择器使用构建器模式。gRPC会将子连接的信息传递给 Build() 方法来设置选择器。我们的选择器会将消费RPC路由到跟随者服务器,将生产RPC路由到领导者服务器。

  1. 实现选择器的 Pick() 方法:
var _ balancer.Picker = (*Picker)(nil)
func (p *Picker) Pick(info balancer.PickInfo) (
    balancer.PickResult, error) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    var result balancer.PickResult
    if strings.Contains(info.FullMethodName, "Produce") ||
        len(p.followers) == 0 {
        result.SubConn = p.leader
    } else if strings.Contains(info.FullMethodName, "Consume") {
        result.SubConn = p.nextFollower()
    }
    if result.SubConn == nil {
        return result, balancer.ErrNoSubConnAvailable
    }
    return result, nil
}
func (p *Picker) nextFollower() balancer.SubConn {
    cur := atomic.AddUint64(&p.current, uint64(1))
    len := uint64(len(p.followers))
    idx := int(cur % len)
    return p.followers[idx]
}

Pick() 方法根据RPC的方法名选择子连接。对于生产RPC,选择领导者子连接;对于消费RPC,使用轮询算法选择跟随者子连接。

  1. 注册选择器:
func init() {
    balancer.Register(
        base.NewBalancerBuilder(Name, &Picker{}, base.Config{}),
    )
}
7. 测试选择器

internal/loadbalance 目录下创建 picker_test.go 文件,代码如下:

package loadbalance_test
import (
    "testing"
    "google.golang.org/grpc/attributes"
    "google.golang.org/grpc/balancer"
    "google.golang.org/grpc/balancer/base"
    "google.golang.org/grpc/resolver"
    "github.com/stretchr/testify/require"
    "github.com/travisjeffery/proglog/internal/loadbalance"
)
func TestPickerNoSubConnAvailable(t *testing.T) {
    picker := &loadbalance.Picker{}
    for _, method := range []string{
        "/log.vX.Log/Produce",
        "/log.vX.Log/Consume",
    } {
        info := balancer.PickInfo{
            FullMethodName: method,
        }
        result, err := picker.Pick(info)
        require.Equal(t, balancer.ErrNoSubConnAvailable, err)
        require.Nil(t, result.SubConn)
    }
}
func TestPickerProducesToLeader(t *testing.T) {
    picker, subConns := setupTest()
    info := balancer.PickInfo{
        FullMethodName: "/log.vX.Log/Produce",
    }
    for i := 0; i < 5; i++ {
        gotPick, err := picker.Pick(info)
        require.NoError(t, err)
        require.Equal(t, subConns[0], gotPick.SubConn)
    }
}
func TestPickerConsumesFromFollowers(t *testing.T) {
    picker, subConns := setupTest()
    info := balancer.PickInfo{
        FullMethodName: "/log.vX.Log/Consume",
    }
    for i := 0; i < 5; i++ {
        pick, err := picker.Pick(info)
        require.NoError(t, err)
        require.Equal(t, subConns[i%2+1], pick.SubConn)
    }
}
func setupTest() (*loadbalance.Picker, []*subConn) {
    var subConns []*subConn
    buildInfo := base.PickerBuildInfo{
        ReadySCs: make(map[balancer.SubConn]base.SubConnInfo),
    }
    for i := 0; i < 3; i++ {
        sc := &subConn{}
        addr := resolver.Address{
            Attributes: attributes.New("is_leader", i == 0),
        }
        sc.UpdateAddresses([]resolver.Address{addr})
        buildInfo.ReadySCs[sc] = base.SubConnInfo{Address: addr}
        subConns = append(subConns, sc)
    }
    picker := &loadbalance.Picker{}
    picker.Build(buildInfo)
    return picker, subConns
}
// subConn implements balancer.SubConn.
type subConn struct {
    addrs []resolver.Address
}
func (s *subConn) UpdateAddresses(addrs []resolver.Address) {
    s.addrs = addrs
}
func (s *subConn) Connect() {}
  • TestPickerNoSubConnAvailable() :测试在解析器发现服务器并更新选择器状态之前,选择器返回 balancer.ErrNoSubConnAvailable
  • TestPickerProducesToLeader() :测试选择器为生产调用选择领导者子连接。
  • TestPickerConsumesFromFollowers() :测试选择器为消费调用以轮询方式选择跟随者子连接。
8. 选择器工作流程
graph LR
    A[开始] --> B[构建选择器]
    B --> C[根据服务器属性区分领导者和跟随者]
    C --> D[接收RPC请求]
    D --> E{是否为生产请求}
    E -- 是 --> F[选择领导者子连接]
    E -- 否 --> G[选择跟随者子连接(轮询)]
    F --> H[处理请求]
    G --> H[处理请求]
    H --> I[结束]
9. 选择器相关信息总结
接口 方法 作用
base.PickerBuilder Build() 根据服务器信息设置选择器,区分领导者和跟随者
balancer.Picker Pick() 根据RPC方法名选择子连接处理请求
10. 端到端测试

我们需要更新代理的测试来进行端到端测试,包括客户端配置解析器和选择器、解析器发现服务器以及选择器为每个RPC选择子连接。

操作步骤如下:
1. 打开 internal/agent/agent_test.go 文件,添加导入:

"github.com/travisjeffery/proglog/internal/loadbalance"
  1. 更新 client() 函数以使用自定义解析器和选择器:
func client(
    t *testing.T,
    agent *agent.Agent,
    tlsConfig *tls.Config,
) api.LogClient {
    tlsCreds := credentials.NewTLS(tlsConfig)
    opts := []grpc.DialOption{
        grpc.WithTransportCredentials(tlsCreds),
    }
    rpcAddr, err := agent.Config.RPCAddr()
    require.NoError(t, err)
    conn, err := grpc.Dial(fmt.Sprintf(
        "%s:///%s",
        loadbalance.Name,
        rpcAddr,
    ), opts...)
    require.NoError(t, err)
    client := api.NewLogClient(conn)
    return client
}

通过在URL中指定自定义方案,gRPC会使用我们的解析器。

  1. 运行代理测试:
    运行 go run ./internal/agent ,会发现领导者客户端的消费调用失败。这是因为之前每个客户端只连接一个服务器,现在每个客户端连接所有服务器,生产到领导者,消费从跟随者,需要等待领导者复制记录。

  2. 更新测试以等待复制完成:
    time.Sleep 移动到合适位置,确保在领导者客户端消费之前记录已复制。

// wait until replication has finished
time.Sleep(3 * time.Second)
consumeResponse, err := leaderClient.Consume(
    context.Background(),
    &api.ConsumeRequest{
        Offset: produceResponse.Offset,
    },
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))
followerClient := client(t, agents[1], peerTLSConfig)
consumeResponse, err = followerClient.Consume(
    context.Background(),
    &api.ConsumeRequest{
        Offset: produceResponse.Offset,
    },
)
require.NoError(t, err)
require.Equal(t, consumeResponse.Record.Value, []byte("foo"))
11. 端到端测试流程
graph LR
    A[开始] --> B[配置客户端解析器和选择器]
    B --> C[解析器发现服务器]
    C --> D[选择器选择子连接]
    D --> E[生产请求到领导者]
    E --> F[等待记录复制]
    F --> G[消费请求从跟随者]
    G --> H[验证结果]
    H --> I[结束]

通过以上步骤,我们实现了gRPC服务的发现和负载均衡,并通过端到端测试验证了整个流程的正确性。掌握这些技术可以帮助我们构建更高效、可靠的分布式系统。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值