1. OVERVIEW

This post is a continuation of Centralized and versioned configuration using Spring Cloud Config Server and Git where I covered the Spring Cloud Config server to prevent hard-coding properties that most-likely would be different for each deployment environment.

At the end of said post I outlined how updating a Git-backed property and sending a POST request to the client application’s /refresh actuator endpoint caused the configuration property to be reloaded and the dependent Spring beans to be refreshed picking up the new value.

But what if it’s not just one or two microservices but maybe dozens or even hundreds that would need to reload those properties changes? Hitting /refresh for each one doesn’t seem like an elegant solution. What if the numbers of instances keep growing?

Spring Cloud Config Server, Spring Cloud Bus, RabbitMQ and Git workflow

In this post I’ll extend the Spring Cloud Config Server and the client service implemented in part 1 with Spring Cloud Bus and RabbitMQ support and a Bitbucket webhook to automatically notify subscribed client services of changes in the Git-backed configuration files.

2. REQUIREMENTS

  • Java 7+.
  • Maven 3.2+.
  • Familiarity with Spring Framework.
  • A Spring Cloud Config server instance for the Refreshable Demo Config client to read properties from.
  • A RabbitMQ host for the Config server to publish changes to and for the subscribed clients to get notifications with the updated properties.

3. SETTING UP RABBITMQ

To keep this tutorial simple, a RabbitMQ service instance from https://www.cloudamqp.com’s free plan is going to be used. All needed is to set an exchange up using these values:

name springCloudBus
type topic
durable true
autoDelete false
internal false


4. UPDATING THE SPRING CLOUD CONFIG SERVER

pom.xml:

...
+   <dependency>
+      <groupId>org.springframework.cloud</groupId>
+      <artifactId>spring-cloud-config-monitor</artifactId>
+   </dependency>
+   <dependency>
+       <groupId>org.springframework.cloud</groupId>
+       <artifactId>spring-cloud-starter-stream-rabbit</artifactId>
+   </dependency>
+
...

spring-cloud-config-monitor provides a /monitor endpoint for the Config server to receive notification events when properties backed by a Git repository change, only if spring.cloud.bus property is enabled.
spring-cloud-starter-stream-rabbit is used to send event notifications from the Config server to a RabbitMQ exchange (again, only if spring.cloud.bus property is enabled) with the properties change for subscribers to get notified with.

ConfigServerApplication.java:

package com.asimio.cloud.config;
...
-@SpringBootApplication
+@SpringBootApplication(exclude = { RabbitAutoConfiguration.class })
@EnableConfigServer
public class ConfigServerApplication extends SpringBootServletInitializer {
...
}

Now that RabbitMQ libraries are in the classpath, the main entry point of the Config server is going to exclude RabbitMQ autoconfiguration and activate it only when the config-monitor Spring profile is used so that this infrastructure microservice could be started as standalone service or in a registration-first approach without the need to process Git push event notifications.

ConfigMonitorConfiguration.java:

package com.asimio.cloud.config
...
@Profile("config-monitor")
@Configuration
@Import(RabbitAutoConfiguration.class)
public class ConfigMonitorConfiguration {

}

This Spring configuration class, which is scanned as part of the application startup process, will only be used when activating the config-monitor Spring profile. It’s at this point where RabbitMQ autoconfiguration takes place.

application.yml:

spring:
  cloud:
+   bus:
+     enabled: false
    config:
      server:
        git:
...
+---
+spring:
+  profiles: config-monitor
+  cloud:
+    bus:
+      enabled: true
+  rabbitmq:
+    host: cat.rmq.cloudamqp.com
+    port: 5672
+#    virtual-host:
+#    username:
+#    password:
+
...

By default spring.cloud.bus.enabled is set to false, meaning the Spring Cloud Config server won’t use Spring Cloud Bus capabilities to process Git push events notifications.
Once the config-monitor profile is activated, through -Dspring.profiles.active=config-monitor for instance, Git push events notifications sent via a Bitbucket webhook are processed by the Config server and changes are sent to a RabbitMQ exchange for subscribed client services listeners to process.

