Consul with Quarkus and SmallRye Stork
This article will teach you to use HashiCorp Consul as a discovery and configuration server for your Quarkus microservices. I wrote a similar article some years ago. However, there have been several significant improvements in the Quarkus ecosystem since that time. What I have in mind is mainly the Quarkus Stork project. This extension focuses on service discovery and load balancing for cloud-native applications. It can seamlessly integrate with the Consul or Kubernetes discovery and provide various load balancer types over the Quarkus REST client. Our sample applications will also load configuration properties from the Consul Key-Value store and use the Smallrye Mutiny Consul client to register the app in the discovery server.
If you are looking for other interesting articles about Quarkus, you will find them in my blog. For example, you will read more about testing strategies with Quarkus and Pact here.
Source Code
If you would like to try it by yourself, you may always take a look at my source code. To do that, you must clone my sample GitHub repository. Then you should only follow my instructions 🙂
Architecture
Before proceeding to the implementation, let’s take a look at the diagram of our system architecture. There are three microservices: employee-service
, departament-service
, and organization-service
. They are communicating with each other through a REST API. They use the Consul Key-Value store as a distributed configuration backend. Every instance of service is registering itself in Consul. A load balancer is included in the application. It reads a list of registered instances of a target service from the Consul using the Quarkus Stork extension. Then it chooses an instance using a provided algorithm.
Running Consul Instance
We will run a single-node Consul instance as a Docker container. By default, Consul exposes HTTP API and a UI console on the 8500
port. Let’s expose that port outside the container.
docker run -d --name=consul \
-e CONSUL_BIND_INTERFACE=eth0 \
-p 8500:8500 \
consul
ShellSessionDependencies
Let’s analyze a list of the most important Maven dependencies using the department-service
application as an example. Our application exposes REST endpoints and connects to the in-memory H2 database. We use the Quarkus REST client and the SmallRye Stork Service Discovery library to implement communication between the microservices. On the other hand, the io.quarkiverse.config:quarkus-config-consul
is responsible for reading configuration properties the Consul Key-Value store. With the smallrye-mutiny-vertx-consul-client
library the application is able to interact directly with the Consul HTTP API. This may not be necessary in the future, once the Stork project will implement the registration and deregistration mechanism. Currently it is not ready. Finally, we will Testcontainers to run Consul and tests our apps against it with the Quarkus JUnit support.
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-stork</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.reactive</groupId>
<artifactId>smallrye-mutiny-vertx-consul-client</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-discovery-consul</artifactId>
</dependency>
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-service-registration-consul</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-scheduler</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.config</groupId>
<artifactId>quarkus-config-consul</artifactId>
<version>${quarkus-consul.version}</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>consul</artifactId>
<version>1.20.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.3</version>
<scope>test</scope>
</dependency>
</dependencies>
XMLDiscovery and Load Balancing with Quarkus Stork for Consul
Let’s begin with the Quarkus Stork part. In the previous section, we included libraries required to provide service discovery and load balancing with Stork: quarkus-smallrye-stork
and stork-service-discovery-consul
. Now, we can proceed to the implementation. Here’s the EmployeeClient
interface from the department-service
responsible for calling the GET /employees/department/{departmentId}
endpoint exposed by the employee-service
. Instead of setting the target URL inside the @RegisterRestClient
annotation we should refer to the name of the service registered in Consul.
@Path("/employees")
@RegisterRestClient(baseUri = "stork://employee-service")
public interface EmployeeClient {
@GET
@Path("/department/{departmentId}")
@Produces(MediaType.APPLICATION_JSON)
List<Employee> findByDepartment(@PathParam("departmentId") Long departmentId);
}
JavaThat service name should also be used in the configuration properties. The following property indicates that Stork will use Consul as a discovery server for the employee-service
name.
quarkus.stork.employee-service.service-discovery.type = consul
PlaintextOnce we create a REST client with the additional annotations, we must inject it into the DepartmentResource
class using the @RestClient
annotation. Afterward, we can use that client to interact with the employee-service
while calling the GET /departments/organization/{organizationId}/with-employees
from the department-service
.
@Path("/departments")
@Produces(MediaType.APPLICATION_JSON)
public class DepartmentResource {
private Logger logger;
private DepartmentRepository repository;
private EmployeeClient employeeClient;
public DepartmentResource(Logger logger,
DepartmentRepository repository,
@RestClient EmployeeClient employeeClient) {
this.logger = logger;
this.repository = repository;
this.employeeClient = employeeClient;
}
// ... other methods for REST endpoints
@Path("/organization/{organizationId}")
@GET
public List<Department> findByOrganization(@PathParam("organizationId") Long organizationId) {
logger.infof("Department find: organizationId=%d", organizationId);
return repository.findByOrganization(organizationId);
}
@Path("/organization/{organizationId}/with-employees")
@GET
public List<Department> findByOrganizationWithEmployees(@PathParam("organizationId") Long organizationId) {
logger.infof("Department find with employees: organizationId=%d", organizationId);
List<Department> departments = repository.findByOrganization(organizationId);
departments.forEach(d -> d.setEmployees(employeeClient.findByDepartment(d.getId())));
return departments;
}
}
JavaLet’s take a look at the implementation of the GET /employees/department/{departmentId}
in the employee-service
called by the EmployeeClient
in the department-service
.
@Path("/employees")
@Produces(MediaType.APPLICATION_JSON)
public class EmployeeResource {
private Logger logger;
private EmployeeRepository repository;
public EmployeeResource(Logger logger,
EmployeeRepository repository) {
this.logger = logger;
this.repository = repository;
}
@Path("/department/{departmentId}")
@GET
public List<Employee> findByDepartment(@PathParam("departmentId") Long departmentId) {
logger.infof("Employee find: departmentId=%s", departmentId);
return repository.findByDepartment(departmentId);
}
@Path("/organization/{organizationId}")
@GET
public List<Employee> findByOrganization(@PathParam("organizationId") Long organizationId) {
logger.infof("Employee find: organizationId=%s", organizationId);
return repository.findByOrganization(organizationId);
}
// ... other methods for REST endpoints
}
JavaSimilarly in the organization-service
, we define two REST clients for interacting with employee-service
and department-service
.
@Path("/departments")
@RegisterRestClient(baseUri = "stork://department-service")
public interface DepartmentClient {
@GET
@Path("/organization/{organizationId}")
@Produces(MediaType.APPLICATION_JSON)
List<Department> findByOrganization(@PathParam("organizationId") Long organizationId);
@GET
@Path("/organization/{organizationId}/with-employees")
@Produces(MediaType.APPLICATION_JSON)
List<Department> findByOrganizationWithEmployees(@PathParam("organizationId") Long organizationId);
}
@Path("/employees")
@RegisterRestClient(baseUri = "stork://employee-service")
public interface EmployeeClient {
@GET
@Path("/organization/{organizationId}")
@Produces(MediaType.APPLICATION_JSON)
List<Employee> findByOrganization(@PathParam("organizationId") Long organizationId);
}
JavaIt involves the need to include the following two configuration properties that set the discovery service type for the target services.
quarkus.stork.employee-service.service-discovery.type = consul
quarkus.stork.department-service.service-discovery.type = consul
PlaintextThe OrganizationResource
class injects and uses both previously created clients.
@Path("/organizations")
@Produces(MediaType.APPLICATION_JSON)
public class OrganizationResource {
private Logger logger;
private OrganizationRepository repository;
private DepartmentClient departmentClient;
private EmployeeClient employeeClient;
public OrganizationResource(Logger logger,
OrganizationRepository repository,
@RestClient DepartmentClient departmentClient,
@RestClient EmployeeClient employeeClient) {
this.logger = logger;
this.repository = repository;
this.departmentClient = departmentClient;
this.employeeClient = employeeClient;
}
// ... other methods for REST endpoints
@Path("/{id}/with-departments")
@GET
public Organization findByIdWithDepartments(@PathParam("id") Long id) {
logger.infof("Organization find with departments: id={}", id);
Organization organization = repository.findById(id);
organization.setDepartments(departmentClient.findByOrganization(organization.getId()));
return organization;
}
@Path("/{id}/with-departments-and-employees")
@GET
public Organization findByIdWithDepartmentsAndEmployees(@PathParam("id") Long id) {
logger.infof("Organization find with departments and employees: id={}", id);
Organization organization = repository.findById(id);
organization.setDepartments(departmentClient.findByOrganizationWithEmployees(organization.getId()));
return organization;
}
@Path("/{id}/with-employees")
@GET
public Organization findByIdWithEmployees(@PathParam("id") Long id) {
logger.infof("Organization find with employees: id={}", id);
Organization organization = repository.findById(id);
organization.setEmployees(employeeClient.findByOrganization(organization.getId()));
return organization;
}
}
JavaRegistration in Consul with Quarkus
After including Stork, the Quarkus REST client automatically splits traffic between all the instances of the application existing in the discovery server. However, each application must register itself in the discovery server. Quarkus Stork won’t do that. Theoretically, there is the stork-service-registration-consul
module that should register the application instance on startup. As far as I know, this feature is still under active development. For now, we will include a mentioned library and use the same property for enabling the registrar feature.
quarkus.stork.employee-service.service-registrar.type = consul
PlaintextOur sample applications will interact directly with the Consul server using the SmallRye Mutiny reactive client. Let’s define the ClientConsul
bean. It is registered only if the quarkus.stork.employee-service.service-registrar.type
property with the consul
value exists.
@ApplicationScoped
public class EmployeeBeanProducer {
@ConfigProperty(name = "consul.host", defaultValue = "localhost") String host;
@ConfigProperty(name = "consul.port", defaultValue = "8500") int port;
@Produces
@LookupIfProperty(name = "quarkus.stork.employee-service.service-registrar.type",
stringValue = "consul")
public ConsulClient consulClient(Vertx vertx) {
return ConsulClient.create(vertx, new ConsulClientOptions()
.setHost(host)
.setPort(port));
}
}
JavaThe bean responsible for catching the startup and shutdown events is annotated with @ApplicationScoped
. It defines two methods: onStart
and onStop
. It also injects the ConsulClient
bean. Quarkus dynamically generates the HTTP listen port number on startup and saves it in the quarkus.http.port
property. Therefore, the startup task needs to wait a moment to ensure that the application is running. We will run it 3 seconds after receiving the startup event. Every instance of the application needs to have a unique id in Consul. Therefore, we retrieve the number of running port and use that number as the id suffix. The name of the service is taken from the quarkus.application.name
property. The instance of the application should save id in order to be able to deregister itself on shutdown.
@ApplicationScoped
public class EmployeeLifecycle {
@ConfigProperty(name = "quarkus.application.name")
private String appName;
private int port;
private Logger logger;
private Instance<ConsulClient> consulClient;
private ScheduledExecutorService executor;
public EmployeeLifecycle(Logger logger,
Instance<ConsulClient> consulClient,
ScheduledExecutorService executor) {
this.logger = logger;
this.consulClient = consulClient;
this.executor = executor;
}
void onStart(@Observes StartupEvent ev) {
if (consulClient.isResolvable()) {
executor.schedule(() -> {
port = ConfigProvider.getConfig().getValue("quarkus.http.port", Integer.class);
consulClient.get().registerService(new ServiceOptions()
.setPort(port)
.setAddress("localhost")
.setName(appName)
.setId(appName + "-" + port),
result -> logger.infof("Service %s-%d registered", appName, port));
}, 3000, TimeUnit.MILLISECONDS);
}
}
void onStop(@Observes ShutdownEvent ev) {
if (consulClient.isResolvable()) {
consulClient.get().deregisterService(appName + "-" + port,
result -> logger.infof("Service %s-%d deregistered", appName, port));
}
}
}
JavaRead Configuration Properties from Consul
The io.quarkiverse.config:quarkus-config-consul
is already included in dependencies. Once the quarkus.consul-config.enabled
property is set to true
, the Quarkus application tries to read properties from the Consul Key-Value store. The quarkus.consul-config.properties-value-keys
property indicates the location of the properties file stored in Consul. Here are the properties that exists in the classpath application.properties
. For example, the default config location for the department-service is config/department-service
.
quarkus.application.name = department-service
quarkus.application.version = 1.1
quarkus.consul-config.enabled = true
quarkus.consul-config.properties-value-keys = config/${quarkus.application.name}
PlaintextLet’s switch to the Consul UI. It is available under the same 8500
port as the API. In the “Key/Value” section we create configuration for all three sample applications.
These are configuration properties for department-service
. They are targeted for the development mode. We enable the dynamically generated port number to run several instances on the same workstation. Our application use an in-memory H2 database. It loads the import.sql
script on startup to initialize a demo data store. We also enable Quarkus Stork service discovery for the employee-service
REST client and registration in Consul.
quarkus.http.port = 0
quarkus.datasource.db-kind = h2
quarkus.hibernate-orm.database.generation = drop-and-create
quarkus.hibernate-orm.sql-load-script = src/main/resources/import.sql
quarkus.stork.employee-service.service-discovery.type = consul
quarkus.stork.department-service.service-registrar.type = consul
PlaintextHere are the configuration properties for the employee-service
.
Finally, let’s take a look at the organization-service
configuration in Consul.
Run Applications in the Development Mode
Let’s run our three sample Quarkus applications in the development mode. Both employee-service
and department-service
should have two instances running. We don’t have to take care about port conflicts, since they are aqutomatically generated on startup.
$ cd employee-service
$ mvn quarkus:dev
$ mvn quarkus:dev
$ cd department-service
$ mvn quarkus:dev
$ mvn quarkus:dev
$ cd organization-service
$ mvn quarkus:dev
ShellSessionOnce we start all the instances we can switch to the Consul UI. You should see exactly the same services in your web console.
There are two instances of the employee-service
and deparment-service
. We can check out the list of registered instances for the selected application.
This step is optional. To simplify tests I also included API gateway that integrates with Consul discovery. It listens on the static 8080
port and forwards requests to the downstream services, which listen on the dynamic ports. Since Quarkus does not provide a module dedicates for the API gateway, I used Spring Cloud Gateway with Spring Cloud Consul for that. Therefore, you need to use the following command to run the application:
$ cd gateway-service
$ mvn spring-boot:run
ShellSessionAfterward, we can make some API tests with or without the gateway. With the gateway-service
, we can use the 8080
port with the /api
base context path. Let’s call the following three endpoints. The first one is exposed by the department-service
, while the another two by the organization-service
.
$ curl http://localhost:8080/api/departments/organization/1/with-employees
$ curl http://localhost:8080/api/organizations/1/with-departments
$ curl http://localhost:8080/api/organizations/1/with-departments-and-employees
ShellSessionEach Quarkus service listens on the dynamic port and register itself in Consul using that port number. Here’s the department-service
logs from startup and during test communication.
After including the quarkus-micrometer-registry-prometheus
module each application instance exposes metrics under the GET /q/metrics
endpoint. There are several metrics related to service discovery published by the Quarkus Stork extension.
$ curl http://localhost:51867/q/metrics | grep stork
# TYPE stork_service_discovery_instances_count counter
# HELP stork_service_discovery_instances_count The number of service instances discovered
stork_service_discovery_instances_count_total{service_name="employee-service"} 12.0
# TYPE stork_service_selection_duration_seconds summary
# HELP stork_service_selection_duration_seconds The duration of the selection operation
stork_service_selection_duration_seconds_count{service_name="employee-service"} 6.0
stork_service_selection_duration_seconds_sum{service_name="employee-service"} 9.93934E-4
# TYPE stork_service_selection_duration_seconds_max gauge
# HELP stork_service_selection_duration_seconds_max The duration of the selection operation
stork_service_selection_duration_seconds_max{service_name="employee-service"} 0.0
# TYPE stork_service_discovery_failures counter
# HELP stork_service_discovery_failures The number of failures during service discovery
stork_service_discovery_failures_total{service_name="employee-service"} 0.0
# TYPE stork_service_discovery_duration_seconds_max gauge
# HELP stork_service_discovery_duration_seconds_max The duration of the discovery operation
stork_service_discovery_duration_seconds_max{service_name="employee-service"} 0.0
# TYPE stork_service_discovery_duration_seconds summary
# HELP stork_service_discovery_duration_seconds The duration of the discovery operation
stork_service_discovery_duration_seconds_count{service_name="employee-service"} 6.0
stork_service_discovery_duration_seconds_sum{service_name="employee-service"} 2.997176541
# TYPE stork_service_selection_failures counter
# HELP stork_service_selection_failures The number of failures during service selection
stork_service_selection_failures_total{service_name="employee-service"} 0.0
ShellSessionAdvanced Load Balancing with Quarkus Stork and Consul
Quarkus Stork provides several load balancing strategies to efficiently distribute requests across multiple instances of a application. It can ensure optimal resource usage, better performance, and high availability. By default, Quarkus Stork uses round-robin algorithm. To override the default strategy, we first need to include a library responsible for providing the selected load-balancing algorithm. For example, let’s choose the least-response-time
strategy, which collects response times of the calls made with service instances and picks an instance based on this information.
<dependency>
<groupId>io.smallrye.stork</groupId>
<artifactId>stork-load-balancer-least-response-time</artifactId>
</dependency>
XMLThen, we have to change the default strategy in configuration properties for the selected client. Let’s add the following property to the config/department-service
in Consul Key-Value store.
quarkus.stork.employee-service.load-balancer.type=least-response-time
PlaintextAfter that, we can restart the instance of department-service
and retest the communication between services.
Testing Integration Between Quarkus and Consul
We have already included the org.testcontainers:consul
artifact to the Maven dependencies. Thanks to that, we can create JUnit tests with Quarkus and Testcontainers Consul. Since Quarkus doen’t provide a built-in support for testing Consul container, we need to create the class that implements the QuarkusTestResourceLifecycleManager
interface. It is responsible for starting and stopping Consul container during JUnit tests. After starting the container, we add required configuration properties to enable in-memory database creation and a service registration in Consul.
public class ConsulResource implements QuarkusTestResourceLifecycleManager {
private ConsulContainer consulContainer;
@Override
public Map<String, String> start() {
consulContainer = new ConsulContainer("hashicorp/consul:latest")
.withConsulCommand(
"""
kv put config/department-service - <<EOF
department.name=abc
quarkus.datasource.db-kind=h2
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.stork.department-service.service-registrar.type=consul
EOF
"""
);
consulContainer.start();
String url = consulContainer.getHost() + ":" + consulContainer.getFirstMappedPort();
return ImmutableMap.of(
"quarkus.consul-config.agent.host-port", url,
"consul.host", consulContainer.getHost(),
"consul.port", consulContainer.getFirstMappedPort().toString()
);
}
@Override
public void stop() {
consulContainer.stop();
}
}
JavaTo start Consul container during the test, we need to annotate the test class with @QuarkusTestResource(ConsulResource.class)
. The test loads configuration properties from Consul on startup and registers the service. Then, it verifies that REST endpoints exposed by the department-service
work fine and the registered service exists in Consul.
@QuarkusTest
@QuarkusTestResource(ConsulResource.class)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class DepartmentResourceConsulTests {
@ConfigProperty(name = "department.name", defaultValue = "")
private String name;
@Inject
ConsulClient consulClient;
@Test
@Order(1)
void add() {
Department d = new Department();
d.setOrganizationId(1L);
d.setName(name);
given().body(d).contentType(ContentType.JSON)
.when().post("/departments").then()
.statusCode(200)
.body("id", notNullValue())
.body("name", is(name));
}
@Test
@Order(2)
void findAll() {
when().get("/departments").then()
.statusCode(200)
.body("size()", is(4));
}
@Test
@Order(3)
void checkRegister() throws InterruptedException {
Thread.sleep(5000);
Uni<ServiceList> uni = Uni.createFrom().completionStage(() -> consulClient.catalogServices().toCompletionStage());
List<Service> services = uni.await().atMost(Duration.ofSeconds(3)).getList();
final long count = services.stream()
.filter(svc -> svc.getName().equals("department-service")).count();
assertEquals(1 ,count);
}
}
JavaFinal Thoughts
This article introduces Quarkus Stork for Consul discovery and client-side load balancing. It shows how to integrate Quarkus with Consul Key-Value store for distributed configuration. It also covers the topics like integration testing with Testcontainers support, metrics, service registration and advanced load-balancing strategies.
Leave a Reply