深入理解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
-
在
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()
获取服务器信息。
-
实现
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在查找与目标方案匹配的解析器时能够识别它。
-
实现
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路由到领导者服务器。
-
实现选择器的
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,使用轮询算法选择跟随者子连接。
- 注册选择器:
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"
-
更新
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会使用我们的解析器。
-
运行代理测试:
运行go run ./internal/agent,会发现领导者客户端的消费调用失败。这是因为之前每个客户端只连接一个服务器,现在每个客户端连接所有服务器,生产到领导者,消费从跟随者,需要等待领导者复制记录。 -
更新测试以等待复制完成:
将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服务的发现和负载均衡,并通过端到端测试验证了整个流程的正确性。掌握这些技术可以帮助我们构建更高效、可靠的分布式系统。
超级会员免费看
41

被折叠的 条评论
为什么被折叠?



