MicroProfile unter der Lupe, Teil 2: Health Check und Metrics API

Neuigkeiten von der Insel  –  0 Kommentare

Um ein verteiltes System verlässlich (a.k.a. reliable) zur Verfügung zu stellen, ist es wichtig, essenzielle Metriken zu monitoren. MicroProfile hat seit der Version 1.2 mit Health Check und Metrics gleich zwei passende APIs im Gepäck.

Microservice-Anwendungen zeichnen­ sich durch die Unabhängigkeit ihrer Services und einer damit verbundenen Verteiltheit des Gesamtsystems aus. Während bei einer klassischen Enterprise-Java-Anwendung ein Deployment-Artefakt in einem Server beziehungsweise einem Server-Cluster läuft und somit recht gut unter Kontrolle zu halten ist, setzt sich eine Microservice-Anwendung in der Regel aus einer Vielzahl von Services zusammen, die jeweils in eigenen Prozessen – zum Beispiel in Docker-Containern – laufen. Um ein solches Szenario in den Griff zu bekommen, sind ein ausgereiftes Monitoring zum Aufdecken aufkommender Problemsituationen sowie die automatisierte Reaktion auf die Ergebnisse des Monitorings notwendig.

Es kommt also nicht von ungefähr, dass das Eclipse-Projekt MicroProfile bereits in Version 1.2 entsprechend APIs zum Monitoren von Microservices in die Spezifikation aufgenommen hat: HealthCheck API, Metrcis APIs.

Warum aber gleich zwei APIs? Hätte denn eine nicht auch ausgereicht? Die beiden zur Verfügung stehenden APIs sprechen tendenziell unterschiedliche Bereiche des Monitorings an. Mithilfe der Health Check API soll von außen festgestellt werden können, ob ein Service beziehungsweise eine Anwendung noch so läuft, wie geplant. Auf die einfache Frage "geht es dir gut?" bekommt der Anfragende ein genauso einfaches "Ja" oder "Nein" beziehungsweise die Statusmeldung "UP" oder "DOWN" als Antwort zurück. Diese Information lässt sich von Tools wie Kubernetes nutzen, um den entsprechenden Service oder einzelne Komponenten des Service gezielt zu ersetzen oder zu beenden und im Anschluss neu zu starten. Die zweite API, Metrics API, dagegen dient dazu, gezielt Detailinformationen zum aktuellen "Gesundheitszustand" eines Service und dessen Verlauf abfragen zu können. Mithilfe von Langzeitmetriken lassen sich so zum Beispiel Wachstumspläne für die genutzte Infrastruktur aufstellen oder proaktiv auf sich abzeichnende Engpässe reagieren.

Erfinden wir hier das Rad nicht neu, wird sich jetzt der eine oder andere fragen? Wir haben doch bereits seit Jahren mit JMX und JSR 77 zwei mehr oder minder etablierte Standards im Enterprise-Java-Umfeld. Ja und nein. Insbesondere in einer per Definition als polyglott angelegten Welt – und das ist die Welt der Microservices – ist es wichtig, einen gemeinsamen und vor allem einfachen Standard für das Monitoren des Gesundheitszustandes der Services und das regelmäßige Abfragen von Metriken zu etablieren. Hierbei spielen, wie wir gleich noch sehen werden, Aspekte, wie ein standardisierter REST-Ressourcenpfad, einheitliche Datentypen oder aber auch die Definition der zu verwendenden HTTP Return-Codes eine wichtige Rolle.

Mithilfe der Eclipse MicroProfile Health Check API soll die generelle Verfügbarkeit sowie der Status ("UP" oder "DOWN") einer MicroProfile-Instanz – also eines auf dem MicroProfile basierenden Microservices – geprüft werden können. Die API ist weniger für den manuellen Gebrauch als vielmehr für Szenarien im Umfeld von Container-basierten Umgebungen gedacht (M2M). So wundert es nicht, dass als Vorbild für die API proprietäre Lösungen, wie Cloud Foundry Health Check oder Kubernetes Liveness and Readiness Probes, herangezogen wurden. Dank der Health Check API können Monitoring- und Managment-Tools durch einen einfachen GET-REST-Call des Endpoints /health automatisiert feststellen, ob ein Service wie gewünscht läuft oder gegebenenfalls ersetzt oder neu gestartet werden muss.

GET /health 

200 OK
{
"outcome" : "UP"
"checks": []
}

Besteht der Service aus mehreren Teilkomponenten, kann die Antwort des Service bei Bedarf noch weiter aufgeschlüsselt werden. Im folgenden Beispiel wird der Status des Customer REST Endpoints sowie der zugehörigen Datenbank separat durchgeführt.

200 OK 
{
"outcome": "UP",
"checks": [
{
"name": "datasource",
"state": "UP",
"data": {
"dbConnection": "...",
"dbName": "...",
"freeSpace": "..."
},
},
{
"name": "resources",
"state": "UP"
"data": {
"customers": "/customers",
"addresses": "/customers/{id}/addresses"
}
}]
}

Der aktuelle "Gesundheitszustand" des Service setzt sich in diesem Beispiel also aus zwei Komponenten zusammen. Nur wenn alle Checks den Status "UP" aufweisen, ist auch der Gesamtstatus des Services "UP", was mit einem HTTP-Status-Code 200 und entsprechender Payload quittiert wird. Andernfalls wird der Status von der Health Check API automatisch als "DOWN" interpretiert (HTTP-Status-Code 503). Konnte der gewünschte Gesundheits-Check aus irgendwelchen Gründen nicht durchgeführt werden, wird dies der aufrufenden Partei mit dem HTTP-Status-Code 500 signalisiert. In diesem Fall gilt der Status des Service als unbestimmt.

Die Implementierung und Einbindung der eben gezeigten Checks in den eigenen Microservice ist denkbar einfach. Die Health Check API stellt für diese Aufgabe sowohl ein entsprechendes Interface – HealthCheck – als auch eine Annotation - @Health - zur Verfügung.

