Daily MaverickGROUNDUP: Joburg fails to open public swimming pools — againESPN DeportesA 10 años del Chile 7-0 México, ¿qué ha pasado en el futbol mexicano?RTP DesportoFernando Gomes tem "esperança" que situação de Ronaldo seja resolvida "a bem"The Jerusalem PostA dangerous habit: Legislating hatred one step at a time - opinionPunchINEC seeks release of outstanding funds ahead of 2027 pollsESPNMMA divisional rankings: It's not just fight results that shuffle top 10sBollywood HungamaNeil Bhoopalam wants to work with Rajkumar Hirani to explore family-oriented films: “It would be a good shift for me”VanguardDifficult terrain stalls evacuation of victims 24 hours after Ondo plane crashPopular ScienceAmazon is blowing out portable power stations and solar generators during its Prime Big Deal Days saleSportstarIndia vs West Indies LIVE Score, 1st T20I: IND wins the toss and opts to bowl against WIZDF heuteAktuelle Pressemitteilungen des ZDFХабрC++ в 2026-м: память, прод, игры, Rust и ИИ — зачем учить язык, который невозможно знать целиком
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

Spring Config Import. Без Магии

Translate

Всем привет! Меня зовут Дмитрий Мазуров, я контрибьютор проекта Axelix. Это open-source продукт, который помогает находить типичные проблемы и неэффективности в Spring Boot приложениях.

В прошлой статье Михаил разбирал EnvironmentPostProcessor и мимоходом упомянул ConfigDataEnvironmentPostProcessor, который грузит application.yml, разбирается с профилями и обрабатывает spring.config.import. Сегодня заглянем внутрь него.

Поводом стала вполне практическая задача. Нам понадобилось подружить Axelix Master с Vault так, чтобы пользователь настраивал его через наши собственные свойства под axelix.master.*, а не через spring.cloud.vault.*. Чтобы сделать это аккуратно, пришлось разобраться, как вообще работают строчки вроде этих:

spring:
  config:
    import:
      - "configserver:http://config:8888"
      - "vault://secret/my-app"
      - "optional:kubernetes:"

TL;DR: за всеми тремя префиксами стоит один и тот же небольшой контракт из двух интерфейсов: ConfigDataLocationResolver и ConfigDataLoader. Вся загрузка происходит внутри одного EnvironmentPostProcessor, за три прохода. Если понять эти три прохода, становится понятно, откуда и в каком порядке приходит каждое значение.

Немного истории

До Spring Boot 2.4 конфиги грузил ConfigFileApplicationListener. Со временем логика в нём обросла таким количеством частных случаев, что менять её стало почти невозможно. Фил Уэбб подробно описал это в посте о переработке.

Параллельно жил второй мир. Spring Cloud Config и Spring Cloud Vault тянули удалённую конфигурацию через отдельный bootstrap-контекст: свой bootstrap.yml, свой родительский ApplicationContext, свои правила приоритета. Два механизма загрузки в одном приложении, и каждый со своими сюрпризами.

В 2.4 оба мира объединили. Появился ConfigData API и свойство spring.config.import, а Spring Cloud (начиная с версии 2020.0) перевёл на него Config, Vault, Consul и Zookeeper, а позже поддержка появилась и в Spring Cloud Kubernetes. Bootstrap-контекст был нужен в основном для того, чтобы подтянуть конфигурацию из удалённых источников, а теперь это делает обычный импорт.

Поэтому в Spring Cloud 2020.0 его выключили по умолчанию. Вернуть его можно свойством spring.cloud.bootstrap.enabled=true или стартером spring-cloud-starter-bootstrap, но это уже legacy-режим.

На практике это значит, что любой источник конфигурации, от файла на диске до Vault, теперь подключается одинаково и живёт по одним правилам приоритета. А значит, достаточно понять механизм один раз.

Один EnvironmentPostProcessor и три прохода

Всю загрузку выполняет ConfigDataEnvironmentPostProcessor. Это обычный EnvironmentPostProcessor, который Spring Boot запускает одним из первых. Работу он передаёт классу ConfigDataEnvironment, а тот трижды обходит источники конфигурации и складывает загруженное в Environment.

ConfigDataEnvironment хранит источники конфигурации в виде дерева объектов ConfigDataEnvironmentContributor. В корне этого дерева лежит всё, что уже есть в Environment, например переменные окружения и аргументы командной строки, а также стандартные локации вроде application.yml. Если любой из этих источников или любой загруженный файл содержит spring.config.import, под ним добавляются дочерние объекты ConfigDataEnvironmentContributor.

