Suche
Einstürzende Neubauten

Resiliente Microservices mit Spring Boot und Failsafe

Malte Pickhan

© Shutterstock / ChiccoDodiFC

Microservices sind nach wie vor das Thema. Mit einher geht in der Regel die Diskussion über Resilienz. Mit der Integration von Failsafe in Microservices kann man diese resilient gestalten.

Stellen wir uns vor, wir sind Mitarbeiter eines Start-ups, das die Zahlungen für einen Onlineshop abwickelt, der Flip-Flops verkauft. Der Name unseres Start-ups ist Paymento Da wir mit den neuesten Trends gehen, sind alle unsere Dienste als Microservices implementiert. Wenn ein Kunde im Shop einkauft und im Warenkorb den Button Jetzt bezahlen drückt, erhält unser System (Payment-Service) eine Anfrage, und wir übernehmen das Risiko für diese Zahlung, sobald wir die Anfrage positiv quittiert haben. Da das Risiko so gering wie möglich gehalten werden soll, rufen wir für jede erhaltene Anfrage einen weiteren Service auf (Solvency-Service), der die Solvenz des Kunden überprüft.

Es kommt der Tag der Tage, und unsere Systeme kommen an ihre Grenzen. Da wir unsere Anwendungen in AWS betreiben, können wir entsprechend einfach skalieren. Während einer Lastspitze passiert es dann aber doch: der Solvency Service bricht unter der Last zusammen und ist nicht mehr erreichbar. Wegen eines schlecht konfigurierten Time-outs im Payment-Service laufen alle Anfragen für 30 Sekunden, bis sie fehlschlagen. Es dauert nicht lange, und alle Threads des Payment-Service-Threadpools sind belegt. Sobald neue Instanzen des Solvency-Service starten, werden sie mit Anfragen bombardiert und brechen direkt wieder unter der Last zusammen. Uns bleibt nichts anderes übrig, als sämtlichen eingehenden Verkehr zu blockieren und genügend Instanzen des Solvency-Service zur Verfügung zu stellen. Das bedeutet zumindest kurzzeitig den Totalausfall, und unsere Kunden verlassen frustriert den Flip Flop Summer Sale. Listing 1 zeigt exemplarisch den REST Controller unseres Service, Listing 2 die Clientimplementierung für den Solvency-Service ohne jegliche Absicherung.

@RestController
@Slf4j
@RequestMapping(value = "/payments")
public class PaymentController {
  private PaymentProcessor processor;
  public PaymentController(PaymentProcessor processor) {
    this.processor = processor;
  }
  @PostMapping
  public ResponseEntity createPayment(@RequestBody final PaymentResource paymentResource) {
    log.debug("Processing payment {}", paymentResource);
    boolean processingResult = processor.processPayment(paymentResource);
    if(processingResult) {
      return ResponseEntity.ok(paymentResource);
    } else {
      return ResponseEntity.badRequest().build();
    }
  }
}
public boolean checkSolvency(final ConsumerResource consumerResource) throws URISyntaxException {
  ResponseEntity<Boolean> booleanResponseEntity = restTemplate.postForEntity(new URI(BASE_URL), consumerResource, Boolean.class);
  return booleanResponseEntity.getBody().booleanValue();
}

Was tun, wenn’s brennt? Resilienz!

Um das beschriebene Szenario zu vermeiden, gibt es mehrere Entwurfsmuster, die zum Einsatz kommen können, z. B Circuit Breaker [1]. Hierbei handelt es sich wortwörtlich um Sicherungen, die durchbrennen, wenn ein bestimmter Grenzwert an Anfragen fehlschlägt. Die Sicherung befindet sich dann im geöffneten Zustand. Im geöffneten Zustand schlagen Anfragen an den aufgerufenen Service direkt fehl (Fail-Fast [2]).

