Spring Cloud: How to Implement Service Discovery (Part 1)
In this article, readers will learn how to configure and run some service discovery sample architectures by using the Spring Cloud components.
Join the DZone community and get the full member experience.
Join For FreeIn 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:
- Spring Boot: 3.0.6
- Spring Cloud: 2022.0.2 (2022.0.X versions are known as Kilburn and are compliant with the 3.0.X Spring Boot version)
- Java 17
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:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.6</version>
</parent>
Then we set the Spring Cloud release train with the following starter in a dependencyManagement
section:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2022.0.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
As a final piece of configuration, we set the Eureka starter in the dependencies
section:
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
</dependency>
</dependencies>
Single Instance Configuration
If we want to run just a single instance server, we can provide the following configuration in the application.yml file:
spring:
application:
name: discovery-service
eureka:
instance:
hostname: localhost
client:
registerWithEureka: false
fetchRegistry: false
server:
port: ${PORT:8760}
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:
registerWithEureka
: By setting this property tofalse
, we are disabling the feature that makes each instance register itself to the other instances.fetchRegistry
: Setting this property tofalse
on the client side avoids fetching the registered services. (The single instance of the discovery server does not need to fetch any services. Only in the high availability scenario would this make sense, since in that case, each instance would fetch the other nodes' metadata, choosing a default node from which to fetch, as set in the configuration).
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.
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 tofalse
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:
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 theshouldDisableDelta
property totrue
, we 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:
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:
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.
Published at DZone with permission of Mario Casari. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments