Health-Checks in Java-Anwendungen

Die Gesundheit immer im Blick

Health-Checks sind für den Betrieb von Anwendungen heute, insbesondere wenn sie als Container in einem Cluster deployt werden, unerlässlich. Dieser Artikel zeigt, wieso Health-Checks so wichtig sind, welche Details beachtet werden sollten und wie Health-Checks in Java mit drei gängigen Bibliotheken umgesetzt werden können.

Um eine Anwendung erfolgreich betreiben zu können, muss diese, neben der korrekten Umsetzung der fachlichen Anforderungen, eine Reihe von technischen Faktoren erfüllen. Hierzu gehören Logging, Metriken, Tracing und Health-Checks. In Kombination ermöglichen alle vier Faktoren zusammen den Betrieb einer Anwendung.

Health-Checks ermöglichen hierbei, direkt mit einem Blick auf die Anwendung zu sehen, wie deren aktueller Status ist. Somit lässt sich schnell erfassen, ob es Fehler gibt. Hierbei sind keine fachlichen Fehler oder fehlerhafte Eingaben gemeint, sondern vor allem Probleme mit externen Ressourcen der Anwendung. Externe Ressourcen sind hierbei die Festplatte, der Arbeitsspeicher oder die Verbindung zu anderen Systemen, wie Datenbanken, Message-Brokern oder anderen Anwendungen.

Ein Health-Check ist sowohl für Menschen als auch für Maschinen gemacht. Einem Menschen wird ermöglicht, im Fehlerfall auf einen Blick erkennen zu können, wo dieser entstanden sein könnte. Maschinen nutzen Health-Checks hingegen, um automatisiert entscheiden zu können, ob eine Instanz der Anwendung noch verwendbar ist. Sollte dies nicht mehr der Fall sein, kann dafür gesorgt werden, dass diese Instanz keinerlei Anfragen mehr bekommt oder die Instanz einfach gestoppt und eine neue gestartet wird.

Im Folgenden werden die Bestandteile von Health-Checks beleuchtet, anschließend gängige Herausforderungen aufgezeigt und im letzten Schritt wird die konkrete Implementierung in drei Frameworks für Java beschrieben.

Bestandteile

Für die vollständige Umsetzung von Health-Checks braucht es mehrere Bestandteile. Der erste Bestandteil sind die konkreten Health-Checks. Damit diese implementiert werden können, gibt es eine Programmierschnittstelle. Zwar unterscheidet diese sich im Detail zwischen den verfügbaren Bibliotheken, aber die Idee ist immer dieselbe. Jeder Health-Check implementiert ein Interface oder eine abstrakte Klasse, die eine Methode vorgibt. Diese Methode erwartet keine Argumente und gibt ein Ergebnisobjekt zurück, das den Status des Health-Checks abbildet. Neben dem Status kann das Objekt häufig auch noch Details enthalten, beispielsweise warum der Check zum konkreten Status gekommen ist. Manche Bibliotheken enthalten auch bereits fertige Health-Checks für Standardressourcen wie beispielsweise JDBC-Verbindungen.

Da der Gesamtzustand einer Anwendung jedoch meistens nicht mit nur einem Health-Check abgebildet werden kann, gibt es einen zweiten Bestandteil, meist Registry genannt. Bei dieser werden alle Health-Checks der Anwendung registriert. Anschließend ist sie dafür verantwortlich, alle Health-Checks auszuführen und aus allen Teil-Ergebnissen einen Gesamtzustand zu berechnen.

Der so gebildete Gesamtzustand soll natürlich von außerhalb der Anwendung abrufbar sein. Hierzu gibt es den dritten und letzten Bestandteil. Dieser implementiert eine von außen abrufbare Schnittstelle, zum Beispiel JSON über HTTP, und definiert damit auch, in welchem konkreten Format der Zustand von außen auslesbar ist.

Herausforderungen/Best Practices

