Rez Moss

Rez Moss

Digital Reflections: Exploring Tech, Innovation & Ideas

Building a Zero-Configuration Service Discovery System in Go

Apr 2025

Service discovery is crucial in distributed systems in modern services. It lets services discover and talk to each other without needing hardcoded connection details. In this post, we’ll develop a basic but effective zero-config service discovery solution with the help of Go’s built-in networks system.

Using multicast DNS (mDNS) and DNS-SD (DNS Service Discovery) protocols, like Apple’s Bonjour or Avahi on Linux. This will enable services on a local network to self announce and discover others without any centralized coordination.

Before we get to the architecture, let’s talk about what exactly our service discovery system needs to do:

  1. Services should be able to advertise themselves on the network
  2. Services to discover other services by type
  3. Provide connection details (IP, port) for discovered services
  4. Handle services joining and leaving the network
  5. Support both one-time and continuous operation modes

Let’s begin with the simple skeleton for our application:

package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"net"
	"os"
	"os/signal"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/grandcat/zeroconf"
)

type ServiceInfo struct {
	Name      string
	Type      string
	Domain    string
	Host      string
	IPv4      []net.IP
	IPv6      []net.IP
	Port      int
	TTL       uint32
	Timestamp time.Time
	Metadata  map[string]string
}

type ServiceRegistry struct {
	services map[string]ServiceInfo
	mutex    sync.RWMutex
}

func NewServiceRegistry() *ServiceRegistry {
	return &ServiceRegistry{
		services: make(map[string]ServiceInfo),
	}
}

func (r *ServiceRegistry) AddOrUpdate(service ServiceInfo) {
	r.mutex.Lock()
	defer r.mutex.Unlock()

	key := fmt.Sprintf("%s.%s.%s", service.Name, service.Type, service.Domain)
	r.services[key] = service
}

func (r *ServiceRegistry) Remove(name, serviceType, domain string) {
	r.mutex.Lock()
	defer r.mutex.Unlock()

	key := fmt.Sprintf("%s.%s.%s", name, serviceType, domain)
	delete(r.services, key)
}

func (r *ServiceRegistry) GetAll(serviceType string) []ServiceInfo {
	r.mutex.RLock()
	defer r.mutex.RUnlock()

	var result []ServiceInfo
	for _, service := range r.services {
		normalizedType := strings.TrimSuffix(service.Type, ".local.")
		normalizedSearchType := strings.TrimSuffix(serviceType, ".local.")
		
		if normalizedType == normalizedSearchType {
			result = append(result, service)
		}
	}
	
	return result
}

func (r *ServiceRegistry) CleanupExpired(maxAge time.Duration) {
	r.mutex.Lock()
	defer r.mutex.Unlock()

	now := time.Now()
	for key, service := range r.services {
		if now.Sub(service.Timestamp) > maxAge {
			delete(r.services, key)
			log.Printf("Removed expired service: %s", key)
		}
	}
}

This gives us the basic layout we will use to keep track of services. The ServiceRegistry remembers discovered services and has methods for adding, updating, and retrieving them.

Service Registration

Now let’s add the functionality to register our own services so that other services can discover them:

func normalizeServiceType(serviceType string) string {
	if strings.HasSuffix(serviceType, ".local.") {
		return serviceType
	}
	
	serviceType = strings.TrimSuffix(serviceType, ".local")
	
	if !strings.HasPrefix(serviceType, "_") {
		serviceType = "_" + serviceType
	}
	
	if !strings.Contains(serviceType, "._") {
		serviceType += "._tcp"
	}
	
	serviceType += ".local."
	
	return serviceType
}

func RegisterService(ctx context.Context, name, serviceType string, port int, metadata map[string]string) (*zeroconf.Server, error) {
	serviceType = normalizeServiceType(serviceType)

	var txtRecords []string
	for k, v := range metadata {
		txtRecords = append(txtRecords, fmt.Sprintf("%s=%s", k, v))
	}
	
	server, err := zeroconf.Register(
		name,        
		serviceType, 
		"local.",    
		port,       
		txtRecords,  
		nil,        
	)

	if err != nil {
		return nil, fmt.Errorf("failed to register service: %w", err)
	}

	log.Printf("Registered service '%s' of type '%s' on port %d", name, serviceType, port)
	return server, nil
}

