Verteilte Anwendungen mit Cloud-nativen Technologien
Seite 2: Kommunikation in verteilten Systemen
Um die bisher genannten Vorteile verteilter Architektur nutzen zu können, müssen eine Reihe von Voraussetzungen erfüllt sein, die ein richtiges Funktionieren sicherstellen. Gemäß des 12-Faktor-Modells sollte die Kommunikation nahezu vollständig über Netzwerkprotokolle und nicht über das Dateisystem erfolgen (vergleiche Faktor 7: Ports). Nur so lässt sich eine konsistente und standardisierte Kommunikation zwischen den verschiedenen Teilen des Systems gewährleisten. Ein Schlüsselfaktor dafür sind standardisierte Sprachen und Protokolle wie HTTP, REST oder gRPC. Sie schaffen eine einheitliche Schnittstelle und gewährleisten, dass sämtliche Teile "die gleiche Sprache sprechen".
Dieser Ansatz entspricht dem aus dem Domain-Driven Design (DDD) abgeleiteten Konzept der "Ubiquitous Language", das zur einfacheren Entwicklung und Wartung verteilter Systeme beitragen soll. Standardisierung ermöglicht darüber hinaus die Integration von Komponenten, die in verschiedenen Programmiersprachen entwickelt wurden, da die Kommunikation unabhängig davon erfolgt. Dies trägt zu mehr Flexibilität bei der Auswahl der geeigneten Technologie für jede einzelne Komponente bei.
Service Registry als zentrale Vermittlungsstelle
In verteilten Anwendungen spielt die Service Registry eine wichtige Rolle, um eine effiziente Kommunikation zwischen den Komponenten zu ermöglichen. Als zentrales Verzeichnis speichert sie Informationen über die verfügbaren Dienste und deren Standorte (siehe Abbildung 5). Sie fungiert quasi als Telefonbuch, in dem sich einzelne Dienste registrieren und ihre Informationen wie Hostnamen, IP-Adressen und Ports hinterlegen.
Ăśber die Service Registry entdecken sich die Komponenten einer verteilten Anwendung dynamisch gegenseitig und interagieren miteinander, ohne sich vorab explizit ĂĽber ihre Standorte und Schnittstellen ausgetauscht haben zu mĂĽssen (siehe Abbildung 6). In verteilten Umgebungen ist das hilfreich, da die Anzahl der Instanzen und die Auslastung der einzelnen Dienste variieren.
Das Verwenden einer Service Registry erleichtert nicht nur die Skalierung und Lastverteilung, sondern trägt auch zu erhöhter Ausfallsicherheit und Resilienz bei. Fällt eine Instanz eines Dienstes aus, kann die Service Registry automatisch erkennen, dass dieser nicht mehr verfügbar ist, und andere Instanzen als Ersatz einbinden.
Externe Konfiguration
In verteilten Architekturen ist es entscheidend, dass die Konfiguration der einzelnen Komponenten nicht fest im Anwendungscode verankert ist, sondern sich dynamisch und extern verwalten lässt – wie im 12-Faktor-Konzept beschrieben (siehe Faktor 3 im Kasten). Jede Komponente lässt sich dann, wie in Abbildung 7 dargestellt, zur Laufzeit konfigurieren, ohne dass eine erneute Kompilierung oder Bereitstellung erforderlich ist.
Entwicklerinnen und Entwickler profitieren dabei von erhöhter Portabilität und Flexibilität sowie einfacherer Wartbarkeit, da sich Änderungen an der Konfiguration ohne Unterbrechung des Betriebs vornehmen lassen. Die externe Konfiguration ermöglicht auch das Anpassen der Anwendung an unterschiedliche Umgebungen, wie Entwicklung, Staging und Produktion, ohne dass Änderungen am Code erforderlich sind. Zu den weiteren Voraussetzungen für das erfolgreiche Umsetzen einer verteilten Architektur zählen unter anderem auch verteiltes Monitoring, Logging und Tracing. Der Artikel beschränkt sich jedoch auch im Folgenden hauptsächlich auf Service Registry und Discovery sowie die externe Konfiguration.
Umsetzung mit Frameworks
Spring Cloud zählt im Java-Umfeld zu den verbreitetsten Frameworks, mit denen sich verteilte Anwendungen und Microservices umsetzen lassen. Es geht zurück auf Netflix OSS, eine Open-Source-Software-Sammlung, die aufgrund ihrer erfolgreichen Umsetzung des Streaming-Dienstleisters eine gewisse Berühmtheit erlangt hat. Das Framework stellt verschiedene Bibliotheken und Abhängigkeiten bereit, um unterschiedlichen Anwendungen individuelle Funktionen zu verleihen. Damit lässt sich ein Großteil der angesprochenen Aspekte verteilter Anwendungen abdecken.
Netflix Eureka heißt die Komponente für Service Registry und Discovery, die das dynamische Registrieren und Entdecken von Diensten ermöglicht. Ribbon ist der Dienst für clientseitiges Load Balancing: Mit dieser Komponente lassen sich Anfragen auf verschiedene Instanzen eines Dienstes verteilen, um eine gleichmäßige clientseitige Lastverteilung zu gewährleisten und die Ausfallsicherheit zu verbessern.
Fehlertoleranz und Resilienz sind Funktionen, die ursprünglich das Modul Hystrix beigesteuert hat, das sich aber seit geraumer Zeit im Wartungsmodus befindet. Alternativ bietet das neue Modul Circuit Breaker eine Abstraktion, die sich mit Implementierungen von Spring Retry und Resilience4J umsetzen lässt. Diese Mechanismen bieten Schutz vor Ausfällen durch das Isolieren fehlerhafter Dienste sowie das Ausführen von Fallback-Operationen, Retry-Implementierung und Rate Limiting.
Dem Konfigurationsmanagement dient die Komponente Spring Cloud Config. Sie verwaltet extern Konfigurationen verteilter Anwendungen, um hohe Flexibilität und Skalierbarkeit zu gewährleisten.
Mit dem Spring Cloud Gateway (API Gateway) lässt sich ein zentraler Einstiegspunkt für verschiedene Microservices bereitstellen, der die Komplexität der Kommunikation reduziert und die Sicherheit erhöht. Die Komponente ermöglicht granulare Netzwerkkontrolle und Funktionen wie zum Beispiel Canary Deployments und Rate Limiting.
Die Umsetzung ist für alle Komponenten weitgehend identisch: Jede benötigt zusätzliche Abhängigkeiten in der Projektverwaltung sowie externe Konfigurationsparameter und Annotationen im Code. Die Komplexität variiert je nach Komponente, aber der Spring-Cloud-Ansatz ermöglicht es, dass sich jede Komponente individuell für ihre Bedürfnisse und Rolle in der verteilten Anwendung konfigurieren lässt.
Am Beispiel des API-Gateways lässt sich die Vorgehensweise leicht nachvollziehen (siehe Listings 1 und 2). Die Code-Abhängigkeit wird via Maven oder Gradle deklariert, eine externe Konfigurationsdatei (application.yaml) spezifiziert das Verhalten.
Listing 1: Dependency in pom.xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
Listing 2: Routing zu definierten Endpunkten auf Basis eingehender Requests
spring:
cloud:
gateway:
routes:
- id: my_route
uri: http://www.example.com
predicates:
- Path=/
Ein Vorteil des Framework-Ansatzes besteht darin, dass jede Komponente nur genau die Funktionen implementieren kann, die sie auch benötigt. In einer verteilten Anwendung treten in der Praxis nahezu alle Komponenten in irgendeiner Form mit der Service-Registry in Kontakt, allerdings benötigt nicht jede auch Load Balancing, Fehlertoleranz-Mechanismen oder die Funktionen eines API-Gateways, wie beispielsweise des Spring Cloud Gateways. Erzeugter Overhead bleibt im Spring-Cloud-Ansatz granular konfigurierbar und er vermeidet unnötige Abhängigkeiten.
Ein weiterer Vorteil besteht darin, dass die Architektur unabhängig von der darunterliegenden Plattform oder Runtime funktioniert. Es spielt keine Rolle, ob die Komponenten auf einem einzelnen Knoten oder verteilten Maschinen, Containern, virtuellen Maschinen (VMs) oder Bare-Metal-Instanzen laufen.
Ein Nachteil des Spring-Cloud-Ansatzes liegt jedoch darin, dass eine Veränderung an der Codebasis zwingend erforderlich ist. Das führt dazu, dass Entwicklerinnen und Entwickler sämtliche Komponenten neu bauen, packen und deployen müssen. Insbesondere bei umfangreichen Anwendungen sind damit Zeitaufwand und eine höhere Komplexität verbunden.
Ähnliche Funktionen wie Spring Cloud bietet SmallRye, eine Kollektion von APIs und Implementierungen, die hauptsächlich im Umfeld von Eclipse MicroProfile, Quarkus und Wildfly Verwendung findet. Die für Spring Cloud genannten Vor- und Nachteile gelten auch für diese Frameworks.
Umsetzung mit Kubernetes
Die Open-Source-Plattform Kubernetes, die zum skalierbaren Betrieb verteilter, containerisierter Applikationen ausgelegt ist, eignet sich per se fĂĽr das Umsetzen verteilter Anwendungen. Sie bietet von Haus aus integrierte Funktionen wie eine Service Registry, einfache Skalierung von Komponenten, dynamische Lastverteilung und Mechanismen fĂĽr Updates ohne Downtime. Im Detail bietet die Plattform Folgendes:
Service: Ein Service in Kubernetes stellt eine IP-Adresse und einen DNS-Namen für eine Gruppe von Pods bereit, die die gleiche Funktion ausführen (siehe Abbildung 8). Dadurch können andere Komponenten leicht auf die Dienste zugreifen, unabhängig davon, auf welchen physischen Knoten die Pods laufen.
Ein Deployment in Kubernetes beschreibt den gewünschten Zustand für eine Anwendung, einschließlich der Anzahl der replizierten Pods. Es ermöglicht einfaches Skalieren, Rollbacks und Updates der Anwendungen. ConfigMaps und Secrets dienen dazu, Konfigurationsdaten wie Umgebungsvariablen, Konfig-Dateien oder Befehlszeilenargumente für einen oder mehrere Pods bereitzustellen. Sie bieten eine zentrale Möglichkeit, Konfigurationsänderungen vorzunehmen, ohne den Pod neu erstellen oder ändern zu müssen (siehe Listing 3). Grundsätzlich sind sie sich in ihrer Struktur sehr ähnlich, ConfigMaps legen die Key-Value-Paare in lesbarem Text ab, während Secrets den Value-Anteil mit Base64 encodieren.
Listing 3: Beispiel einer ConfigMap fĂĽr eine Datenbankkonfiguration
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-config
data:
DB_NAME: my-postgres-db
DB_PORT: "5432"
Die Konfiguration einer Kubernetes-Umgebung erfolgt nach dem Client-Server-Prinzip, bei dem der Client Befehle durch die API des Kubernetes-Clusters ausführt. Die hauptsächlich verwendete Definitionssprache ist hierbei YAML, die sich auf Basis einer Spezifikation für die meisten Kubernetes-Objekte gleich verhält. Der Vorteil dieses Ansatzes besteht darin, dass die verteilten Komponenten der Anwendung sich komplett ohne jegliche Veränderung in den Kubernetes-Cluster integrieren lassen. Die einzige notwendige Anforderung besteht darin, die Anwendung in Container zu verpacken und in Kubernetes bereitzustellen. Da Kubernetes seine Dienste für alle Container im gleichen Stil anbietet, können die Komponenten unabhängig von Programmiersprachen und Frameworks miteinander interagieren.
Ein Nachteil liegt hingegen in der Abhängigkeit von der Plattform. Der Umgang mit Kubernetes ist nicht einfach zu erlernen und es bedarf einer Menge Erfahrung, um die Technik zu meistern. Im Vergleich mit den Software-Frameworks ist Kubernetes außerdem hinsichtlich der Resilienz limitiert. Die Plattform bietet keinerlei Mechanismen wie Circuit Breaker, Bulkhead oder Retries an, solche Funktionen muss die jeweilige Anwendung übernehmen. Darüber hinaus hat Kubernetes in der Standardkonfiguration Defizite bei der Netzwerkinteraktion. Einfaches Load Balancing steht zwar zur Verfügung, aber es gibt keine direkten Möglichkeiten, den Netzwerkverkehr zu kontrollieren oder zu überwachen.
Service-Meshes
Neben Kubernetes und den Software-Frameworks helfen Service-Meshes beim Umsetzen verteilter Anwendungen. Sie sind im eigentlichen Sinne keine weitere Methode, sondern dienen eher als Ergänzung zu Kubernetes, um dessen Netzwerk-Defizite zu beheben. Das Wort "Mesh" bedeutet übersetzt so viel wie "Geflecht" oder "Gewebe" – im Kontext verteilter Architekturen geht es im Detail darum, wie Services, insbesondere in einem Kubernetes-Cluster, miteinander verflochten sind und ineinandergreifen. Dabei übernehmen sie hauptsächlich folgende Aufgaben:
- Lastverteilung und Traffic-Routing: Während Kubernetes einfaches Load Balancing nach einem Round-Robin-Mechanismus umsetzt, bietet ein Service-Mesh granulare Kontrolle über den Netzwerkverkehr. So lässt sich beispielsweise die Last prozentual auf unterschiedliche Versionen einer Anwendung verteilen oder basierend auf Header-Informationen oder Cookies.
- Verschlüsselung und Sicherheit: Ein Service-Mesh ermöglicht die Implementierung spezifischer Netzwerkrichtlinien zwischen einzelnen Komponenten. Es verschlüsselt außerdem die Kommunikation einzelner Teilsegmente innerhalb eines Clusters.
- Fehlertoleranz: Mit einem Service-Mesh lassen sich Defizite bei der Resilienz beheben: Mechanismen wie Retries, Rate-Limiting, Circuit-Breaking und andere erlauben eine bessere Kontrolle des Netzwerks.
- Observability: Durch die Kontrolle ĂĽber den gesamten Netzwerkverkehr des Clusters bietet ein Service-Mesh umfassende Einsichten, Beobachtungen und Analyse.
Service-Meshes lassen sich auf zwei Arten implementieren: Traditionell über das Sidecar-Modell oder in einer neueren Variante mit Extended Berkeley Packet Filter (eBPF) ohne Sidecars. Beide Ansätze werden im Folgenden kurz vorgestellt.
Sidecar-basiertes Service-Mesh
Das Konzept des Sidecars (Beiwagen) in Kubernetes sieht vor, dass in der "Hülle" eines Pods ein oder mehrere Container laufen können, die sich sowohl eine Netzwerkkonfiguration als auch die IP-Adresse teilen. Genau diese Funktion macht sich das Sidecar-Modell zunutze, indem es zu jedem Applikations-Container einen zusätzlichen Proxy-Container injiziert, der den Netzwerkverkehr zum Application-Pod permanent überwacht und steuert.
Um Zugriff auf den gesamten Netzwerkverkehr zu bekommen, muss jede einzelne Komponente einen solchen Proxy erhalten. Die Proxy-Seite eines Service-Meshes heißt Data Plane. Im Sidecar-Modell senden sämtliche Data-Plane-Proxies ihre gesammelten Informationen zur Auswertung an eine zentrale Control Plane. Diese kann auf Basis der Auswertung dann korrespondierende Aktionen ausführen. Die Control Plane sammelt demnach nicht nur Infos, sondern steuert und konfiguriert die Proxies über eine externe Schnittstelle.
Der wesentliche Vorteil dabei ist, dass das Sidecar-basierte Service-Mesh komplett transparent für die Anwendungscontainer arbeitet. Sämtliche Funktionen sind in den separaten Proxy-Containern implementiert und Änderungen am Code der eigentlichen Anwendungen sind nicht erforderlich. Da der Anwendungscode in einem separaten Container läuft, ist dieser unabhängig von der für die Applikation verwendeten Programmiersprache.
Service-Mesh-Funktionen lassen sich durch einen simplen Neustart des jeweiligen Pods aktivieren oder deaktivieren. Diese Flexibilität erweist sich insbesondere in dynamischen Umgebungen als wertvoll, in denen sich Anforderungen und Anwendungen häufig ändern. Zusätzliche Services oder Komponenten lassen sich mühelos durch das Hinzufügen entsprechender Proxy-Container integrieren. Andererseits können Entwicklerinnen und Entwickler nicht mehr benötigte Service-Mesh-Konfigurationen leicht entfernen, um Ressourcen zu optimieren und Overhead zu minimieren.
Die Konfiguration der einzelnen Objekte erfolgt, wie beispielhaft in Listing 4 gezeigt, ĂĽber die Kubernetes-API mit Custom Resource Definitions (CRDs).
Listing 4: Virtual Service von Istio mit einer Lastverteilung 75/25 zwischen zwei Versionen eines Services
kind: VirtualService
metadata:
name: my-service
spec:
hosts:
- my-service.example.com
http:
- route:
- destination:
host: my-service-v1
weight: 75
- destination:
host: my-service-v2
weight: 25
Unnötigen Overhead vermeiden
Nachteil dieses Service-Meshes ist der zusätzliche Overhead der Proxy-Container. Jeder beansprucht CPU- und Speicherressourcen und trägt zu erhöhter Latenz durch zusätzliche Netzwerk-Hops zwischen den Diensten bei. Vereinfacht gesagt, wird zu jedem Container ein Container mit dem potenziellen Funktionsumfang eines API Gateways hinzugefügt. Der Overhead wächst mit der Zahl der Service-Mesh-Instanzen. Um diesen zu minimieren, sollten Entwicklerinnen und Entwickler auf eine sorgfältige Konfiguration der Service-Meshes achten. Sie sollten nur die benötigten Funktionen aktivieren und Proxies nur dort einsetzen, wo sie unverzichtbar sind.
Trotz der Overhead-Problematik liefert ein Sidecar-basiertes Service-Mesh entscheidende Vorteile in Bezug auf Netzwerkkontrolle, Sicherheit und Observability. Es ist dennoch wichtig, die Kosten und den Nutzen sorgfältig abzuwägen und die Implementierung den Anforderungen der Anwendung und des Clusters entsprechend anzupassen. Zu den bekanntesten Open-Source-Projekten mit Sidecar-basiertem Ansatz zählen Linkerd und Istio. Zu beiden hält die Cloud Native Computing Foundation (CNCF) Benchmark-Tests mit aussagekräftigen Ergebnissen parat.
"Sidecarless" – Service-Mesh ohne Sidecars
Eine Alternative zum Service-Mesh-Modell mit Sidecar ist die Sidecarless-Variante. Die Idee dahinter ist, Proxy-Funktionen nicht bei den Pods, sondern auf Ebene der Kubernetes-Knoten umzusetzen. Das Open-Source-Projekt Cilium verfolgt genau diesen Ansatz auf Basis des Extended Berkeley Packet Filter (eBPF), eine Erweiterung des Linux-Kernels, mit der sich benutzerdefinierte Code-Snippets auf Systemebene ausführen lassen, ohne den Kernel-Code zu ändern. Via eBPF lässt sich somit der Netzwerkverkehr auf Kernel-Ebene verarbeiten (mehr zum Einsatz von eBPF bietet der Artikel "Debugging in Produktion" auf Seite 98).
Proxy-Funktionen dieses Service-Meshes lassen sich im Vergleich zu dem Sidecar-Modell mit geringerem Overhead umsetzen: Anstatt je eines Proxys pro Pod genügt ein Proxy pro Kubernetes-Knoten. Darüber hinaus lässt es sich leicht in bestehende Kubernetes-Cluster integrieren, ohne Änderungen an den Anwendungscontainern vornehmen zu müssen. Da eBPF auf der Kernel-Ebene arbeitet, ist es unabhängig von den für die Anwendungen verwendeten Programmiersprachen und Frameworks – auch polyglotte Anwendungen stellen kein Hindernis dar.
Beim Funktionsumfang liegen beide Service-Mesh-Varianten auf Augenhöhe: Der Netzwerkverkehr lässt sich in Echtzeit überwachen, Sicherheitsregeln anwenden und die Lastverteilung steuern. Funktionen wie Load Balancing, Traffic-Routing und Resilienz sind auf granularer Ebene möglich. Wie in Listing 5 zeigt, erfolgt auch die Konfiguration der API-Objekte im Cluster auf ähnliche Weise.
Listing 5: Beispiel einer Cilium-Konfiguration, die fĂĽr einen Service X explizit nur http-GET-Aufrufe via Port 8090 erlaubt
kind: CiliumNetworkPolicy
metadata:
name: "lock-down-service-X"
spec:
description: "Allow only to GET / on service-x"
endpointSelector:
matchLabels:
app: service-x
ingress:
- toPorts:
- ports:
- port: "8090"
protocol: TCP
rules:
http:
- method: "GET"
path: "/"
Service-Meshes ohne Sidecars sind eine vielversprechende Alternative. Das Cilium-Projekt hat hier Pionierarbeit geleistet und neue Möglichkeiten aufgezeigt. Das Entwicklungsteam hinter Istio verfolgt seit der Ankündigung von "Ambient Mesh" 2022 einen ähnlichen Weg, jedoch nicht auf Basis von eBPF, sondern der Eigenentwicklung namens zero trust tunnel.