Pull to refresh

Spring Cloud Config и обновление компонентов в рантайме

Reading time8 min
Views9K

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

На проекте используется spring boot 2.6.4 и kotlin 1.5.31. Также для конфигурации сервисов используется spring cloud config server, где в качестве backend используются Git + Vault.

Spring Cloud Config Server

Для тестирования подходов легче использовать в качестве backend Spring Cloud Config Server файловую систему:

#docker-compose.yml
version: '2'
services:
 config-server-env:
   container_name: config-server-env
   image: hyness/spring-cloud-config-server:2.2
   ports:
     - "8888:8888"
   environment:
     SPRING_PROFILES_ACTIVE: native
   volumes:
     - ./config:/config

Поместим конфигурацию для приложения refresh_app с профилем dev в директорию ./config:

#config/refresh_app-dev.yml
refresh:
  property1: 1

После запуска данной конфигурации можно проверить, какую конфигурацию отдает Spring Cloud Config Server для тестового сервиса refresh_app:

 >curl http://localhost:8888/refresh_app/dev
{"name":"refresh_app","profiles":["dev"],"label":null,"version":null,"state":null,"propertySources":[{"name":"file:config/refresh_app-dev.yml","source":{"refresh.property1":1}}]}

Конфигурация Spring Cloud Config Client (сервиса refresh_app)

//build.gradle
..............
implementation "org.springframework.boot:spring-boot-starter"
implementation "org.springframework.cloud:spring-cloud-starter-config"
implementation "org.springframework.boot:spring-boot-starter-actuator"
implementation "org.springframework.boot:spring-boot-starter-web"
..............
#application.properties
spring.config.import=optional:configserver:http://localhost:8888
spring.application.name=refresh_app
server.port=8080
management.endpoints.web.exposure.include=*

Обновление конфигурации и синхронизация с сервисом

Для обновления конфигурации refresh_app на стороне Spring Cloud Config Server в нашем примере достаточно просто обновить файл config/refresh_app-dev.yml. Чтобы убедиться в том, что наш сервер отдает уже обновленную конфигурации можно снова выполнить запрос GET /refresh_app/dev к spring cloud config server.

Далее будут использоваться actuator endpoints сервиса refresh_app:

  • POST /actuator/refresh

    Вызов данного endpoint выполняет запрос текущей конфигурации у Spring Cloud Server. Сверяет полученную конфигурацию с текущей конфигурацией приложения и генерирует список properties, которые изменились, и обновляет Environment сервиса. Response body данного запроса содержит Set измененных properties. Также внутри приложения генерируется событие EnvironmentChangeEvent, которое тоже содержит Set обновлений, на него можно подписаться.

  • GET /actuator/env

    Response body отображает текущий Environment сервиса. С помощью данного вызова можно убедиться, что обновленная конфигурация подтянулась.

Для того, чтобы включить actuator endpoints для сервиса необходимо:

  • в build.gradle зависимости spring-boot-starter-actuator и spring-boot-starter-web

  • в application.properties прописать "management.endpoints.web.exposure.include=*"

Как сделать так, чтобы компоненты приложения использовали обновленную конфигурацию

Рассмотрим самый простой пример: есть некий сервис, который в ответ на запрос формирует ответ на основе значения refresh.property1. Реализуем такую простую логику тремя различными способами.

1) Environment

@RestController
class Rest1(val applicationContext: ApplicationContext) {
   @GetMapping("/test1")
   fun test(): ResponseEntity<String> = 
    ResponseEntity.ok(applicationContext.environment["refresh.property1"])
}

2) @ConfigurationProperties

@ConfigurationProperties("refresh")
class TestProperties {
   lateinit var property1: String
}
@RestController
class Rest2(val testBean: TestProperties ) {
   @GetMapping("/test2")
   fun test(): ResponseEntity<String> = ResponseEntity.ok(testBean.property1)
}

Хочу обратить внимание на особенность работы kotlin и spring. Следующие конструкции будут корректно инициализироваться во время старта приложения, но не смогут обновляться в runtime:

@ConstructorBinding 
@ConfigurationProperties("refresh") 
data class TestBean(val property1: String)

@ConstructorBinding 
@ConfigurationProperties("refresh") 
data class TestBean(var property1: String)

