DEV Community

Cover image for How to Build Service Discovery and Load Balancing for Distributed Systems in Go
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

How to Build Service Discovery and Load Balancing for Distributed Systems in Go

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Imagine you have a team of workers in a large factory. Each worker performs a specific task, like painting or assembling. Now, imagine workers can appear, disappear, or get sick at any moment. Your job is to make sure that every request for a task—like "paint this car blue"—always finds a healthy, available painter. If one painter is busy, you send the request to another. If a painter gets sick, you stop sending them work until they recover. This is the core challenge of distributed computing, and it's what we solve with service discovery and load balancing.

I want to show you how to build the system that manages this. We'll create a central registry where services announce themselves. We'll build a health checker that constantly pings them. We'll design a router that picks the best instance for each new request. The goal is to make a network of services that feels as reliable as a single, solid machine.

Let's start with the foundation: the service registry. Think of it as a dynamic phone book. When a new service instance starts, it calls the registry to say, "I'm here." It provides its address, port, and some details about itself.

package main

import (
    "fmt"
    "sync"
    "time"
)

// ServiceStatus tracks the health of an instance.
type ServiceStatus int

const (
    StatusPending ServiceStatus = iota
    StatusHealthy
    StatusUnhealthy
)

// Service holds the data for one running instance.
type Service struct {
    ID         string
    Name       string
    Address    string
    Port       int
    Tags       []string
    Status     ServiceStatus
    LastHealth time.Time
}

// ServiceRegistry is our dynamic phone book.
type ServiceRegistry struct {
    services map[string]*Service // Map from Service ID to the Service object.
    mu       sync.RWMutex        // A lock to protect the map from concurrent access.
}

// NewServiceRegistry creates a new, empty registry.
func NewServiceRegistry() *ServiceRegistry {
    return &ServiceRegistry{
        services: make(map[string]*Service),
    }
}

// Register is called by a service when it starts.
func (sr *ServiceRegistry) Register(name, address string, port int, tags []string) error {
    sr.mu.Lock()
    defer sr.mu.Unlock() // This ensures the lock is released when the function finishes.

    // Create a unique ID for this instance.
    serviceID := fmt.Sprintf("%s-%s:%d", name, address, port)

    // Check if it's already registered.
    if _, exists := sr.services[serviceID]; exists {
        return fmt.Errorf("service %s already registered", serviceID)
    }

    // Add the new service to our map.
    sr.services[serviceID] = &Service{
        ID:         serviceID,
        Name:       name,
        Address:    address,
        Port:       port,
        Tags:       tags,
        Status:     StatusPending, // Starts as pending until health-checked.
        LastHealth: time.Now(),
    }

    fmt.Printf("Registered new service: %s\n", serviceID)
    return nil
}
Enter fullscreen mode Exit fullscreen mode

When a service shuts down gracefully, it should call Deregister. But what if it crashes? That's where our next component comes in. We need a health checker. It will periodically ask each service, "Are you okay?" If a service doesn't answer, we mark it as unhealthy.

We can check health in different ways: by making an HTTP request to a /health endpoint, by trying to open a TCP connection, or even by running a custom piece of code.

type HealthCheckType int

const (
    HealthCheckHTTP HealthCheckType = iota
    HealthCheckTCP
)

type HealthCheck struct {
    Type         HealthCheckType
    HTTPEndpoint string // For HTTP checks.
    Address      string // For TCP checks.
    Port         int
    Interval     time.Duration // How often to check, e.g., every 10 seconds.
    Timeout      time.Duration // How long to wait for a response.
}

// HealthChecker runs all checks and updates the registry.
type HealthChecker struct {
    registry   *ServiceRegistry
    checks     map[string]*HealthCheck // Map from Service ID to its check config.
    httpClient *http.Client
}

