Doping für Java-EE-basierende Microservices

So werden Java-Anwendungen fit für Microservices

Michael Hofmann

©Shutterstock / bitt24

Die Implementierung von Microservices bringt einige neue Herausforderungen mit sich. Als Architekt muss man sich deswegen verstärkt mit der Anwendungskonfiguration, Resilienz und Fehlertoleranz (aufgrund des Kommunikationsverhaltens der Microservices untereinander) sowie Security und Laufzeitüberwachung beschäftigen, um nur ein paar Anforderungen zu nennen. Um die Implementierung auch mit Java-EE-Mitteln zu ermöglichen, wurde die MicroProfile-Community gegründet.

MicroProfile Fault Tolerance

MicroProfile Fault Tolerance

Verteilte Systeme wie Microservices-Anwendungen leiden immer wieder darunter, dass Aufrufe von beteiligten Services fehlschlagen können. Der Aufrufer wartet dann zum Beispiel entweder bis ein Time-out auftritt oder bekommt vom aufgerufenen Service eine unerwartete Fehlermeldung. In allen Fällen sollte der Aufrufer mit diesen Fehlersituationen umgehen können. Ein Service, der das kann, wird als resilient bezeichnet und wurde entsprechend einer fehlertoleranten Architektur entwickelt. Applikationsserver besitzen schon seit Langem verschiedene Funktionalitäten, wie zum Beispiel Threadpools oder Connection-Pools, die eine Fehlertoleranz zur Verfügung stellen. Da diese Konzepte bei Weitem nicht ausreichen, können die Entwickler mithilfe von Design Patterns, wie zum Beispiel Circuit Breaker, Retry, Fallback, Bulkhead, Time-out und Asynchronous eine bessere Resilienz ihrer Microservices-Anwendung erreichen. MicroProfile basiert auf der kleinen, aber mächtigen Failsafe-Bibliothek (ca. 82 KB) und stellt alle aufgeführten Design Patterns zur Verfügung. Die Implementierungen basieren dabei auf den CDI Interceptors der Java-EE-Plattform. Eine Kombination mit dem MicroProfile Config ist natürlich auch möglich, wodurch sich zum Beispiel der Time-out-Wert von außerhalb der Anwendung konfigurieren lässt.

Time-out

Eine einfache Möglichkeit, um nicht ewig auf die Antwort des aufgerufenen Microservice zu warten, ist der Time-out. Dazu wird die aufrufende Methode mit @ annotiert:

@Timeout(500)
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

Sollte der Aufruf des Payment Microservice länger als 500 ms dauern, wird eine TimeoutException geworfen. Falls der Aufrufer verspätet antwortet, wird das Ergebnis trotzdem verworfen. Bei einem asynchronen Aufruf (@Asynchronous) in Kombination mit @Timeout wird mittels Thread.interrupt() versucht, den aufrufenden Thread nach Erreichen des Time-outs zu beenden. Der Thread steht somit schneller für andere Aufrufe wieder zur Verfügung.

Retry

Wer im Fehlerfall versuchen möchte, den Aufruf noch einmal zu wiederholen, kann dafür eine sog. Retry Policy angeben. Als mögliche Policies stehen verschiedene Attribute zur Verfügung: für die maximale Anzahl an Wiederholungen maxRetries, die Verzögerung zwischen den Aufrufen delay und die maximale Dauer der Aufrufversuche maxDuration. Zur Zufallsvariation der Verzögerung der Aufrufe gehört jitter, zur Angabe des Fehlers, bei dem erneut versucht wird retryOn und bei der Angabe des Fehlers, bei dem abgebrochen wird abortOn. Im folgenden Beispiel wird versucht, den Payment Microservice höchstens dreimal aufzurufen, wobei jeder der Aufrufe mit einer Verzögerung im Bereich von 0 ms (delay minus jitter) bis hin zu 400 ms (delay plus jitter) erfolgt:

@Retry(maxRetries = 3, delay = 200, jitter = 200)
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

Der Retry lässt sich auch für eine spezielle Exception festlegen:

@Retry(maxRetries = 3, retryOn = {IOException.class})
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

Fallback

Sollte das mehrmalige Aufrufen nicht zum gewünschten Erfolg führen, könnte der Entwickler über einen Alternativaufruf nachdenken. Eine gängige Fallback-Alternative wäre zum Beispiel der Aufruf eines Messagingservices, der die Aufrufwerte empfängt und sie später von einem Queue Listener abarbeiten lässt. Als Fallback-Möglichkeiten kann man entweder eine Fallback-Methode:

@Retry(maxRetries = 3)
@Fallback(fallbackMethod= "callFallback")
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

public ResultDTO callFallback(PaymentDTO) {
// Alternative zum Aufruf des Microservice Payment
  return result;
}

oder eine Fallback-Klasse:

@Fallback(MyPaymentFallbackHandler.class)

angeben. Das heißt, Fallback lässt sich mit den anderen Möglichkeiten, beispielsweise mit Retry und Circuit Breaker kombinieren. Dies gilt im Übrigen für alle Fehlertoleranzstrategien.

Circuit Breaker

