Microservices zijn populair. Er zijn verscheidene frameworks beschikbaar die je kunnen helpen bij de implementatie. Daarna volgt natuurlijk het daadwerkelijk gebruik van de service in een productieomgeving. Het komt regelmatig voor dat, met name in complexe omgevingen, performance uitdagingen optreden. Bijvoorbeeld doordat het daadwerkelijk gebruik veel hoger ligt dan het initieel ingeschatte gebruik. De keuzes voor een specifiek framework, een JVM, garbage collection (GC) algoritme en of je bijvoorbeeld van just-in-time (JIT) of ahead-of-time (AOT) compilatie gebruik maakt, zijn van invloed. Juiste keuzes op die vlakken kunnen je helpen pijn te verlichten of soms zelfs problemen voorkomen. Ze hebben ook gevolgen voor resourcegebruik, waar mogelijk een prijskaartje aan hangt. Onder het motto van ‘meten is weten’ heb ik diverse tests uitgevoerd. Hier kwamen interessante resultaten uit. Hoewel deze natuurlijk niet zonder meer op andere situaties/omgevingen zijn toe te passen, bieden ze suggesties voor schroeven waar je aan kan draaien om performance mogelijk te verbeteren.

Auteur: Maarten Smeets

JVMs

Afgelopen periode zijn veel veranderingen geweest in de wereld van JVMs. Oracle heeft zijn licentie en supportbeleid met betrekking tot Oracle JDK aangepast. Meerdere software leveranciers zijn met eigen OpenJDK distributies en ondersteuningsmodellen gekomen, zoals Amazon en SAP. Red Hat is tegenwoordig steward van OpenJDK 8 en 11. Er waren natuurlijk al een tijdje alternatieve implementaties, zoals Eclipse OpenJ9 en Azul Systems Zing. In het verleden was Oracle JDK veelal de de-facto standaard. Tegenwoordig is deze keuze niet meer zo van zelfsprekend.

GraalVM, een nieuwe polyglot virtuele machine van Oracle, heeft in mei 2019 zijn eerste officiële release gehad. Hiermee is het mogelijk, met een early adopter plug-in, om code AOT te compileren naar native executables in plaats van gebruik te maken van JIT compilatie. AOT gecompileerde code start sneller op en neemt minder schijfruimte en geheugen in. Responsetijden zijn wel wat slechter dan JIT gecompileerde code. De onderstaande afbeelding geeft beknopt weer hoe AOT en JIT compilatie zich verhouden op verscheidene performance gerelateerde vlakken.

Afbeelding door Thomas Wuerthinger, Research Director bij Oracle Labs, hoofd van het GraalVM project

 

Aangezien verschillende JVMs dezelfde specificatie implementeren, wordt interoperabiliteit bereikt. Dit houdt in dat Java bytecode niet specifiek is voor een JVM, maar op verschillende JVMs kan draaien. Er zijn echter JVM leveranciers die, hoewel ze zich aan dezelfde specificatie conformeren, een eigen implementatie hebben opgesteld of additionele features aan de JVM hebben toegevoegd. Voorbeelden hiervan zijn Azul Systems met Zing en Eclipse OpenJ9. Een belangrijke afweging die meespeelt in de keuze voor een JVM, naast natuurlijk ondersteuning en compatibiliteit, is performance.

Microservice frameworks

Applicaties maken gebruik van frameworks. Deze frameworks bieden abstracties van veel voorkomende uitdagingen en nemen ontwikkelaars werk uit handen. Ze hebben echter ook een bepaalde mate van overhead, wat gevolgen voor de performance kan hebben.

Hieronder zijn een aantal Microservice frameworks beschreven die bekeken zijn op een aantal performance karakteristieken. De onderstaande tabel geeft de bekeken versies aan en de status van native compilatie in het framework.

Oracle’s Helidon

Dit framework van Oracle heeft 2 varianten:

–        Helidon SE is een microframework die het reactive programmeermodel ondersteund;

–        Helidon MP is een Eclipse Microprofile implementatie.

Code gebaseerd op Helidon MP kan vanwege aanwezigheid van CDI niet native compileren. Dit kan wel met Helidon SE.

Micronaut

Micronaut is een full stack framework voor het implementeren van microservices. Het biedt de mogelijkheid tot native compilatie met GraalVM.

Akka

Akka is een implementatie van het actor model op de JVM en geschreven om met name schaalbaar en resilient te zijn. Native compilatie is mogelijk, maar niet out-of-the-box.

Quarkus

Quarkus is een door Red Hat gesponsorde open source Kubernetes native Java stack die geschikt is voor native compilatie op GraalVM. Tevens kan gebruik worden gemaakt van SmallRye voor de implementatie van verscheidene MicroProfile specificaties. Quarkus biedt ook een Vert.x implementatie.

Eclipse Vert.x