// performCheck runs a single health check.
func (hc *HealthChecker) performCheck(serviceID string, check *HealthCheck) {
    var healthy bool
    var latency time.Duration

    start := time.Now()

    switch check.Type {
    case HealthCheckHTTP:
        // Try to make a GET request.
        resp, err := hc.httpClient.Get(check.HTTPEndpoint)
        if err == nil && resp.StatusCode == 200 {
            healthy = true
        }
        if resp != nil {
            resp.Body.Close()
        }
    case HealthCheckTCP:
        // Try to open a socket.
        address := fmt.Sprintf("%s:%d", check.Address, check.Port)
        conn, err := net.DialTimeout("tcp", address, check.Timeout)
        if err == nil {
            healthy = true
            conn.Close()
        }
    }

    latency = time.Since(start)

    // Update the service's status in the registry.
    hc.registry.mu.Lock()
    defer hc.registry.mu.Unlock()
    if service, exists := hc.registry.services[serviceID]; exists {
        if healthy {
            service.Status = StatusHealthy
        } else {
            service.Status = StatusUnhealthy
        }
        service.LastHealth = time.Now()
    }
}
Enter fullscreen mode Exit fullscreen mode

The health checker runs in a loop, performing each check at its defined interval. This keeps our registry's view of the world up-to-date. Now we have a list of services and we know which ones are healthy. The next step is to decide where to send incoming work. This is load balancing.

The simplest method is round-robin. Just go down the list, one after another.

type RoundRobinStrategy struct {
    currentIndex uint32 // Use an atomic counter for thread-safety.
    instances    []*Service
}

func (rr *RoundRobinStrategy) Pick() *Service {
    if len(rr.instances) == 0 {
        return nil
    }
    // Atomically increment the index and wrap around using modulo.
    index := atomic.AddUint32(&rr.currentIndex, 1) % uint32(len(rr.instances))
    return rr.instances[index]
}
Enter fullscreen mode Exit fullscreen mode

But what if some instances are more powerful than others? Or what if some are already handling many requests? We might want a "least connections" strategy.

type LeastConnectionsStrategy struct{}

func (lc *LeastConnectionsStrategy) Pick(instances []*Service) *Service {
    if len(instances) == 0 {
        return nil
    }
    var selected *Service
    minConns := int32(1 << 30) // Start with a very large number.

    for _, instance := range instances {
        // Assume each Service has an atomic counter for active connections.
        conns := atomic.LoadInt32(&instance.Connections)
        if conns < minConns {
            minConns = conns
            selected = instance
        }
    }
    return selected
}
Enter fullscreen mode Exit fullscreen mode

Sometimes, the network is the bottleneck. A server might be healthy but slow because it's far away. A latency-aware strategy can track response times and prefer faster instances.

We need a way to route actual user requests. In an HTTP server, we can use a middleware. This middleware intercepts each request, figures out which service it's for, uses the load balancer to pick an instance, and then forwards the request.

func DiscoveryMiddleware(registry *ServiceRegistry, lb *LoadBalancer) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Extract the target service name from the request path or a header.
            // For example, a path like /api/users/ might route to the "user-service".
            serviceName := "user-service" // Simplified extraction.

            // Get a list of healthy instances for this service.
            instances, err := registry.Discover(serviceName, true) // true = healthy only.
            if err != nil {
                http.Error(w, "Service not available", http.StatusServiceUnavailable)
                return
            }

            // Use the load balancer strategy to pick one.
            instance := lb.Pick(instances)
            if instance == nil {
                http.Error(w, "Service not available", http.StatusServiceUnavailable)
                return
            }

            // Forward the request to the chosen instance.
            // This is a simple reverse proxy setup.
            proxy := httputil.NewSingleHostReverseProxy(&url.URL{
                Scheme: "http",
                Host:   fmt.Sprintf("%s:%d", instance.Address, instance.Port),
            })
            proxy.ServeHTTP(w, r)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the basic flow. But a production system needs more. It needs to watch for changes. If a health check fails, the registry should immediately notify the load balancer so it stops using that instance. We can use a watcher pattern.

type RegistryEvent struct {
    Type    string // "REGISTER", "DEREGISTER", "HEALTH_CHANGE"
    Service *Service
}

type RegistryWatcher interface {
    Notify(event RegistryEvent)
}