5. THE DEMO REFRESHABLE CONFIG CLIENT

The refreshable demo config client is very similar to the Centralized and versioned configuration using Spring Cloud Config Server and Git’s demo config client, in fact the refreshable version is just a clone with the main changes discussed next.

pom.xml:

...
<dependency>
  <groupId>org.springframework.cloud</groupId>
-  <artifactId>spring-cloud-starter-eureka</artifactId>
+  <artifactId>spring-cloud-starter-bus-amqp</artifactId>
</dependency>
...

spring-cloud-starter-eureka was left out to keep this demo simple. This service won’t register with a Discovery server and neither will retrieve the Config server metadata from it. If you are interested in find out more about this topic please refer to Centralized and versioned configuration using Spring Cloud Config Server and Git post.
spring-cloud-starter-bus-amqp brings the dependencies required for this service to subscribe to the RabbitMQ exchange where Spring Cloud Config server will send messages with the properties that need to be refreshed.

bootstrap.yml:

...
cloud:
  config:
    uri: http://localhost:8100
profiles:
  active: development
...

The Config service is found at http://localhost:8100 and the development Spring profile is active by default.

application.yml:

...
spring:
  rabbitmq:
    host: cat.rmq.cloudamqp.com
    port: 5672
#    virtual-host:
#    username:
#    password:
...

Similarly to Config server’s application.yml, these are properties used to connect to the RabbitMQ host.
No Eureka-related setting is found in this file, because again, registering with the Discovery server was removed to keep this post simple.

ActorResource.java:

package com.asimio.api.demo.rest;
...
@RefreshScope
@RestController
@RequestMapping(value = "/actors", produces = "application/json")
public class ActorResource {

