← назад к разделу

В Python-сервисе конфигурация это объект pydantic-settings, который собрали при старте из переменных окружения и файлов и раздали компонентам. Механизма «перечитать настройки по сигналу» в нём нет, и это честнее, чем кажется: обновление на лету всё равно состоит из трёх вопросов, на которые отвечает код. Какие значения вообще можно менять без перезапуска. Как новое значение доезжает из ConfigMap до файла в поде. Как процесс это замечает и подменяет значение так, чтобы ни один обработчик не увидел половину старого и половину нового.

Обязательно

Какие настройки можно менять на лету, а какие нельзя

У настройки две судьбы внутри процесса. Лимит запросов к партнёру, размер страницы, флаг «новый чекаут», тариф доставки читают в момент использования: пришёл запрос, взяли текущее число, сравнили. Это параметры поведения, и их можно подменять в любой момент.

Другая судьба у значений, из которых при старте собирают долгоживущий объект: размер пула соединений, адрес брокера и группа потребителя, порт, имя схемы. Само число после старта никто не читает, читают engine, который по нему построен. «Поменять на лету» здесь означает закрыть пул с живыми соединениями и открыть новый, остановить потребителя aiokafka и пройти ребаланс заново: перезапуск внутри процесса, но без страховки платформы. Readiness не снимет под с трафика, выкат не остановится на первой сломанной реплике, откатывать rollout undo будет нечего. Такие значения меняют только через выкат.

Третий класс коварнее: значение поведенческое, но компонент успел его скопировать. Таймаут, который ушёл в httpx.AsyncClient(timeout=...) при сборке, порог размыкателя, переданный в конструктор, скорость ограничителя. После перечитывания файла объект настроек новый, а клиент, который «съел» значение, старый. Такие настройки обновляются, только если код после перечитывания явно применяет их к объекту: пересборка клиента, вызов метода у ограничителя. Поэтому у хранилища настроек есть обработчик on_change.

Правило: на лету меняют то, что читается при каждом использовании и не порождает ресурсов. И про цену: изменение на лету не видно в артефакте выката, поэтому первым меняют источник правды (файл в Git, из которого собирается ConfigMap), а процесс лишь перечитывает.

Как правка ConfigMap доезжает до пода

ConfigMap попадает в контейнер двумя способами, и у них разная судьба после правки.

env, envFrom правка ConfigMap значение в процессе не меняется только перезапуск пода том с файлом правка ConfigMap kubelet подменяет ссылку ..data до минуты-двух процесс заметит, если следит subPath, immutable правка ConfigMap обновление не придёт никогда

Переменные окружения живут до перезапуска, файл из тома обновляет kubelet с задержкой, а subPath и immutable не обновляются вовсе.

  • Переменные окружения (env, envFrom) читаются один раз при создании контейнера и не меняются до его перезапуска. Никакой код в процессе этого не изменит; BaseSettings() можно пересоздать хоть сто раз — os.environ останется прежним.
  • Файлы из тома обновляет 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. Для лимитов и флагов этого достаточно почти всегда.

Следить за каталогом через watchfiles. Реакция за секунды вместо десятков, но с двумя оговорками. Следят за каталогом тома, а не за файлом: при обновлении kubelet подменяет ссылку, инода старого файла умирает вместе с подпиской на неё. И на одно обновление приходит несколько событий (создание нового каталога, переключение ссылки, удаление старого); watchfiles сам собирает их в одну пачку с задержкой, которую задаёт debounce, а код сравнивает содержимое по хешу.

Сигнал снаружи. SIGHUP как у классических демонов: loop.add_signal_handler(signal.SIGHUP, store.reload), и процесс перечитывает конфигурацию по сигналу. В Kubernetes отправить сигнал во все поды неоткуда, кроме цикла kubectl exec по списку, поэтому способ остаётся для сервисов на виртуальных машинах.

Хранилище настроек, которое закрывает первые два способа:

import hashlib
from collections.abc import Callable
from pathlib import Path

import yaml
from pydantic import BaseModel, Field


class Limits(BaseModel):
    partner_rps: int = Field(gt=0)
    page_size: int = Field(gt=0, le=500)


class Config(BaseModel):
    limits: Limits


class ConfigStore:
    def __init__(self, path: Path) -> None:
        self.path = path
        self.current, self.digest = self._read()
        self.listeners: list[Callable[[Config], None]] = []

    def get(self) -> Config:
        return self.current

    def on_change(self, listener: Callable[[Config], None]) -> None:
        self.listeners.append(listener)

    def _read(self) -> tuple[Config, str]:
        raw = self.path.read_bytes()
        config = Config.model_validate(yaml.safe_load(raw))
        return config, hashlib.sha256(raw).hexdigest()

    def reload(self) -> None:
        try:
            config, digest = self._read()
        except (OSError, ValueError) as e:
            log.error("config reload failed, keeping previous", path=str(self.path), error=str(e))
            config_reload_errors.inc()
            return
        if digest == self.digest:
            return
        self.current, self.digest = config, digest
        config_version.info({"sha256": digest[:12]})
        log.info("config reloaded", path=str(self.path), sha256=digest[:12])
        for listener in self.listeners:
            listener(config)

