---
title: "Viper: слежение за конфигом и секреты"
description: "viper watchconfig и секреты в конфиге go: WatchConfig с OnConfigChange и живой reload настроек, горутина-подписчик, гонки, env поверх файлов, .gitignore."
course: "Cobra: CLI-приложения на Go"
url: "https://golangmaster.ru/courses/cobra/lessons/24-viper-watch-sekrety/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Viper: слежение за конфигом и секреты

*Курс «Cobra: CLI-приложения на Go», урок 24 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cobra/lessons/24-viper-watch-sekrety/*

viper watchconfig включает живой reload настроек без перезапуска: агент, висящий на сервере месяц, подхватывает новый интервал опроса из YAML за секунду. Вторая половина урока — обратная сторона гибкости: что нельзя класть в конфиг, почему секреты в конфиге go живут в переменных окружения и как не засветить токен в логах.

Утилита-агент опрашивает API каждые тридцать секунд и уже месяц живёт на сервере. Прод оказался под нагрузкой, и админ просит увеличить интервал и затихомошить логи. Перезапускать агента нельзя: он держит локальное состояние очереди, а просто перечитать файл он не умеет — конфиг читается один раз при старте, как мы и разложили в уроке про структуру. Выход есть: fsnotify умеет сообщать о изменениях файлов, а viper обёртывает это в два вызова — WatchConfig и OnConfigChange. Заодно разберём тему, о которой при слове «конфиг» забывают чаще всего: в YAML нельзя класть секреты, и это не паранойя, а следствие того, как устроен git и как живут логи.

### WatchConfig: что происходит под капотом

viper.WatchConfig() заводит вотчер библиотеки fsnotify — на Linux это inotify, на macOS kqueue и FSEvents — и подписывает его на файл конфига. Обязательный порядок вызовов: сначала SetConfigFile или SetConfigName с ReadInConfig, и только затем WatchConfig — наблюдать за ещё не открытым файлом нечего, и viper промолчит, а вы узнаете об этом по стагнации настроек. Важная деталь реализации: viper следит не только за самим файлом, но и за каталогом, поэтому переживает атомарную замену — vim, sed -i и большинство редакторов пишут временный файл и переименовывают его на место старого, меняя inode. Обработчик изменения регистрируется через viper.OnConfigChange и получает fsnotify.Event с путём файла — дальше всё зависит от того, как вы храните значения.

### Базовая связка watch и события

```go
func loadConfig(path string) (*config.Config, error) {
        v := viper.New()
        v.SetConfigFile(path)
        if err := v.ReadInConfig(); err != nil {
                return nil, err
        }
        v.WatchConfig()
        v.OnConfigChange(func(e fsnotify.Event) {
                log.Printf("конфиг изменён: %s", e.Name)
        })
        var cfg config.Config
        if err := v.Unmarshal(&cfg); err != nil {
                return nil, err
        }
        return &cfg, nil
}
```

viper.New() вместо глобального синглтона — изоляция для тестов из урока про структуру: каждый тест строит свой экземпляр. WatchConfig стоит после ReadInConfig — за нечитанным файлом следить нечего. Unmarshal здесь делает снимок на старте: о том, как значения доходят до работающего агента, — следующий блок.

### Свежие значения: viper.Get против снимка

После перезагрузки конфига вопрос «откуда брать актуальное» встаёт сразу, и ответов два. Первый: вызовы viper.GetString и viper.GetInt всегда возвращают свежее значение — viper перечитал файл и держит его в памяти. Второй: структура, полученная Unmarshal'ом при старте, — снимок момента, она не обновится сама. Отсюда два рабочих стиля: либо спрашивать viper по требованию в каждой команде — для CLI с пятью настройками это честно и просто; либо поддерживать структуру конфига в актуальном состоянии — и вот тут появляется проблема параллельности, потому что писать структуру из обработчика событий, пока её читают команды, нельзя без синхронизации. Я выбираю стиль по объёму: пара значений — viper.Get, десяток полей, используемых в горячем цикле, — снимок под локом. Отдельная ловушка: горутины, стартовавшие до перезагрузки, держат старое значение в локальной переменной, и reload их не коснётся — снимок надо брать в момент использования, а не в момент старта горутины.

### Живой reload: горутина-подписчик

Обработчик OnConfigChange выполняется в горутине fsnotify, а не в вашей: заснув там, вы заморозите приём следующих событий, и конфиг формально «наблюдается», но не обновляется. Правило: обработчик лёгкий — перечитать структуру, опубликовать её, уведомить подписчиков — и наружу. Публикация снимка решается cheapest way: atomic.Value, где Store кладёт новую структуру, Load её отдаёт, и никто никого не блокирует. Для значений, требующих реакции, — интервал опроса, лимиты — заводится горутина-подписчик: она читает свежий снимок и пересоздаёт тикер с новым периодом. Последний нюанс — дребезг: некоторые редакторы сохраняют файл дважды за секунду, и на каждое сохранение придёт своё событие; если перечитывание дорогое, ставьте дебаунс таймером на двести-триста миллисекунд. Собирается он из time.AfterFunc за три строки: обработчик отменяет предыдущий таймер и заводит новый, а перечитывание стартует, только когда пауза прошла без новых событий — журнал перестаёт пестреть повторами.

### Публикация снимка через atomic.Value

```go
var current atomic.Value // хранит config.Config

v.OnConfigChange(func(e fsnotify.Event) {
        var fresh config.Config
        if err := v.Unmarshal(&fresh); err != nil {
                log.Printf("конфиг битый, держим старый: %v", err)
                return
        }
        current.Store(fresh)
        changes <- fresh // уведомить горутину-подписчика
})

func CurrentConfig() config.Config {
        return current.Load().(config.Config)
}
```

atomic.Value — самый дешёвый способ опубликовать новый снимок: читатели не блокируются, писатель один. Битый YAML не роняет агента — свежий снимок не проходит валидацию, и продолжает работать старый. Канал changes получает то же значение: горутина-подписчик пересоздаёт тикер, не касаясь общих полей напрямую. Проверка ошибки Unmarshal перед Store — не украшение: без неё в current уехали бы нулевые поля, и агент «успешно» сбросил бы себе настройки.

### Проверка: где живёт обработчик

**Вопрос:** В какой горутине выполняется функция, переданная в OnConfigChange?

- В main, сразу после вызова WatchConfig
- ✅ В горутине, которую завёл fsnotify: блокировать её нельзя
- В отдельной, которую нужно создать руками перед подпиской
- Одновременно во всех горутинах программы

Подписку и рассылку событий ведёт внутренняя горутина fsnotify/viper: уснувший обработчик остановит приём следующих изменений. Тяжёлую работу — пересоздание тикеров, долгие вычисления — выносите в канал и отдельную горутину-подписчика, оставляя колбэку только публикацию снимка.

### Ловушки live-reload

- WatchConfig до ReadInConfig — следить не за чем, настройки молча стагнируют
- Файла нет при старте — вотчерить нечего, ConfigFileNotFoundError из урока про файлы конфигов
- Двойное сохранение редактором — два события за секунду, нужен дебаунс
- Блокирующий OnConfigChange — замораживает приём событий в горутине fsnotify
- Unmarshal без синхронизации — гонка с читателями, берите atomic.Value или мьютекс
- WatchConfig при отсутствии файла молча не ставит вотчер — после первого успешного ReadInConfig вызовите повторно

### Секреты в конфиге go: что нельзя коммитить

Теперь вторая половина урока. YAML со строкой token: eyJhbGc... — это токен, попавший в git-историю навсегда: файл удалят, а строки в истории останутся, и git log достанет их через год. Правило слоёв из урока про приоритеты конфигурации уже даёт правильный ответ: секреты читаются из переменных окружения, поверх файла, а в YAML живут только несекретные значения — интервалы, адреса, уровни логов. Для локальной разработки заводится .env рядом с проектом: он грузится шеллом или direnv и стоит в .gitignore с первого дня; в репозиторий кладут .env.example со списком ключей и пустыми значениями. Связка viper.AutomaticEnv и SetEnvKeyReplacer из урока про переменные окружения делает это прозрачным для кода: команды продолжают читать viper.GetString, источник меняется без правок.

### Куда класть настройку: этажи реестра

| Настройка | Место | Почему |
| --- | --- | --- |
| Интервал опроса, лимиты | YAML-конфиг | меняется админом без перезапуска — WatchConfig подхватит |
| API-токен, пароль базы | Переменная окружения | не попадает в git и в сгенерированные файлы |
| Путь хранилища на конкретной машине | .env рядом с проектом | локальная специфика, в репозиторий не едет |
| Флаг одной команды | pflag с BindPFlag | пользователь решает на каждый запуск |

### Env поверх файла

```go
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// viper.Get("api.token") прочитает API_TOKEN,
// даже если в YAML этого ключа нет
```

Replacer переводит точки ключа в подчёркивания имени переменной: api.token ищется как API_TOKEN. Приоритет из урока про приоритеты — env выше файла: ключ, заданный окружением, перекрывает YAML-значение. Сам viper .env-файлы не читает — их грузит шелл, direnv или systemd-юнит на сервере. Заметьте: viper сам поднимает имя переменной в верхний регистр — реплейсер заботится только о точках и дефисах, капс писать не нужно.

> **Типичная ошибка: префикс окружения не совпал.** SetEnvPrefix из урока про окружение чаще всего и пропускают: вы задаёте TASKS_TOKEN в CI, а viper ищет API_TOKEN — ключ api.token без префикса превращается ровно в него, и утилита молча читает пустую строку. Проверка на живом реестре: Get вернёт пусто, IsSet — false, и только печать AllKeys покажет, что слой вообще не подключён. Ставьте префикс рядом с AutomaticEnv одной строкой: v.SetEnvPrefix("tasks") — и ключи ищутся как TASKS_API_TOKEN.

### Минимизация данных и дисциплина логов

Секреты — частный случай общего правила: собирайте и храните только то, что нужно работе. Это и требование GDPR-минимума — персональные данные обрабатываются по необходимости, а не «на всякий случай» — и просто меньшая площадь атаки. Вторая линия обороны — логи: секрет не должен появиться ни в fmt.Fprintf, ни в структурных полях zap, куда он часто просачивается через небрежное поле вроде token: myToken. Маскируйте по умолчанию: печатайте первые два символа и звёздочки, а лучше вообще булево поле hasToken. Если организация всё же требует держать секреты в файлах — шифруйте их age на диске или берите из vault-агента при старте, но это уже вопросы инфраструктуры, а не библиотеки конфигурации, и за рамки курса они выходят.

> **Токен уехал в коммит.** Сценарий, который случается чаще, чем кажется: токен добавили в config.yaml для локального теста, закоммитили вместе с остальным, заметили через неделю. Удаление файла следующим коммитом проблему не решает — git-история отдаёт старые версии по любому хешу. Порядок лечения: сначала ротация токена у провайдера — это главная операция, потому что скомпрометированный секрет живёт у чужих людей; затем вычищение истории через git filter-repo или BFG и force-push; затем .gitignore и перенос значения в переменную окружения. Отдельное спорное мнение: я не кладу в репозиторий даже фейковые .env с заведомо не-настоящими ключами — привычка коммитить такие файлы однажды стрельнёт настоящим ключом, и цена привычки слишком высока.

### Проверка: место для токена

**Вопрос:** Где держать API-токен в продакшене?

- В YAML рядом с остальным конфигом — так всё в одном месте
- В комментарии YAML, чтобы не забыть значение
- ✅ В переменной окружения: YAML хранит только несекретные настройки
- В .env-файле, закоммиченном в репозиторий для команды

Env читается поверх файла по приоритетам урока про конфигурацию и не попадает в git-историю. YAML остаётся несекретным: его можно править, ревьюить и шарить. Закоммиченный .env — та же утечка, что токен в YAML, только завёрнутый в удобный формат.

### Что вернёт viper.Get

```go
// config.yaml содержит: level: debug
os.Setenv("TASKS_LEVEL", "info")

v := viper.New()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
v.SetConfigFile("config.yaml")
_ = v.ReadInConfig()

fmt.Println(v.GetString("level"))
```

**Что выведет программа?**

- debug — файл прочитан последним и победил
- ✅ info — переменные окружения выше файла
- Пустую строку — имя переменной не совпало
- Ошибку: ключ задан в двух источниках

AutomaticEnv поднимает env над файлом в лестнице приоритетов — тот же порядок, что в уроке про приоритеты конфигурации. Имя переменной собралось по replacer: ключ level, префикс по конвенции приложения — TASKS_LEVEL. Конфликта источников viper не боится: он просто выбирает по приоритету.

### Соберите включение наблюдения

Впишите вызов вотчера и тип аргумента обработчика.

```go
v.___()
v.OnConfigChange(func(e fsnotify.___) {
        v.Unmarshal(&fresh)
})
```

Подсказка: Метод viper, включающий слежение; событие fsnotify, который получает колбэк.

Правильные ответы в пропусках: `WatchConfig`, `Event`

### Соберите инициализацию по порядку

Расставьте вызовы от создания экземпляра до включения слежения.

Правильный порядок строк:

1. `v := viper.New()`
2. `v.SetConfigFile(path)`
3. `if err := v.ReadInConfig(); err != nil { return err }`
4. `v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))`
5. `v.AutomaticEnv()`
6. `v.WatchConfig()`

### Карточки: watch и секреты

- **WatchConfig** — Включает наблюдение за файлом конфига через fsnotify; вызвать после ReadInConfig. Viper следит и за каталогом — переживает атомарную замену редактором.
- **OnConfigChange** — Колбэк на изменение конфига; выполняется в горутине fsnotify — не блокировать, а публиковать снимок и уведомлять подписчиков.
- **atomic.Value** — Публикация нового снимка конфига: Store(fresh) у писателя, Load() у читателей, никто никого не блокирует. Битый YAML — держим старый.
- **SetEnvKeyReplacer** — Переводит точки ключа в подчёркивания: api.token читается как API_TOKEN вместе с AutomaticEnv. Env стоит выше файла в приоритетах.
- **.env и .gitignore** — Локальные секреты в .env вне git, в репозитории — .env.example без значений. Просочившийся в историю токен ротируют и вычищают filter-repo.
- **Маскирование в логах** — Секрет не попадает в вывод ни fmt, ни структурных полей zap: печатают префикс и звёздочки или булево hasToken вместо значения.

### Что дальше

Конфиг живой, секреты под контролем — впереди финиш: [итоговый проект](/courses/cobra/lessons/25-itogovyj-proekt/) соберёт менеджер задач со всей цепочки курса, от фабрик до версий. Основа уже готова: чтение файлов разбиралось в [уроке про конфиг-файлы](/courses/cobra/lessons/08-viper-fajly-konfigov/), лестница источников — в [уроке про приоритеты](/courses/cobra/lessons/10-prioritety-konfiguraciya/). Если захочется углубить дисциплину логов и маскирование секретов в структурных полях — откройте курс [zap](/courses/zap/): там эта тема разобрана на уровне библиотеки. Обзор всего курса — на странице [Cobra](/courses/cobra/). И оговорка: fsnotify-события видны только живому процессу — запустите утилиту локально, измените YAML и убедитесь, что журнал отреагировал; песочница файловую систему не исполняет.

## Коротко: что запомнить

- WatchConfig включает живой reload конфига; вызвать строго после ReadInConfig.
- OnConfigChange выполняется в горутине fsnotify: не блокировать, дребезг гасить дебаунсом.
- viper.Get всегда актуален; Unmarshal-структура — снимок, обновляйте её через atomic.Value.
- Битый YAML на reload не роняет процесс: не распарсился — продолжаем на старой конфигурации.
- Секреты — из переменных окружения поверх файла: AutomaticEnv с SetEnvKeyReplacer, в YAML только несекретное.
- .env — вне git, в репозитории .env.example без значений; токен из истории вычищают и ротируют.
- Секрет не попадает в логи: маскирование по умолчанию, минимум собираемых данных.

## Домашнее задание

Включите WatchConfig и OnConfigChange в своей утилите: на событие перечитывайте конфиг в atomic.Value и печатайте журнал без секретов. Соберите локально, запустите, измените YAML редактором и убедитесь, что новые значения подхватились без перезапуска. Затем уберите ключ из файла и передайте его через переменную окружения — проверьте, что env победил файл. И сломайте YAML во время работы: битый файл не должен ронять процесс — агент обязан продолжить на старом снимке и оставить сообщение о битом конфиге в журнале.

## Вопросы и ответы по уроку

### Как перечитывать конфиг без перезапуска CLI?

viper.WatchConfig() после ReadInConfig плюс viper.OnConfigChange(func(e fsnotify.Event){...}): viper следит за файлом и каталогом через fsnotify. Свежие значения отдаёт viper.Get сразу, а структуру из Unmarshal обновляйте в обработчике под мьютексом или публикуйте через atomic.Value.

### Почему обработчик OnConfigChange нельзя блокировать?

Он вызывается в горутине fsnotify, которая рассылает события: уснувший обработчик остановит приём следующих изменений. Тяжёлую работу — перечитать структуру, пересоздать тикер, уведомить подписчиков — выносите в канал и отдельную горутину, оставляя колбэку только публикацию снимка.

### Куда положить API-токен для CLI?

В переменную окружения поверх файла: viper.AutomaticEnv с SetEnvKeyReplacer прочитает API_TOKEN для ключа api.token, даже если в YAML его нет. Файлы с секретами в репозиторий не попадают: .env вне git, а просочившийся в историю токен требует ротации и вычистки filter-repo.