Дерево обходится три раза:

  1. Начальный проход, без контекста активации. Обрабатываются импорты из всех источников без условий активации: переменные окружения, аргументы командной строки и файлы. Условия активации бывают только у загруженных источников, например у файлов или данных из Config Server, а не у переменных окружения или аргументов командной строки. Таких условий всего два: spring.config.activate.on-profile, с которым источник учитывается, только если активен один из указанных профилей, и spring.config.activate.on-cloud-platform, с которым он учитывается только на указанной платформе, например в Kubernetes.

  2. Проход без профилей. Первичная конфигурация уже загружена, и по ней Spring Boot определяет облачную платформу. Это включает источники с spring.config.activate.on-cloud-platform, и их импорты тоже обрабатываются.

  3. Проход с профилями. По всему, что уже загружено, Spring Boot вычисляет активные профили. Затем он заново резолвит каждую локацию, на этот раз с учётом профилей, и включает источники с spring.config.activate.on-profile.

Три разные фазы

Три разные фазы

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

Поэтому второй проход не повторяет первый, а только подхватывает источники, которые стали активны после определения облачной платформы. Третий проход, уже после активации профилей, находит всё, что от них зависит. Например, та же стандартная локация с профилем dev находит не только application.yml, но и application-dev.yml.

ConfigDataImporter помнит уже загруженные ресурсы, поэтому application.yml повторно не грузится, и в дерево добавляется только новое. А источники с spring.config.activate.on-profile до активации профилей неактивны, так что их импорты обрабатываются только в третьем проходе.

Кто кого перекрывает?

Загружаются источники во время проходов, но в Environment они попадают только в самом конце. Environment хранит источники списком и ищет свойство сверху вниз, так что побеждает первый источник, в котором нашёлся ключ. Поэтому «важнее» дальше значит «выше в этом списке».

Когда все три прохода завершены, ConfigDataEnvironment обходит дерево и дописывает загруженные источники в конец списка, под теми, что там уже были. Правила получаются такие:

  • Переменные окружения, системные свойства и аргументы командной строки важнее файлов конфигурации и всего, что подключено через spring.config.import. Они лежали в Environment ещё до загрузки конфигов, а загруженные источники встают под ними. Поэтому --server.port=9090 перебьёт server.port и из application.yml, и из Vault.

  • Импортированный источник важнее того, в котором объявлен импорт. Импорт встаёт в списке сразу над файлом, который его объявил. Если в application.yml написан vault://, значения из Vault перебьют одноимённые значения из самого application.yml.

  • Из нескольких импортов в одном списке важнее тот, что объявлен позже. В spring.config.import: ["configserver:", "vault://"] при совпадении ключей выиграет Vault.

  • Профильные источники важнее обычных. application-dev.yml перебивает application.yml, а secret/my-app/dev перебивает secret/my-app.

Сверху самый важный источник, снизу наименее важный, и каждый перекрывает всё, что ниже.

Кто кого оверрайдит

Кто кого перекрывает

Возьмём итоговый порядок для application.yml, который импортирует optional:vault://, при активном профиле dev. application-dev.yml стоит выше Vault, хотя Vault перекрывает application.yml.

Дело в том, что импорт встаёт сразу над файлом, в котором объявлен, а не над всеми файлами. Vault объявлен в application.yml и стоит прямо над ним, а application-dev.yml найден в третьем проходе как самостоятельный файл и поэтому идёт первым.

Отсюда неочевидное следствие: если одно и то же свойство задано и в Vault, и в application-dev.yml, победит application-dev.yml.

Итоговый порядок источников в работающем приложении видно в эндпоинте /actuator/env, где источники перечислены от самого важного к наименее важному. Ещё удобнее смотреть на странице Окружение в Axelix. Свойства там сгруппированы по источникам, а значок короны отмечает значение, которое реально используется, если один и тот же ключ задан в нескольких местах.

Резолвер и загрузчик

Каждая строчка в spring.config.import проходит два шага. Сначала резолвер превращает строку-локацию в один или несколько ресурсов. Потом загрузчик превращает каждый ресурс в набор PropertySource.

И те и другие регистрируются в META-INF/spring.factories. Сам Spring Boot так подключает свои реализации: ConfigTreeConfigDataLocationResolver для префикса configtree:, SystemEnvironmentConfigDataLocationResolver для env: и StandardConfigDataLocationResolver для обычных файлов.

У резолвера есть два метода: resolve и resolveProfileSpecific. В первых двух проходах вызывается только resolve, в третьем оба, и их результаты склеиваются. Vault и Kubernetes нужны профили, поэтому всю работу они делают в resolveProfileSpecific и срабатывают только в третьем проходе.

Spring Boot выбирает резолвер так:

  • Резолверы сортируются по Ordered / @Order, и у каждого по очереди спрашивается isResolvable. Локацию забирает первый, кто ответил true, остальных даже не спрашивают.

  • StandardConfigDataLocationResolver готов взять любую локацию: его isResolvable всегда возвращает true. Чтобы он не перехватывал чужие префиксы, ConfigDataLocationResolvers при старте сам переставляет его в конец списка, независимо от порядка.

Загрузчик в этой паре отвечает за сам поход в источник. Он читает файл, обращается к Vault или к Kubernetes API и превращает ответ в PropertySource. Какой загрузчик возьмётся за ресурс, Spring Boot определяет по типу ресурса, который вернул резолвер, поэтому резолвер и загрузчик одного источника всегда работают в паре, как VaultConfigDataLocationResolver и VaultConfigDataLoader. Если источник недоступен или ресурса нет, ошибка возникает именно на этом шаге.

Префикс optional: помечает локацию как необязательную. Если по ней ничего не нашлось, приложение всё равно стартует без ошибки. Но он глушит ошибки и тогда, когда источник выключен в настройках или его стартер не подключён, и локацию забирает StandardConfigDataLocationResolver. Поэтому опечатка в префиксе и выключенный источник выглядят одинаково: ничего не загрузилось, и ошибки нет.

Откуда резолвер берёт настройки

Свои настройки, например адрес Vault, namespace в Kubernetes или имя приложения, резолвер читает через Binder из ConfigDataLocationResolverContext. Этот Binder видит источники, которые уже есть в Environment, и всё, что успело загрузиться к этому моменту, но не видит того, что загрузится позже.

Именно поэтому настройки для Vault можно положить в application.yml. Файл грузится раньше, чем обрабатывается импорт vault://, который в нём же и объявлен.

Один паттерн, много реализаций

Реализаций этого контракта много, например для Consul, Zookeeper и AWS Parameter Store. Мы посмотрим на три популярных источника из Spring Cloud. Структура у всех одинаковая, а различия как раз в тех местах, о которых мы говорили выше.

Config Client ходит на сервер дважды. Ресурс, созданный без профилей, и ресурс с профилями не равны друг другу, поэтому дедупликация в ConfigDataImporter не срабатывает. Это осознанное решение: первый запрос отдаёт конфигурацию до вычисления профилей, и она может на эти профили повлиять.

У Kubernetes есть ещё проверка платформы. Его isResolvable возвращает true только внутри кластера или при spring.main.cloud-platform=kubernetes. Локально импорт optional:kubernetes: просто молча пропускается. Это частая причина вопроса «Почему мои ConfigMap не подтянулись?».

Префикс

Когда срабатывает

configserver:

В первом и третьем проходах, поэтому сервер запрашивается дважды

vault:

Только в третьем проходе

kubernetes:

Только в третьем проходе и только внутри кластера

EnvironmentPostProcessor до и после ConfigData

В прошлой статье было правило: EnvironmentPostProcessor, который читает конфигурацию, должен идти после ConfigDataEnvironmentPostProcessor, а EnvironmentPostProcessor, который определяет, откуда она берётся, должен идти до него. Теперь, когда мы знаем, как устроена загрузка, понятно, откуда взялось это правило.

EnvironmentPostProcessor, который запускается до ConfigData, то есть с getOrder() меньше ConfigDataEnvironmentPostProcessor.ORDER, пишет в Environment, и всё, что он туда добавит, становится частью корня дерева ConfigDataEnvironmentContributor. В результате это видит Binder любого резолвера. Так можно подложить spring.cloud.vault.uri, поменять namespace для Kubernetes или добавить spring.config.additional-location. Именно так Axelix Master превращает собственное свойство axelix.master.config.location в дополнительную локацию конфигов.

EnvironmentPostProcessor, который запускается после ConfigData, то есть с getOrder() больше, работает, когда всё уже загружено, включая секреты из Vault и ConfigMap из Kubernetes. Он может прочитать итоговые значения и перебить их. Именно так Axelix дописывает свои эндпоинты в management.endpoints.web.exposure.include. А вот на саму загрузку такой EnvironmentPostProcessor уже не влияет: резолверы уже отработали, и загрузка закончена.

Но бывает, что не подходит ни один из вариантов. EnvironmentPostProcessor, который запускается до ConfigData, не видит application.yml, потому что этот файл ещё не загружен. Если настройки, которые нужно переложить, пользователь пишет в application.yml, то ранний EnvironmentPostProcessor их не прочитает, а поздний уже опоздает. Именно с этим мы столкнулись в Axelix, когда переносили настройки Vault под свой префикс.

EnvironmentPostProcessor до и после ConfigData

EnvironmentPostProcessor до и после ConfigData

Выход из этой ловушки один: вмешаться в работу ConfigData там, где application.yml уже прочитан, а удалённый источник ещё нет. В Axelix мы сделали это двумя способами: плейсхолдерами и своим резолвером.

