engineering-fullcontact-quarkus-blog-header-3200px

The Case for Quarkus: FullContact takes a new development framework out for a spin

Learning new technologies through innovation and hacking away at new solutions is deeply ingrained in FullContact Engineering culture. During our last hackathon, FullContact asked our team to imagine an entirely new API and dataset to expose to our customers. What if we took our core identity resolution and enrichment data and used that to build a brand new set of APIs to address the emerging need for Identity Verification and Fraud products? Is this person who they say they are? Do these pieces of PII “belong” together? How much should I trust this email? By combining  FullContact’s existing identity graph with other first and third-party datasets we can answer these questions and more.

FullContact has a long history of being an API-first company and developing several cloud-native microservices that power our services and products behind the scenes. Traditionally these microservices have been built using the Dropwizard framework running inside the Java Virtual Machine (JVM) and deployed to Kubernetes. When Dropwizard originally came out, the need to have small service footprints with fast startup times to optimize running in containers was not a concern at the front of people’s minds. Fast forward to today and these are essential factors to consider when deploying and scaling your services in a cloud native containerized  environment. Since a vital part of any good hackathon is learning something new and applying it to solve your problems, we chose to explore other frameworks to see what has evolved to fit these requirements since the introduction of Dropwizard. We landed on a framework called Quarkus.

Why build our new API with Quarkus instead of Dropwzard? Well, there are a few reasons:

  • All of our services run on cloud infrastructure, so going with a framework that is cloud native only makes sense; Quarkus is also much more modern than Dropwizard.
  • Quarkus provides many tools to help speed up testing, configuration, and development in general.
  • It’s fun to try out new technologies!

The Good

One of the most immediately apparent benefits of Quarkus is the developer experience. For example, here at FullContact, we use Kafka heavily. Our APIs need to be able to produce messages to Kafka topics. Our Dropwizard services need a developer configuration that will allow the service to connect to Kafka brokers and produce to a non-production topic. This means creating non-production topics on our production Kafka cluster or leaving it up to the developer to get Kafka brokers running locally on their machine. Neither is a great solution. With Quarkus, we can change one single line in our configuration, and we can run without any reliance on a Kafka cluster at all.

This:

mp:
  messaging:
    outgoing:
      lum-usage:
        connector: smallrye-kafka

Becomes this:

mp:
  messaging:
    outgoing:
      lum-usage:
        connector: smallrye-in-memory

Now, the developer can run locally without worrying about Kafka. But what if the developer wants to connect to and produce to a Kafka cluster while running locally? Quarkus is to the rescue again with its dev services! With Quarkus, all we need to do is enable dev services, and Quarkus will automatically provision and connect to a Kafka cluster running in local Docker containers.

Moving on from the benefits Quarkus provides in terms of developer environment, we’ve also seen improvements in the amount of boilerplate code required thanks to Quarkus and a concept called Context and Dependency Injection (CDI). For instance, one of our clients built with Dropwizard consists of one interface, one abstract class, one class, and 165 lines of code. We can do the same with Quarkus and Microprofile rest clients using a single interface and 16 lines of code.

@ApplicationScoped
@RegisterRestClient
interface ZoidbergRestClient {

  @POST
  @Path("/internal/person.enrich.fields")
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(ProtoMediaType.APPLICATION_PROTOBUF)
  FieldsMessage.Fields personEnrichFields(
      MultiFieldReq req, @QueryParam("reportToLum") String reportToLum);

  @POST
  @Path("/internal/person.enrich.fields")
  @Consumes(MediaType.APPLICATION_JSON)
  @Produces(ProtoMediaType.APPLICATION_PROTOBUF)
  Uni personEnrichFieldsUni(
      MultiFieldReq req, @QueryParam("reportToLum") String reportToLum);
}

GraalVM native image size/deployment time

One of the nice features of GraalVM is the ability to create a native executable, which GraalVM calls a native-image. Two significant benefits of compiling to a native-image are blazing fast startup times and a tiny memory footprint.

While neither Quarkus nor GraalVM require you to use this feature, we built and deployed our new identity verification and fraud service as a native-image specifically to take advantage of these benefits. Deploying as a native-image allows our service to start up in around 40ms, allowing us to deploy and scale with virtually no downtime. Additionally, our entire CI/CD pipeline, from merging to building to running deploygate tests and finally to deployment, takes only around 5 minutes. Resource requirements are also significantly reduced when running a native-image. The memory footprint for our new service is now in the MBs rather than GBs, with our pods configured with a max memory of 128MB and using less than 30MB on average.

The Challenges

Implementing a new framework or tool is never without a few challenges and struggles. With Quarkus, we encountered a few of these, but luckily we were able to overcome all of them.

Native runtime failures and confusing stack traces

Running in a native image compiled down to machine code using GraalVM presents new and compelling problems. Some native runtime errors are  related to missing classes that failed to bundle due to reflection or existing pieces of native code (JNI) etc that were bundled incorrectly. When these exceptions occur, interpreting them and deciding on the next course of action can be somewhat time-consuming (especially if you have never solved a problem like this before).

When GraalVM compiles your code, it analyzes all the classes and code referenced in order to only bundle the actively used code in the native image . If your program ends up using other classes via reflection, GraalVM will not be aware of this, and as a result, those dependencies will not get compiled into your application. Quarkus does its best to include these classes automatically but sometimes fails to find all of them, such as the case with protocol buffers.

Luckily there is a pretty easy workaround to this. GraalVM provides a tool you can run as a java agent and will monitor all the runtime reflection classes your program accesses. Our usual process has been:

    1. Run our service using the java agent
    2. Exercise all of the typical code paths using an integration test
    3. Take the resulting config file and feed that into the GraalVM build, so it knows what classes to bundle

Example code:

java -agentlib:native-image-agent=config-output-dir=config/ -jar build/quarkus-app/quarkus-run.jar

cp reflect-config.json src/main/resources/reflect-config.json

Once you update the reflect-config.json file in src/main/resources, the next GraalVM build will use this to ensure all classes and resources accessed via reflection will bundle into the native image.

The Verdict

The benefits of using Quarkus as a microservice development framework greatly outweigh the cons. Initially, there was a steep learning curve. Still, that extra time has more than paid off with the increase in developer efficiency, decreased deployment image sizes, and a vast increase in program startup time. When considering frameworks to use for new microservices, Quarkus remains a top contender.

Recent Blogs