Ein Circuit Breaker unterbricht Aufrufe sofort, wenn der aufgerufene Service vorher als fehlerhaft erkannt worden ist. Man unterscheidet dabei drei Zustände: Geschlossen (closed) bedeutet, der Aufruf wird ausgeführt, im Zustand offen (open) wird der Aufruf sofort unterbunden und Zustand halboffen (half-open) besagt, dass nur einzelne Aufrufe zugelassen werden. Das bedeutet, bei half-open testet der Aufrufer vorsichtig, ob der aufgerufene Service stabil antwortet. Sollte dies der Fall sein, so wechselt der Circuit Breaker in den Zustand closed. Im Fehlerfall dagegen wechselt er in den Zustand open. Nach einer gewissen Schonzeit wird wieder versucht, den fehlerhaften Service aufzurufen. Sinn und Zweck eines Circuit Breakers soll es also sein, einen fehlerhaften Microservice mit Aufrufen zu verschonen, in der Hoffnung, der Service erhole sich irgendwann wieder, und zugleich dem Aufrufer im Fehlerfall Wartezeiten und Ressourcenverschwendung zu ersparen.

Im folgenden Codebeispiel wird der Aufruf des Payment Microservice mittels eines Circuit Breakers überwacht. Wenn innerhalb einer Sequenz von vier Aufrufen (requestVolumeThreshold) drei fehlerhafte Aufrufe erfolgen (requestVolumeThreshold * failureRatio) wechselt der Circuit Breaker in den Zustand open. Dort verbleibt er für 5 000 ms (delay) und wechselt nach Ablauf dieser Wartezeit in den Zustand half-open. Jetzt werden nur noch einzelne Aufrufe zugelassen und nach fünf erfolgreichen Aufrufen in Folge (successThreshold) wechselt der Zustand auf close:

@CircuitBreaker(delay = 5000, requestVolumeThreshold = 4, 
  failureRatio=0.75, successThreshold = 5)
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

Was derzeit noch fehlt, ist die Möglichkeit, den Zustand der Circuit Breaker zentral zu überwachen. Schließlich sollten die Administratoren wissen, in welchem Zustand sich die einzelnen Circuit Breaker in den verschiedenen Microservices befinden. Ein Dashboard analog dem Hystrix-Dashboard von Netflix fehlt derzeit leider in der Spezifikation. Allerdings spricht nichts dagegen, das Hystrix-Dashboard anzubinden oder die Circuit-Breaker-Zustände in die Metrics-Ausgaben zu integrieren (siehe unten). Bleibt also abzuwarten, was die MicroProfile-Community in dieser Richtung zukünftig anbieten wird.

Bulkhead

Der Bulkhead-Pattern dient dem Versuch, Fehlerursachen möglichst lokal zu halten. So wie die Titanic damals versuchte, mit den Schotten den Wassereinbruch zu begrenzen. Leider mit überschaubarem Erfolg. Trotzdem wird das Prinzip im Schiffsbau heute noch erfolgreich eingesetzt. Übertragen auf die Microservices versucht man, die konkurrierenden Aufrufe auf einen Service zu begrenzen. Das verhindert, dass eine Überlastung des aufgerufenen Services auf den Aufrufer zurückschlägt, d. h. der Fehlerzustand bleibt lokal und überträgt sich nicht auf die gesamte Aufrufkette. Dies wird erreicht, indem die verfügbaren Ressourcen beim Aufrufer limitiert werden. Bei synchronen Aufrufen sorgen dafür Semaphoren. Im asynchronen Fall kommt ein eigener Threadpool zur Anwendung. Diesen Threadpool kann man bezüglich maximaler paralleler Zugriffe zusammen mit einer Warteschlange für weitere Aufrufe konfigurieren. Der Semaphor arbeitet analog, jedoch ohne Warteschlange. Das folgende synchrone Beispiel ermöglicht zehn parallele Aufrufe zum Payment-Service. Der elfte Aufruf würde zu einer Bulkhead Exception führen:

@Bulkhead(10)
public ResultDTO callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return result;
}

Der folgende asynchrone Fall definiert zusätzlich noch die Größe der Warteschlange:

@Asynchronous
@Bulkhead(value = 10, waitingTaskQueue = 5)
public Future callPaymentService(PaymentDTO) {
// Aufruf des Microservice Payment
  return CompletableFuture.completedFuture(result);
}

Natürlich lässt sich auch hier wieder die Bulkhead-Annotation zusammen mit den anderen Fault-Tolerance-Annotationen (CircuitBreaker, Fallback, Time-out usw.) kombinieren.

Damit die Bulkheads in der Microservices-Anwendung ihren Zweck erfüllen, sollte der Entwickler beachten: Die Bulkhead-Einstellungen sind natürlich nur auf eine Microservices-Instanz bezogen und gelten nicht global für alle aufrufenden Instanzen. Das heißt, bei dynamischer Skalierung der aufrufenden Microservices wird natürlich auch die gesamte Anzahl möglicher Aufrufe erhöht. Dies kann trotzdem zu einer Überlastung der aufgerufenen Instanzen führen, falls diese nicht passend skalieren. Dies sollte mit einer entsprechenden Skalierung der aufgerufenen Microservices kompensiert werden. Außerdem besteht die Möglichkeit, die Bulkhead-Werte mittels dynamischer Konfiguration zu setzen und so auf die veränderte Situation zu reagieren. Diese Veränderung kann auch global erfolgen, sprich für alle Codestellen im jeweiligen Microservice.

Geschrieben von
Michael Hofmann
Michael Hofmann
Michael Hofmann ist freiberuflich als Berater, Coach, Referent und Autor tätig. Seine langjährigen Projekterfahrungen in den Bereichen Softwarearchitektur, Java Enterprise und DevOps hat er im deutschen und internationalen Umfeld gesammelt. Mail: info@hofmann-itconsulting.de
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: