Spring Cloud: How to Implement Service Discovery (Part 1)

Mario Casari
Published: June 13, 2023

In previous articles, Spring Cloud: How to Deal with Microservice Configuration (Part 1) and Spring Cloud: How to Deal with Microservice Configuration (Part 2),  we have seen how to deal with microservices remote configuration. In this post, we are going to talk about another important feature in the context of microservices, namely Service Discovery. Service Discovery plays the role of a central registry in which all the services store their metadata information and from which they can get the metadata of other services.

The service discovery feature is implemented by a server and a corresponding client counterpart. In this article, we are going to describe how to configure a discovery server and make the client services able to reach it and use it. We will also see how to set a configuration based on the so-called “zone affinity.”

Service Discovery: Basic Choices and Versions

Spring Cloud has been gradually dismissing the Netflix OSS solutions in favor of native implementations for the various micro-services features. Despite that, Eureka is still the natural choice for service discovery. 

In this article, we are going to use Eureka as a service discovery client and server with the following versions of Spring Boot and the corresponding Spring Cloud train of dependencies:

We will also use a Gateway component in order to implement two different architectural scenarios. We mentioned in the article A Brief Overview of the Spring Cloud Framework that the Netflix solution Zuul is a possible choice to implement the service discovery features. With the last versions of Spring Cloud, though, Zuul is not supported anymore and the Spring Cloud Gateway project took its place. SCG offers a non-blocking API model and is more performant. In this article, we will use SCG.

Dependencies and Configuration for the Server Side

To implement a service discovery server, we have to provide the necessary Maven dependencies in the first place. First of all, we provide the Spring Boot starter as a parent dependency:

Then we set the Spring Cloud release train with the following starter in a dependencyManagement section:

As a final piece of configuration, we set the Eureka starter in the dependencies section:

Single Instance Configuration

If we want to run just a single instance server, we can provide the following configuration in the application.yml file:

With the above Maven configuration, the client-side Eureka dependencies are imported as well. They are needed in case we want to provide high availability with more than one instance of the server.  In that case, each instance will register itself with all the other instances and fetch the configuration from the first instance available based on the configuration of default Eureka nodes, as we will see below when discussing HA.

In order to disable this synchronization mechanism in the above single instance configuration, we must set the following two properties:

Discovery Server’s High Availability

If we want our service discovery feature to achieve high availability, we have to run more than one instance of the discovery server. The current Eureka version provides high availability using peer-to-peer replicating nodes. Every node is synchronized with all the others, that is to say, every node registers itself to each of the other nodes, fetches the metadata of the other nodes, and signals itself being up by sending  “heartbeats” to all of them.

Eureka High Availability Scenario
Eureka High Availability Scenario

Shared Configuration Properties

In order to configure the application for the kind of deployment depicted above, we make use of the Spring Boot profile feature. First of all, we set the shared properties in an application.yml file:

spring:
   application:
      name: discovery-service
            
eureka:
   instance:
      hostname: localhost
   client:
      registerWithEureka: false
      fetchRegistry: false
server:
   port: ${PORT:8760}

Self-Preservation and Cache Parameters

In the configuration above, in addition to the application name, we have set some Eureka server parameters:

  • enableSelfPreservation: By default, its value is true and that means that if many services are unreachable because of a network failure, they won’t be removed from the registry. By setting it to false we are simplifying testing our sample architecture, which we are going to describe in the following sections.
  • evictionIntervalTimerInMs: Its default value is 60000 milliseconds. It configures the interval used by an internal task to check if the heartbeats are still received by the client services. We set it to just 1000 ms for our test purposes.
  • responseCacheUpdateIntervalMs: The server caches its API responses, so if we check the /eureka/apps endpoint, we obtain the result updated during the last interval. Its default value is 30000 milliseconds. We set it to just 1000 ms for our test purposes.

Configuration Properties Specific to the Single Instances

We can then set the properties specific to the single instances in separate files whose name is suffixed with the name of the profile, which is configured in the spring.config.activate.on-profiles property:

  • application-node1.yml
  • application-node2.yml
  • application-node3.yml

We show below the single configuration pieces corresponding to each file above, containing the name of the profile, the instance port, and the list of the other server nodes to contact.

spring:
   config:
      activate:
         on-profiles: node1
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8762/eureka/,http://localhost:8763/eureka/
server:  
  port: ${PORT:8761}

spring:
   config:
      activate:
         on-profiles: node2
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/,http://localhost:8763/eureka/
server:  
  port: ${PORT:8762}

spring:
   config:
      activate:
         on-profiles: node3
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/,http://localhost:8762/eureka/
server:  
  port: ${PORT:8763}

Running the Instances

After having compiled the application using the “mvn clean install” command, we can start the instances using the above configuration by the following, specifying the profile as a command line argument:

java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node1
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node2
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node3

Then we can access the Eureka dashboard by one of the nodes – the URL http://localhost:8761, for example. We can see here an example with just two running nodes:

Eureka dashboard
Eureka dashboard

As we can see, both nodes are shown in the dashboard, since the registry of each node, due to the peer-to-peer synchronization, contains all the running instances.