Die Schnittstelle nach außen sollte die vom gewählten Protokoll zur Verfügung gestellte Semantik, um Fehler zu signalisieren, nutzen. Nutzt man beispielsweise HTTP als Protokoll, so sollte eine Abfrage, die einen fehlerhaften Gesamtzustand der Anwendung signalisiert, einen HTTP-Statuscode nutzen, der einen Server-Fehler signalisiert. In diesem konkreten Fall bietet sich 503, also „Service Unavailable“, an.

Je nach Plattform und Anforderung, wie schnell ein fehlerhafter Zustand erkannt werden soll, kann es passieren, dass der Gesamtzustand sehr häufig, gegebenenfalls alle paar Sekunden, abgefragt wird. Hierzu muss sichergestellt werden, dass dies nicht zu Instabilitäten führt und dass der Gesamtzustand schnell genug herausgefunden werden kann. Findet beispielsweise alle 5 Sekunden eine Abfrage statt, die Durchführung aller Health-Checks dauert jedoch länger, können Probleme entstehen. Dieser Herausforderung kann man mit Caching und der asynchronen Ausführung der Health-Checks begegnen. Zwar erhält man anschließend nicht mehr bei jeder Abfrage den wirklich aktuellen Zustand der Anwendung, dafür verbessert sich die Stabilität.

Die letzte Herausforderung besteht im Aspekt der Sicherheit. Health-Checks enthalten neben dem eigentlichen Status oft noch weitere Informationen. Diese helfen dabei zu verstehen, wieso der aktuelle Status zustande gekommen ist. Natürlich kann dies auch ein Sicherheitsrisiko sein, da hierüber zum Beispiel herausgefunden werden kann, welche Datenbank in welcher Version genutzt wird. Idealerweise unterstützt die gewählte Bibliothek hierfür ein Konzept, das anonymen Nutzern diese Details nicht anzeigt.

Nun kommen wir zu den drei Health-Check-Bibliotheken Metrics, Actuator und MicroProfile.

DropWizard Metrics

Metrics ist ein Sub-Projekt von DropWizard, das neben Metriken auch eine Implementierung von Health-Checks bietet. Um einen eigenen Health-Check zu implementieren, erbt man von der Klasse HealthCheck und überschreibt die Methode check. Das als Rückgabewert erwartete Result kann mit den vorhandenen statischen Factory-Methoden oder mit einem Builder erzeugt werden. Listing 1 zeigt eine Implementierung, welche den verfügbaren Festplattenplatz überprüft und bei zu geringem Rest von Healthy auf Unhealthy umspringt.

public final class DiskSpaceHealthCheck extends HealthCheck {
  private final File path;
  private final long threshold;

  public DiskSpaceHealthCheck(File path, long threshold) {
    this.path = path;
    this.threshold = threshold;
  }

  @Override
  protected Result check() throws Exception {
    final long diskFreeInBytes = path.getUsableSpace();
    if (diskFreeInBytes >= threshold) {
      return Result.healthy();
    } else {
      return Result.unhealthy(
        "Available %d bytes are lower than required %d bytes.",
        diskFreeInBytes, threshold);
    }
  }
}
Listing 1: Eigener Health-Check mit Metrics

Anschließend muss von diesem Health-Check noch eine Instanz erzeugt und bei einer HealthCheckRegistry registriert werden. Über diese kann der Health-Check anschließend auch ausgeführt werden (s. Listing 2).

final HealthCheckRegistry registry = new HealthCheckRegistry();
registry.register("diskSpace",
  new DiskSpaceHealthCheck(File.listRoots()[0], Long.MAX_VALUE));

final SortedMap<String, HealthCheck.Result> results =
  registry.runHealthChecks();
results.forEach((n, r) -> {
  System.out.println(n + ": " + r);
});
Listing 2: Health-Check Registry in Metrics

Aktuell werden also mit jedem Aufruf der Registry alle Health-Checks ausgeführt. Um die Ausführung eines Health-Checks vom Aufruf der Registry zu entkoppeln, kann man den Health-Check mit @Async annotieren. Anschließend wird der Status dieses Health-Checks asynchron abgefragt und bei Aufrufen auf der Registry wird der letzte bekannte Wert genutzt (s. Listing 3).

@Async(period = 5)
public final class JdbcHealthCheck extends HealthCheck {

  private final Connection connection;

  public JdbcHealthCheck(Connection connection) {
    this.connection = connection;
  }

  @Override
  protected Result check() throws Exception {
    try (PreparedStatement pstmt =
        connection.prepareStatement("SELECT 1");
        ResultSet rs = pstmt.executeQuery()) {
      if (rs.next()) {
        return Result.healthy();
      } else {
        return Result.unhealthy("Database connection lost");
      }
    } catch (Exception e) {
      return Result.unhealthy(e);
    }
  }
}
Listing 3: Asynchroner Health-Check in Metrics

Als Letztes stellt Metrics noch das HealthCheckServlet zur Verfügung. Neben dem Anzeigen aller Health-Check-Resultate ermittelt dieses auch einen Gesamtzustand und setzt den HTTP-Statuscode auf einen passenden Wert.

Leider bringt Metrics bis auf einen Health-Check zur Erkennung von Threads, die sich in einem Deadlock befinden (ThreadDeadlockHealthCheck), keine weiteren direkt nutzbaren Health-Checks mit.

Spring Boot Actuator

Innerhalb von Spring Boot kümmert sich Actuator um viele Belange rund um Betriebsaspekte. Teil davon sind auch Health-Checks.

Actuator bringt dazu bereits eine Menge fertiger Health-Checks für externe Ressourcen, vor allem Datenbanken, mit. Wird eine solche innerhalb von Spring Boot konfiguriert, wird innerhalb von Actuator auch der Health-Check automatisch registriert.

Reichen die vorhandenen Health-Checks nicht aus, kann entweder das Interface HealthIndicator implementiert oder von der abstrakten Klasse AbstractHealthIndicator geerbt werden, um einen neuen Health-Check hinzuzufügen. Der zweite Weg hat dabei den Vorteil, dass im Falle einer Exception der Status des Health-Checks automatisch auf einen fehlerhaften gesetzt wird.

Dies ist ein Unterschied zu Metrics. Actuator legt großen Wert darauf, an allen Stellen erweiterbar zu sein. Aus diesem Grund wird der Status eines Health-Checks nicht über ein boolean ausgedrückt, sondern über die eigene Klasse Status. Standardmäßig werden dabei die vier Werte Unknown, Up, Down und Out of Service unterstützt. Es ist jedoch auch möglich, eigene Werte zu definieren, sofern man diese benötigt.

Um anschließend aus allen Health-Checks einen Gesamtstatus zu berechnen, nutzt Actuator das Konzept eines HealthAggregator. In diesem wird die Strategie implementiert, um die verschiedenen Status-Werte auf einen Gesamtstatus zu reduzieren. Im Standard nutzt Actuator den OrderedHealthAggregator, in dem eine Reihenfolge der Status-Werte definiert wird. Der Gesamtzustand entspricht anschließend dem Status, der in der Reihenfolge als Erstes kommt und der in der Liste aller Health-Checks gefunden wurde. Innerhalb von Spring Boot kann die Reihenfolge per Konfiguration geändert und um eigene Status-Werte erweitert werden. Außerdem kann natürlich auch eine eigene Strategie implementiert werden. Ähnlich wie bei den Indikatoren gibt es auch hierfür eine abstrakte Klasse, AbstractHealthAggregator, die im Gegensatz zur direkten Implementierung des Interface ein wenig Arbeit abnimmt.

