Spring Cloud Microservices


Viele große Unternehmen setzen mittlerweile auf Microservice-Architekturen, die als Ergebnis feingranulare, lose gekoppelte Dienste hervorrufen, die gerade den hohen Anforderungen an Ausfallsicherheit und Skalierbarkeit gerecht werden können.
Die Firma Netflix stellt in ihrem Netflix Open Source Software Center (Netflix OSS) freie Komponenten zum Aufbau solcher Architekturen zur Verfügung, von denen einige Komponenten in Unterprojekte von Spring Cloud eingeflossen sind. Nachfolgend wird in knapper Form der Aufbau einer einfachen Microservice-Struktur auf Basis von Spring Cloud beschrieben und die größeren Zusammenhänge etwas näher erläutert.

Microservice-Struktur

Die folgende Abbildung liefert zunächst einen Gesamtblick auf den Aufbau einer typische Microservice-Struktur und welche Spring-Cloud-Bausteine hierfür verwendet werden können.


Die derzeit häufig verwendeten Bausteine sind Spring Cloud Config, Netflix Eureka, Netflix Hystrix, Netflix Ribbon, Netflix Feign, Netflix Zuul, Hystrix Dashboard, Netflix Turbine, Sleuth und Zipkin.
Nachfolgend eine kurze Auflistung einschließlich der Aufgaben der Bausteine:

  • Spring Cloud Config: dient zur zentralen Verwaltung von Konfigurationsparametern der Anwendungen über alle Umgebungen hinweg
  • Eureka: Registrierungsstelle für Dienste und Überwachung derer Verfügbarkeit
  • Hystrix: Circuit Breaker für den Dienst, der für Resilienz sorgt und Statistiken zu Fehlerquoten und Antwortzeiten sammelt
  • Ribbon: clientseitiger Load-Balancer zur Lastverteilung zwischen mehreren Diensten, die dieser bei Eureka anfragt
  • Feign: deklarative REST-Schnittstellen-Erweiterung für Dienste, welcher mit Annotations erweitert leicht zu Diensten mit REST-Schnittstelle gemachen werden können, die dann direkt mit Eureka, Hystrix und Ribbon kombiniert werden können
  • Zuul: Dient als Edge-Server, also quasi das Zugangstor zu den Diensten von außen. Zuul routet Anfragen zu den richtigen Diensten. Die Dienstinformationen holt sich dieser aus Eureka und kann Ribbon und Hystrix für das Load-Balancing und die Ausfallsicherheit verwenden.
  • Turbine: Sammelt die Hysterix-Daten von allen Diensten und aggregieret diese für die Anzeige im Dashboard.
  • Sleuth: Verteiltes Tracing mittels Logs
  • Zipkin: Visualisierung der Tracing-Daten


Spring-Cloud-Bausteine

Im Folgenden werden die bereits erwähnten und im Beispiel verwendeten Bausteine etwas detaillierter vorgestellt, um sich dann im Beispiel besser orientieren zu können.

Was ist Eureka?
Die Registrierungsstelle für Dienste ist ein essentieller Bestandteil einer Microservice-Infrastruktur, damit Dienste sich in einem verteilten System gegenseitig finden und ansprechen können. Eureka ist eine solche Registrierungsstelle und hilft bei der Registrierung von Microservices unter einem logischen Namen und dem anschließenden Zugriff über diesen. Eine nötige Kenntnis von URLs auf einen Dienst entfällt dadurch und nur noch der logische Name ist nötig, ganz gleich auf welchen Instanzen die Dienste laufen.
Eureka erkennt, wenn ein Dienst nicht mehr verfügbar ist und entfernt diesen automatisch. Zudem können mehrere Instanzen eines Dienstes unter gleichen Namen angemeldet werden, wobei die Lastverteilung, also die Client-Anfragen an die einzelnen Instanzen des Dienstes, automatisch von Eureka verteilt werden. Wenn der Eureka-Dienst redundant betrieben wird, um eine höhere Ausfallsicherheit zu gewährleisten, übernehmen die einzelnen Instanzen des Eureka-Dienstes automatisch die Synchronisierung der Informationen, welche Dienste bei ihnen registriert sind. Folgende Abbildung zeigt den Ablauf der Registrierung und des Zugriffs auf einen Dienst.