Zum einen können wir damit die Ressourcen unseres Service schonen, zum anderen wird der aufgerufene Service damit entlastet und kann sich eventuell schneller erholen. Erholt sich der aufgerufene Service, werden nach und nach wieder Anfragen durchgelassen, und der Circuit Breaker befindet sich im Status halb geöffnet. Wird ein vorher konfigurierter Grenzwert an erfolgreichen Anfragen überschritten, geht der Circuit Breaker wieder in den geschlossenen Zustand und lässt alle Anfragen durch. Auch Fallbacks helfen. Falls eine Sicherung geöffnet ist, kann eine Funktion definiert werden, die alternativ aufgerufen wird. Beispielsweise können wir alle Anfragen in einer Queue ablegen, um sie zu einem späteren Zeitpunkt erneut zu lesen und auszuführen. Auch ein Retry ist möglich. Da wir nicht bei jedem fehlgeschlagenen Aufruf des Solvency Checks die Zahlung direkt ablehnen wollen, ist es außerdem sinnvoll, das Retry-Pattern anzuwenden. Das führt dazu, dass wir unter bestimmten Bedingungen die gleiche Anfrage erneut ausführen und beispielsweise darauf spekulieren, dass uns der vorgelagerte Load Balancer auf eine andere Instanz leitet, die sich in einem gesunden Zustand befindet.

Ich werde mich ändern!

In einem Krisenmeeting wurde beschlossen, dass die genannten Muster zum Einsatz kommen sollen, um für einen nächsten Ausfall besser gewappnet zu sein. Als Erstes erweitern wir unseren Client um die Nutzung eines Circuit Breakers mit der Bibliothek Failsafe [3]. Falls der Solvency-Service den HTTP-Status 500 als Antwort liefert, wollen wir weitere Aufrufe vermeiden und ihm Zeit zum Regenerieren geben (Listing 3).

@Component
@Slf4j
public class SolvencyClient {
  private static final String BASE_URL = "localhost:8181/solvency";
  private final RestTemplate restTemplate;
  private final CircuitBreaker circuitBreaker;
  public SolvencyClient(final RestTemplate restTemplate, @FailsafeBreaker(value = "solvency-circuit") final CircuitBreaker circuitBreaker) {
    this.restTemplate = restTemplate;
    this.circuitBreaker = circuitBreaker;
  }

  @PostConstruct
public void init() {
  configureCircuit();
}

private void configureCircuit() {
  this.circuitBreaker
    .withFailureThreshold(3, 6)
    .withSuccessThreshold(3)
    .withDelay(10, TimeUnit.SECONDS);
this.circuitBreaker.failOn(Arrays.asList(InterruptedIOException.class, ResourceAccessException.class));
}
  public boolean checkSolvency(final ConsumerResource consumerResource) throws URISyntaxException {
    ResponseEntity<Boolean> booleanResponseEntity = Failsafe.with(this.circuitBreaker).get(() -> restTemplate.postForEntity(new URI(BASE_URL), consumerResource, Boolean.class));
    return booleanResponseEntity.getBody().booleanValue();
  }
}

In der configureCircuit-Methode haben wir definiert, welche Exceptions zu einem Fehlschlag innerhalb des Circuit Breakers führen. Das ist zum einen die InterruptedIOException, die geworfen wird, wenn es zu Verbindungsabbrüchen kommt, zum anderen die ResourceAccessException, die z. B. im Fall von Time-outs geworfen wird. Wir weisen Failsafe an, die Sicherung erst bei drei dieser Exceptions innerhalb von sechs Ausführungen durchbrennen zu lassen. Nach drei aufeinander folgenden erfolgreichen Ausführungen des Aufrufs im halb geöffneten Zustand wird der Circuit Breaker wieder geschlossen.

Des Weiteren definieren wir, dass die Sicherung zehn Sekunden, nachdem sie geöffnet wurde, wieder in den halb geöffneten Zustand geht. Zu guter Letzt verschachteln wir den eigentlichen Aufruf zum Solvency-Service mit Failsafe, um ihn mit dem Circuit Breaker abzusichern. Die globale Variable des Circuit Breakers haben wir mit @FailsafeBreaker annotiert, einen eindeutigen Identifikator für diese Sicherung vergeben und im Actuator zu registrieren. Das sorgt dafür, dass eine neue Instanz eines Circuit Breakers injiziert wird. Falls bereits ein Circuit Breaker mit diesem Identifikator existiert, wird er wieder verwendet. So lassen sich beispielsweise verschiedene Abschnitte mit dem gleichen Circuit Breaker absichern.