Eureka provides also a REST API, available through the …/eureka URL prefix. For instance, to obtain all the instances we can execute the following:

http://localhost:8761/eureka/apps

Service Discovery Configuration: Client Side

Suppose we have a simple Spring Boot application that exposes a single REST service. We make that service simply print the spring.config.activate.on-profiles property of the current instance:

@RestController
public class ClientController {
			         
	@Value("${spring.config.activate.on-profiles}")
	private String zone;
	@GetMapping("/checkZone")
	public String ping() {
		return "This service runs in zone " + zone;
	}
}

As we did for the discovery server instances, we are going to use here the profile feature of Spring Boot to run several instances of the above application. We will use values such as zone1, zone2, and zone3 for the profiles. In this context, the term “zone” does not have a particular meaning, but we will talk later about “zone affinity” and for that scenario, it will make more sense. The REST service has the purpose to print on the screen which client node, identified by its profile, is responding to a particular request.

In order to make our Spring Boot application aware of the discovery server, we have to provide the Eureka client dependencies to it. It is only a matter of setting the appropriate Spring Boot starter as shown in the snippet below:

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

Configuration for Three Instances

Supposing we want to run three instances: We set the shared properties in an application.yml file and the more specific ones in the following: application_zone1.yml, application_zone2.yml, and application_zone3.yml. As shared properties, we set the application name and other properties that we are going to describe below:

spring:
   application:
      name: client-service
eureka:
  instance:
    leaseRenewalIntervalInSeconds: 1 
    leaseExpirationDurationInSeconds: 1
  client:
    registryFetchIntervalSeconds: 1
    shouldDisableDelta: true

In the piece of configuration above we have set two additional eureka instance parameters:

  • leaseRenewalIntervalInSeconds: It configures the interval in seconds between the client’s heartbeats sent to the discovery server. Its default value is 30 seconds.
  • leaseExpirationDurationInSeconds: It configures how many seconds the server has to wait for the next heartbeat before deleting the client instance from its registry. The default value is 90 seconds. We set it to just 1 second for testing purposes.
  • registryFetchIntervalSeconds: As the server side, also the client side has its registry cache. We set it to just 1 second for testing purposes. 
  • shouldDisableDelta: The client-side registry cache works by default updating itself with the delta of the previous registry state. By setting the shouldDisableDelta property to truewe are disabling such behavior.

The configuration for the single client instances is the following:

spring: 
   config:
      activate:
         on-profiles: zone1  
         
eureka:
  instance:
    hostname: localhost
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/,http://localhost:8762/eureka/,http://localhost:8763/eureka/
            
server: 
   port: ${PORT:8181}

spring: 
   config:
      activate:
         on-profiles: zone2  
         
eureka:
  instance:
    hostname: localhost
  client:
    serviceUrl:
      defaultZone: http://localhost:8762/eureka/,http://localhost:8761/eureka/,http://localhost:8763/eureka/
          
server: 
   port: ${PORT:8182}

spring: 
   config:
      activate:
         on-profiles: zone3  
         
eureka:
  instance:
    hostname: localhost
  client:
    serviceUrl:
      defaultZone: http://localhost:8763/eureka/,http://localhost:8762/eureka/,http://localhost:8761/eureka/
          
server: 
   port: ${PORT:8183}

The eureka.client.serviceUrl.defaultZone property contains the three server nodes. Each client instance will contact the first node available from the left to the right, in order to register itself and fetch the configuration.

Running The Three Instances

After having compiled the application, we can run the three client instances with the following:

java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone1
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone2
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone3

Service Discovery Configuration: Basic Architecture

Using the configuration for the discovery server and client parts described above, we can implement the basic architecture shown in the following diagram:

Basic Service Discovery Configuration
Basic Service Discovery Configuration

We have the discovery server running as a cluster with three instances fully synchronized with each other. We also have three nodes for the client service, where each of them fetches the configuration from one of the instances, based on the value of the eureka.client.serviceUrl.defaultZone property.

In the above picture, we can also see an additional component: a gateway. The gateway part represents the entrance of our system from the standpoint of the external world. We introduce it here just in order to make our architecture similar to a real scenario: we use it to dispatch external requests into our system. As we said before, the latest versions of Spring Cloud favor and support a module named Spring Cloud Gateway, and this is what we will use in our example.

The gateway is configured to fetch the registry by default from the first Eureka node. If that node for some reason goes down, it will use the remaining nodes in the configured order. In the next section, you can see the settings for the gateway of the above architecture.

Spring Cloud Gateway Settings

Our gateway, like the other components, is a Spring Boot application. To characterize this application as a Spring Cloud Gateway, all we have to do is to set the spring-cloud-starter-gateway starter in the Maven dependencies section. We also need the client side, since the gateway instance has to fetch the services metadata information from the discovery server layer:

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
                         <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

Then we set the following properties in the application.yml file:

server:
   port: ${PORT:8080}
spring:  
  application:
    name: gateway-service
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
   