  @Value("${app.message:Default}")
  private String msg;
...

This is the resource that includes a property app.message coming from a remote Git-backed file, that it’s supposed to get updated once the file including it is pushed, thanks to the @RefreshScope annotation.

6. ADDING A BITBUCKET WEBHOOK

Let’s add a Bitbucket webhook so that the Config server gets notified of repository push events:

Bitbucket Config server webhook Bitbucket Config server webhook

7. RUNNING THE CONFIG SERVER

It could be run using either java <VM args> -jar target/config-server.jar or spring-boot:run Maven goal as shown next:

mvn spring-boot:run -Dspring.profiles.active=config-monitor -Dspring.rabbitmq.virtual-host=<your virtual host> -Dspring.rabbitmq.username=<your username> -Dspring.rabbitmq.password=<your password>
...
2017-01-31 11:50:13 INFO  RabbitMessageChannelBinder:261 - declaring queue for inbound: springCloudBus.anonymous.dXBEi1eeTeqcRMy6VuGKLA, bound to: springCloudBus
2017-01-31 11:50:13 INFO  AmqpInboundChannelAdapter:97 - started inbound.springCloudBus.anonymous.RCZzp_VpR7qaA7SilhZCWA
2017-01-31 11:50:13 INFO  EventDrivenConsumer:108 - Adding {message-handler:inbound.springCloudBus.default} as a subscriber to the 'bridge.springCloudBus' channel
2017-01-31 11:50:13 INFO  EventDrivenConsumer:97 - started inbound.springCloudBus.default
2017-01-31 11:50:13 INFO  DefaultLifecycleProcessor:341 - Starting beans in phase 2147483647
2017-01-31 11:50:14 INFO  Http11NioProtocol:179 - Initializing ProtocolHandler ["http-nio-8100"]
2017-01-31 11:50:14 INFO  Http11NioProtocol:179 - Starting ProtocolHandler [http-nio-8100]
2017-01-31 11:50:14 INFO  NioSelectorPool:179 - Using a shared selector for servlet write/read
2017-01-31 11:50:14 INFO  TomcatEmbeddedServletContainer:185 - Tomcat started on port(s): 8100 (http)
2017-01-31 11:50:14 INFO  ConfigServerApplication:57 - Started ConfigServerApplication in 7.482 seconds (JVM running for 11.027)

Notice the RabbitMQ message channel / handler logs.

8. RUNNING THE DEMO REFRESHABLE CONFIG CLIENT

Lets run this demo using Java instead:

mvn clean package
...
java -Dspring.rabbitmq.virtual-host=<your virtual host> -Dspring.rabbitmq.username=<your username> -Dspring.rabbitmq.password=<your password> -jar target/demo-refreshable-config-client.jar
...
2017-02-01 23:03:59 INFO  PublishSubscribeChannel:81 - Channel 'demo-refreshable-config-client:development:8700.errorChannel' has 1 subscriber(s).
2017-02-01 23:03:59 INFO  EventDrivenConsumer:97 - started _org.springframework.integration.errorLogger
2017-02-01 23:03:59 INFO  DefaultLifecycleProcessor:341 - Starting beans in phase 2147482647
2017-02-01 23:03:59 INFO  RabbitMessageChannelBinder:261 - declaring queue for inbound: springCloudBus.anonymous.It1ZPSVBT2WvTAhsLg61iw, bound to: springCloudBus
2017-02-01 23:03:59 INFO  AmqpInboundChannelAdapter:97 - started inbound.springCloudBus.anonymous.GtK1FshYRc61pqblZ6Pkdw
2017-02-01 23:03:59 INFO  EventDrivenConsumer:108 - Adding {message-handler:inbound.springCloudBus.default} as a subscriber to the 'bridge.springCloudBus' channel
2017-02-01 23:03:59 INFO  EventDrivenConsumer:97 - started inbound.springCloudBus.default
2017-02-01 23:03:59 INFO  DefaultLifecycleProcessor:341 - Starting beans in phase 2147483647
2017-02-01 23:03:59 INFO  Http11NioProtocol:179 - Initializing ProtocolHandler ["http-nio-8700"]
2017-02-01 23:03:59 INFO  Http11NioProtocol:179 - Starting ProtocolHandler [http-nio-8700]
2017-02-01 23:03:59 INFO  NioSelectorPool:179 - Using a shared selector for servlet write/read
2017-02-01 23:03:59 INFO  TomcatEmbeddedServletContainer:192 - Tomcat started on port(s): 8700 (http)
2017-02-01 23:03:59 INFO  Application:57 - Started Application in 9.547 seconds (JVM running for 10.512)

It registers a listener to the springCloudBus topic, uses the development Spring profile and reads the app.message property from the remote file demo-refreshable-config-client-development.properties found in a Git repo.

8.1. Sending an initial request to the endpoint

curl -v "http://localhost:8700/actors/1"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8700 (#0)
> GET /actors/1 HTTP/1.1
> Host: localhost:8700
> User-Agent: curl/7.51.0
> Accept: */*
>
< HTTP/1.1 200
< X-Application-Context: demo-refreshable-config-client:development:8700
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Thu, 02 Feb 2017 04:11:00 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
{"actorId":"1","firstName":"First1","lastName":"Last1. App message from development profile - Update 6","lastUpdate":null}

Nothing out of the ordinary, App message from development profile - Update 6 value comes from the Git-backed file.

8.2. Simulating a push event

Lets send a POST request to the Config server /monitor endpoint to simulate a request Bitbucket would send when a Git repo file is pushed:

curl -v -X POST "http://localhost:8100/monitor" -H "Content-Type: application/json" -H "X-Event-Key: repo:push" -H "X-Hook-UUID: webhook-uuid" -d '{"push": {"changes": []} }'
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8100 (#0)
> POST /monitor HTTP/1.1
> Host: localhost:8100
> User-Agent: curl/7.51.0
> Accept: */*
> Content-Type: application/json
> X-Event-Key: repo:push
> X-Hook-UUID: webhook-uuid
> Content-Length: 26
>
* upload completely sent off: 26 out of 26 bytes
< HTTP/1.1 200
< X-Application-Context: Configuration-Server:config-monitor:8100
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Thu, 02 Feb 2017 04:30:17 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact

The response is not necessarily relevant at this point, just the is’s a 200 OK and it causes the Config server to process it:

Config server logs:

2017-02-01 23:30:16 INFO  PropertyPathEndpoint:89 - Refresh for: *
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@2955a586: startup date [Wed Feb 01 23:30:17 EST 2017]; root of context hierarchy
2017-02-01 23:30:17 INFO  AutowiredAnnotationBeanPostProcessor:155 - JSR-330 'javax.inject.Inject' annotation found and supported for autowiring
2017-02-01 23:30:17 INFO  PostProcessorRegistrationDelegate$BeanPostProcessorChecker:325 - Bean 'configurationPropertiesRebinderAutoConfiguration' of type [class org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration$$EnhancerBySpringCGLIB$$c9492049] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2017-02-01 23:30:17 INFO  SpringApplication:665 - The following profiles are active: config-monitor
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@14f46b7d: startup date [Wed Feb 01 23:30:17 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@2955a586
2017-02-01 23:30:17 INFO  AutowiredAnnotationBeanPostProcessor:155 - JSR-330 'javax.inject.Inject' annotation found and supported for autowiring
2017-02-01 23:30:17 INFO  SpringApplication:57 - Started application in 0.201 seconds (JVM running for 1928.343)
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:987 - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@14f46b7d: startup date [Wed Feb 01 23:30:17 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@2955a586
2017-02-01 23:30:17 INFO  RefreshListener:27 - Received remote refresh request. Keys refreshed []
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@6f42513e: startup date [Wed Feb 01 23:30:17 EST 2017]; root of context hierarchy
2017-02-01 23:30:17 INFO  AutowiredAnnotationBeanPostProcessor:155 - JSR-330 'javax.inject.Inject' annotation found and supported for autowiring
2017-02-01 23:30:17 INFO  NativeEnvironmentRepository:228 - Adding property source: file:/var/folders/lg/n7p2jx0j4qjb0jdy09_lzc7m0000gp/T/config-repo-371856225706501714/demo-refreshable-config-client-development.properties
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:987 - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@6f42513e: startup date [Wed Feb 01 23:30:17 EST 2017]; root of context hierarchy

Notice the Refresh for: and RefreshListener:27 - Received remote refresh request. Keys refreshed [] logs, it refreshes its properties, nothing changed though, because this was just a simulation with an empty changeset payload.

Refreshable config demo client logs:

2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@2b5fe6a3: startup date [Wed Feb 01 23:30:17 EST 2017]; root of context hierarchy
2017-02-01 23:30:17 INFO  PostProcessorRegistrationDelegate$BeanPostProcessorChecker:325 - Bean 'configurationPropertiesRebinderAutoConfiguration' of type [class org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration$$EnhancerBySpringCGLIB$$8b13d9ad] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2017-02-01 23:30:17 INFO  ConfigServicePropertySourceLocator:80 - Fetching config from server at: http://localhost:8100
2017-02-01 23:30:17 INFO  ConfigServicePropertySourceLocator:94 - Located environment: name=demo-refreshable-config-client, profiles=[development], label=master, version=2a3947c8c524260aa276dff31e053a3f9cd1f3b3, state=null
2017-02-01 23:30:17 INFO  PropertySourceBootstrapConfiguration:93 - Located property source: CompositePropertySource [name='configService', propertySources=[MapPropertySource [name='configClient'], MapPropertySource [name='https://bitbucket.org/asimio/demo-config-properties/demo-refreshable-config-client-development.properties']]]
2017-02-01 23:30:17 INFO  SpringApplication:666 - The following profiles are active: development
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@57925029: startup date [Wed Feb 01 23:30:17 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@2b5fe6a3
2017-02-01 23:30:17 INFO  SpringApplication:57 - Started application in 0.686 seconds (JVM running for 1588.469)
2017-02-01 23:30:17 INFO  AnnotationConfigApplicationContext:987 - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@57925029: startup date [Wed Feb 01 23:30:17 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@2b5fe6a3
2017-02-01 23:30:18 INFO  RefreshListener:27 - Received remote refresh request. Keys refreshed []

Again, RefreshListener:27 - Received remote refresh request. means a refresh event was successfully received.
But sending another request to the /actors/1 won’t result in a different response because the file in the Git repo hasn’t changed, lets now update app.message property in the Git repository:

8.3. Updating app.message property in Bitbucket

demo-refreshable-config-client-development.properties:

-app.message=App message from development profile - Update 6
+app.message=App refreshable message from development profile - Update 7

Refreshable config demo client logs:

2017-02-02 00:29:45 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@19845524: startup date [Thu Feb 02 00:29:45 EST 2017]; root of context hierarchy
2017-02-02 00:29:45 INFO  PostProcessorRegistrationDelegate$BeanPostProcessorChecker:325 - Bean 'configurationPropertiesRebinderAutoConfiguration' of type [class org.springframework.cloud.autoconfigure.ConfigurationPropertiesRebinderAutoConfiguration$$EnhancerBySpringCGLIB$$8b13d9ad] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2017-02-02 00:29:45 INFO  ConfigServicePropertySourceLocator:80 - Fetching config from server at: http://localhost:8100
2017-02-02 00:29:46 INFO  ConfigServicePropertySourceLocator:94 - Located environment: name=demo-refreshable-config-client, profiles=[development], label=master, version=5a65e70fa7f244911a801c6ddaefca9f434fb20d, state=null
2017-02-02 00:29:46 INFO  PropertySourceBootstrapConfiguration:93 - Located property source: CompositePropertySource [name='configService', propertySources=[MapPropertySource [name='configClient'], MapPropertySource [name='https://bitbucket.org/asimio/demo-config-properties/demo-refreshable-config-client-development.properties']]]
2017-02-02 00:29:46 INFO  SpringApplication:666 - The following profiles are active: development
2017-02-02 00:29:46 INFO  AnnotationConfigApplicationContext:582 - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@6f6fddce: startup date [Thu Feb 02 00:29:46 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@19845524
2017-02-02 00:29:46 INFO  SpringApplication:57 - Started application in 1.681 seconds (JVM running for 5157.41)
2017-02-02 00:29:46 INFO  AnnotationConfigApplicationContext:987 - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@6f6fddce: startup date [Thu Feb 02 00:29:46 EST 2017]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@19845524
2017-02-02 00:29:47 INFO  RefreshListener:27 - Received remote refresh request. Keys refreshed [app.message, config.client.version]

Notice RefreshListener:27 - Received remote refresh request. Keys refreshed [app.message, config.client.version] log, this time the ActorResource’s msg attribute should have gotten updated with the new app.message value.
Lets now send a GET request to /actors/1 endpoint:

curl -v "http://localhost:8700/actors/1"
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8700 (#0)
> GET /actors/1 HTTP/1.1
> Host: localhost:8700
> User-Agent: curl/7.51.0
> Accept: */*
>
< HTTP/1.1 200
< X-Application-Context: demo-refreshable-config-client:development:8700
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Thu, 02 Feb 2017 05:34:44 GMT
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
{"actorId":"1","firstName":"First1","lastName":"Last1. App refreshable message from development profile - Update 7","lastUpdate":null}

There you have it.

Thanks for reading and as always, feedback is very much appreciated. If you found this post helpful and would like to receive updates when content like this gets published, sign up to the newsletter.

9. SOURCE CODE

Accompanying source code for this blog post can be found at:

10. REFERENCES