Lesen Sie auch: Spring Boot Tutorial: In 10 Schritten zur Microservices-Architektur

Da wir nicht direkt beim ersten Fehlschlag aufgeben wollen, definieren wir zusätzlich eine Retry Policy. Diese sorgt dafür, dass Aufrufe wiederholt werden. Das hat zum einen den Vorteil, dass wir einen Schluckauf im Netzwerk besser aushalten, kann aber auch dazu führen, dass eine Sicherung potenziell schneller durchbrennt und somit fälschlicherweise ein Ausfall des aufgerufenen Service suggeriert wird. Wie immer gilt, dass man einen Mittelweg zwischen zu konservativer und zu optimistischer Konfiguration finden sollte.

Zusätzlich zum Konfigurieren des Circuit Breakers haben wir die Methode configureRetries definiert, um die Retry Policy zu initialisieren (Listing 4). Wiederholungen werden für die gleichen Fehler ausgeführt, die auch den Circuit Breaker auslösen. Eine Anfrage wird maximal dreimal wiederholt, bevor sie final als fehlgeschlagen gewertet wird. Weiterhin haben wir die Regel so konfiguriert, dass zwischen jedem Retry eine Sekunde vergehen sollte. Failsafe erhöht die Wartezeit zwischen jedem neuen Versuch allerdings als Standard exponentiell, bis der Grenzwert von fünf Sekunden erreicht ist. Darüber hinaus haben wir einen Jitter-Faktor von zehn Millisekunden definiert. Dieser führt dazu, dass auf die Verzögerungen ein zufälliger Wert im Bereich des Jitter-Faktors addiert beziehungsweise subtrahiert wird. Falls der Client mehrere Threads verwendet, um einen Service aufzurufen, führt das Jittering dazu, dass nicht alle Threads gleichzeitig die Wiederholungen ausführen.

@PostConstruct
public void init() {
  configureCircuit();
  configureRetries();
}
private void configureRetries() {
  this.retryPolicy.retryOn(Arrays.asList(InterruptedIOException.class, ResourceAccessException.class))
    .withMaxRetries(3)
      .withBackoff(1,5, TimeUnit.SECONDS)
        .withJitter(10, TimeUnit.MILLISECONDS);
}
public boolean checkSolvency(final ConsumerResource consumerResource) throws URISyntaxException {
  ResponseEntity<Boolean> booleanResponseEntity = Failsafe.with(this.circuitBreaker).with(this.retryPolicy).get(() -> restTemplate.postForEntity(new URI(BASE_URL), consumerResource, Boolean.class));
  return booleanResponseEntity.getBody().booleanValue();
}

a wir im Fall eines Ausfalls des Solvency Checks immer noch für unsere Kunden verfügbar sein wollen, haben wir einen Service bei der Konkurrenz gebucht, die ebenfalls Solvenzabfragen durchführt. Hierbei kann uns die Failsafe-Bibliothek ebenfalls unterstützen. Sie implementiert das Fallback-Muster, das wir uns zu Nutze machen. In Listing 5 sehen wir, dass wir einen weiteren Aufruf zu einer alternativen Methode mittels withFallback eingefügt haben. Diese wird immer dann aufgerufen, wenn der eigentliche Aufruf fehlgeschlagen ist. Es sollte berücksichtigt werden, dass wir damit die Antwortzeit des Service erhöhen. Wenn wir beispielsweise einen Time-out von fünf Sekunden konfiguriert haben, dann in den Fallback übergehen und einen weiteren Service mit einem Time-out von fünf Sekunden aufrufen, bedeutet das im schlimmsten Fall eine Antwortzeit von zehn Sekunden.