Как Axelix подключает Config Server и Vault через свои настройки

В Axelix Master все настройки живут под axelix.master.*, и мы хотим, чтобы внешняя конфигурация не была исключением. Пользователь пишет axelix.master.external-config.spring-cloud-vault.*, а не spring.cloud.vault.*.

Задача ровно та, что описана выше: эти свойства лежат в application.yml, а применить их нужно до того, как отработает импорт vault://.

Config Server и плейсхолдеры

У Spring Cloud Config Client немного настроек, поэтому хватило плейсхолдеров:

spring:
  config:
    import:
      - "optional:configserver:"
      - "optional:vault://"
  cloud:
    config:
      enabled:   "${axelix.master.external-config.spring-cloud-config.enabled}"
      fail-fast: true
      uri:       "${axelix.master.external-config.spring-cloud-config.uri:}"
      username:  "${axelix.master.external-config.spring-cloud-config.username:}"
      password:  "${axelix.master.external-config.spring-cloud-config.password:}"

Это работает, потому что Binder резолвера разрешает плейсхолдеры в момент чтения, а к этому моменту application.yml уже загружен.

Обратите внимание на optional: перед configserver:. Здесь пригодилось знание про isResolvable: резолвер Config Client отвечает true, только когда spring.cloud.config.enabled равен true. Если пользователь выключил Config Server, локацию configserver: заберёт StandardConfigDataLocationResolver и не сможет разобрать её как файл.

Без optional: это ошибка на старте, а с ним импорт просто пропускается. При этом fail-fast для включённого сервера продолжает работать.

Vault и свой резолвер

С Vault плейсхолдеры не подходят. У VaultProperties десятки свойств, включая разные методы аутентификации, SSL, таймауты и не только. Перечислять их все руками значит поддерживать копию чужого класса.

Вместо этого мы написали свой резолвер: AxelixVaultConfigDataLocationResolver. Он наследуется от стандартного резолвера Vault и стоит раньше него, поэтому локацию vault:// забирает он.

В resolveProfileSpecific он читает наши настройки через Binder, кладёт их в BootstrapContext как VaultProperties через registerIfAbsent и передаёт управление родительскому методу resolveProfileSpecific. Родительский метод пытается зарегистрировать свой экземпляр VaultProperties, но место уже занято, и дальше Vault работает на наших настройках.

Трюк держится на BootstrapContext. Это небольшой реестр объектов, который живёт с самого старта приложения до появления ApplicationContext. Через него резолвер передаёт загрузчику клиенты и уже собранные настройки.

Метод registerIfAbsent не заменяет объект, если объект такого типа уже зарегистрирован, поэтому в реестре остаётся наш экземпляр VaultProperties.

С настройками KV-движка этот трюк не сработал. VaultKeyValueBackendProperties резолвер Vault не берёт из BootstrapContext, а каждый раз биндит из Binder по префиксу spring.cloud.vault.kv. Благо настроек у KV немного, поэтому для них мы вернулись к плейсхолдерам, как с Config Server. Свойства spring.cloud.vault.kv.* в application.yml ссылаются на axelix.master.external-config.spring-cloud-vault.kv.*.

Как увидеть это самому

Всё описанное выше видно в логах. Достаточно включить уровень TRACE для пакета ConfigData, например в application.yml, аргументом командной строки или переменной окружения:

logging:
  level:
    org.springframework.boot.context.config: TRACE

Настоящая система логирования в этот момент ещё не поднята, поэтому Spring Boot копит эти сообщения и выводит их чуть позже, уже с учётом настроенных уровней.

Заключение

Мы разобрали, как Spring Boot обрабатывает spring.config.import, за сколько проходов грузятся источники, кто кого перекрывает и почему EnvironmentPostProcessor не всегда может повлиять на загрузку. Если держать в голове несколько вещей, большинство вопросов снимается само:

  1. Все импорты обрабатываются на старте, ещё до создания ApplicationContext, за три прохода — до вычисления профилей и после.

  2. Настройки источника (например, адрес Vault) можно держать в application.yml и в том, что загружено раньше него, но не в том, что загрузится позже.

  3. Разные источники срабатывают в разное время. Одни грузятся сразу, другие ждут профилей.

  4. Импорт встаёт сразу над файлом, в котором объявлен, поэтому профильный файл может оказаться важнее того, что импортировано из базового.

  5. optional: молча пропускает всё, что не удалось загрузить, так что при странном поведении стоит включить TRACE-логи, заглянуть в /actuator/env или на страницу Окружение в Axelix.

Axelix — open-source проект. Код, о котором шла речь, лежит на GitHub, и вы всегда можете посмотреть, как всё устроено целиком.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.