Vert.x is een toolkit om reactive applicaties op een JVM mee te kunnen ontwikkelen. Hoewel niet out-of-the-box, is het mogelijk Vert.x code native te compileren.

Spring Boot

Spring Boot maakt het eenvoudig om stand-alone Spring gebaseerde applicaties te ontwikkelen, onder andere door keuzes te maken met betrekking tot de te gebruiken libraries en intelligente defaults voor configuratie. Er wordt gesproken over Spring 5.3 (verwacht Q2 2020). Native compilatie ondersteund.

Spring Fu

Spring Fu is een incubator project voor Kofu (Ko voor Kotlin en fu voor functional). Het biedt een Kotlin API om Spring Boot applicaties programmatisch mee te configureren.  Het is nog niet geschikt voor productie, maar kan op termijn een onderdeel van Spring Boot worden.

Eclipse MicroProfile (Open Liberty implementatie)

MicroProfile is een specificatie van een beperkte set aan Java EE APIs om microservices mee te realiseren. Open Liberty is de MicroProfile implementatie waar naar gekeken is. MicroProfile ondersteunt geen native compilatie vanwege gebruik van CDI.

Test opzet

Performance

Performance is een breed begrip. Wikipedia geeft aan dat ‘betere performance’ betekent ‘meer werk verrichten in minder tijd en/of met minder resources’. Binnen deze definitie is veel ruimte voor interpretatie. Immers, wat is ‘werk’ en over welke ‘resources’ heb je het? Deze onderwerpen zijn vaak moeilijk los van elkaar te zien. Bijvoorbeeld een service responsetijd of opstarttijd. Dat geeft aan dat een hoeveelheid werk in een periode is verzet. Het is echter wel een heel ander soort werk. Kijken we naar een moeilijk te simuleren representatieve werklast voor een service of creëren we een testsituatie die eenvoudig reproduceerbaar is, maar mogelijk niet representatief? Werken we met een volledige stack van frontend tot persistent storage of pakken een specifieke component om naar te kijken? Naar welke resources gaan we kijken en hoe relateren we deze aan de hoeveelheid werk welke verzet is?

Niet alleen is niet eenduidig aan te geven wat je zou moeten meten om performance te bepalen. Er zijn ook veel factoren die van invloed zijn op performance en waarin je zou willen variëren. Hierin moeten natuurlijk keuzes gemaakt worden. Er is gekozen om vooral focus te leggen op het meten van responsetijden van verschillende frameworks op verschillende JVMs.

Resource isolatie

De verwachte verschillen tussen frameworks en JVMs zijn klein. Immers, veelal zijn er andere bottlenecks, zoals de persistent store of het netwerk. Om zonder deze ruis verschillen tussen frameworks inzichtelijk te kunnen maken, is gekozen om:

–        de load te genereren op dezelfde machine waar de services draaiden, zodat het netwerk geen rol speelt.

–        een minimale, maar wel zo vergelijkbaar mogelijke implementatie van microservices op te stellen, gebruikmakend van de verschillende frameworks.

Aangezien één enkele machine is gebruikt, is hierin de isolatie van resources van belang. De services mochten geen significante last hebben van de load generator. Om dit te realiseren, zijn onder andere specifieke CPUs toegekend aan de load generator, de microservice code en overige processen. Daarnaast is gekozen om de resultaten eerst in het geheugen weg te schrijven en pas nadat de test was afgerond, naar de schijf. Immers, de performance van de schijf was niet het aandachtspunt van de test en geheugensnelheid bleek geen bottleneck.

Er is naast Docker containers geen gebruik gemaakt van virtualisatie. Dus geen Windows machine met Virtualbox met een Linux Guest met Docker daarin of een Docker Desktop (nu Hyper-V, binnenkort WSL2) omgeving op Windows, maar alleen Docker op Linux.

Resultaten

Disclaimer

Van belang bij de interpretatie van deze resultaten is dat testen zijn uitgevoerd op specifieke hardware in specifieke omstandigheden (met specifieke test en analysescripts) met een specifieke minimale implementatie van services gebruikmakend van verschillende frameworks. De gebruikte code is terug te vinden in de referenties. Dit is natuurlijk heel wat anders dan een volledige applicatie welke op een productie omgeving draait. Vat de resultaten daarom indicatief op. Gebruik ze ter inspiratie en niet als absolute waarheid om beslissingen op te baseren. Voer eigen testen uit als je zeker wilt weten dat een bepaalde maatregel het gewenste effect heeft.

Performance bestaat uit meer dan waar ik naar heb kunnen kijken. Eén enkel artikel is onvoldoende om alle resultaten in te presenteren. Daarom is ervoor gekozen het te beperken tot enkele highlights. Daarnaast is er natuurlijk meer van belang bij de keuze voor een framework en JVM.

Resultaten JVMs

Aan de bovenstaande afbeeldingen is te zien dat over het algemeen de framework keuze van groter belang is voor responsetijden dan de JVM keuze.