This function uses the zeroconf pkg to announce our service on the network with multicast DNS. We have added a service type normalization function that takes user input (for example, “http”, “_http”, etc.) and formats it for mDNS.

Service Discovery

Now, let’s add service discovery to discover other services on the network:

func DiscoverServices(ctx context.Context, serviceType string, registry *ServiceRegistry) error {
	originalType := serviceType
	serviceType = normalizeServiceType(serviceType)

	entriesCh := make(chan *zeroconf.ServiceEntry)

	resolver, err := zeroconf.NewResolver(nil)
	if err != nil {
		return fmt.Errorf("failed to create resolver: %w", err)
	}

	err = resolver.Browse(ctx, serviceType, "local.", entriesCh)
	if err != nil {
		return fmt.Errorf("failed to browse for services: %w", err)
	}

	go func() {
		for entry := range entriesCh {
			metadata := make(map[string]string)
			for _, txt := range entry.Text {
				parts := strings.SplitN(txt, "=", 2)
				if len(parts) == 2 {
					metadata[parts[0]] = parts[1]
				}
			}

			service := ServiceInfo{
				Name:      entry.Instance,
				Type:      originalType, 
				Domain:    entry.Domain,
				Host:      entry.HostName,
				IPv4:      entry.AddrIPv4,
				IPv6:      entry.AddrIPv6,
				Port:      entry.Port,
				TTL:       entry.TTL,
				Timestamp: time.Now(),
				Metadata:  metadata,
			}

			registry.AddOrUpdate(service)
			log.Printf("Discovered service: %s.%s on %v:%d", 
				service.Name, service.Type, service.IPv4, service.Port)
		}
	}()

	return nil
}

It searches for services on the network of a given type. Once a service is discovered, it will be registered to our registry. The function is called in a background loop, meaning it constantly checks and updates the registry as services are asked to register/deregister.

Putting It All Together

Moving on, lets implement the main function where all of these will come together:

