Вечер пятницы, партнёрский API отвечает по четыре секунды вместо одной, и сервис заказов копит очередь. Лечение известно: опустить лимит запросов к партнёру с 200 в секунду до 50 и поднять таймаут. Оба числа лежат в ConfigMap. Из статьи про Spring Boot в Kubernetes вы уже знаете, чем кончается kubectl apply на ConfigMap: поды работают, значения старые. Штатный путь — выкат с хэшем конфига в шаблоне пода: двенадцать реплик перезапускаются по очереди, шесть-восемь минут, и каждая теряет прогретые кэши и JIT ровно тогда, когда сервису и так плохо.
Отсюда вопрос: а можно без перезапуска? Можно, и Spring Cloud умеет это давно. Но «на лету» — не бесплатная кнопка, а второй канал доставки изменений в прод, мимо выката, со своими режимами отказа. Разберём, для каких настроек он уместен, что происходит внутри приложения при POST /actuator/refresh, как донести изменение до всех реплик, кто и по какому процессу нажимает кнопку и когда параметру место не в ConfigMap, а в таблице базы.
Какие настройки можно менять на лету, а какие нельзя
Настройка — это число в YAML, но внутри приложения у неё две судьбы. Лимит запросов к партнёру, размер страницы, флаг «показывать новый чекаут», тариф доставки — такие значения читают в момент использования: пришёл запрос, взяли текущее число, сравнили. Замените число — следующий запрос увидит новое, и ничего больше в процессе не изменится. Это параметры поведения, и они меняются на лету безопасно.
Другая судьба у значений, из которых при старте собирают долгоживущий объект: размер пула соединений, адрес брокера и группа потребителя, порт, число потоков исполнителя, имя схемы базы. Само число после старта никто не читает — читают пул, который по нему построен. «Поменять на лету» здесь означает закрыть пул с тридцатью живыми соединениями и открыть новый, остановить потребителя Kafka и заново пройти ребаланс — перезапуск внутри процесса, но без страховки платформы: readiness не снимет под с трафика, выкат не остановится на первой сломанной реплике, rollout undo откатывать нечего. Spring Cloud знает это про самый частый случай: HikariDataSource стоит в spring.cloud.refresh.never-refreshable по умолчанию, и при refresh пул не трогают, что бы ни поменялось в spring.datasource.*.
Есть и третий класс, коварнее: значение поведенческое, но потребитель успел его закэшировать. Таймаут HTTP-клиента ушёл в фабрику соединений при сборке бина, порог срабатывания предохранителя (circuit breaker) — в реестр Resilience4j при старте. После refresh свойство в Environment новое, а объект, который его «съел», старый. Такие настройки меняются на лету, только если код пересобирает объект по свежему значению — для этого и существует @RefreshScope.
Правило: на лету меняют то, что читается при каждом использовании и не порождает ресурсов; всё, у чего есть жизненный цикл, едет через выкат. И про цену: изменение на лету не видно в артефакте выката, и без журнала «кто, когда, какой ключ» через месяц никто не объяснит, почему прод отвечает не так, как staging. Поэтому первым меняют источник правды — файл в Git, ConfigMap, строку в базе, — а refresh лишь просит приложение перечитать. Обратный порядок — подкрутить в памяти через POST /actuator/env и «потом закоммитить» — живёт до первого перезапуска пода.
Что на самом деле делает POST /actuator/refresh
Слова «перечитывает конфигурацию» скрывают четыре шага, и от каждого зависит, увидит ли ваш бин новое значение. Ниже — по исходникам spring-cloud-context 4.3, класс ContextRefresher.
Первый шаг — снимок: все свойства из всех источников Environment складываются в карту «ключ → значение». Второй — пересборка: создаётся копия Environment, по ней заново прогоняют все EnvironmentPostProcessor Spring Boot, в том числе тот, что читает application.yml и spring.config.import, — смонтированный файл ConfigMap или ответ Config Server читаются заново. Источники из копии подменяют старые, кроме «стандартных»: системные свойства, переменные окружения, параметры сервлета и JNDI, аргументы командной строки остаются как были. Третий — разница: старый снимок сравнивают с новым, и множество изменившихся ключей уходит в EnvironmentChangeEvent. Четвёртый — RefreshScope.refreshAll(): кэш бинов этой области очищается. Ответ эндпоинта — тот самый список ключей, и пустой [] — самый частый результат у тех, кто пробует refresh впервые.
Четыре шага ContextRefresher.refresh(): переменные окружения в пересборку не входят, поэтому ConfigMap через envFrom даёт пустой ответ.
Пустой он потому, что ConfigMap через envFrom — это переменные окружения, а они в списке нетронутых: окружение живого процесса выдаёт ядро при запуске, и ни Spring, ни JVM его не изменят. Refresh имеет смысл только для конфигурации, у которой есть что перечитать: файл, смонтированный из ConfigMap (SPRING_CONFIG_IMPORT=optional:file:/etc/config/application.yml), Config Server, Vault. И даже с файлом есть окно: kubelet обновляет содержимое тома не в момент kubectl apply, а со своей задержкой, до пары минут, и refresh, нажатый сразу после apply, честно перечитает старый файл. Порядок: apply → убедиться, что файл в поде изменился (kubectl exec … cat /etc/config/application.yml) → refresh. Через subPath файл не обновляется никогда.
На EnvironmentChangeEvent подписаны два штатных слушателя. ConfigurationPropertiesRebinder перепривязывает каждый @ConfigurationProperties-бин: на том же экземпляре вызывает destroyBean и initializeBean, и привязка проходит заново — объект не подменяется, ссылка у всех, кто его держит, остаётся рабочей. Отсюда два следствия. Классы с конструкторной привязкой и записи (record) не перепривязываются: у них нет сеттеров, новое значение им не положить. И перепривязка не атомарна для читателей из других потоков: в Spring Cloud 5 поля сначала сбрасываются к значениям по умолчанию, потом заполняются, и документация прямо предупреждает, что параллельный вызов может застать промежуточное состояние — для лимита без значения по умолчанию это запрос, пропущенный с лимитом 0. Второй слушатель переставляет уровни логирования по logging.level.* — единственный случай, когда refresh полезен без единого своего бина: включить DEBUG пакету на десять минут в проде.
Эндпоинт записывающий, наружу его не открывают: Actuator живёт на отдельном management-порту, который не публикуют через Ingress, а refresh попадает в management.endpoints.web.exposure.include явно, а не через *.
@RefreshScope: что происходит с бином
Перепривязка чинит числа в @ConfigurationProperties, но не пересобирает объекты, которые из этих чисел построены. Клиент партнёра с таймаутом, зашитым в фабрику соединений, останется со старым таймаутом. Для этого и есть @RefreshScope: бин, который при refresh выбрасывают и создают заново.
Аннотация ставит бину область видимости refresh с proxyMode = TARGET_CLASS, поэтому в зависимые бины внедряется не сам объект, а CGLIB-прокси. Настоящий экземпляр живёт в кэше области и создаётся лениво, при первом вызове метода. refreshAll() очищает кэш: для каждого бина берётся блокировка на запись, выполняются методы уничтожения (@PreDestroy, DisposableBean), ссылка сбрасывается. Следующий вызов через прокси создаёт новый экземпляр — с конструктором, @PostConstruct и уже новым Environment. Вызов метода всё это время держит блокировку на чтение, поэтому refresh дождётся текущих вызовов, а следующие увидят уже новый объект: промежуточного состояния, как у перепривязки, здесь нет.
Из механики видно, что refresh переживёт, а что нет. Не переживёт состояние: счётчик, накопленный буфер, кэш в поле — новый экземпляр начинает с нуля. Бин, владеющий ресурсом — потребитель Kafka, планировщик, пул, — закроется по @PreDestroy и откроется на первом вызове; это перезапуск внутри процесса, к тому же ленивый: если метод никто не вызывает, потребитель не поднимется. Ссылки в обход прокси — библиотека, которой отдали this при регистрации (слушатель событий, реестр метрик), — держат старый объект и дальше. И @RefreshScope на @Configuration-классе не делает refresh-областью его @Bean-методы — документированная ловушка.
Самая частая ошибка проще всех этих тонкостей: значение скопировали в поле при старте. Перепривязка обновит бин настроек, а снимок в соседнем бине — нет.
// так не надо: снимок настройки при старте, refresh его не увидит
@Service
class OrderPolicy {
private final int maxItems;
OrderPolicy(OrderLimits limits) {
this.maxItems = limits.getMaxItems();
}
void check(Order order) {
if (order.items().size() > maxItems) throw new TooManyItemsException(maxItems);
}
}
// так надо: ссылка на бин настроек, чтение при каждом вызове
@Service
class OrderPolicy {
private final OrderLimits limits;
OrderPolicy(OrderLimits limits) {
this.limits = limits;
}
void check(Order order) {
int maxItems = limits.getMaxItems();
if (order.items().size() > maxItems) throw new TooManyItemsException(maxItems);
}
}
OrderLimits при этом — класс с сеттерами и значением по умолчанию в каждом поле (private int maxItems = 100;), а не запись. А @RefreshScope оставляют объектам, которые дёшево собрать и которые ничем не владеют, — клиенту, зашивающему таймаут в фабрику:
@Configuration
class PartnerClientConfig {
@Bean
@RefreshScope
RestClient partnerClient(PartnerProperties props) {
var factory = new JdkClientHttpRequestFactory();
factory.setReadTimeout(props.getTimeout());
return RestClient.builder().baseUrl(props.getUrl()).requestFactory(factory).build();
}
}
Все реплики сразу: Bus, watcher или rollout
curl -X POST http://order-service:8081/actuator/refresh через Service попадёт в один под из двенадцати — какой, решит kube-proxy. Остальные одиннадцать останутся со старым лимитом, и ошибка обнаружится по графику «почему партнёр всё ещё падает». Донести refresh до всех можно четырьмя способами, и отличаются они не удобством, а тем, что нового появляется в системе.
Обход подов по списку: kubectl get pods -l app=order-service и цикл с curl по IP каждого пода. Ничего нового в кластере, скрипт в репозитории эксплуатации; ломается тихо, когда HPA добавил под между get и циклом, и не оставляет следа нигде, кроме истории терминала.
Spring Cloud Bus: стартер spring-cloud-starter-bus-kafka или -amqp подключает каждую реплику к топику springCloudBus (имя меняется через spring.cloud.bus.destination). POST /actuator/busrefresh на любой реплике публикует RefreshRemoteApplicationEvent; каждая реплика, получив его, делает у себя то же, что /actuator/refresh: перечитывает источники, перепривязывает @ConfigurationProperties, чистит RefreshScope. Адресация — через spring.cloud.bus.id вида app:index:id: POST /actuator/busrefresh/order-service:** дойдёт до всех реплик заказов и не тронет соседей в том же топике. Условие: идентификатор обязан быть уникальным — событие с собственным id реплика считает своим же и пропускает. index берётся из server.port, у всех подов он одинаков, и уникальность держится на случайном хвосте id или на spring.application.index из имени пода. Цена — брокер на пути доставки конфигурации: если Kafka недоступна, лимит не поменять именно тогда, когда это нужно; плюс подписка в каждом поде и ещё один топик, за которым надо следить.
Spring Cloud Kubernetes Configuration Watcher: контроллер в кластере, который следит за ConfigMap с меткой spring.cloud.kubernetes.config: "true", находит поды приложения по аннотации spring.cloud.kubernetes.configmap.apps и шлёт им POST /actuator/refresh по одному — или, с профилем bus-kafka, публикует событие в Bus. Триггером становится сама правка ConfigMap, а не человек с curl; стратегия — refresh, restart (перезапуск контекста) или shutdown: под гасит себя, Deployment поднимает новый, и это уже почти rollout. Встроенный в приложение вариант того же — spring.cloud.kubernetes.reload.enabled с наблюдением за API из самого пода — объявлен устаревшим, и права watch на ConfigMap каждому поду выдавать не надо.
Rollout с хэшем конфига разобран в соседней статье: аннотация checksum/config в шаблоне пода превращает правку ConfigMap в обычный выкат — по одной реплике, под защитой readiness, с rollout undo и историей ревизий. Минуты вместо секунд и потеря прогретого состояния — но ни одного нового режима отказа.
Четыре пути до всех реплик. Rollout ничего нового не добавляет; Bus ставит брокер на путь конфигурации; Watcher делает триггером саму правку ConfigMap; обход держится на скрипте.
| Кто триггер | Что видно после | Что ломается | |
|---|---|---|---|
| rollout по checksum | правка ConfigMap | ревизия Deployment | ничего нового; минуты и холодный старт |
| обход подов | человек или скрипт | ничего | пропущенный под, автомасштабирование |
| Spring Cloud Bus | busrefresh на любой реплике | лог каждой реплики | брокер на пути конфига, дубли id |
| Configuration Watcher | сама правка ConfigMap | события контроллера | ещё один контроллер и его RBAC |
Выбор по умолчанию — rollout по хэшу: он бесплатен и совпадает с тем, как в кластер попадает всё остальное. На refresh переходят, когда сходятся три условия: настройки из безопасного класса, изменения частые или время реакции измеряется в деньгах, и есть журнал, куда попадает каждое такое изменение. Без третьего условия «на лету» — способ получить прод, состояние которого никто не может воспроизвести.
Кто нажимает кнопку: процесс вокруг изменения в проде
Механизм refresh отвечает на «как» и не отвечает на «кто». Технически ConfigMap правит любой, у кого есть kubectl edit, и через месяц никто не помнит, кто поднял таймаут до 30 секунд и зачем. Процесс вокруг изменения важнее механизма, и строится он из четырёх частей.
Источник правды — Git. Значения окружений лежат в репозитории конфигурации или в values-prod.yaml чарта (см. Helm), изменение — merge request с ревью, применяет Argo CD. Права edit на ConfigMap в проде есть только у сервисного аккаунта Argo; ручная правка — дрейф, Argo покажет OutOfSync и при включённом self-heal откатит её. Так и должно быть: правка мимо Git не живёт дольше минуты.
Кто меняет. Для параметров поведения — лимитов, таймаутов, флагов — это не разработчик, а дежурный или вторая линия поддержки, по инструкции к конкретному ключу: где лежит, в каких пределах можно двигать, что посмотреть на графиках через пять минут, как откатить. Инструкция — часть определения настройки: ключ без инструкции на лету не меняют.
Триггер refresh — часть конвейера, не команда в терминале: задание CI после слияния в ветку окружения или PostSync-хук Argo — Job, который дожидается обновления файла в подах и вызывает busrefresh изнутри кластера. Так у изменения есть время, автор и ссылка на MR в одном месте.
Изменение на лету идёт тем же путём, что и любое другое: Git — Argo CD — ConfigMap, и только последний шаг заменён с перезапуска на refresh.
Аудит и откат. git log даёт кто и когда, MR — зачем, история синхронизаций Argo — когда доехало до кластера, и последнее звено — сам refresh: RefreshEndpoint пишет в лог Refreshed keys : [partner.rate-limit] — ключи, не значения, секреты в журнал не утекают. Откат — git revert и тот же путь; второго механизма отката не существует, и это достоинство. Значение, подкрученное в памяти во время инцидента, закрывает инцидент только после коммита.
Таблица настроек в базе: что там живёт, а что нет
Часть параметров меняют не инженеры и не раз в квартал: тариф доставки по регионам, лимит суммы заказа для новых клиентов, расписание акций, флаг «новый чекаут» для 10 % пользователей. Для них путь «MR → ревью → sync → refresh» не подходит по устройству: у менеджера нет Git, у ConfigMap нет истории строки «кто и когда поменял тариф Москвы» и нет значений на сущность — тариф на регион, лимит на клиента. Это уже не конфигурация, а данные, и живут они в базе.
Устройство: таблица app_setting(key text primary key, value jsonb, version bigint, updated_at timestamptz, updated_by text) для общих параметров и предметные таблицы там, где значение привязано к сущности (delivery_tariff, client_limit). Читать её на каждом запросе нельзя — лишний запрос в базу на каждом вызове и отказ базы, роняющий чтение настроек, — поэтому значения держат в памяти с временем жизни 30–60 секунд (Caffeine или Spring Cache). Инвалидация — по TTL: изменение доезжает до всех реплик за минуту, и задачи «донести до всех реплик» здесь нет вовсе — каждая реплика перечитывает сама, Bus не нужен. Нужно быстрее — LISTEN/NOTIFY PostgreSQL или событие в Kafka как сигнал сбросить кэш, но это редко стоит своей сложности. Если база недоступна при обновлении кэша, работают на последних известных значениях: запрос покупателя не должен падать из-за того, что не удалось перечитать тариф.
Что в таблице не живёт. Всё, что нужно раньше, чем открыто соединение с базой: адрес и учётные данные самой базы, порты, брокеры, размеры пулов — конфигурация запуска, её место в ConfigMap и Secret. Секреты вообще: таблица читается всеми, у кого есть доступ к базе, и уезжает в каждый дамп. Параметры, различающиеся по средам: содержимое таблицы едет вместе с данными, и прод-дамп, восстановленный на staging, привезёт прод-лимиты. И параметры, которые должны включиться атомарно с выкатом кода, — новый ключ, которого ждёт новая версия: они едут с релизом, в миграции.
Таблица живёт дольше любой версии кода, поэтому у каждого ключа есть значение по умолчанию в коде, а первая запись — в changeset Liquibase с ON CONFLICT DO NOTHING: новая реплика не падает, если строки ещё нет, старая не затирает то, что уже поменяли руками. Удаление — в два шага: сначала код перестаёт читать ключ, потом строку удаляют. Аудит — таблица истории, которую заполняет триггер или тот же метод, что пишет значение, плюс updated_by из учётной записи админки; UI — экран под отдельной ролью, с ограничением диапазона и обязательным комментарием.
Feature flags — частный случай этой таблицы: флаг с историей включений, значением на когорту или процент и кнопкой «выключить» у дежурного. Зачем они и как отделяют выкат от релиза — в стратегиях релизов; здесь важно одно: флагу с историей и UI место в базе или в сервисе флагов, а не в ConfigMap.
Spring Cloud Config против ConfigMap
Config Server читает конфигурацию из Git-репозитория и раздаёт приложениям по HTTP, а в связке с Bus и модулем spring-cloud-config-monitor принимает webhook от Git и сам рассылает refresh при каждом push. В Kubernetes это дублирование платформы: ConfigMap плюс GitOps дают тот же Git как источник правды, а обновление на лету — Configuration Watcher или Bus без отдельного сервера, который нужно держать доступным раньше всех остальных. Когда Config Server всё же оправдан — сервисы вне кластера, один конфиг на несколько платформ — разобрано в Spring Cloud против Kubernetes.
Коротко
- На лету меняют параметры поведения — то, что читается при каждом вызове и не порождает ресурсов. Пул, порты, брокер, схема — только через выкат;
HikariDataSourceвnever-refreshableпо умолчанию. /actuator/refreshзаново читаетapplication.ymlиspring.config.import, считает разницу ключей, шлётEnvironmentChangeEvent, чиститRefreshScope. Переменные окружения не перечитываются: ConfigMap черезenvFromдаёт[].@ConfigurationPropertiesперепривязывается на том же объекте: записи и конструкторная привязка не обновляются, читатель из другого потока может застать промежуточное состояние. Значение, скопированное вfinal-поле при старте, не обновится никогда.@RefreshScope— прокси и ленивый кэш: при refresh бин уничтожается, создаётся при следующем вызове; состояние теряется, ресурсы переоткрываются, ссылки мимо прокси остаются старыми.- До всех реплик: rollout по checksum — по умолчанию; Bus (
busrefresh/order-service:**) — когда изменения частые и есть журнал; Configuration Watcher — когда триггером должна быть сама правка ConfigMap. - Источник правды меняют первым (Git → Argo → ConfigMap), refresh — из конвейера, аудит — MR плюс лог
Refreshed keys, откат —git revert. Изменения черезPOST /actuator/envживут до перезапуска. - Параметры, которые меняет продукт, с историей и UI, — в таблице базы с кэшем на 30–60 с и дефолтом в коде; инфраструктура, секреты и всё, что нужно до соединения с базой, — нет.
Что почитать дальше
- Spring Boot в Kubernetes — откуда берутся ConfigMap и Secret, почему
envFromне обновляется и как устроен выкат по хэшу конфига. - Argo CD — Git как источник правды, OutOfSync и self-heal, без которых процесс вокруг изменения не держится.
- Стратегии релизов — feature flags: выкат и релиз как разные события.
- Spring Cloud против Kubernetes — где заканчивается платформа и начинается приложение, и когда Config Server ещё оправдан.