104 lines
3.2 KiB
Markdown
104 lines
3.2 KiB
Markdown
# Viper WatchConfig and Hot Reload
|
|
|
|
## Basic setup
|
|
|
|
```go
|
|
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:**
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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.
|