eureka: 
   client: 
      serviceUrl:
         defaultZone: http://node1:8761/eureka/ ,http://node1:8762/eureka/,http://node1:8763/eureka/
      registerWithEureka: false 
      registryFetchIntervalSeconds: 1 
      shouldDisableDelta: true
   instance:
      leaseRenewalIntervalInSeconds: 1 
      leaseExpirationDurationInSeconds: 1

By setting the spring.cloud.gateway.discovery.locator.enabled to true, we are enabling the automatic discovery of services based on the configuration of service discovery instances. The other locator property lower-case-service-id forces the use of lowercase service ID. As for the other properties, they are the same as we already set for the client service, with the only difference of the registerWithEureka one, which is set to false, because we don’t need the gateway to register itself to the service discovery server.

Running the Architecture

To run our system, we have to compile and build the JAR files with the Maven command “mvn clean install” and then execute all the instances for the client, server, and gateway on the command line, specifying the profile:  

java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node1
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node2
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node3
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone1
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone2
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone3
java -jar spring-cloud-discovery-gateway-localconfig-nozone-1.0-SNAPSHOT.jar

We can then test the system through the gateway executing the REST service by the following URL:

http://localhost:8080/client-service/checkZone

We expect to see the gateway to load balance the requests using a simple round-robin algorithm. So, by refreshing the URL multiple times we will see, consecutively:

This service runs in zone zone1
...
This service runs in zone zone2
...
This service runs in zone zone3
...
This service runs in zone zone1

Service Discovery Configuration: Zone Affinity

In more general scenarios, we could have separate groups of instances running on different machines. If we keep it simple, we can imagine a configuration in which separate triplets of server, client, and gateway instances run on separate machines.  As in the above example, we can imagine the discovery server instances to be part of the same cluster and fully synchronized with each other. Look at the following picture, for instance:

Zone Affinity Example
Zone Affinity Example

We can think of Zone1, Zone2 and Zone3 in the above diagram as separate machines, geographically distant from each other, and hosting each a single triplet of discovery server, client service, and gateway. We would wish our system to have the best performance, but if we recall our discussion on the previous sample architecture, we have seen that the requests are load balanced in a round-robin manner. So, if we send requests through the first gateway shown in the above diagram, they will be dispatched circularly to all three service nodes. This way, two-thirds of the requests will be dispatched to geographically distant services, not an ideal situation in terms of performance.

We can avoid the above behavior by introducing a new concept: Zone Affinity. In a zone affinity scenario, the gateway will dispatch requests preferably to the same “zone” in which it is running. Only if the service node in its own zone is not available, other zones would be considered, based on the configured list of URLs of the eureka.client.serviceUrl.defaultZone property.

In order to obtain this behavior, we must change the configuration a little. We must add the following Eureka configuration to the client, discovery server, and gateway part (for each instance):

eureka:
  instance:
    metadataMap:
       zone: zone1

And we also have to add the following to the gateway configuration:

spring:  
  cloud:
    loadbalancer:
       configurations: zone-preference

Note: The profile feature doesn’t seem to work as expected for the Spring Cloud Gateway component. So, just for the gateway, we should pass the instance-specific properties on the command line instead, as we can see below.

Running the Architecture

With these modifications in place, we can compile and run the above architecture with the following:

java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node1
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node2
java -jar spring-cloud-discovery-server-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=node3
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone1
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone2
java -jar spring-cloud-discovery-client-localconfig-nozone-1.0-SNAPSHOT.jar --spring.profiles.active=zone3

java -jar spring-cloud-discovery-gateway-localconfig-1.0-SNAPSHOT.jar --server.port=8081  --eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/ --eureka.instance.metadataMap.zone=zone1
java -jar spring-cloud-discovery-gateway-localconfig-1.0-SNAPSHOT.jar --server.port=8082  --eureka.client.serviceUrl.defaultZone=http://localhost:8762/eureka/ --eureka.instance.metadataMap.zone=zone2
java -jar spring-cloud-discovery-gateway-localconfig-1.0-SNAPSHOT.jar --server.port=8083  --eureka.client.serviceUrl.defaultZone=http://localhost:8763/eureka/ --eureka.instance.metadataMap.zone=zone3

If we try to send requests through the three gateway instances, we will see that a request through the first gateway will always print “This service runs in zone zone1.” The second will always be “This service runs in zone zone2,” and “This service runs in zone zone3” for the third. If we turn off one of the services, the request will be sent to the next service instance available, based on the eureka.client.serviceUrl.defaultZone setting.

Conclusion

We have seen in this post how to use the service discovery features of Netflix Eureka, through Spring Cloud integration, in order to implement some basic scenarios. In the second part of this article, we will show how to secure the discovery server and the client services. We will also see two different ways of combining Spring Cloud Discovery with Spring Cloud Remote Configuration.

You can find the source code of the examples in this article in the following GitHub module:

Note: The URL above actually is a submodule of a root repository that contains a number of modules for other articles, and in turn, is the root of other submodules with the specific examples of this article. From the standpoint of dependencies, it does not inherit from the general repository though, and you can just take it and compile it as it is.

Source: dzone.com