И слежение за каталогом:

import asyncio

from watchfiles import awatch


async def watch_config(store: ConfigStore, stop: asyncio.Event) -> None:
    async for _changes in awatch(store.path.parent, debounce=300, stop_event=stop):
        store.reload()

Задача watch_config стартует из lifespan рядом с остальными фоновыми задачами и живёт до события остановки. Обработчики получают настройки через store.get().limits.partner_rps в момент вызова, а не из поля, скопированного при старте. Если бы настройки собирались из переменных окружения через BaseSettings, перечитывать было бы нечего — поэтому всё, что хотят менять на лету, кладут в файл из тома, а в окружении оставляют то, что едет через выкат.

Атомарная замена и те, кто уже скопировал значение

Строка self.current, self.digest = config, digest даёт главное свойство: обработчик, который взял store.get(), до конца запроса видит один согласованный объект, а подмена ссылки происходит целиком — присваивание атрибута в Python атомарно относительно других потоков и задач. Менять поля существующего объекта на месте нельзя: читатель может застать половину старых значений с половиной новых, и model_config = ConfigDict(frozen=True) у моделей настроек делает такую правку невозможной по ошибке.

Невалидный файл не должен сносить рабочую конфигурацию. Поэтому _read проверяет инварианты (лимиты положительные, страница не больше пятисот) силами Pydantic и при ошибке хранилище остаётся на прошлой версии с записью в лог уровня ERROR и счётчиком config_reload_errors_total: опечатка в ConfigMap превращается в алерт, а не в сервис с лимитом ноль. Рядом живёт метрика config_version с хешем файла: по ней на графике видно, какие реплики уже перечитали, а какие ещё нет.

Для компонентов, которые копируют значения, хранилище зовёт слушателей:

store.on_change(lambda config: partner_limiter.set_rate(config.limits.partner_rps))

Пересобирать по on_change клиент httpx или engine уже не стоит: это тот самый перезапуск внутри процесса из первого раздела, и такие значения едут через выкат.

Отдельная оговорка про воркеры: uvicorn --workers 4 это четыре процесса, у каждого свой ConfigStore и своя задача слежения. Они перечитают файл независимо друг от друга в пределах секунды, и это нормально; важно лишь, чтобы слежение запускалось в lifespan каждого воркера, а не один раз в родительском процессе до форка.

Все реплики сразу

Каждый под следит за своим файлом, а файл в каждом поде обновляет свой 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 секунд (та же схема, что и с файлом: прочитать, проверить моделью Pydantic, подменить ссылку). У каждого ключа есть значение по умолчанию в коде, первая запись делается миграцией с ON CONFLICT DO NOTHING, удаление в два шага: сначала код перестаёт читать ключ, потом удаляют строку. Что в таблице не живёт: всё, что нужно раньше соединения с базой (адрес и учётные данные самой базы, брокеры, порты), секреты вообще и параметры, которые должны включиться атомарно с выкатом кода. Флаги с историей, когортами и кнопкой «выключить» у дежурного это частный случай такой таблицы или отдельный сервис флагов, а не ConfigMap; зачем они нужны, разобрано в стратегиях релизов.

Глубже: библиотеки вместо своих шестидесяти строкрасширенное

pydantic-settings читает источники один раз при создании объекта и перечитывать не умеет — это по замыслу, и спорить с ним не стоит. dynaconf умеет перечитать настройки по вызову, но следить за файлом, проверять инварианты и держать прошлую версию при ошибке всё равно приходится вокруг него. uvicorn --reload следит за кодом, а не за конфигурацией, и в проде его не включают. Свои шестьдесят строк с моделью Pydantic, хешем и подменой ссылки понятнее любому, кто откроет их через год, и это редкий случай, когда собственная реализация оправдана.

Коротко

  • На лету меняют параметры поведения, которые читаются при каждом вызове; пулы, порты, брокеры и схема едут через выкат.
  • Переменные окружения не обновляются до перезапуска, и пересоздание BaseSettings этого не изменит; файл из тома kubelet подменяет атомарно с задержкой до минуты-двух.
  • subPath и immutable: true обновлений не получают никогда.
  • Следят за каталогом тома через watchfiles.awatch, а не за файлом; события собираются с debounce, содержимое сравнивают по хешу.
  • Настройки подменяют целиком одним присваиванием ссылки на новый объект; модели настроек делают frozen, поля на месте не меняют.
  • Невалидный файл оставляет прошлую версию, пишет ERROR и растит счётчик ошибок; метрика версии показывает отставшие реплики.
  • Компонентам, которые копируют значения, нужен on_change; клиент httpx и engine не пересобирают; у каждого воркера своё слежение из lifespan.
  • Реплики перечитывают каждая в своё время; когда нужна одновременность или история, правка идёт выкатом с хешем конфигурации.
  • Источник правды Git, применяет Argo CD, меняет дежурный по инструкции к ключу, лог хранит ключи и хеш, откат через git revert.
  • Параметры продукта с историей и значением на сущность живут в таблице базы с кэшем на 30–60 секунд, не в ConfigMap.

Что почитать дальше