// The LoadBalancer can implement this interface.
func (lb *LoadBalancer) Notify(event RegistryEvent) {
    lb.mu.Lock()
    defer lb.mu.Unlock()

    switch event.Type {
    case "HEALTH_CHANGE":
        if event.Service.Status == StatusUnhealthy {
            // Remove this instance from the active pool.
            lb.removeInstance(event.Service.ID)
        } else {
            // Add it back.
            lb.addInstance(event.Service)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Dynamic routing means your system automatically adjusts to failures and new capacity. When you deploy a new version of a service, you can register the new instances, let them pass health checks, and the load balancer will start sending them traffic. You can then gracefully deregister the old ones.

Let's look at a more complete example, putting the registry, health checker, and a simple load balancer together.

func main() {
    // Create the core components.
    registry := NewServiceRegistry()
    healthChecker := NewHealthChecker(registry)
    lb := NewLoadBalancer(registry, "round_robin")

    // Start the health checker in the background.
    go healthChecker.Run()

    // Simulate a service registering.
    registry.Register("cart-service", "10.0.0.1", 8080, []string{"v1"})

    // Define its health check.
    healthChecker.AddCheck("cart-service-10.0.0.1:8080", &HealthCheck{
        Type:         HealthCheckHTTP,
        HTTPEndpoint: "http://10.0.0.1:8080/health",
        Interval:     10 * time.Second,
        Timeout:      2 * time.Second,
    })

    // Set up an HTTP server that uses our discovery middleware.
    router := http.NewServeMux()
    // Your actual application routes would go here on `router`.

    // Wrap the router with our discovery middleware.
    app := DiscoveryMiddleware(registry, lb)(router)

    fmt.Println("Gateway listening on :8080")
    http.ListenAndServe(":8080", app)
}
Enter fullscreen mode Exit fullscreen mode

In this code, the gateway listens on port 8080. A request comes in for /api/cart. The middleware asks the registry for a healthy "cart-service". The registry checks its list, which is being updated by the health checker. The load balancer picks one instance, say 10.0.0.1:8080. The request is forwarded there.

What about more complex rules? Maybe you want to send 1% of traffic to a new version for testing. This is where routing policies come in. You can tag your services with versions and have the load balancer read those tags.

type RoutingPolicy struct {
    ServiceName string
    MatchTags   map[string]string // e.g., {"version": "canary"}
    Weight      int               // e.g., 1 for 1% traffic.
}

func (lb *LoadBalancer) PickWithPolicy(serviceName string, policies []RoutingPolicy) *Service {
    // First, get all healthy instances.
    instances := lb.registry.GetHealthyInstances(serviceName)

    // Filter instances based on the policy tags.
    var filteredInstances []*Service
    for _, instance := range instances {
        if lb.matchesPolicy(instance, policies) {
            filteredInstances = append(filteredInstances, instance)
        }
    }

    // If the filtered list has instances, pick from it based on weight.
    // Otherwise, fall back to the general pool.
    if len(filteredInstances) > 0 {
        // Implement weighted logic here.
        return lb.weightedPick(filteredInstances, policies)
    }
    return lb.Pick(instances) // Default strategy.
}
Enter fullscreen mode Exit fullscreen mode

Building this yourself teaches you the mechanics, but for a real, large-scale system, you'd likely use existing tools like Consul, etcd, or Kubernetes' built-in service discovery. These provide the distributed, consistent storage we glossed over. Our in-memory map works for a single registry node, but what if that node crashes? You need multiple registry nodes that agree on the state. That's a problem solved by consensus algorithms like Raft, which is used by Consul and etcd.

The code patterns, however, remain similar. You register services, you check health, you balance load. The difference is in the durability and fault-tolerance of the registry itself.

The system I've walked you through is like the nervous system for your microservices. It knows where everything is, it feels when something is wrong, and it directs traffic smoothly. It turns a collection of independent, fragile processes into a resilient, adaptable application.

Start simple. Build a registry that keeps a list. Add a health checker that updates it. Make a load balancer that reads from it. You'll learn more from getting these three pieces talking to each other than from any diagram or lecture. Once you have that basic loop working—registration, health, routing—you've built the heart of the system. Everything after that is making it faster, more reliable, and easier to manage.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)