3.2 KiB
Viper WatchConfig and Hot Reload
Basic setup
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
log.Printf("config changed: %s (op: %s)", e.Name, e.Op)
// re-read affected values and apply them
})
WatchConfig starts a background goroutine that watches the config file using fsnotify. Call it after ReadInConfig.
The atomic-rename trap
Most editors (vim, neovim, many CI tools) write config files by creating a new file and then renaming it over the old one. This replaces the inode that fsnotify is watching — the watch may not fire, or may fire with Op: RENAME instead of Op: WRITE, or may fire twice.
Test hot reload with direct file writes, not editor saves:
// reliable in tests:
os.WriteFile("config.yaml", newContent, 0644)
// unreliable for testing:
// opening vim and :w — may trigger RENAME instead of WRITE
In production, this is less of an issue if your config management tool (Kubernetes ConfigMap volume mount, Consul Template, etc.) is aware of inode behavior.
Race-safe reload pattern
Config reload happens in a background goroutine. Any shared state updated in OnConfigChange must be synchronized:
type Config struct {
mu sync.RWMutex
LogLevel string `mapstructure:"log_level"`
MaxConn int `mapstructure:"max_conn"`
}
var cfg Config
viper.OnConfigChange(func(e fsnotify.Event) {
var newCfg Config
if err := viper.Unmarshal(&newCfg); err != nil {
log.Printf("error reloading config: %v", err)
return // keep old config on error
}
cfg.mu.Lock()
cfg.LogLevel = newCfg.LogLevel
cfg.MaxConn = newCfg.MaxConn
cfg.mu.Unlock()
log.Printf("config reloaded: log_level=%s", newCfg.LogLevel)
})
Reads use appCfg.mu.RLock(). Never read directly from viper in hot paths during reload — the window between OnConfigChange firing and viper updating its internal state is non-deterministic.
Debouncing rapid changes
Some filesystems fire multiple events per save. Debounce to avoid reloading multiple times:
var reloadTimer *time.Timer
var reloadMu sync.Mutex
viper.OnConfigChange(func(e fsnotify.Event) {
reloadMu.Lock()
defer reloadMu.Unlock()
if reloadTimer != nil {
reloadTimer.Stop()
}
reloadTimer = time.AfterFunc(100*time.Millisecond, func() {
applyNewConfig()
})
})
Validating config before applying
Always validate reloaded config before applying it — an invalid config mid-reload should keep the previous working config:
viper.OnConfigChange(func(e fsnotify.Event) {
var candidate Config
if err := viper.Unmarshal(&candidate); err != nil {
log.Printf("reload: invalid config, keeping previous: %v", err)
return
}
if err := validate(candidate); err != nil {
log.Printf("reload: validation failed, keeping previous: %v", err)
return
}
applyConfig(candidate)
})
Stopping the watcher
There is no documented way to stop WatchConfig once started. Design your application so that the watcher's lifetime matches the process lifetime. For testing, create a new viper.New() instance per test — the watcher is per-instance and is garbage-collected with the instance.