OpenJDK distributies en Oracle JDK toonden geen duidelijke verschillen in responsetijden.

Als gekeken wordt naar verschillen tussen Java versies, dan doet JDK 8 het bij bijna elke JDK leverancier het nét iets beter dan JDK 11. Er is met dezelfde code op beide JDK versies getest. Wel is respectievelijk de JDK 8 of 11 compiler gebruikt om bytecode te genereren (niet voor gebruikte libraries). Het is natuurlijk mogelijk features te gebruiken in JDK 11 die niet in JDK 8 beschikbaar zijn. Hierdoor is mogelijk betere performance te behalen. Een uitspraak, zoals ‘JDK 8 geeft betere performance dan JDK 11’ is daarom ook niet algemeen toepasbaar.

Opvallend was dat OpenJ9 het niet zo goed deed vergeleken met de andere JVMs bij 2Gb heap. Bij het beperken van het heap geheugen (20Mb in mijn tests) wordt OpenJ9 uiteindelijk sneller dan de andere JVMs.

Ook opvallend was het vergelijken van garbage collection algoritmes. Bij veel geheugen (2Gb) waren verschillen in responsetijden minimaal, maar bij weinig geheugen (20Mb) werden de verschillen tussen de algoritmes goed zichtbaar. De keuze voor een JVM was echter van groter belang dan de keuze voor een garbage collection algoritme. Bij weinig geheugen was OpenJ9 met elk gekozen garbage collection algoritme sneller dan bijvoorbeeld OpenJDK.

Azul Systems Zing wordt door Azul Systems ondersteund vanaf een minimale heap van 1Gb. Dit maakt deze JVM minder geschikt voor een scenario waar veel kleine containers met beperkt geheugen wenselijk zijn. Tevens was de opstarttijd van Zing slechter dan de andere JVMs. Zing heeft features om de opstarttijd te reduceren, zoals Zings ReadyNow! en Compile Stashing. Deze features waren echter niet zonder meer toepasbaar in de stateless containers die ik bij mijn tests gebruikt heb.

GraalVM, die AOT compilatie mogelijk maakt, is alleen voor Java 8 beschikbaar op het moment van schrijven. Native gecompileerde code doet het slechter in responsetijden dan niet native gecompileerde code voor elk framework wat hier ondersteuning voor biedt. Opstarttijden van native gecompileerde code zijn echter veel beter, de executables zijn kleiner en het geheugengebruik is minder.

Native executables lenen zich goed voor serverless functionaliteit en kunnen bijvoorbeeld in FnProject (Oracle) of Knative (Google) draaien. Daarnaast kan dit ook gebruikt worden in AWS lambda functies door het met een stukje Python te wrappen. De Windows native compilatie bevindt zich in een erg experimenteel stadium. Als het je lukt om .exe files te genereren, dan kan je die in Azure Functions hangen.

Resultaten Microservice Frameworks

Alle bekeken frameworks draaiden op elke bekeken JVM zowel op versie 8u202 als 11.0.3. Er zijn geen compatibiliteitsuitdagingen gevonden bij gebruik van minimale implementaties. Compatibiliteit vormt dus waarschijnlijk geen beperking bij de keuze voor een combinatie van framework en JVM. Dat is natuurlijk zoals je mag verwachten wanneer verschillende partijen dezelfde standaarden implementeren. Het is mooi dat deze verwachting waargemaakt lijkt te worden.

Open Liberty wordt door de leverancier, IBM, beschreven als een applicatieserver. Dat een applicatieserver zwaarder is dan een gemiddeld framework blijkt onder andere uit het geheugengebruik, responsetijden en opstarttijden. Een service met een embedded Open Liberty viel het eerst om bij reductie van geheugen. Open Liberty is natuurlijk slechts 1 MicroProfile implementatie. Het is te verwachten dat de andere implementaties ook andere performance karakteristieken hebben.

Eclipse Vert.x bleek een duidelijke winnaar met betrekking tot responsetijden op weinig afstand gevolgd door Helidon SE en Quarkus. Deze drie frameworks bevonden zich ook in de top 3 met betrekking tot korte opstarttijden en als kers op de taart bieden Helidon SE en Quarkus (die een Vert.x implementatie biedt), ook de mogelijkheid tot native compilatie met GraalVM.

Conclusie

Er zijn verschillen in performance tussen frameworks en JVMs. Framework keuze is over het algemeen van groter belang voor performance dan JVM keuze. Er zijn een aantal algemene zaken met betrekking tot performance aangegeven die kunnen helpen bij het kiezen van een JVM, AOT of JIT compilatie en framework.

Daarnaast moet je, vooral bij een beperkt geheugen, effecten van garbage collection ook niet vergeten en wordt OpenJ9 interessant. Een dergelijk onderzoek, zoals in dit artikel, is natuurlijk nooit klaar, maar verschaft wel veel inzicht. Houdt daarom mijn blogs en presentaties in de gaten voor updates!