Micronaut - A perfect Microservice framework?

Whereas the previous blogposts focused on the internal architecture view of Micronaut, we decided to change now the perspective in our last blogpost and look at Micronaut through the eyes of a software architect who wants to build a microservice application. Micronaut claims to be “a modern, JVM-based, full-stack framework for building modular, easily testable microservices and serverless applications”.1 Therefore, we explore how Micronaut solves common challenges when developing microservices.

Introduction

“Microservices” are an architectural approach, where a complex system is split into smaller independent applications that can be deployed independently. Ideally, these microservices are small in size and fulfill exactly one aspect of the business domain (Bounded Context). This approach, enables distributed teams to work independently on different microservices, allows to scale microservices individually and enforces strict modularity which increases maintainability in the long run. However, it comes with some downsides. Generally spoken, through the distributed nature of the system the complexity of the interactions increases and managing the complete system becomes more difficult. This results, for example, in more complex integration strategies and challenges in operations. Furthermore, guaranteeing availability and reliability is much more difficult, because the system must be able to handle network latency and failures of individual services.2

To really benefit from those advantages and to be able to manage the additional complexity several best practices for implementing microservices exist in the community. Eberhard Wolff describes many of those in Part 3 Implementing Microservices of his book Microservices: Flexible Software Architecture3. In this blogpost, we will take his recommendations and guidelines and how they can be realised with Micronaut. This includes general architecture concepts, communication, integration and operation strategies.

Architecture Concepts of Microservices

As Wolff describes, generally the microservice architecture allows using a different technology stack for every microservice. However, to fulfill certain quality properties in a microservice-based system, it is useful to make certain technical decisions on the level of the entire system. Those decisions influence the individual architecture and enforce specific functionality. This section discusses various, common high-level decisions and explains Micronaut’s approaches to fulfill them.

Configuration

Typically, configuring a microservice-based system is more tiresome than configuring a monolithic system, because all individual microservices need the right parameters. Furthermore, to be able to run the same application in different environments (e.g. test, production) the configuration parameters should be stored externally from the source code.4

Micronaut’s mechanism to read external configuration is inspired by Spring Boot and Grails and allows injecting configuration parameters as beans from different sources. Firstly, these sources include property files, environment variables, and Java system properties. Furthermore, it allows users to add new sources by implementing the interface PropertySourceLoader.5

For microservice-based systems, often a central key/value store is used to make the configuration parameters available to all microservices at runtime. Micronaut provides an interface to implement clients to read configuration properties from those systems. Furthermore, it comes with default integrations to widely-used services as the Spring Cloud Config server and HashiCorp Consul.5

Service Discovery

Microservice instances can be stopped and started at every time which makes it difficult for services to find each other. Therefore, a discovery mechanism is needed, which resolves a service name to an IP address (and port). From the different available technologies probably DNS is the most well-known. In microservice-based systems often a central service registry is used, where different instances register themselves after startup. A potential client can then request the connection information from this registry.3

Likewise, this concept is supported by Micronaut and it has intended interfaces for integrating such discovery services. At the time of writing, this includes the popular service registries Eureka and HashiCorp Consul.5

Security

Naturally, every microservice needs to know which user has triggered the current call and has to authenticate it. Obviously, it does not make sense to validate the password in every microservice. Instead this should be delegated to a central authentication server. To avoid unnecessary load the authentication server often issues an access token, which is attached to all requests and can be validated by the microservices directly.3 A well-known protocol which follows this idea, is OAuth2.6 We all know it from websites, where we can login by using another service (e.g. Google).

The Micronaut framework includes a separate project Micronaut Security which adds customizable security solutions to the application. This also includes the support of OAuth2. From the supported authentication providers JSON Web Token (JWT) is worth to mention.7 This is an industry standard for transferring signed claims (access tokens).8 As the following example shows, this access token often includes information about the user, its roles and information about the issuer.

Example JSON Web Token with roles

Resilience

As discussed, in the introduction the failure of a single microservice should have a minimal impact on the availability and reliability of the overall system. To archive this, the individual microservices should be resilient which means that the services accept that failures happen and have mitigation and recovering strategies in place.

One strategy to reach this is the circuit breaker pattern. This pattern says, that as soon as a client detects a failure (e.g. Http timeout) it considers the server as down and prevents further calls until it detects the server to be available again.9 This has the advantage, that the load on the target system is reduced and future calls fail faster. Within Micronaut, this pattern can be enabled with the @CircuitBreaker Annotation.5

@Client("/pets")
@CircuitBreaker
public interface PetClient extends PetOperations {

    @Override
    Single<Pet> save(String name, int age);
}

Integration and communication

Microservices should be able to communicate with each other, for which they can be integrated at various levels. Microservices: Flexible Software Architecture3 defines three different levels of integration, namely the UI level, logic level, and database level.

