В Go-сервисе конфигурация это структура, которую собрали при старте из переменных окружения и файлов и раздали компонентам. Механизма «перечитать настройки по сигналу» в стандартной библиотеке нет, и это честнее, чем кажется: обновление на лету всё равно состоит из трёх вопросов, на которые отвечает код. Какие значения вообще можно менять без перезапуска. Как новое значение доезжает из ConfigMap до файла в поде. Как процесс это замечает и подменяет значение так, чтобы ни один обработчик не увидел половину старого и половину нового.
Какие настройки можно менять на лету, а какие нельзя
У настройки две судьбы внутри процесса. Лимит запросов к партнёру, размер страницы, флаг «новый чекаут», тариф доставки читают в момент использования: пришёл запрос, взяли текущее число, сравнили. Это параметры поведения, и их можно подменять в любой момент.
Другая судьба у значений, из которых при старте собирают долгоживущий объект: размер пула соединений, адрес брокера и группа потребителя, порт, имя схемы. Само число после старта никто не читает, читают пул, который по нему построен. «Поменять на лету» здесь означает закрыть пул с живыми соединениями и открыть новый, остановить потребителя Kafka и пройти ребаланс заново: перезапуск внутри процесса, но без страховки платформы. Readiness не снимет под с трафика, выкат не остановится на первой сломанной реплике, откатывать rollout undo будет нечего. Такие значения меняют только через выкат.
Третий класс коварнее: значение поведенческое, но компонент успел его скопировать. Таймаут, который ушёл в http.Client.Timeout при сборке, порог размыкателя, переданный в gobreaker.Settings, скорость rate.Limiter. После перечитывания файла структура новая, а объект, который «съел» значение, старый. Такие настройки обновляются, только если код после перечитывания явно применяет их к объекту: limiter.SetLimit(...), пересборка клиента. Поэтому у хранилища настроек есть обработчик onChange.
Правило: на лету меняют то, что читается при каждом использовании и не порождает ресурсов. И про цену: изменение на лету не видно в артефакте выката, поэтому первым меняют источник правды (файл в Git, из которого собирается ConfigMap), а процесс лишь перечитывает.
Как правка ConfigMap доезжает до пода
ConfigMap попадает в контейнер двумя способами, и у них разная судьба после правки.
- Переменные окружения (
env,envFrom) читаются один раз при создании контейнера и не меняются до его перезапуска. Никакой код в процессе этого не изменит. - Файлы из тома обновляет kubelet: он проверяет свежесть смонтированных ConfigMap на каждом периодическом проходе (по умолчанию раз в минуту) и берёт значения из своего кэша, который сам обновляется подпиской на API. Задержка от правки до нового файла в поде складывается из периода прохода и задержки кэша: обычно десятки секунд, в худшем случае минута-две.
Замена файла атомарна: kubelet пишет новую версию в каталог с меткой времени (..2026_10_03_08_15_00.123), а потом одной операцией переключает символическую ссылку ..data на новый каталог. Файл config.yaml в точке монтирования это тоже ссылка, через ..data. Процесс никогда не прочитает файл, записанный наполовину, но инода файла меняется при каждом обновлении, и это важно для слежения.
Два исключения, из-за которых обновление не придёт никогда. Том, смонтированный через subPath, обновлений не получает: kubelet подменяет ссылку в каталоге тома, а subPath привязан к конкретному файлу. И ConfigMap с immutable: true после создания не меняется в принципе: kubelet перестаёт за ним следить, а правка это создание нового ConfigMap с другим именем и выкат. Для Secret всё то же самое.
Как процесс замечает изменение
Три способа, по возрастанию сложности.
Перечитывать по таймеру. Раз в 30 секунд прочитать файл, сравнить хеш с прошлым, при разнице разобрать и подменить. Двадцать строк, никаких зависимостей, задержка до полуминуты поверх задержки kubelet. Для лимитов и флагов этого достаточно почти всегда.
Следить за каталогом через fsnotify. Реакция за секунды вместо десятков, но с двумя оговорками. Следят за каталогом тома, а не за файлом: при обновлении kubelet подменяет ссылку, инода старого файла умирает вместе с подпиской на неё. И на одно обновление приходит несколько событий (создание нового каталога, переключение ссылки, удаление старого), поэтому перечитывание откладывают на пару сотен миллисекунд после последнего события и сравнивают содержимое по хешу.
Сигнал снаружи. SIGHUP как у классических демонов: процесс перечитывает конфигурацию по сигналу. В Kubernetes отправить сигнал во все поды неоткуда, кроме цикла kubectl exec по списку, поэтому способ остаётся для сервисов на виртуальных машинах.
Хранилище настроек, которое закрывает первые два способа:
type Config struct {
Limits Limits `yaml:"limits"`
}
type Store struct {
path string
current atomic.Pointer[Config]
sum []byte
}
func Load(path string) (*Store, error) {
s := &Store{path: path}
c, sum, err := read(path)
if err != nil {
return nil, err
}
s.current.Store(c)
s.sum = sum
return s, nil
}
func (s *Store) Get() *Config { return s.current.Load() }
func read(path string) (*Config, []byte, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
var c Config
if err := yaml.Unmarshal(raw, &c); err != nil {
return nil, nil, err
}
if c.Limits.PartnerRPS <= 0 || c.Limits.PageSize <= 0 {
return nil, nil, errors.New("limits must be positive")
}
sum := sha256.Sum256(raw)
return &c, sum[:], nil
}
И слежение за каталогом:
func (s *Store) Watch(ctx context.Context, onChange func(*Config)) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer w.Close()
if err := w.Add(filepath.Dir(s.path)); err != nil {
return err
}
var timer *time.Timer
reload := func() {
c, sum, err := read(s.path)
if err != nil {
slog.Error("config reload failed, keeping previous", "path", s.path, "err", err)
return
}
if bytes.Equal(sum, s.sum) {
return
}
s.current.Store(c)
s.sum = sum
slog.Info("config reloaded", "path", s.path)
if onChange != nil {
onChange(c)
}
}
for {
select {
case <-ctx.Done():
return nil
case _, ok := <-w.Events:
if !ok {
return nil
}
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(200*time.Millisecond, reload)
case err, ok := <-w.Errors:
if !ok {
return nil
}
slog.Warn("config watcher", "err", err)
}
}
}
Горутина Watch стартует из main рядом с HTTP-сервером и живёт до отмены контекста. Обработчики получают настройки через store.Get().Limits.PartnerRPS в момент вызова, а не из поля, скопированного при старте.
Атомарная замена и те, кто уже скопировал значение
atomic.Pointer[Config] даёт главное свойство: обработчик, который взял указатель, до конца запроса видит один согласованный набор значений, а подмена указателя происходит целиком. Менять поля существующей структуры на месте нельзя: это гонка данных, которую покажет go test -race, и читатель может застать половину старых значений с половиной новых.
Невалидный файл не должен сносить рабочую конфигурацию. Поэтому read проверяет инварианты (лимиты положительные, адреса непустые) и при ошибке хранилище остаётся на прошлой версии с записью в лог уровня ERROR и счётчиком config_reload_errors_total: опечатка в ConfigMap превращается в алерт, а не в сервис с лимитом ноль. Рядом живёт метрика config_version с хешем или временем файла: по ней на графике видно, какие реплики уже перечитали, а какие ещё нет.
Для компонентов, которые копируют значения, хранилище зовёт onChange:
go store.Watch(ctx, func(c *Config) {
partnerLimiter.SetLimit(rate.Limit(c.Limits.PartnerRPS))
partnerLimiter.SetBurst(c.Limits.PartnerRPS)
})
Пересобирать по onChange клиент HTTP или пул соединений уже не стоит: это тот самый перезапуск внутри процесса из первого раздела, и такие значения едут через выкат.
Все реплики сразу
Каждый под следит за своим файлом, а файл в каждом поде обновляет свой kubelet в своё время. Двенадцать реплик увидят новый лимит в окне в минуту-две, и какое-то время часть трафика работает по старому значению. Для лимитов, таймаутов и флагов это допустимо, для изменений, которые должны включиться одновременно (новый формат сообщения, смена партнёра), нет.
Когда одновременность важна или история изменения нужна в том же виде, что и у кода, правка ConfigMap превращается в выкат: аннотация с хешем конфигурации в шаблоне пода (checksum/config в Helm) меняет шаблон при каждой правке, и Deployment катит реплики по одной под защитой readiness, с rollout undo и историей ревизий. Минуты вместо секунд и потеря прогретого состояния, но ни одного нового режима отказа. Оператор вроде Reloader делает то же самое без правки чарта: следит за ConfigMap и перезапускает Deployment, который его использует.
Выбор простой: по умолчанию выкат с хешем; слежение за файлом там, где значение меняют часто (дежурный крутит лимит во время инцидента) и окно несогласованности безвредно.
Кто нажимает кнопку
Механизм отвечает на «как» и не отвечает на «кто». Технически ConfigMap правит любой с kubectl edit, и через месяц никто не вспомнит, кто и зачем поднял таймаут до тридцати секунд. Процесс строится из четырёх частей.
Источник правды это Git: значения окружений лежат в репозитории конфигурации или в values-prod.yaml чарта (см. Helm), изменение идёт через merge request с ревью, применяет его Argo CD. Права на правку ConfigMap в проде есть только у сервисного аккаунта Argo; ручная правка это дрейф, который Argo покажет как OutOfSync и при включённом самовосстановлении откатит.
Меняет параметры поведения не разработчик, а дежурный по инструкции к конкретному ключу: где лежит, в каких пределах можно двигать, что посмотреть на графиках через пять минут, как откатить. Ключ без инструкции на лету не меняют.
Аудит складывается сам: git log даёт кто и когда, MR даёт зачем, история синхронизаций Argo показывает, когда доехало до кластера, а лог config reloaded в каждой реплике показывает, когда применилось. В лог пишут имена ключей и хеш, не значения: в ConfigMap рядом с лимитами могут оказаться адреса и идентификаторы, которые незачем раскладывать по журналам. Откат это git revert тем же путём; второго механизма отката не существует, и это достоинство.
Глубже: таблица настроек в базерасширенное
Часть параметров меняют не инженеры и не раз в квартал: тариф доставки по регионам, лимит суммы заказа для новых клиентов, флаг «новый чекаут» для десяти процентов пользователей. У менеджера нет Git, у ConfigMap нет истории строки «кто поменял тариф Москвы» и нет значений на сущность. Это не конфигурация, а данные, и живут они в базе: таблица app_setting(key, value jsonb, version, updated_at, updated_by) для общих параметров и предметные таблицы там, где значение привязано к сущности.
Читать её на каждом запросе нельзя, поэтому значения держат в памяти процесса с временем жизни 30-60 секунд (та же схема, что и с файлом: прочитать, проверить, подменить указатель). У каждого ключа есть значение по умолчанию в коде, первая запись делается миграцией с ON CONFLICT DO NOTHING, удаление в два шага: сначала код перестаёт читать ключ, потом удаляют строку. Что в таблице не живёт: всё, что нужно раньше соединения с базой (адрес и учётные данные самой базы, брокеры, порты), секреты вообще и параметры, которые должны включиться атомарно с выкатом кода. Флаги с историей, когортами и кнопкой «выключить» у дежурного это частный случай такой таблицы или отдельный сервис флагов, а не ConfigMap; зачем они нужны, разобрано в стратегиях релизов.
Глубже: библиотеки вместо своих шестидесяти строкрасширенное
viper.WatchConfig делает примерно то же, что Watch выше, и подводит в тех же местах, только молча: слежение за файлом, а не за каталогом, два события на одно обновление, перечитывание без валидации и глобальное состояние, из которого компоненты читают значения в неожиданные моменты. koanf легче и честнее, но слежение в нём тоже придётся обвешивать отложенным перечитыванием и проверкой. Свои шестьдесят строк с atomic.Pointer, хешем и валидацией понятнее любому, кто откроет их через год, и это редкий случай, когда собственная реализация оправдана.
Коротко
- На лету меняют параметры поведения, которые читаются при каждом вызове; пулы, порты, брокеры и схема едут через выкат.
- Переменные окружения не обновляются до перезапуска; файл из тома kubelet подменяет атомарно с задержкой до минуты-двух.
subPathиimmutable: trueобновлений не получают никогда.- Следят за каталогом тома, а не за файлом; события собирают с задержкой в сотни миллисекунд и сравнивают хеш содержимого.
- Настройки подменяют через
atomic.Pointerцеликом; поля на месте не меняют. - Невалидный файл оставляет прошлую версию, пишет
ERRORи растит счётчик ошибок; метрика версии показывает отставшие реплики. - Компонентам, которые копируют значения, нужен
onChange:SetLimit, пересборка размыкателя; клиент и пул не пересобирают. - Реплики перечитывают каждая в своё время; когда нужна одновременность или история, правка идёт выкатом с хешем конфигурации.
- Источник правды Git, применяет Argo CD, меняет дежурный по инструкции к ключу, лог хранит ключи и хеш, откат через
git revert. - Параметры продукта с историей и значением на сущность живут в таблице базы с кэшем на 30-60 секунд, не в ConfigMap.
Что почитать дальше
- Деплой и конфигурация в Kubernetes — ConfigMap, Secret, тома и пробы, с которых всё начинается.
- Helm —
values-prod.yamlи аннотация с хешем конфигурации в шаблоне пода. - Argo CD — синхронизация из Git, дрейф и самовосстановление.
- Что Kubernetes берёт на себя у Go-сервиса — где граница между платформой и кодом.
- Завершение Go-сервиса в Kubernetes — почему перезапуск через выкат безопаснее перезапуска внутри процесса.
- Стратегии релизов — флаги, которые отделяют выкат от включения.