func main() {
	var (
		registerMode = flag.Bool("register", false, "Register a service")
		discoverMode = flag.Bool("discover", false, "Discover services")
		once         = flag.Bool("once", false, "Run discovery once and exit")
		name         = flag.String("name", "my-service", "Service name")
		serviceType  = flag.String("type", "http", "Service type (e.g., http, ssh, ftp)")
		port         = flag.Int("port", 8080, "Port")
		metadata     = flag.String("meta", "version=1.0,api=rest", "Metadata as key=value,key2=value2")
	)
	flag.Parse()

	if !*registerMode && !*discoverMode {
		log.Fatal("Either -register or -discover mode must be specified")
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	registry := NewServiceRegistry()

	signalCh := make(chan os.Signal, 1)
	signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
	go func() {
		<-signalCh
		log.Println("Shutting down...")
		cancel()
		os.Exit(0)
	}()

	metaMap := make(map[string]string)
	if *metadata != "" {
		for _, pair := range strings.Split(*metadata, ",") {
			parts := strings.SplitN(pair, "=", 2)
			if len(parts) == 2 {
				metaMap[parts[0]] = parts[1]
			}
		}
	}

	var server *zeroconf.Server
	if *registerMode {
		var err error
		server, err = RegisterService(ctx, *name, *serviceType, *port, metaMap)
		if err != nil {
			log.Fatalf("Error registering service: %v", err)
		}
		defer server.Shutdown()
		
		log.Printf("Service registered. Press Ctrl+C to shut down.")
		
		if *once {
			time.Sleep(5 * time.Second)
			log.Println("Registration complete. Service will no longer be discoverable.")
			return
		}
	}

	if *discoverMode {
		err := DiscoverServices(ctx, *serviceType, registry)
		if err != nil {
			log.Fatalf("Error discovering services: %v", err)
		}

		if *once {
			log.Printf("Discovering services for 5 seconds...")
			time.Sleep(5 * time.Second)
			
			services := registry.GetAll(*serviceType)
			fmt.Printf("\n--- Discovered %d services of type %s ---\n", len(services), *serviceType)
			for _, service := range services {
				var addrs []string
				for _, ip := range service.IPv4 {
					addrs = append(addrs, ip.String())
				}
				fmt.Printf("Service: %s\n", service.Name)
				fmt.Printf("  Addresses: %s\n", strings.Join(addrs, ", "))
				fmt.Printf("  Port: %d\n", service.Port)
				fmt.Printf("  Metadata: %v\n", service.Metadata)
				fmt.Println()
			}
			return
		}

		go func() {
			ticker := time.NewTicker(30 * time.Second)
			defer ticker.Stop()

			for {
				select {
				case <-ticker.C:
					registry.CleanupExpired(1 * time.Minute)
				case <-ctx.Done():
					return
				}
			}
		}()

		ticker := time.NewTicker(5 * time.Second)
		defer ticker.Stop()

		log.Printf("Listening for services. Press Ctrl+C to exit.")
		
		for {
			select {
			case <-ticker.C:
				services := registry.GetAll(*serviceType)
				fmt.Printf("\n--- Discovered %d services of type %s ---\n", len(services), *serviceType)
				for _, service := range services {
					var addrs []string
					for _, ip := range service.IPv4 {
						addrs = append(addrs, ip.String())
					}
					fmt.Printf("Service: %s\n", service.Name)
					fmt.Printf("  Addresses: %s\n", strings.Join(addrs, ", "))
					fmt.Printf("  Port: %d\n", service.Port)
					fmt.Printf("  Metadata: %v\n", service.Metadata)
					fmt.Println()
				}
			case <-ctx.Done():
				return
			}
		}
	} else {
		<-ctx.Done()
	}
}

This main function processes command-line arguments to register a service or discover services of a certain type. It sets up signal handlers to shut down gracefully and regularly prints the services that have been discovered.

Before we can build and run our service discovery system, we need to install the required dependency:

go mod init servicediscovery
go get github.com/grandcat/zeroconf

Now, let’s build the application:

go build -o servicediscovery

Registering a Service

To register a service and keep it running until terminated:

./servicediscovery -register -name web-server -type http -port 8080 -meta "version=1.0,api=rest"

To register a service once (registers for 5 seconds then exits):

./servicediscovery -register -name web-server -type http -port 8080 -once

Discovering Services

To continuously discover and monitor services:

./servicediscovery -discover -type http

To discover services once and exit:

./servicediscovery -discover -type http -once

You can run these commands in separate terminals to see how services are discovered. The service type format is flexible - you can use “http”, “_http”, “_http._tcp”, etc., and the program will normalize it correctly.

Testing in a Real Network

To test our service discovery system in a real network, we can run multiple instances on different machines. Here’s a simple test plan:

  1. Run the service discovery in register mode on one machine:

    ./servicediscovery -register -name web-server-1 -type http -port 8080
  2. Run it in register mode with a different name and port on another machine:

    ./servicediscovery -register -name web-server-2 -type http -port 8081
  3. Run it in discover mode on a third machine:

    ./servicediscovery -discover -type http

You should see both services being discovered and listed. If you’re not seeing the expected results, try these troubleshooting steps:

  1. Make sure multicast traffic is allowed on your network (some corporate or public networks block it)
  2. Check your firewall settings to ensure UDP traffic on port 5353 (mDNS) is allowed

Here’s how we built a simple but functional zero-configuration service discovery system using Go networking and the zeroconf library. This allows services to announce themselves on the network, and to discover other services without any coordination from a central unit.

You can find code here https://github.com/rezmoss/zero-configuration-service-discovery-system/tree/main