gRPC Client-Side Load Balancing in Go

Daniel Ammar
3 min readSep 17, 2018

--

One of the key elements of scalable and robust application is the Load Balancer (LB). This key components plays significant role in microservice architecture, and has two main types: client and proxy LB.

There are numerous available solutions to implements LB, and the decision between client or proxy is a matter of architectural choice.

For further read: https://grpc.io/blog/loadbalancing

Why Client-Side?

Although client-side LB increases complexity and considered in most cases inferior to proxy LB, it especially useful when requiring low latency and low overheads. In this approach the client fully aware of the available servers, and in the naive way can delivers good results.

gRPC Client-Side LB

A basic requirement for LB in gRPC is to direct each and every RPC call of the channel to different server. However, since gRPC is a thin layer above HTTP/2 protocol and every RPC call is corresponding to HTTP/2 stream, the end result is that all RPC calls are sent to the same server.

The vanilla version of gRPC has a basic implementation of round robin LB. The resolver resolve DNS record, base on the resolved A records, the client is aware on the servers and perform load balance between them.

However, although sounds simple, the gRPC-go documentation is not the best*.

Working Example

Basic Round Robin Load Balancing

In this example, I will take the gRPC Hello-World client example and modify it to work in round-robin manner.

package mainimport (
"context"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/balancer/roundrobin"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
"google.golang.org/grpc/resolver"
)
const (
address = "dns-record-name:443"
defaultName = "world"
)
func main() {
// The secret sauce
resolver.SetDefaultScheme("dns")
// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBalancerName(roundrobin.Name))
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
c := pb.NewGreeterClient(conn)
// Contact the servers in round-robin manner.
for i := 0; i < 3; i++ {
ctx := context.Background()
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: defaultName})
if err != nil {
log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.Message)
}
}

The astute reader notice my two changes, the first is the line grpc.Dial method, instructing the grpc to use balancing scheme of round-robin. The second change, however, is what does all the magic. I discovered resolver.SetDefaultScheme(“dns”) almost by accident in this short blog text https://www.marwan.io/blog/grpc-dns-load-balancing

Basically, without setting the default scheme of the resolver, the default is set to “passthrough” which does not do anything.

Here is the code snippet of passthrough.go of grpc-go:

func (*passthroughResolver) ResolveNow(o resolver.ResolveNowOption) {}

When setting the scheme to “dns”, the grpc client use the resolver to know all the A records. This step happens during the establishment of the transport and periodically in its watcher.

*My assumption is that the community doesn’t want to encourage the use of this approach. The mention gRPC Load Balancing documentation push the use of lookaside in favor of thick clients.

--

--