public boolean checkSolvency(final ConsumerResource consumerResource) throws URISyntaxException {
  ResponseEntity<Boolean> booleanResponseEntity = Failsafe
    .with(this.circuitBreaker)
    .with(this.retryPolicy)
    .withFallback(callAlternativeProvider())
    .get(() -> restTemplate.postForEntity(new URI(BASE_URL), consumerResource, Boolean.class));
  return booleanResponseEntity.getBody().booleanValue();
}

Vertrauen ist gut, Kontrolle ist besser

Wir nutzen zusätzlich die Bibliothek Failsafe Actuator [4], um die Verwendung mit Spring zu erleichtern. Neben des Dependency Injection Features ermöglicht uns die Bibliothek das Monitoren der Circuit Breaker out of the Box.

Spring Actuator
Actuators sind Endpunkte in einer Spring-Boot-Anwendung, die sich für das Überwachen der Applikation oder zum Interagieren mit ihr nutzen lassen. Die Endpunkte werden beim Hochfahren automatisch anhand der vorhandenen Abhängigkeiten identifiziert und gestartet. In unserem Fall wird der Failsafe-Actuator-Endpunkt beispielsweise gestartet, da sich die Failsafe-Bibliothek im Classpath befindet.

Der Failsafe-Endpunkt liefert eine Übersicht aller registrierten Circuit Breaker im JSON-Format als Antwort. Die Antwort können wir mit einem Tool unserer Wahl auswerten und entsprechende Alarme definieren, falls ein Circuit Breaker geöffnet ist. Wie in Listing 6 zu sehen, ist der solvency-circuit geschlossen und somit in der Lage, Anfragen durchzuführen.

$ curl -s localhost:8080/failsafe | jq .
[
  {
    "name": "solvency-circuit",
    "closed": true,
    "open": false,
    "halfOpen": false
  }
]
}

Ausblick

Wer eine Microservices-Architektur betreibt, sollte sich auch Gedanken über Resilienz machen, um auf der einen Seite die Verfügbarkeit der eigenen Services zu gewährleisten. Auf der anderen Seite unterstützen uns diese Muster aber auch dabei, wenn ein Teil unserer Services bereits ausgefallen ist. Denn Anfragen schlagen schneller fehl und entlasten somit neu startende Instanzen. Des Weiteren können wir einfach alternative Logik definieren, die ausgeführt werden soll, falls ein Service nicht verfügbar ist. Failsafe implementiert all diese Muster und ermöglicht eine einfache Integration in bereits bestehende Anwendungen. Darüber hinaus lassen sich die einzelnen Komponenten detailliert konfigurieren. Die Dokumentation im GitHub Repository ist ausführlich, und auch das Javadoc ist verständlich geschrieben. In der Regel lassen sich Fragen zügig über GitHub Issues klären. Zu guter Letzt handelt es sich um ein Open-Source-Projekt, in dem wir Wünsche und Änderungen auch durch Pull Requests einfließen lassen können. Failsafe Actuator ergänzt die Failsafe-Bibliothek und ermöglicht es uns, die erzeugten Circuit Breaker einfach zu überwachen. Darüber hinaus erleichtert es durch die Verwendung von Annotationen die Integration in Spring-Boot-Anwendungen enorm. Alle aufgeführten Codebeispiele können auch auf GitHub [5] gefunden werden.

Fazit

Bei Zalando Payments konnte sich Failsafe bereits im produktiven Einsatz beweisen. Wie man im Wiki [6] der Failsafe-Bibliothek sehen kann, ist dies auch für mehrere andere Firmen der Fall. Für Failsafe Actuator ist in naher Zukunft eine Erweiterung geplant, sodass das Definieren der Circuit Breaker über die application.properties möglich ist, was die Verwendung von Failsafe im Spring-Boot-Kontext erheblich komfortabler macht.

Geschrieben von
Malte Pickhan
Malte Pickhan
Malte Pickhan arbeitet bei der Zalando Payments GmbH. Seit fünf Jahren ist er als Java-Backend-Entwickler tätig. Hier entwickelt er ein verteiltes System, das die Zahlungsanfragen der Zalando-Kunden verarbeitet.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.