Hot Config Reload
Watch configuration files for changes and reload application settings without restarting.
Contributors
When to Use
- In long-running services where restart downtime is unacceptable
- When tuning parameters like rate limits or feature flags in production
- For microservices that need dynamic configuration updates
- When configuration changes are frequent during development
When NOT to Use
- For static configuration that never changes after startup
- When config changes require restarting dependencies anyway
- In short-lived processes like CLI tools or batch jobs
- When atomic configuration updates aren't critical
Hot Config Reload allows applications to detect and apply configuration changes without restarting. This pattern uses file system watchers to monitor config files and atomically swaps configuration when changes are detected, enabling zero-downtime updates.
Implementation
package config
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"sync"
"time"
"github.com/fsnotify/fsnotify"
)
// Config holds application configuration
type Config struct {
Server ServerConfig `json:"server"`
Redis RedisConfig `json:"redis"`
Features FeatureFlags `json:"features"`
}
type ServerConfig struct {
Port int `json:"port"`
ReadTimeout time.Duration `json:"read_timeout"`
WriteTimeout time.Duration `json:"write_timeout"`
}
type RedisConfig struct {
Host string `json:"host"`
Port int `json:"port"`
PoolSize int `json:"pool_size"`
}
type FeatureFlags struct {
EnableNewUI bool `json:"enable_new_ui"`
EnableBetaAPI bool `json:"enable_beta_api"`
MaxUploadSizeMB int `json:"max_upload_size_mb"`
}
// ConfigManager manages hot reloading of configuration
type ConfigManager struct {
configPath string
mu sync.RWMutex
config *Config
watcher *fsnotify.Watcher
onChange []func(*Config)
}
func NewConfigManager(configPath string) (*ConfigManager, error) {
cm := &ConfigManager{
configPath: configPath,
onChange: make([]func(*Config), 0),
}
// Load initial config
if err := cm.reload(); err != nil {
return nil, fmt.Errorf("load initial config: %w", err)
}
// Setup file watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("create watcher: %w", err)
}
cm.watcher = watcher
if err := watcher.Add(configPath); err != nil {
return nil, fmt.Errorf("watch config file: %w", err)
}
return cm, nil
}
// Get returns current config (thread-safe)
func (cm *ConfigManager) Get() *Config {
cm.mu.RLock()
defer cm.mu.RUnlock()
// Return a copy to prevent external modifications
cfg := *cm.config
return &cfg
}
// OnChange registers a callback for config changes
func (cm *ConfigManager) OnChange(callback func(*Config)) {
cm.mu.Lock()
defer cm.mu.Unlock()
cm.onChange = append(cm.onChange, callback)
}
// Watch starts watching for config changes
func (cm *ConfigManager) Watch(ctx context.Context) error {
log.Printf("Watching config file: %s", cm.configPath)
// Debounce timer to handle rapid file changes
var debounceTimer *time.Timer
const debounceDelay = 500 * time.Millisecond
for {
select {
case <-ctx.Done():
return cm.watcher.Close()
case event, ok := <-cm.watcher.Events:
if !ok {
return fmt.Errorf("watcher closed")
}
// Only care about write and create events
if event.Op&fsnotify.Write == fsnotify.Write ||
event.Op&fsnotify.Create == fsnotify.Create {
// Reset debounce timer
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(debounceDelay, func() {
log.Println("Config file changed, reloading...")
if err := cm.reload(); err != nil {
log.Printf("Failed to reload config: %v", err)
} else {
log.Println("Config reloaded successfully")
cm.notifyCallbacks()
}
})
}
case err, ok := <-cm.watcher.Errors:
if !ok {
return fmt.Errorf("watcher error channel closed")
}
log.Printf("Watcher error: %v", err)
}
}
}
// reload reads and parses the config file
func (cm *ConfigManager) reload() error {
data, err := os.ReadFile(cm.configPath)
if err != nil {
return fmt.Errorf("read config file: %w", err)
}
var newConfig Config
if err := json.Unmarshal(data, &newConfig); err != nil {
return fmt.Errorf("parse config: %w", err)
}
// Validate config before applying
if err := cm.validate(&newConfig); err != nil {
return fmt.Errorf("validate config: %w", err)
}
// Atomic swap
cm.mu.Lock()
cm.config = &newConfig
cm.mu.Unlock()
return nil
}
// validate checks if config is valid
func (cm *ConfigManager) validate(cfg *Config) error {
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
return fmt.Errorf("invalid port: %d", cfg.Server.Port)
}
if cfg.Redis.PoolSize < 1 {
return fmt.Errorf("pool size must be positive")
}
return nil
}
// notifyCallbacks calls all registered change callbacks
func (cm *ConfigManager) notifyCallbacks() {
cm.mu.RLock()
callbacks := cm.onChange
config := cm.config
cm.mu.RUnlock()
for _, callback := range callbacks {
go callback(config)
}
}
Usage
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// Create sample config file
createSampleConfig("config.json")
// Initialize config manager
cfgMgr, err := NewConfigManager("config.json")
if err != nil {
log.Fatalf("Failed to create config manager: %v", err)
}
// Register callbacks for config changes
cfgMgr.OnChange(func(cfg *Config) {
log.Printf("Config updated! New port: %d", cfg.Server.Port)
log.Printf("New UI enabled: %v", cfg.Features.EnableNewUI)
})
// Start watching for changes
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if err := cfgMgr.Watch(ctx); err != nil {
log.Printf("Watcher stopped: %v", err)
}
}()
// Start HTTP server using config
mux := http.NewServeMux()
mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
cfg := cfgMgr.Get()
fmt.Fprintf(w, "Current Config:\n")
fmt.Fprintf(w, "Port: %d\n", cfg.Server.Port)
fmt.Fprintf(w, "Redis Pool: %d\n", cfg.Redis.PoolSize)
fmt.Fprintf(w, "New UI: %v\n", cfg.Features.EnableNewUI)
})
mux.HandleFunc("/feature/new-ui", func(w http.ResponseWriter, r *http.Request) {
cfg := cfgMgr.Get()
if cfg.Features.EnableNewUI {
fmt.Fprintf(w, "New UI is ENABLED\n")
} else {
fmt.Fprintf(w, "New UI is DISABLED\n")
}
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
go func() {
log.Printf("Server starting on :8080")
if err := server.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down...")
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}
func createSampleConfig(path string) {
config := Config{
Server: ServerConfig{
Port: 8080,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
},
Redis: RedisConfig{
Host: "localhost",
Port: 6379,
PoolSize: 10,
},
Features: FeatureFlags{
EnableNewUI: false,
EnableBetaAPI: false,
MaxUploadSizeMB: 10,
},
}
data, _ := json.MarshalIndent(config, "", " ")
os.WriteFile(path, data, 0644)
}
// Test it:
// 1. Run: go run main.go
// 2. Check config: curl http://localhost:8080/config
// 3. Edit config.json - change "enable_new_ui": true
// 4. Watch logs for "Config reloaded successfully"
// 5. Check again: curl http://localhost:8080/feature/new-ui
Benefits
- Zero downtime: Update configuration without restarting service
- Fast iteration: Change feature flags or limits in production instantly
- Safe updates: Validation prevents invalid config from being applied
- Atomic changes: RWMutex ensures no partial config is ever read
- Debuggable: Callbacks allow logging and monitoring of config changes
Production Enhancements
// Add version tracking
type ConfigManager struct {
// ... existing fields
version int64
lastReload time.Time
}
// Add metrics
func (cm *ConfigManager) reload() error {
start := time.Now()
err := cm.doReload()
metrics.RecordConfigReload(time.Since(start), err == nil)
if err == nil {
cm.version++
cm.lastReload = time.Now()
}
return err
}
// Add config source abstraction (file, etcd, consul, etc.)
type ConfigSource interface {
Read() ([]byte, error)
Watch(ctx context.Context) (<-chan []byte, error)
}
Common Gotchas
- File editors: Some editors create temp files and rename, causing multiple events. Debouncing helps.
- Validation: Always validate before applying to prevent bad config
- Callbacks: Keep them fast and non-blocking (use goroutines)
- Container volumes: Docker bind mounts may need special watch configuration
- Credentials: Don’t log sensitive config values in callbacks