Der zu registrierende Dienst, bzw. Eureka-Client, muss zunächst wissen, wo er den Eureka-Server (also die Registrierungsstelle) findet. Diesem wird der logische Name des Dienstes und die URL, unter welchem dieser erreichbar ist, übermittelt. In der Standardeinstellung sendet der Dienst dann alle 30 Sekunden einen sogenannten Heartbeat als Lebenszeichen an den Server. Bleibt das Lebenszeichen aus, wird die Instanz aus dem Register des Eureka-Servers gelöscht. Alternativ kann der Client auch so konfiguriert werden, dass dieser über eine Health-URL Informationen zu seinem aktuellen Status dem Eureka-Server liefert. Wenn ein Dienst einen weiteren aufrufen möchte, so kann dieser die zugehörige URL über den logischen Namen des Dienstes beim Eureka-Server erfragen.

Was ist Hystrix?
Falls ein Microservice ausfällt, soll das Gesamtsystem weiterhin lauffähig bleiben. Dieses Thema und alles was damit zusammenhängt, wird meist unter dem Oberbegriff Resilience behandelt, was so viel wie Widerstandsfähigkeit bedeutet. Hysterix ist eine Bibliothek des Netflix-Stacks und hilft als Integration in Spring Clood für die Gewährleistung der Ausfallsicherheit. Dem Ausfall von Microservices kann auf verschiedenem Wege begegnet werden (z.B. Caches, Fallbacks usw.), wobei im Kontext des hier vorgestellten Beispiels nur eine Möglichkeit der vielen von Hysterix angebotenen Features genutzt wird.

Bei Ausfall eines Microservices wird der versuchte Aufruf irgendwann mit einem Timeout abbrechen, was eine Blockierung des aufrufenden Microservices verhindert. Allerdings hat dieser Mechanismus für sich alleine genommen, die beiden Nachteile, dass zum Einen der aufrufende Dienst bis zum Timeout wartet und so lange ggf. blockiert ist und zum Anderen, dass nach dem erfolgten Timeout, andere Dienste in das gleiche Problem laufen. Abhilfe schaffen hier Circuit Breaker (Stabilitätsmuster der Sicherung), die bei Nicht-Erreichbarkeit eines Dienstes bzw. der Nicht-Beantwortung einer Menge von Anfragen innerhalb eines bestimmten Zeitfensters, auslösen und weitere Aufrufe unterbinden oder die Anzahl der durchgereichten Anfragen an den Dienst langsam zurückfahren. Nach einer bestimmten Zeit werden Anfragen schließlich wieder nach und nach durchgereicht und die Last des Dienstes langsam erhöht, was die 100%-tige Funktionalität nach Neustart des ausgefallen Dienstes wahrscheinlicher macht. Hystrix bietet zudem ein eigenes Monitoring in Form eines Dashboards, dem alle Hysterix-Metriken entnommen werden können. Um diese Informationen gebündelt mit denen anderer Dienste zu überwachen, kann die Turbine-Bibliothek verwendet werden, die ebenfalls dem Netflix-Stack entstammt. Dadurch ist es möglich, ganze Service-Cluster zu überwachen.

Was ist Ribbon?
Ribbon ist für ein clientseitiges Load-Balancing zuständig, also für die Kommunikation nach außen abgeschirmter (z.B. Firewall) interner Dienste untereinander bzw. generell für die Kommunikation mit anderen Servern. Hierfür hält jeder Client die Adressen der einzelnen Service-Instanzen vor, die auf Wunsch auch in einzelne Zonen gruppiert werden können. Dem Client obliegt dann die Verteilung der Anfragen auf die einzelnen Dienste. Die Kriterien nach denen entschieden wird, welche Instanz angefragt wird, ist beliebig und kann z.B. nach einem Round-Robin-Verfahren oder auch nach der gewünschten Zone erfolgen. Zur Laufzeit werden Statistiken zu dem Antwortverhalten der einzelnen Dienste erfasst, so dass bei Folgeaufrufen diese mit berücksichtigt werden können. Beispielsweise können Dienste mit schlechten Antwortzeiten zunächst ausgeklammert werden. Bei der kombinierten Verwendung mit Eureka, wird die Liste mit den Diensten automatisch zur Verfügung gestellt.

Was ist Feign?
Mittels Feign lässt sich aus einem Dienst sehr leicht ein Webservice-Client mit REST-Schnittstelle bauen, wobei dieser direkt mit Ribbon, Hysterix und Eureka zusammenarbeitet, so dass mit geringem Aufwand ein Dienst mit Load-Balancing und der Möglichkeit der Reaktion auf Dienste-Ausfälle zur Verfügung steht.