3) @RefreshScope + @Value

@RefreshScope
@RestController
class Rest3(@Value("\${refresh.property1}") val property1: String) {
   @GetMapping("/test3")
   fun test(): ResponseEntity<String> = ResponseEntity.ok(property1)
}

Все три приведенных выше варианта реализации в ответе будут отдавать актуальное значение refresh.property1 после синхронизации со Spring Cloud Config Server с помощью вызова POST /actuator/refresh.

В данном случае логика работы сервиса не требует выполнения какой-то логики по обновлению бинов, но в некоторых случаях требуется более комплексный подход.

Реинициализация существующих бинов

В предыдущем разделе рассмотрен самый простой случай по изменению логики работы приложения после обновления конфигурации. Но в некоторых случаях после обновления некоторых properties требуется реинициализация компонента приложения.

Например есть бин, который является оберткой над скажем KafkaConsumer. В @PostConstruct методе выполняется инициализация и запуск Consumer, в @PreDestroy методе выполняется остановка и деинициализация Consumer. В таком случае хотелось бы, чтобы данный бин реагировал на изменение Environment и ,если связанные с ним properties изменились, реинициализировался. Что значит реинициализация в данном контексте - например вызов @Predestroy метода и вызов @PostConstruct метода с учетом уже обновленной конфигурации.

Как было указано ранее результатом выполнения запроса к сервису POST /actuator/refresh является генерация события EnvironmentChangeEvent со списком изменившихся properties, на которое можно подписаться и вызвать определенную логику.

Т.е. можно реализовать свой Bean Post Processor вызывающий процесс реинициализации определенных компонентов сервиса, если определенные properties были обновлены. Я ввел новые аннотации @Refreshable и @Refresh:

@Target(ElementType.METHOD, ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
annotation class Refreshable(val property: String)

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
annotation class Refresh

@Refreshable задает property от которого зависит бин и при изменение которого, происходит его обновление. @Refresh - для метода, который будет выполнен, если связанные с бином property будут обновлены.

Изначально хотелось обойтись одной аннотацией @Refreshable и в случае обновления деинициализировать и инициализировать бин как это делает спринговый контекст, но пришло понимание, что нужно учитывать много факторов и лучше дать возможность явно указать какой метод будет выполняться при реинициализации с помощью @Refresh.

Реализация Bean Post Processor:

class RefreshBeanPostProcessor(private val applicationContext: GenericApplicationContext) : BeanPostProcessor {

   private val refreshPropertyTasks = mutableListOf<RefreshPropertyTask>()

   override fun postProcessBeforeInitialization(bean: Any, beanName: String): Any {
       (getRefreshablePropertyByBean(bean) ?: getRefreshablePropertyByBeanDefinition(beanName))
           ?.let { property ->
               bean.javaClass.methods
                   .firstOrNull { it.getAnnotation(Refresh::class.java) != null }
                   ?.let { method -> 
                     refreshPropertyTasks.add(RefreshPropertyTask(property) 
                                              { method.invoke(bean) }) }
           }
       return bean
   }

   @PostConstruct
   fun init() {
       applicationContext.addApplicationListener { event ->
           if (event is EnvironmentChangeEvent) {
               onApplicationEvent(event)
           }
       }
   }

   private fun onApplicationEvent(event: EnvironmentChangeEvent) {
       refreshPropertyTasks
           .filter { task -> event.keys.firstOrNull { key -> key.startsWith(task.property) } != null }
           .forEach { task ->
               try {
                   log.info("Update task ${task.property}")
                   task.action()
               } catch (ex: Exception) {
                   log.error("Error for task ${task.property}", ex)
               }
           }
   }

   private data class RefreshPropertyTask(val property: String, val action: () -> Unit)

   private fun getRefreshablePropertyByBean(bean: Any): String? =
       bean.javaClass.getAnnotation(Refreshable::class.java)?.property

   private fun getRefreshablePropertyByBeanDefinition(beanName: String): String? =
       applicationContext.beanFactory
           .let { kotlin.runCatching { it.getBeanDefinition(beanName) }.getOrNull() }
           ?.source
           ?.takeIf { it is AnnotatedTypeMetadata }
           ?.let { it as AnnotatedTypeMetadata }
           ?.getAnnotationAttributes(Refreshable::class.java.name)
           ?.let { it[Refreshable::property.name] as String? }
}

Важно упомянуть, что необходимо для данного постпроцессора добавить @DependsOn("configurationPropertiesRebinder"). configurationPropertiesRebinder - это компонент, который собственно обновляет @ConfigurationProperties классы. Он точно также реализует интерфейс ApplicationListener<EnvironmentChangeEvent> как и реализованный Bean Post Processor, и, если при инициализации ApplicationListener он будет в списке контекста стоять после нашего, то на момент выполнения нашей логики реинициализации, классы @ConfigurationProperties будут еще не обновлены и наша реинициализация произойдет с неактуальными значениями, если она зависит от классов @ConfigurationProperties.

@DependsOn("configurationPropertiesRebinder")
@Bean
fun refreshBeanPostProcessor(ctx: GenericApplicationContext) = 
  RefreshBeanPostProcessor(ctx)

Это гарантирует нам, что ApplicationListener реализованный внутри Bean Post Processor будет в списке после configurationPropertiesRebinder и соответственно будет выполняться после.

Примеры использования:

@ConfigurationProperties("refresh")
class TestProperties{
   lateinit var property1: String
   lateinit var property2: String
}

@Refreshable("refresh")
@Service
class Bean1(testProperties: TestProperties){
  
   @PostConstruct fun init(){}
   @PreDestroy fun destroy(){}
  
   @Refresh
   fun refresh(){
       destroy()
       init()
   }
}

class Bean2(testProperties: TestProperties){

   @PostConstruct fun init(){}
   @PreDestroy fun destroy(){}

   @Refresh
   fun refresh(){
       destroy()
       init()
   }
}

@Configuration
class TestConfiguration{

   @Refreshable("refresh")
   @Bean
   fun bean2(testProperties: TestProperties) = Bean2(testProperties)
}

Автообновление Environment сервиса

Spring Cloud Config Server ничего не знает о клиентах и для обновления конфигурации на каждом инстансе нашего сервиса необходимо вызывать POST /actuator/refresh.

Одним из вариантов упростить обновление сервисов может стать использование Spring Cloud Bus. Необходимо настроить интеграцию на сервисах и Spring Cloud Config Server через добавление зависимости и настройки конфигурации. Для интеграции могут быть использованы Kafka, RabbitMQ и др. После настройки вызовом GET /monitor Spring Cloud Config Server будет вызвано обновление всех сервисов, через обмен сообщениями через topic Kafka. Если в качестве backend для Spring Cloud Config Server используется система, которая поддерживает webhook, то можно настроить вызов GET /monitor через webhook. Я пока решил не останавливаться на данном решение, т.к. пока кажется, что такая дополнительная интеграция усложняет поддержку и настройку системы.

На данный момент решил сделать автообновление внутри каждого сервиса. Выглядит так, что периодический запрос не должен добавить какую-то существенную нагрузку на систему, даже если всего всех инстансов всех сервисов суммарно около 100.

Вариант реализации периодического запроса на обновление Environment:

class RefreshEventPublisherScheduler(
   private val refreshSchedulerProperties: RefreshSchedulerProperties,
   private val applicationEventPublisher: ApplicationEventPublisher
) {

   companion object : Log()

   private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()

   @PostConstruct
   fun init() {
       schedule()
   }

   private fun schedule() {
       executor.schedule(
           {
               if (refreshSchedulerProperties.enabled) {
                   publishRefreshEvent()
               }
               schedule()
           },
           refreshSchedulerProperties.interval.toMillis(),
           TimeUnit.MILLISECONDS
       )
   }

   private fun publishRefreshEvent() {
       applicationEventPublisher.publishEvent(
           RefreshEvent(
               this,
               "Refresh event",
               "Refresh scope"
           )
       )
   }
}

Т.е. каждый клиент по расписанию вызывает метод publishRefreshEvent(), который соответствует вызову POST /actuator/refresh.

Реализованные Bean Post Processor и RefreshEventPublisherScheduler можно включить в стартер и использовать в сервисах для автообновления и реинициализации бинов.

Tags:
Hubs:
Total votes 6: ↑5 and ↓1+4
Comments6

Articles