The Consul KV store is surprisingly effective for distributed configuration management precisely because it’s not designed for it.
Let’s watch it in action. Imagine you have two services, frontend and backend, both running on different machines. They need to know the address of the backend service.
Here’s how you’d set that in Consul:
consul kv put services/backend/address 192.168.1.100:8080
Now, your frontend service can query this value:
package main
import (
"fmt"
"log"
"net/http"
"time"
"github.com/hashicorp/consul/api"
)
func main() {
config := api.DefaultConfig()
client, err := api.NewClient(config)
if err != nil {
log.Fatalf("failed to create Consul client: %v", err)
}
key := "services/backend/address"
// Poll Consul for changes
go func() {
lastIndex := uint64(0)
for {
kvp, meta, err := client.KV().Get(key, &api.QueryOptions{WaitIndex: lastIndex})
if err != nil {
log.Printf("error fetching KV pair: %v", err)
time.Sleep(5 * time.Second) // Backoff on error
continue
}
if kvp != nil && meta.LastIndex != lastIndex {
backendAddress := string(kvp.Value)
fmt.Printf("Detected backend address change: %s\n", backendAddress)
// In a real app, you'd update your service discovery or config here
lastIndex = meta.LastIndex
} else if kvp == nil {
fmt.Println("Backend address not set in Consul KV yet.")
lastIndex = meta.LastIndex // Update index even if nil to avoid retrying immediately
}
// If WaitIndex times out, meta.LastIndex will be the same as lastIndex,
// and we'll just loop again. Consul's default wait is 5 minutes.
}
}()
// Simulate the frontend service running
fmt.Println("Frontend service started, watching for backend address...")
select {} // Block forever
}
When you run this main function on the frontend and then run the consul kv put command, you’ll see the Detected backend address change: 192.168.1.100:8080 output. If you change the value again, the frontend will pick it up.
The core problem this solves is dynamic service discovery and configuration in a distributed system. Traditionally, you’d hardcode IPs, use DNS, or rely on orchestrator-specific mechanisms. Consul KV offers a centralized, API-driven way to manage these values that can be read by any service that can talk to Consul.
Internally, Consul KV is a distributed, strongly consistent key-value store replicated across your Consul agents. When you put a value, Consul ensures it’s written to a quorum of nodes. When you get with WaitIndex, Consul can hold the request on the server-side until the value changes or a timeout occurs. This "long polling" is efficient because it avoids constant client-side polling and reduces the latency of configuration updates. The meta.LastIndex is crucial here; it’s a version number for the key. By passing the last index you saw, Consul knows to only respond if the value has been updated since that index.
The counterintuitive part of using Consul KV for configuration is its eventual consistency model for reads when not using blocking queries. While writes are strongly consistent, if you were to poll for a value repeatedly without using the WaitIndex mechanism, you might, for a brief period, read a stale value from a replica that hasn’t yet received the latest update. However, the WaitIndex (or WaitTime) query options effectively turn this into a strongly consistent read for the purpose of configuration updates, as the client will block until it sees the latest version. This is why relying on the blocking query features is paramount for robust configuration management.
The next logical step is to integrate this dynamic configuration directly into service-to-service communication.