Deploy a new version with Spring Cloud

6 minute read

The goal of this article is to introduce a solution to facilitate deployments of your applications based on Spring Cloud. When deploying a new release, both versions will coexist without communication between them. For instance, this allows to avoid the versioning of APIs.

Goal

The expected result is to have two versions of the application deployed at the same time on your environment:

Targeted architecture

The reverse proxy is the access point of all the APIs’ application. This entry point will perform intelligent routing, based on a request header that specifies the target version. Then, services are isolated from those with a different version. This allows a gradual switch to the newly deployed release. The main target of this article is to expose the concepts of our solution. This solution should be adapted according to your needs.

Services

The microservice architecture set up is as follows:

  • Eureka: Registry of microservices that are available on the platform
  • Gateway: Reverse proxy that routes requests to the required service by performing a load balancing between the different instances registered in Eureka

To validate the desired behavior, the API call to the USER service will trigger a request to an API of the POST service which will result in X calls to COMMENT service:

HTTP Request

Service configuration

Versions used in this example:

The source code is available on Github. It contains all classes that are not listed as models, Feign APIs, dependencies of each services…

Eureka

First of all, you have to create a project based on eureka-server via Spring Initializr. All platform services are registered with Eureka. Eureka has metadata on each instance stored in the following property: eureka.instance.metadata-map

Zuul

The gateway is the entry point for all HTTP requests. They will be routed to the requested service according to the url: /user/api/... which triggers a call to the microservice USER. The URL is transformed to /api/….

In this article, the choice that has been made is to rely on a query header that contains the target version: X-VERSION. The first step is to create a Zuul filter to extract the header from the query.

@Component
public class VersionPreFilter extends ZuulFilter {
	
 @Value("${eureka.instance.metadata-map.version}")
 private String currentVersion;
	
 @Override
 public Object run() {
  RequestContext requestContext = RequestContext.getCurrentContext();
  // Extract the request header
  String version = requestContext.getRequest().getHeader("X-VERSION");
	
  // Default value is the gateway version
  if (!StringUtils.hasLength(version)) {
   version = currentVersion;
  }

  // Transmit to ribbon
  requestContext.put(FilterConstants.LOAD_BALANCER_KEY, version);
  return null;
 }
}

When accessing the user resource via the gateway, Ribbon chooses the server that will be queried. The following class aims to filter the servers according to the version header that was extracted.

public class VersionAvoidancePredicate extends CompositePredicate {

 private final EurekaServerIntrospector introspector = new EurekaServerIntrospector();

 @Override
 public List<Server> getEligibleServers(List<Server> servers, Object loadBalancerKey) {
  // Link to FilterConstants.LOAD_BALANCER_KEY
  final String targetVersion = String.valueOf(loadBalancerKey);
  return servers.stream()
   .filter(server -> targetVersion
    .equalsIgnoreCase(introspector.getMetadata(server).get("version")))
   .collect(Collectors.toList());
 }
}

Then you have to create a Ribbon rule that uses this filter :

public class VersionAvoidanceRule extends PredicateBasedRule {

 private final CompositePredicate compositePredicate;

 public VersionAvoidanceRule() {
  super();
  compositePredicate = new VersionAvoidancePredicate();
 }

 @Override
 public AbstractServerPredicate getPredicate() {
  return this.compositePredicate;
 }
}

Finally, we must create the default configuration that will be used for all requests made by Ribbon.

@RibbonClients(defaultConfiguration = RibbonGatewayConfig.class)
@Configuration
public class RibbonGatewayConfig {
 @Bean
 public IRule ribbonRule() {
  return new VersionAvoidanceRule();
 }
}

The gateway is now ready to perform intelligent routing according to the version header.

Common - Ribbon

This part contains the code shared between multiple microservices. The Ribbon configuration is to filter the servers according to the current version of the microservice.

public class VersionZoneAffinityServerListFilter 
 extends ZoneAffinityServerListFilter<Server> {

 private final EurekaServerIntrospector introspector = new EurekaServerIntrospector();
	
 // Current version
 private final String currentVersion;

 @Override
 public List<Server> getFilteredListOfServers(List<Server> servers) {
  // Filter version base on the current version of the microservice
  List<Server> serverWithAffinity = servers.stream()
   .filter(server -> this.currentVersion
    .equalsIgnoreCase(this.introspector.getMetadata(server).get("version")))
   .collect(Collectors.toList());
  // Affinity of zone
  return super.getFilteredListOfServers(serverWithAffinity);
 }
}

Then you have to add this filter as default configuration of Ribbon.

@Configuration
@RibbonClients(defaultConfiguration = CommonRibbonConfig.class)
public class CommonRibbonConfig {

 @Value("${eureka.instance.metadata-map.version}")
 public String currentVersion;

 @Bean
 public VersionZoneAffinityServerListFilter serverListFilter() {
  IClientConfig config = DefaultClientConfigImpl.getClientConfigWithDefaultValues();
  return new VersionZoneAffinityServerListFilter(config, currentVersion);
 }
}

Microservices

The microservices will define the following property:

eureka:
 instance:
  metadata-map:
   version: ${RELEASE_VERSION:@release.version@}

@release.version@ is a Maven property defined in pom.xml file.

<properties>
 <release.version>1.0.0</release.version>