Damit die Health-Checks von außen erreichbar sind, bietet Actuator zudem eine Komponente, die diese per HTTP oder JMX exponiert. Neben dem Verfügbarmachen setzt diese auch noch den Sicherheits- und Verfügbarkeitsaspekt um. Die Verfügbarkeit wird durch das Cachen des letzten Ergebnisses für eine bestimmte Zeit (im Standard 1 s) erledigt. Die Sicherheit wird durch diverse Mechanismen erhöht. Zum einen lässt sich der Endpunkt auf einem eigenen Port starten, sodass Health-Checks nur aus dem internen Netzwerk, nicht aber von außen abgefragt werden können. Eine andere Alternative besteht darin, den Endpunkt per Authentifikation abzusichern. Somit erhält ein anonymer Nutzer nur den Wert des Gesamtzustands, aber keinerlei Details. Authentifiziert sich der Nutzer vorher, zum Beispiel per HTTP Basic-Auth, kann er jedoch alle Details sehen.

MicroProfile: Service Healthchecks

Die Idee von [Eclipse MicroProfile][] ist es, Java EE um ein Profil zu erweitern, das es ermöglicht, Microservices zu implementieren. In diesem Rahmen entstehen auch Spezifikationen für bisher noch fehlende technische Belange. Die Spezifikation für Health-Checks nennt sich „Service Healthchecks“.

Die Spezifikation besteht dabei aus zwei Teilen, dem API zum Implementieren von Health-Checks und der Definition des Formates und Protokolls, über das die Health-Checks von außen erreichbar sind. Eigene Health-Checks müssen hierzu das Interface HealthCheck implementieren und mit @Health annotiert werden. Das Ergebnis des Checks wird mittels der Klasse HealthCheckResponse abgebildet und kann durch die Verwendung von HealthCheckResponseBuilder erzeugt werden. Dabei stehen die beiden Status-Werte Up und Down zur Verfügung (s. Listing 4).

public final class DiskSpaceHealthCheck implements HealthCheck {
  private final File path;
  private final long threshold;

  public DiskSpaceHealthCheck(File path, long threshold) {
    this.path = path;
    this.threshold = threshold;
  }

  @Override
  public HealthCheckResponse call() {
    final HealthCheckResponseBuilder builder =
      HealthCheckResponse.builder();
    final long diskFreeInBytes = path.getUsableSpace();
    if (diskFreeInBytes >= threshold) {
      builder.up();
    } else {
      builder.down();
    }
    return builder.withData("total", path.getTotalSpace())
      .withData("free", diskFreeInBytes)
      .withData("threshold", threshold)
      .build();
  }
}
Listing 4: Health-Check in MicroProfile

Als Protokoll sieht die Spezifikation HTTP vor, überlässt es allerdings jeder Implementierung, auch weitere Protokolle zu unterstützen. Der Payload wird in JSON repräsentiert und die Spezifikation enthält ein JSON-Schema, um diesen zu definieren. Alle weiteren Punkte sind in der Spezifikation optional. So kann jede Implementierung Sicherheitsmaßnahmen ergreifen, um den Health-Check-Endpunkt abzusichern. Und auch welche Strategie zur Berechnung des Gesamtzustands genutzt wird, ist Sache einer jeden Implementierung.

Fazit

Health-Checks sind sowohl für Menschen als auch für Maschinen von hoher Bedeutung. Als Mensch kann man auf einen Blick den Status einer Anwendung erkennen. Dies hilft, bei einer Fehlersuche den Bereich einzuschränken, in dem man suchen muss.

Maschinen können dieselbe Schnittstelle benutzen, um automatisierte Entscheidungen zu treffen. So kann zum Beispiel ein Load-Balancer eine Instanz, die nicht mehr Healthy ist, aus seinem Pool streichen.

Um Health-Checks anzubieten und auch eigene Checks zu schreiben, hat man in Java die Auswahl zwischen verschiedenen Bibliotheken. Drei davon, Metrics, Actuator und MicroProfile, wurden in diesem Artikel vorgestellt. Alle erledigen dabei den Hauptanwendungsfall und können eingesetzt werden. Die Unterschiede liegen im Detail und eine Wahl hängt häufig davon ab, in welchem generellen Ökosystem man sich bewegt.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more