12、gRPC 服务发现与负载均衡实现指南

gRPC 服务发现与负载均衡实现指南

1. 服务端点配置

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

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

// ClientSideServiceDiscovery/internal/agent/agent.go
serverConfig := &server.Config{
    CommitLog:    a.log,
    Authorizer:   authorizer,
    GetServerer:  a.log,
}

这样,我们就有了一个客户端可以调用的服务器端点,用于获取集群的服务器信息。

2. 构建解析器

2.1 创建解析器包

首先,创建一个新包用于存放解析器和选择器代码:

mkdir internal/loadbalance

2.2 实现解析器

internal/loadbalance 目录下创建 resolver.go 文件,并编写以下代码:

// ClientSideServiceDiscovery/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
}

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

2.3 实现解析器接口

init() 函数之后,实现 gRPC 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 调用此方法来解析目标、发现服务器,并使用服务器信息更新客户端连接。
- Close() :关闭解析器,在我们的解析器中,关闭在 Build() 中创建的与服务器的连接。

2.4 解析器地址字段说明

resolver.Address 有三个字段:
| 字段名 | 是否必需 | 说明 |
| ---- | ---- | ---- |
| Addr | 是 | 要连接的服务器地址 |
| Attributes | 否,但有用 | 包含对负载均衡器有用的任何数据的映射,我们将使用此字段告知选择器哪个服务器是领导者,哪些是追随者 |
| ServerName | 否,通常无需设置 | 用于该地址的传输证书颁发机构的名称,而不是从 Dial 目标字符串中获取的主机名 |

2.5 解析器测试

internal/loadbalance 目录下创建 resolver_test.go 文件,并编写以下测试代码:

// ClientSideServiceDiscovery/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 来设置解析器应找到的服务器。

2.6 解析器测试流程

graph TD;
    A[创建监听] --> B[设置 TLS 配置];
    B --> C[创建 gRPC 服务器];
    C --> D[启动服务器];
    D --> E[创建客户端连接];
    E --> F[设置客户端 TLS 配置];
    F --> G[构建解析器];
    G --> H[解析服务器];
    H --> I[验证解析结果];

运行解析器测试以验证其是否通过:

go test ./internal/loadbalance

3. 实现选择器

3.1 创建选择器文件

internal/loadbalance 目录下创建 picker.go 文件,并编写以下代码:

// ClientSideServiceDiscovery/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 路由到领导者服务器。地址属性有助于我们区分服务器。

3.2 实现选择器接口

Build() 方法之后,实现 balancer.Picker 接口:

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]
}

func init() {
    balancer.Register(
        base.NewBalancerBuilder(Name, &Picker{}, base.Config{}),
    )
}

选择器有一个方法 Pick(balancer.PickInfo) ,gRPC 会提供包含 RPC 名称和上下文的 balancer.PickInfo ,帮助选择器选择子连接。我们根据 RPC 的方法名来决定选择领导者子连接还是追随者子连接,并使用轮询算法在追随者之间平衡消费调用。

3.3 选择器测试

internal/loadbalance 目录下创建 picker_test.go 文件,并编写以下测试代码:

// ClientSideServiceDiscovery/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
}

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() :测试选择器以轮询方式为消费调用选择追随者子连接。

3.4 选择器测试流程

graph TD;
    A[创建测试选择器] --> B[测试无可用子连接情况];
    B --> C[测试生产调用选择领导者子连接];
    C --> D[测试消费调用选择追随者子连接];

运行选择器测试以验证其是否通过:

go test ./internal/loadbalance

4. 端到端测试

4.1 更新代理测试

打开 internal/agent/agent_test.go 文件,添加以下导入:

// ClientSideServiceDiscovery/internal/agent/agent_test.go
"github.com/travisjeffery/proglog/internal/loadbalance"

然后更新 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 会知道使用我们的解析器。

4.2 解决测试问题

运行代理测试时,领导者客户端的消费调用可能会失败。这是因为之前每个客户端只连接到一个服务器,领导者客户端连接到领导者,生产的记录可以立即被领导者客户端消费。现在,每个客户端连接到所有服务器,生产到领导者,消费从追随者进行,因此我们必须等待领导者将记录复制到追随者。