Was ist Zuul?
Zuul dient dem Routing von Anfragen, wodurch eine Webseite von außen betrachtet aussieht, als wenn diese von einem einzelnen Server kommt, aber in Wirklichkeit über verschiedene Server verteilt ist. Mit Zuul (angelehnt an den Torwächter-Namen (Gatekeeper) von Gozer aus dem Film Ghostbusters;) können somit bestimmte URLs auf andere Server gemappt werden und dieser ist somit zuständig für die Bündelung, Bearbeitung und Verteilung von Anfragen. In diesem Zusammenhang spricht man auch von einem sogenannten Edge-Server, einem Gatekeeper, der neben der Funktion als API-Gateway auch für sicherheitsrelevante Aufgaben verwendet wird. In Kombination mit Ribbon und Hysterix bekommt der Edge-Server zudem Load-Balancing und zusätzliche Möglichkeiten der Fehlerbehandlung.

Was sind Sleuth und Zipkin?
Sleuth (engl. Detektiv, Schnüffler) hilft beim Verstehen eines Systems durch die Unterstützung von verteiltem Tracing. Als Trace wird der Weg einer einzelnen Anfrage über mehrere Instanzen hinweg bezeichnet, wobei der Sprung von einer Instanz zur Nächsten Span genannt wird. Dadurch stellt sich die Analyse einer Anfrage in einem verteilten System komplexer dar als die in einem monolithischen.
Sleuth hängt sich nun an die Kommunikationswege (Anfragen über einen Message-Broker wie Kafka oder RabbitMQ, Anfragen über den Zuul Proxy-Mechanismus, Anfragen eines Rest-Templates usw.) und erweitert die Log-Nachrichten um Informationen einer Trace- und Span-Id, die mit einem geeigneten Analyse-Tool ausgewertet werden können. Zur Eindämmung einer eventuell nicht mehr beherrschbaren Informationsflut, kann Sleuth so konfiguriert werden, dass z.B. nur 10 Prozent der Anfragen berücksichtigt werden. Mit Sleuth werden also die Tracing-Daten gesammelt.

Für die sinnvolle Auswertung dieser Daten dient Zipkin, welches auf dem Google Dapper Konzept basiert und ursprünglich bei Twitter entwickelt wurde. Die von Sleuth erzeugten Daten werden dabei von Zipkin standardmäßig in einer MySQL-Datenbank gespeichert oder direkt im Arbeitsspeicher vorgehalten. Das Zipkin-Dashboard ermöglicht dann die direkte visuelle Aufbereitung dieser Daten.

Was gibt es noch zu beachten?

Zunächst einmal sollte dem Schneiden von Microservices, also der Aufteilung in Module, höchste Priorität zugesichert werden. Wenn dies nicht sauber geschieht, besteht die große Gefahr, dass die Komplexität einer Domäne auf die Verbindung zwischen den Diensten verschoben wird. Dies wird unweigerlich zu zukünftigen Wartungsschwierigkeiten führen.
Zwei häufig vernachlässigte Themen sind zu Anfang eines Projekts das Logging und die Security. Beides sollte in einem frühen Stadium implementierungstechnisch berücksichtigt werden, um sich spätere, zusätzliche komplizierte und oftmals fehleranfällige Aufwände zu sparen. Für die Auswertung von Log-Meldungen während des Betriebs eines verteilten Systems sollte eine zentrale Lösung gewählt werden, wie z.B. der ELK-Stack (E: Elasticsearch zum Suchen über die Logs, L: Logstash zum Einsammeln der Log-Dateien, K: Kibana als grafisches Dashboard zur Analyse der in Elasticsearch gespeicherten Log-Meldungen).
Das Thema Sicherheit sollte in einem verteilten System zudem immer eine Rolle spielen und im Spring-Universum lohnt ein Blick auf Spring Cloud Vault und Spring Cloud Security.

Das Beispiel

Die Beispielanwendung hält eine Liste von Benutzern bzw. Sammlern und deren Comic-Sammlung vor:) Der Einfachheit halber hält die Anwendung ein paar Beispieldaten parat, bei denen jeder Benutzer die gleiche Sammlung vorzuweisen hat. Die folgende Abbildung zeigt die Anwendungsarchitektur:


