Skip to main content
โšก Calmops

Service Discovery and Load Balancing in Go

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