Service Discovery and Load Balancing in Go
Introduction
Service discovery and load balancing are critical components of microservices architecture. As services scale horizontally, you need mechanisms to discover available service instances and distribute traffic efficiently. This guide covers implementing these patterns in Go.
Service discovery allows services to find and communicate with each other dynamically, while load balancing distributes requests across multiple instances to ensure optimal resource utilization and high availability.
Service Discovery Patterns
DNS-Based Discovery
package main
import (
"context"
"fmt"
"net"
"time"
)
// DNSDiscovery uses DNS for service discovery
type DNSDiscovery struct {
resolver *net.Resolver
}
// NewDNSDiscovery creates a new DNS discovery
func NewDNSDiscovery() *DNSDiscovery {
return &DNSDiscovery{
resolver: net.DefaultResolver,
}
}
// DiscoverService discovers service instances via DNS
func (d *DNSDiscovery) DiscoverService(ctx context.Context, serviceName string) ([]string, error) {
// Set timeout for DNS lookup
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Perform DNS lookup
addrs, err := d.resolver.LookupHost(ctx, serviceName)
if err != nil {
return nil, fmt.Errorf("DNS lookup failed: %w", err)
}
return addrs, nil
}
// DiscoverServiceWithSRV discovers service instances via SRV records
func (d *DNSDiscovery) DiscoverServiceWithSRV(ctx context.Context, service, proto, name string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, srvs, err := d.resolver.LookupSRV(ctx, service, proto, name)
if err != nil {
return nil, fmt.Errorf("SRV lookup failed: %w", err)
}
var addresses []string
for _, srv := range srvs {
address := fmt.Sprintf("%s:%d", srv.Target, srv.Port)
addresses = append(addresses, address)
}
return addresses, nil
}
Consul-Based Discovery
package main
import (
"context"
"fmt"
"time"
)
// ConsulClient represents a Consul client
type ConsulClient struct {
baseURL string
client interface{} // Would be actual Consul client
}
// ServiceInstance represents a service instance in Consul
type ServiceInstance struct {
ID string
Name string
Address string
Port int
Tags []string
Meta map[string]string
}
// RegisterService registers a service with Consul
func (c *ConsulClient) RegisterService(ctx context.Context, instance ServiceInstance) error {
// Implementation would call Consul API
fmt.Printf("Registering service: %s at %s:%d\n", instance.Name, instance.Address, instance.Port)
return nil
}
// DeregisterService deregisters a service from Consul
func (c *ConsulClient) DeregisterService(ctx context.Context, serviceID string) error {
fmt.Printf("Deregistering service: %s\n", serviceID)
return nil
}
// DiscoverService discovers service instances from Consul
func (c *ConsulClient) DiscoverService(ctx context.Context, serviceName string) ([]ServiceInstance, error) {
// Implementation would call Consul API
instances := []ServiceInstance{
{
ID: "service-1",
Name: serviceName,
Address: "192.168.1.1",
Port: 8080,
},
{
ID: "service-2",
Name: serviceName,
Address: "192.168.1.2",
Port: 8080,
},
}
return instances, nil
}
// WatchService watches for service changes
func (c *ConsulClient) WatchService(ctx context.Context, serviceName string) (<-chan []ServiceInstance, error) {
ch := make(chan []ServiceInstance)
go func() {
defer close(ch)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
instances, err := c.DiscoverService(ctx, serviceName)
if err == nil {
ch <- instances
}
}
}
}()
return ch, nil
}
Load Balancing Strategies
Round-Robin Load Balancer
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
)
// RoundRobinBalancer implements round-robin load balancing
type RoundRobinBalancer struct {
instances []string
counter uint64
mu sync.RWMutex
}
// NewRoundRobinBalancer creates a new round-robin balancer
func NewRoundRobinBalancer(instances []string) *RoundRobinBalancer {
return &RoundRobinBalancer{
instances: instances,
counter: 0,
}
}
// SelectInstance selects the next instance
func (b *RoundRobinBalancer) SelectInstance(ctx context.Context) (string, error) {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.instances) == 0 {
return "", fmt.Errorf("no instances available")
}
index := atomic.AddUint64(&b.counter, 1) - 1
return b.instances[index%uint64(len(b.instances))], nil
}
// UpdateInstances updates the list of instances
func (b *RoundRobinBalancer) UpdateInstances(instances []string) {
b.mu.Lock()
defer b.mu.Unlock()
b.instances = instances
}
Least Connections Load Balancer
package main
import (
"context"
"fmt"
"sync"
)
// InstanceMetrics tracks metrics for an instance
type InstanceMetrics struct {
Address string
Connections int
mu sync.Mutex
}
// LeastConnectionsBalancer implements least connections load balancing
type LeastConnectionsBalancer struct {
instances map[string]*InstanceMetrics
mu sync.RWMutex
}
// NewLeastConnectionsBalancer creates a new least connections balancer
func NewLeastConnectionsBalancer(addresses []string) *LeastConnectionsBalancer {
instances := make(map[string]*InstanceMetrics)
for _, addr := range addresses {
instances[addr] = &InstanceMetrics{Address: addr}
}
return &LeastConnectionsBalancer{
instances: instances,
}
}
// SelectInstance selects the instance with least connections
func (b *LeastConnectionsBalancer) SelectInstance(ctx context.Context) (string, error) {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.instances) == 0 {
return "", fmt.Errorf("no instances available")
}
var selected *InstanceMetrics
for _, instance := range b.instances {
if selected == nil || instance.Connections < selected.Connections {
selected = instance
}
}
return selected.Address, nil
}
// IncrementConnections increments connection count
func (b *LeastConnectionsBalancer) IncrementConnections(address string) {
b.mu.RLock()
instance, exists := b.instances[address]
b.mu.RUnlock()
if exists {
instance.mu.Lock()
instance.Connections++
instance.mu.Unlock()
}
}
// DecrementConnections decrements connection count
func (b *LeastConnectionsBalancer) DecrementConnections(address string) {
b.mu.RLock()
instance, exists := b.instances[address]
b.mu.RUnlock()
if exists {
instance.mu.Lock()
if instance.Connections > 0 {
instance.Connections--
}
instance.mu.Unlock()
}
}
Weighted Load Balancer
package main
import (
"context"
"fmt"
"math/rand"
"sync"
)
// WeightedInstance represents an instance with weight
type WeightedInstance struct {
Address string
Weight int
}
// WeightedBalancer implements weighted load balancing
type WeightedBalancer struct {
instances []WeightedInstance
totalWeight int
mu sync.RWMutex
}
// NewWeightedBalancer creates a new weighted balancer
func NewWeightedBalancer(instances []WeightedInstance) *WeightedBalancer {
totalWeight := 0
for _, instance := range instances {
totalWeight += instance.Weight
}
return &WeightedBalancer{
instances: instances,
totalWeight: totalWeight,
}
}
// SelectInstance selects an instance based on weights
func (b *WeightedBalancer) SelectInstance(ctx context.Context) (string, error) {
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.instances) == 0 {
return "", fmt.Errorf("no instances available")
}
if b.totalWeight == 0 {
return "", fmt.Errorf("total weight is zero")
}
random := rand.Intn(b.totalWeight)
cumulative := 0
for _, instance := range b.instances {
cumulative += instance.Weight
if random < cumulative {
return instance.Address, nil
}
}
return b.instances[len(b.instances)-1].Address, nil
}
Health Checking
package main
import (
"context"
"fmt"
"net/http"
"sync"
"time"
)
// HealthChecker checks service health
type HealthChecker struct {
instances map[string]bool
mu sync.RWMutex
client *http.Client
}
// NewHealthChecker creates a new health checker
func NewHealthChecker() *HealthChecker {
return &HealthChecker{
instances: make(map[string]bool),
client: &http.Client{
Timeout: 2 * time.Second,
},
}
}
// CheckHealth checks if an instance is healthy
func (hc *HealthChecker) CheckHealth(ctx context.Context, address string) bool {
url := fmt.Sprintf("http://%s/health", address)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return false
}
resp, err := hc.client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// StartHealthChecking starts periodic health checks
func (hc *HealthChecker) StartHealthChecking(ctx context.Context, instances []string, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for _, instance := range instances {
healthy := hc.CheckHealth(ctx, instance)
hc.mu.Lock()
hc.instances[instance] = healthy
hc.mu.Unlock()
}
}
}
}
// GetHealthyInstances returns only healthy instances
func (hc *HealthChecker) GetHealthyInstances(instances []string) []string {
hc.mu.RLock()
defer hc.mu.RUnlock()
var healthy []string
for _, instance := range instances {
if isHealthy, exists := hc.instances[instance]; exists && isHealthy {
healthy = append(healthy, instance)
}
}
return healthy
}
Client-Side Load Balancing
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
// ClientSideLoadBalancer implements client-side load balancing
type ClientSideLoadBalancer struct {
discovery Discovery
balancer LoadBalancer
healthChecker *HealthChecker
client *http.Client
}
// Discovery interface for service discovery
type Discovery interface {
DiscoverService(ctx context.Context, serviceName string) ([]string, error)
}
// LoadBalancer interface for load balancing
type LoadBalancer interface {
SelectInstance(ctx context.Context) (string, error)
UpdateInstances(instances []string)
}
// NewClientSideLoadBalancer creates a new client-side load balancer
func NewClientSideLoadBalancer(discovery Discovery, balancer LoadBalancer) *ClientSideLoadBalancer {
return &ClientSideLoadBalancer{
discovery: discovery,
balancer: balancer,
healthChecker: NewHealthChecker(),
client: &http.Client{
Timeout: 5 * time.Second,
},
}
}
// DoRequest performs a request with load balancing
func (lb *ClientSideLoadBalancer) DoRequest(ctx context.Context, serviceName, path string) ([]byte, error) {
// Discover service instances
instances, err := lb.discovery.DiscoverService(ctx, serviceName)
if err != nil {
return nil, err
}
// Filter healthy instances
healthyInstances := lb.healthChecker.GetHealthyInstances(instances)
if len(healthyInstances) == 0 {
return nil, fmt.Errorf("no healthy instances available")
}
lb.balancer.UpdateInstances(healthyInstances)
// Select instance
instance, err := lb.balancer.SelectInstance(ctx)
if err != nil {
return nil, err
}
// Make request
url := fmt.Sprintf("http://%s%s", instance, path)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := lb.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Best Practices
1. Health Check Configuration
// Configure appropriate health check intervals
healthChecker.StartHealthChecking(ctx, instances, 10*time.Second)
2. Retry Logic
func (lb *ClientSideLoadBalancer) DoRequestWithRetry(ctx context.Context, serviceName, path string, maxRetries int) ([]byte, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
data, err := lb.DoRequest(ctx, serviceName, path)
if err == nil {
return data, nil
}
lastErr = err
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
return nil, lastErr
}
3. Circuit Breaker Pattern
type CircuitBreaker struct {
failureThreshold int
successThreshold int
timeout time.Duration
state string // "closed", "open", "half-open"
failures int
successes int
lastFailureTime time.Time
}
Common Pitfalls
1. Stale Instance Lists
Always refresh instance lists periodically.
2. Ignoring Health Status
Don’t send requests to unhealthy instances.
3. No Retry Logic
Implement retry logic for transient failures.
4. Unbalanced Load Distribution
Monitor and adjust weights based on actual performance.
Resources
Summary
Service discovery and load balancing are essential for microservices. Key takeaways:
- Use DNS or service registries for discovery
- Implement appropriate load balancing strategies
- Monitor service health continuously
- Implement retry logic and circuit breakers
- Use client-side or server-side balancing appropriately
- Keep instance lists fresh and accurate
These patterns ensure your microservices remain resilient and performant at scale.
Comments