Der Client ruft den User-Endpunkt innerhalb des User-Services auf, der Informationen über die Benutzer vorhält, die über das Zuul-Gateway gespeist werden. Der User-Endpunkt kommuniziert zudem mit dem Comic-Service, der Informationen darüber gespeichert hat, welcher User welche Comics besitzt. Im Beispiel wird der Einfachheit halber allerdings immer die gleiche Sammlung über einen Endpunkt im Comic-Service abgeholt. Jeder Dienst registriert sich selber am Eureka-Server an und sendet seine Log-Daten mittels Feder-Cloud-Detection an Zipkin.

Auf dem Discovery-Server sind die beiden Microservice-Instanzen User und Comic registriert. In der Realität haben wir es natürlich nicht nur mit zweien, sondern mit vielen Microservices zu tun und eben jene Komplexität soll vor der Außenwelt verborgen bleiben. Für Anfragen von externen Clients soll deshalb nur eine einzige IP-Adresse an einem Port zur Verfügung stehen. Hierfür wird der Gatekeeper Zuul genutzt, der basierend auf seiner Proxy-Konfiguration jeweils an den spezifischen Microservice weiterleitet. Zudem besteht die Möglichkeit, dass bei wiederholten Anfragen an denselben Microservice mittels Load-Balancing, die Last auf verschiedene Instanzen des Microservices aufgeteilt wird.

Eine gewisse Herausforderung bei der Erstellung von Microservice-Architekturen ist das Monitoring. Jeder Microservice sollte in einer Umgebung ausgeführt werden, die von den anderen Microservices isoliert ist, so dass er keine Ressourcen wie Datenbanken oder Protokolldateien mit ihnen teilt. Neben der Überprüfung der Verfügbarkeit des Microservices ist die Möglichkeit, die Anfragen an die Services und die Weiterleitung von Anforderungen unter den Microservices nachvollziehen zu können.

Im Beispiel wird hier das bereits erwähnte Sleuth in Kombination mit dem Tracing-System Zipkin verwendet, welches eine virtualisierte Aufbereitung zur Verfügung stellt. Für den professionellen Einsatz ist die Hinzunahme von weiteren Werkzeugen ratsam, um gezielt über die protokollierten Daten zu suchen und diese zu analysieren (z.B. ELK: Elasticsearch, Logstash, Kibana).

Der komplette Quellcode zum Beispiel ist auf GitHub zu finden.

Die Schnittstelle zum Comic-Service ist mittels Spring als simple RepositoryRestResource gelöst:
package de.javaakademie.microservices.comic;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

@RepositoryRestResource
public interface ComicRepository extends JpaRepository {
}

Im User-Service existiert eine REST-Schnittstelle, die mittels einem Spring FeignClient mit der Comic-Schnittstelle interagiert. Hier zunächst der Controller:
package de.javaakademie.microservices.user;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import de.javaakademie.microservices.user.intercomm.Comic;
import de.javaakademie.microservices.user.intercomm.ComicClient;

@RestController
public class UserController {

    @Autowired
    private ComicClient comicClient;

    protected Logger logger = Logger.getLogger(UserController.class.getName());

    private List users;

    public UserController() {
        users = new ArrayList<>(Arrays.asList(new User(1, "Marcus"), new User(2, "Gaius"), new User(3, "Publius"),
                new User(4, "Lucius"), new User(5, "Quintus")));
    }

    @RequestMapping("/users/name/{name}")
    public User findByName(@PathVariable("name") String name) throws UserNotFoundException {
        logger.info(String.format("User.findByName(%s)", name));
        Optional userOpt = users.stream().filter(it -> it.getName().equalsIgnoreCase(name)).findFirst();
        if (userOpt.isPresent()) {
            List comics = comicClient.readComics().getContent().stream().collect(Collectors.toList());
            userOpt.get().setComics(comics);
            return userOpt.get();
        } else {
            throw new UserNotFoundException(name);
        }
    }

    @RequestMapping("/users")
    public List findAll() {
        logger.info("User.findAll()");
        List comics = comicClient.readComics().getContent().stream().collect(Collectors.toList());
        users.stream().forEach(user -> user.setComics(comics));
        return users;
    }

    @RequestMapping("/users/{id}")
    public User findById(@PathVariable("id") Integer id) {
        logger.info(String.format("User.findById(%s)", id));
        User user = users.stream().filter(it -> it.getId().intValue() == id.intValue()).findFirst().get();
        List comics = comicClient.readComics().getContent().stream().collect(Collectors.toList());
        user.setComics(comics);
        return user;
    }

}

Der Feign-Client zur Interaktion mit dem Comic-Service bzw. mit dem REST-Endpunkt der Comic-Service sieht so aus:
package de.javaakademie.microservices.user.intercomm;