UI level

To integrate microservices at UI level, each microservice provides its own web interface in the form of a single-page-application (SPA), SPA module, or an HTML page. This individual interfaces are then integrated on system level.

Logic level

The most noteworthy approaches for logic level integration are REST and messaging.

When integrating through RESTful HTTP, each microservice is identified by its own URI. Microservices can then communicate by invoking each other’s endpoints using HTTP methods. The drawback of using RESTful HTTP is its synchronous communication, meaning that an application can be blocked for a long time when waiting for a response to a request.

Communication through messaging is asynchronous, which solves the aforementioned problem. With messaging, a microservice sends a message to a queue or topic. Another microservice can subscribe to the same queue or topic to receive these messages. Using this approach the sender and receiver are decoupled. There are a variety of messaging technologies available, including AMQP, JMS, and Kafka.

Database level

There are several approaches to share data between microservices when integrating them at the database level. A widespread approach is to share a database schema between the microservices. The problem of this approach, however, is that microservices are no longer able to change their internal data representation without affecting other microservices.

Micronaut’s approach

Integration at the UI level happens in the frontend of an application, while integration at the database level using data replication should be supported by the DBMS. Both are beyond the scope of Micronaut and hence Micronaut only supports integration at the logic level, through RESTful HTTP and messaging.

RESTful HTTP

We will illustrate how integration through RESTful HTTP works using an example. We have two microservices, a book catalogue service and a book recommendation service, with the latter requesting books from the former. The book catalogue service can define an endpoint by defining a controller class using the @Controller annotation.5

@Controller("/books")
public class BooksController {

    @Get("/")
    List<Book> index() {
        Book releaseIt = new Book("1680502395", "Release It!");
        Book cd = new Book("0321601912", "Continuous Delivery:");
        return Arrays.asList(releaseIt, cd);
    }
}

Now the book recommendation service can define a client for the book catalogue by defining a client class with the @Client annotation specifying the URI or identifier of the microservice.

@Client("http://localhost:8081")
interface BookCatalogueClient extends BookCatalogueOperations {

    @Get("/books")
    Flowable<Book> findAll();
}

Using this client the book recommendation service can communicate with the book catalogue service, for instance, to request a list of books.

Messaging

For communication through messaging, Micronaut provides integrations with the message brokers Kafka and RabbitMQ, which is an implementation of the AMQP standard. Micronaut’s integration with these message brokers makes communication very simple.10

Using the same example as before, the book catalogue service defines a Kafka client to send messages using @KafkaClient. The @Topic annotation specifies the topic the messages are sent to.

@KafkaClient
public interface BookClient {

    @Topic("books")
    void sendBook(@KafkaKey String isbn, String title);

    void sendBook(@Topic String topic,
                    @KafkaKey String isbn, String title);
}

Now the book recommendation service can receive messages by defining a Kafka listener using @KafkaListener. The @Topic annotation indicates which topic the listener wants to receive messages from.

@KafkaListener(offsetReset = OffsetReset.EARLIEST)
public class BookListener {

    @Topic("books")
    public void receive(@KafkaKey String isbn, String title) {
        System.out.println("Got Book - " + isbn + " " + title);
    }
}

Messaging through RabbitMQ works very similar to Kafka. The client is defined using @RabbitClient, and the listener using @RabbitListener. The queue to send messages to is defined with @Binding, and the queue to receive from with @Queue.

@RabbitClient
public interface BookClient {

    @Binding("books")
    void send(byte[] data);
}
@RabbitListener
public class BookListener {

    @Queue("books")
    public void receive(byte[] data) {
        System.out.println("Received " 
                + data.length + " bytes from RabbitMQ");
    }
}

Operations and Continuous Deployment of Microservices

Operating an established number of services is one of the central challenges when working with microservices because of the many deployable artifacts that need to be survailled.

Logging and Monitoring

To easily retrieve information from the events that occur in our system, like errors, status of the running applications or user-centered statistics we can make use of logging and monitoring strategies. To log our events, the most common solution is to have a central infrastructure that gathers all logs from the running microservices. This avoids checking the logs of every service individually. The continously monitoring of logs provides feedback that is helpful for operations but also for developers and users of the system.

Micronaut’s approach

The way Micronaut supports logging and monitoring of microservices its heavily inspired by Spring Boot and Rails. By adding the micronaut-management dependency we can monitor our service using endpoints, which are special URIs that return details about the health and state of the application.

Micronaut support two types of endpoints: custom and built-in endpoints. The custom endpoints are created by annotating a class with the @Endpoint annotation and one id. Then, a method from the class can be annotated with @Read, @Write and @Delete to respectively return a respond to GET, POST and DELETE requests. The image below illustrates a custom endpoint /date and its different responses.

Microanut's custom endpoint