@Health
@ApplicationScoped
public class ResourcesHealthCheck implements HealthCheck {

public HealthCheckResponse call() {
return HealthCheckResponse
.named("resources")
.withData("customers","/customers" )
.withData("addresses","/customers/{id}/addresses" )
.state(checkCustomersResource()
&& checkAddressesResource ())
.build();
}

private boolean checkCustomersResource() { return … }

private boolean checkAddressesResource() { return … }

Die Annotation werden im Kontext von CDI automatisch erkannt und die entsprechenden Beans beziehungsweise deren call()-Methoden aufgerufen, sobald ein externer Aufruf des /health-Endpoints auf dem Service erfolgt.

Was aber, wenn wir nicht nur wissen wollen, ob ein Service ordnungsgemäß läuft oder eben nicht? Was, wenn uns zum Beispiel die aktuelle CPU- oder Speicherauslastung oder aber die Anzahl der derzeit verwendeten Threads interessiert? Und was, wenn wir aus historischen Daten und deren aktuellen Pendants Rückschlüsse darauf treffen möchten, wie sich unser Systemzustand entwickelt, um so zum Beispiel bei Bedarf mehr Plattenplatz zur Verfügung zu stellen oder neue Service-Instanzen zu starten beziehungsweise bestehende Instanzen zu stoppen.

Die MicroProfile Metrcis API bietet eine genormte und individuell erweiterbare Schnittstelle zur Beantwortung genau dieser Fragestellungen – und vieler anderer mehr. Mithilfe eines REST-Calls gegen den Endpoint /metrics beziehungsweise einen von drei untergeordneten Metrik-Scopes (Sub-Ressourcen: base, vendor, application) lassen sich die gewünschten Telemetrie-Daten via HTTP abfragen und über Tools, wie Prometheus, automatisch auswerten, um so im Anschluss entsprechende Aktionen anzustoßen.

Die Rückgabe erfolgt dabei entweder im JSON-Format, bei Angabe des Accept-Headers "application/json" oder alternativ im Prometheus Text Format (bei fehlendem Accept-Header). Es ist davon auszugehen, dass zukünftig noch weitere Formate durch die Spezifikation oder aber durch herstellerspezifische Erweiterungen unterstützt werden.

Bei dem ersten Scopes – base – handelt es sich um Telemetriedaten, die jede MicroProfile-Implementierung beim Aufruf des allgemeinen Endpoints /metrics beziehungsweise des Scope-spezifischen Endpoints /metrics/base zur Verfügung stellen muss. Hier finden sich hauptsächlich JVM-relevante Informationen:

GET /metrics/base
Accept: application/json
Authorization: Basic ....


200 OK
{
"classloader.totalLoadedClass.count": 12595,
"cpu.systemLoadAverage": 3.88525390625,
"gc.PSScavenge.time": 262,
"thread.count": 30,
"classloader.currentLoadedClass.count": 12586,
"jvm.uptime": 4795005,
"memory.committedHeap": 740818944,
"thread.max.count": 46,
"cpu.availableProcessors": 4,
"gc.PSMarkSweep.count": 3,
"thread.daemon.count": 28,
"classloader.totalUnloadedClass.count": 9,
"gc.PSScavenge.count": 13,
"memory.maxHeap": 3817865216,
"cpu.processCpuLoad": 0.00571799781527977,
"memory.usedHeap": 238923968,
"gc.PSMarkSweep.time": 448
}

Anmerkung: Wie der Beispielaufruf zeigt, geht die Spezifikation davon aus, dass der /metrics-Endpoint in irgendeiner Weise abgesichert werden kann. In welcher Art und Weise dies passiert, ist aktuell nicht spezifiziert und somit dem jeweiligen Hersteller überlassen.

Zusätzlich zu diesen universellen Metriken hat jeder Hersteller optional die Möglichkeit, eigene, in der Regel nicht portable Metriken via /metrics beziehungsweise /metrics/vendor anzubieten. Dies könnten zum Beispiel Informationen zum Zustand eines intern verwendeten Cache oder eines Messaging-Systems sein.

Besonders interessant, aus Sicht eines Microservices-Entwicklers, ist der dritte und letzte Scope: application. Auch dieser Scope ist optional und kann zur Bereitstellung von anwendungsspezifische Metriken genutzt werden.

Im folgenden Beispiel kann die Anzahl an Einschreibungen sowie die jeweils für die Einschreibungen benötigte Zeitspanne als Metrik abgefragt werden. Das Beispiel zeigt ebenfalls, dass sich, je nach gewählter Metrik, neben den aktuellen Werten auch Durchschnitts- und Trendwerte abfragen lassen:

GET /metrics/application
Accept: application/json
Authorization: Basic ....

200 OK
{
"microprofile.demo.subscriptions": 6,
"microprofile.demo.doSubscribe": {
"fiveMinRate": 0.18984199379480962,
"max": 1949047687,
"count": 6,
"p50": 901908241,
"p95": 1949047687,
"p98": 1949047687,
"p75": 1909288320,
"p99": 1949047687,
"min": 423270500,
"fifteenMinRate": 0.19664645980623557,
"meanRate": 0.12968582020280628,
"mean": 1164575631.8269844,
"p999": 1949047687,
"oneMinRate": 0.148852445426026,
"stddev": 589520048.3135717
}
}

Der Abfrage von Metriken mittels Metrics API kann auf drei unterschiedlichen Ebenen erfolgen:

  1. GET /metrics: liefert alle Metriken für alle Scopes
  2. GET /metrics/<scope>: liefert alle Metriken für den angegebenen Scope
  3. GET /metrics/<scope>/<metric_name>: liefert die Metrik für den angegebenen Namen und Scope

Jede Metrik ist optional mit einer Reihe von Metadaten verbunden. Sie sollen aufrufenden Tools, aber auch Menschen (Operators) dabei helfen, die gelieferten Wert besser interpretieren zu können. Neben Informationen wie description und displayName sind vor allem die beiden Metadaten type (counter, gauge, meter, timer, histogram) und unit von besonderem Interesse.

Welche Metadaten einer Metrik zugeordnet sind, lässt sich leicht erfragen, indem statt eines GET- ein OPTIONS-Request gegen den entsprechenden REST-Endpoint abgesetzt wird.

Während die Metadaten bei den Scopes base von der Spezifikation vorgegeben sind, können sie bei den beiden anderen Scopes – vendor und application – frei zugeordnet werden.

In dynamischen Welten, wie denen der Microservices, spielen Label beziehungsweise Tags eine besondere Rolle. Während sich die ID eines Service durch automatisches (Re-)Deployment ändern kann, können eindeutige Tags als gemeinsamer Aufhänger für gezielte Abfragen von Metriken durch Tools, wie Kubernetes und Prometheus, genutzt werden. So wäre es zum Beispiel denkbar, dass man die Metriken einer bestimmten Anwendung (app=myApp) oder alternativ einer bestimmten Schicht einer Anwendung (app=myApp && tier=database) abfragen und visuell darstellen beziehungsweise verdichtet auswerten möchte.

Damit dies möglich wird, erlaubt die Metrics API die Angabe von Tags. Dies kann entweder mithilfe der MicroProfile Config API und dem Key MP_METRIC_TAGS geschehen oder alternativ über das Application Metrics Programming Model erfolgen.

Die Metrics API erlaubt es, neben den allgemeinen und herstellerspezifischen Metriken auch eigene, anwendungsspezifische Metriken im Scope application zu registrieren. Dies sind genau diejenigen Metriken, die bei einem Aufruf von /metrics/application zurückgeliefert werden.

Zur einfachen Handhabung gibt es für jeden Metrik-Typen eine eigene Annotation (@Counted, @Gauge, @Metered, @Metric, @Timed), die je nach Typ auf Klassen, Konstruktoren, Methoden oder Parametern angewandt werden können.

Das folgende Beispiel zeigt eine REST-Ressource zum Einschreiben in beziehungsweise Ausschreiben aus einem Online-Kurs. Mittels Metric API lässt sich die Anzahl der aktuellen Einschreibungen sowie Telemetrie-Daten zur dafür benötigten Zeit abfragen. Das Resultat des Aufrufs haben wir bereits weiter oben gesehen:

@Path("subscriptions")
public class SubscriptionResource {

@Inject
@Metric
(absolute = true,
name = "microprofile.demo.subscriptions",
description = "number of subscriptions ",
displayName = "current subscriptions for online course",
unit = MetricUnits.NONE)
Counter subscriptionCounter;

@POST
@Produces(MediaType.APPLICATION_JSON)
@Timed(absolute = true,
name = "microprofile.demo.doSubscribe",
displayName = "Subscription time",
description = "Time of subscription in ns",
unit = MetricUnits.NANOSECONDS)
public Response subscribe(...) {
// subscribe to online course
...
subscriptionCounter.inc();
return Response.ok()… build();
}

@DELETE
@Produces(MediaType.APPLICATION_JSON)
@Timed(absolute = true,
name = "microprofile.demo.doUnsubscribe",
displayName = "Unsubscription time",
description = "Time of unsubscription in ns",
unit = MetricUnits.NANOSECONDS)
public Response unsubscribe() {
// unsubscribe to online course
...
subscriptionCounter.dec();
return Response.ok()… build();
}
}

Wer jetzt denkt, dass ihm das Ganze bekannt vorkommt, der hat wahrscheinlich recht. Die Macher der Metrics API haben bewusst versucht, das Rad nicht neu zu erfinden und sich stattdessen an der etablierten DropWIzard Metrics API orientiert.

Wie das Beispiel zeigt, ist die Wahl des Metriknamens (name = "...") nicht ganz unwichtig. Und dies gilt gleich in zweierlei Hinsicht. Zum einen erleichtert die Wahl eines geeigneten Namens inklusive eindeutigem Namensraum, das gezielte Abfragen und Filtern von Metriken durch angebundene Tools enorm. Zum anderen verhindern versehentlich doppelt vergebene Namen das Deployment eines Microservices, da CDI dies zum Zeitpunkt des Scannings feststellt und eine IllegalArgumentException wirft. Eine Ausnahme ist nur dann erlaubt, wenn eine Metrik bewusst mit dem Attribut reusable markiert wird. In diesem Fall müssen aber alle Varianten vom selben Typ sein.

Um ein verteiltes System verlässlich (a.k.a. reliable) zur Verfügung zu stellen, ist es wichtig, essenzielle Metriken zu monitoren. MicroProfile hat seit der Version 1.2 mit Health Check und Metrics gleich zwei passende APIs im Gepäck. Während die Health Check API lediglich die Frage nach dem allgemeinen Gesundheitszustand eines Service klärt ("UP" oder "DOWN"), lassen sich dank Metrics API Rückschlüsse auf die allgemeine Entwicklung des Service und seiner Umgebung anstellen und so proaktiv auf Tendenzen, wie die negative Entwicklung von Speicherverbrauch oder Antwortzeiten, reagieren.

Metrics unterstützt als Austauschformat zur Anbindung von Monitoring- und Management- Tools sowohl JSON und auch das Prometheus Text Format. Über weitere Formate wird bereits nachgedacht. Man darf gespannt sein.