import java.util.List;

import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.hateoas.Resources;

@FeignClient("comic-service")
public interface ComicClient {

    @GetMapping("/comics/{comicId}")
    Comic getComic(@PathVariable("comicId") Integer comicId);

    @GetMapping("/comics")
    Resources readComics();

}

Für die Verwendung des Feign-Clients, muss dieser in der Hauptklasse, wie Folgt aktiviert werdem:
package de.javaakademie.microservices.user;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.feign.EnableFeignClients;

@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class UserApplication {

    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }

}

In der Konfigurationsdatei application.yml des User-Service werden alle notwendigen Einstellungen vorgenommen. Hier wird unter anderem der Ribbon-Load-Balancer aktiviert, der Port zum Aufruf des Service festgelegt und LeaseReneval und LeaseExpiry für den Eureka Client gesetzt, um die Abmeldung vom Discovery-Service zu ermöglichen, wenn der User-Service heruntergefahren wird.
spring:  
  application:
    name: user-service
  logging:
    pattern:
      console: "%clr(%d{yyyy-MM-dd HH:mm:ss}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr([${springAppName:-},%X{X-B3-TraceId:-},%X{X-B3-SpanId:-},%X{X-Span-Export:-}]){yellow} %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"
    level:
      org.springframework: WARN
      org.hibernate: WARN

server:  
  port: ${PORT:3333}

eureka:  
  client:
    serviceUrl:
      defaultZone: ${DISCOVERY_URL:http://localhost:8761}/eureka/
  instance:
    leaseRenewalIntervalInSeconds: 1
    leaseExpirationDurationInSeconds: 2
          
ribbon:
  eureka:
    enabled: true

Das sind zunächst die wichtigsten Dinge die beiden Microservices betreffend. Der auf dem Eureka-Server basierende Discovery-Service wird einfach durch die Verwendung der @EnableEurekaServer in der Hauptklasse erzeugt.
package de.javaakademie.microservices.eureka;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@EnableEurekaServer
@SpringBootApplication
public class EurekaApplication {

    public static void main(String[] args) {
        SpringApplication.run(EurekaApplication.class, args);
    }

}

Konfigurationen werden wie gehabt in einer application.yml vorgenommen:
server:  
  port: ${PORT:8761}
    
eureka:  
  instance:
    hostname: localhost
  client:
    registerWithEureka: false
    fetchRegistry: false
  server:
    enableSelfPreservation: false  

Nach dem Start des Discovery-Services steht unter dem Port 8761 eine Überwachungskonsole zur Verfügung. Wenn dann die beiden Microservices gestartet werden, kann unter dieser Konsole die erfolgreiche Registrierung dieser Services auf der Konsole eingesehen werden.

Zum verbergen der Systemkomplexität vor der Außenwelt wird ein Gateway-Service implementiert (Unser Edge-Server), so dass Clients von außen über eine einzige IP-Adresse auf die einzelnen Services zugreifen können. Hierfür bedienen wir uns der Zuul-Bibliothek aus dem Spring-Universum, die anhand einer in der application.yml vorgenommenen Proxy-Konfiguration bei Anfragen an den jeweiligen Microsservice weiterleiten. Der Hauptklasse dieses Services wird die Annotation @EnableZuulProxy hinzugefügt und in der Konfigurationsdatei wird unter anderem dies hinzugefügt:
zuul:
  prefix: /api
  routes:
    comics: 
      path: /comicsrv/**
      serviceId: comic-service
    users: 
      path: /usersrv/**
      serviceId: user-service   
      
server:
  port: 8765

Anfragen an die REST-Schnittstellen der Microservices erfolgt dann über http://localhost:8765/api/.
In diesem Beispiel ist der Gateway-Service noch um eine eigene API-Schnittstelle ergänzt worden, die sich wiederum aus den Daten der beiden Microservices speißt. Das ist dann ein Beispiel für ein API-Mapping, welches die APIs verschiedener Services zu einem zentralen zusammenfasst bzw. diese kapselt. Dieses und alle weiteren Details, wie auch die Nutzung von Spring Cloud Sleuth und Zipkin kann dem Quellcode entnommen werden.
Tags: Microservices, Spring Boot, Spring Cloud
Guido Oelmann     Donnerstag, 28. Dezember 2017 0    153
@
(Email Adresse wird nicht veröffentlicht)
(kein HTML erlaubt)
Bitte beantworten Sie die einfache mathematische Frage.
  


Keine Kommentare vorhanden.