</properties>

It may be overridden by an environment variable passed at runtime. This will facilitate the tests.

User

@RestController
@Slf4j
@RequestMapping("/api/user")
public class UserController {

 @Value("${eureka.instance.metadata-map.version}")
 public String currentVersion;

 @Autowired
 private PostApi api;

 private final List<User> userList;

 @GetMapping("/{id}")
 public User findOne(@PathVariable Long id) {
  log.info("Version : {}", currentVersion);
  User user = this.userList.stream()
   .filter(u -> id.equals(u.getId()))
   .findFirst()
   .orElseThrow(ResourceNotFoundException::new);
  List<Post> postList = this.api.findForUser(id);
  user.setPosts(postList);
  return user;
 }
}

Post

@RestController
@RequestMapping("/api/post")
@Slf4j
public class PostController {

 @Value("${eureka.instance.metadata-map.version}")
 public String currentVersion;

 @Autowired
 private CommentApi api;

 private List<Post> postList;

 @GetMapping
 public List<Post> findByUserId(@RequestParam Long userId) {
  log.info("Version : {}", currentVersion);
  return this.postList.stream()
   .filter(post -> userId.equals(post.getUserId()))
   // X call to service COMMENT
   .peek(post -> post.setComments(api.findAll(post.getId())))
   .collect(Collectors.toList());
 }
}

Comment

@RestController
@RequestMapping("/api/comment")
@Slf4j
public class CommentController {

 @Value("${eureka.instance.metadata-map.version}")
 public String currentVersion;

 private final List<Comment> commentList;

 @GetMapping
 public List<Comment> findAll(@RequestParam Long postId) {
  log.info("Version : {}", currentVersion);
  return commentList.stream()
   .filter(comment -> postId.equals(comment.getPostId()))
   .collect(toList());
 }
}

Result

Thanks to docker-compose, one can launch the whole stack with two instances of USER, POST and COMMENT in two different versions.

curl http://localhost:8081/user/api/user/1 -H "X-VERSION: 1.0.0" && docker-compose logs 
user-1     | INFO 1 --- fr.worldline.user.UserController         : Version : 1.0.0
post-1     | INFO 1 --- fr.worldline.post.PostController         : Version : 1.0.0
comment-1  | INFO 1 --- fr.worldline.comment.CommentController   : Version : 1.0.0
comment-1  | INFO 1 --- fr.worldline.comment.CommentController   : Version : 1.0.0
...
curl http://localhost:8081/user/api/user/1 -H "X-VERSION: 2.0.0" && docker-compose logs
user-2     | INFO 1 --- fr.worldline.user.UserController         : Version : 2.0.0
post-2     | INFO 1 --- fr.worldline.post.PostController         : Version : 2.0.0
comment-2  | INFO 1 --- fr.worldline.comment.CommentController   : Version : 2.0.0
comment-2  | INFO 1 --- fr.worldline.comment.CommentController   : Version : 2.0.0
...

Actuator service registry

Spring actuator exposed APIs for monitoring a Spring application. Spring-Cloud offers an endpoint that returns services’ status within the server registry (for instance eureka). You can manually change the status of a service by performing the following query:

curl "comment-2:8080/actuator/service-registry?status=OUT_OF_SERVICE" \
 -X POST \
 -H 'Content-Type: application/json'

Beforehand, you should enable the service-registry API via the properties file of each microservice:

management:
 endpoints:
  web:
   exposure:
    # Active all web endpoints actuator 
    include: '*'

Ribbon is doing an asynchronous update of its list of servers. It filters services having a status different of “UP”. Once updated, it will no longer be reachable by other services.

curl http://localhost:8081/user/api/user/1 -H "X-VERSION: 2.0.0"
user-2     | INFO  1 --- fr.worldline.user.UserController         : Version : 2.0.0
post-2     | INFO  1 --- fr.worldline.post.PostController         : Version : 2.0.0
post-2     | ERROR 1 --- o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in [...]
post-2     | 
post-2     | com.netflix.client.ClientException: Load balancer does not have available server for client: comment

This log shows that Ribbon could not find a server in version 2.0.0 for the COMMENT service. This behavior is common to Zuul who uses Ribbon to make his server choice. It may be interesting to take this into account for your deployments before stopping your instances. Otherwise, until ribbon updates its server list, it could request an instance that has just been stopped.

Take away

The main ideas to remember in this article are :

  • Zuul filters the services based on the header X_VERSION of the HTTP request;
  • Each service communicates with the others microservices registered in the same version;
  • Before stopping a service, you should de-register it from Eureka using the service-registry endpoint exposed by actuator.

This solution should be adapted to your needs: The deployment remains to be automated by yourself. You can have a look at Eureka API. A short workflow to deploy a new release could be:

  • List all instances in your registry
  • De-register about half of each services’ instances
  • Restart those services in the new version
  • Wait for the users to switch to the new release of the back-end
  • Then restart the residual services

N.B.: Today, Spring Cloud changes direction to step out of Netflix stack (Netflix using Spring Cloud now): Consul (from Hashicorp) to replace Eureka, Gateway (Spring made) to substitute Zuul… Keep in mind that this article presents a resilient deployment and delivery workflow that need to be adapted to your framework, tools …


Written by

Jordan Couret

Java & Spring fan