Besides the custom endpoints, the micronaut-management dependency offers built-in endpoints which are shown in the table below.

Microanut's built-in endpoints

For our case study the most relevant ones are the MetricsEndpoint accessible by the /metrics URI. The development team has integrated Micrometer so it can gather the metrics from the /metrics endpoint.11

Additionally, through the integration with Elasticsearch, Micronaut can forward all logs to this log aggregating system.12

Deployment of microservices

An independent deployment is a central goal in a microservices architecture. To achieve a good level of isolation, it is best to run each service on a virtual machine. But when this case is not possible and different services are running in the same virtual machine, the deployment of a microservice can generate a high load or introduce changes that also concern other microservices.

There are two approaches to ensure an individual deployment: using virtualization technologies like containers or deploy the microservices in a cloud, where all details of the actual infrastructure are hidden from the application.

Deploying a Micronaut Application in a Docker Container

A Micronaut application is packaged in a uber jar and can be therefore executed on every JVM. But, because Micronaut itself does not rely on reflection or any dynamic classloading its deployment can get advantage of running in GraalVM, a Java VM that improves the startup time and reduces the memory footprint.

The best approach following the Micronaut team guideline is to construct a GraalVM native image and deploy it inside a Docker container.

For this, first we need to add the svm and graal dependencies into our Micronaut application:

dependencies {
    ...
    compileOnly "org.graalvm.nativeimage:svm"
    annotationProcessor "io.micronaut:micronaut-graal"
}

Then to simplify the building process we need to create a native-image.properties file in the directory src/main/resources/META-INF/native-image with the following parameters:

Args = -H:IncludeResources=logback.xml|application.yml \
       -H:Name=<name> \
       -H:Class=<package.<main-class>>

After this, we have to create a Dockerfile to assemble our image with the running application inside:

FROM oracle/graalvm-ce:20.0.0-java8 as graalvm
#FROM oracle/graalvm-ce:20.0.0-java11 as graalvm # For JDK 11
RUN gu install native-image

COPY . /home/app/<name>
WORKDIR /home/app/<name>

RUN native-image --no-server -cp build/libs/complete-*-all.jar

FROM frolvlad/alpine-glibc
RUN apk update && apk add libstdc++
EXPOSE 8080
COPY --from=graalvm /home/app/<name>/<name> /<name>/<name>
ENTRYPOINT ["/<name>/<name>", "-Xmx68m"]

Finally this docker container can be easily deployed in different cloud-hosting services. Such as AWS EC2 Container Service, or Google Compute Engine.

Conclusion

As this blogpost shows, Micronaut’s functionality and architecture has considered all discussed best practices and patterns for implementing microservices. Because it is not necessary that all microservices have the same technology stack, it is easy to implement individual services with Micronaut and integrate them into existing systems. Herby the included 3rd-party integrations help.

Certainly, Micronaut does not provide as much functionality or integrations as Spring Boot, although, it has one key advantage. As discussed in the previous blogposts, its AOT compiling approach leads to a shorter startup time and low memory consumption. These quality properties allow fast and cost-efficient scaling and make the applications ideal for cloud environments.

Concluding, we can say Micronaut can keep its promise and is a good fit for microservice applications. Especially, with its AOT compiling approach it has huge advantages compared to other frameworks on the market.

  1. Micronaut. Website. https://micronaut.io/ 

  2. Wikipedia. Microservices. accessed 7th of April 2020 https://en.wikipedia.org/wiki/Microservices 

  3. Eberhard Wolff. Microservices: Flexible Software Architecture. Addison-Wesley Professional, 2016 https://www.oreilly.com/library/view/microservices-flexible-software/9780134650449/  2 3 4

  4. Alejandro Duarte. Microservices: Externalized Configuration. DZone, 2018 https://dzone.com/articles/microservices-externalized-configuration 

  5. Micronaut Docs https://docs.micronaut.io/latest/guide/index.html  2 3 4 5

  6. RFC 6749. The OAuth 2.0 Authorization Framework. https://tools.ietf.org/html/rfc6749 

  7. Micronaut Security Docs https://micronaut-projects.github.io/micronaut-security/latest/guide/index.html 

  8. RFC 7519. JSON WebToken (JWT) https://tools.ietf.org/html/rfc7519 

  9. Wikipedia. Circuit breaker design pattern. accessed 8th of April 2020 https://en.wikipedia.org/wiki/Circuit_breaker_design_pattern 

  10. Micronaut Kafka Docs https://micronaut-projects.github.io/micronaut-kafka/latest/guide/ 

  11. Micronaut Micrometer Integration https://micronaut-projects.github.io/micronaut-micrometer/latest/guide/ 

  12. Micronaut Elasticsearch Integration https://micronaut-projects.github.io/micronaut-elasticsearch/latest/guide/index.html 

Micronaut