更新测试,在领导者客户端消费之前等待服务器复制记录。将 time.Sleep 从第 14 行之前移动到第 4 行之前:

// 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"))

再次运行测试:

make test

现在测试应该可以通过。

通过以上步骤,我们实现了 gRPC 服务的发现和负载均衡,包括自定义解析器和选择器,并通过端到端测试验证了整个流程的正确性。

5. 技术要点总结

5.1 解析器要点

  • 核心功能 :解析器的主要任务是发现服务器并将服务器信息更新到客户端连接中。通过实现 resolver.Builder resolver.Resolver 接口,我们可以自定义解析器的行为。
  • 关键方法
    • Build() :负责建立与服务器的连接,以便调用 GetServers() API 获取服务器信息。
    • Scheme() :返回解析器的方案标识符,用于 gRPC 匹配解析器。
    • ResolveNow() :解析目标、发现服务器并更新客户端连接。
    • Close() :关闭解析器与服务器的连接。
  • 地址字段 resolver.Address Addr Attributes ServerName 字段为负载均衡提供了必要的信息。

5.2 选择器要点

  • 核心功能 :选择器负责处理 RPC 负载均衡逻辑,根据 RPC 的方法名和服务器信息选择合适的子连接。
  • 关键方法
    • Build() :根据子连接信息设置选择器。
    • Pick() :根据 RPC 信息选择子连接,实现生产和消费请求的不同路由。
    • nextFollower() :使用轮询算法在追随者服务器之间平衡消费请求。

5.3 端到端测试要点

  • 配置更新 :在代理测试中,更新 client() 函数以使用自定义的解析器和选择器。
  • 问题解决 :处理由于服务器复制延迟导致的测试失败问题,通过等待服务器复制记录来确保测试通过。

5.4 技术要点对比

组件 核心功能 关键方法 作用
解析器 发现服务器并更新客户端连接 Build() Scheme() ResolveNow() Close() 为负载均衡提供服务器信息
选择器 处理 RPC 负载均衡 Build() Pick() nextFollower() 根据请求类型选择合适的服务器
端到端测试 验证整个流程的正确性 更新 client() 函数、处理复制延迟 确保系统的稳定性和可靠性

6. 实际应用与拓展

6.1 实际应用场景

  • 微服务架构 :在微服务环境中,服务数量众多,需要动态发现和负载均衡。通过自定义解析器和选择器,可以实现服务之间的高效通信和负载均衡。
  • 分布式系统 :分布式系统中,数据存储在多个节点上,需要根据节点的负载和状态进行请求路由。解析器和选择器可以根据节点信息进行智能路由,提高系统的性能和可用性。

6.2 拓展思路

  • 多数据中心支持 :可以扩展解析器和选择器,支持多数据中心的服务器发现和负载均衡。通过在解析器中添加数据中心信息,选择器可以根据数据中心的位置和负载进行请求路由。
  • 动态负载均衡策略 :可以实现动态的负载均衡策略,根据服务器的实时负载和性能指标进行请求路由。例如,根据服务器的 CPU 使用率、内存使用率等指标,动态调整负载均衡策略。

6.3 实际应用流程

graph TD;
    A[微服务或分布式系统] --> B[解析器发现服务器];
    B --> C[选择器进行负载均衡];
    C --> D[客户端请求路由到合适的服务器];
    D --> E[服务器处理请求并返回结果];
    E --> F[根据性能指标动态调整负载均衡策略];
    F --> B;

7. 总结与展望

7.1 总结

通过自定义解析器和选择器,我们实现了 gRPC 服务的发现和负载均衡。解析器负责发现服务器,选择器负责负载均衡,端到端测试确保了整个流程的正确性。这些技术可以提高系统的性能、可用性和可扩展性。

7.2 展望

未来,随着技术的不断发展,我们可以进一步优化解析器和选择器的性能,支持更多的负载均衡策略和复杂的应用场景。例如,结合机器学习算法,实现智能的负载均衡和故障预测。同时,我们可以将这些技术应用到更多的领域,如云计算、物联网等,为这些领域的发展提供支持。

通过本文的介绍,你已经了解了如何实现 gRPC 服务的发现和负载均衡,希望这些知识对你的项目有所帮助。在实际应用中,你可以根据具体需求进行调整和优化,以实现更好的性能和效果。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值