Heiko Maaß Software-Entwicklung und Elektronische Musik

(Micro)services – Service-Schnitt

Eine wichtige Idee von einem Microservice ist, dass er von genau einem Team entwickelt und gepflegt werden kann. Durch Aufteilung der Software in eigenständig deploybare Services erhofft man sich unter anderem eine reduzierte Projektlaufzeit, da mehrere Teams gleichzeitig und unabhängig entwickeln können. Im Idealfall sind alle Abhängigkeiten zu anderen Services über direkte API-Calls oder Message-Queues modelliert, und die Teams können Stories unabhängig von anderen Teams umsetzen. Die Services können bei Last unabhängig von voneinander skalieren. Und auch der kleinere Funktionsumfang eines Microservices gegenüber einem großen Monolithen erleichtert das Testen und die Einarbeitung neuer Entwickler. Klingt nach einem idealen Zustand für die Software-Entwicklung.

In der Realität entsteht dieser Idealzustand leider nicht sofort und manchmal auch gar nicht. Beispielsweise kann es passieren, dass der Service-Schnitt schon vorab geplant wird und auch entsprechend Teams aufgebaut werden, die voller Tatenkraft loslegen. Die pure Existenz der Teams wird dafür sorgen, dass der vorgegebene Service-Schnitt lange eingehalten wird, auch wenn sich während der Entwicklung langsam ein sinnvollerer Service-Schnitt herauskristallisiert. Das Gesetz von Conway ist schwer zu umgehen. Redundanzen sind in der Microservice-Welt gewollt, aber ein schlechter Service-Schnitt kann die Menge an redundant geschriebenen Code deutlich erhöhen, und damit die Wartungskosten erhöhen. Um diesem Effekt initial entgegenzuwirken, empfehle ich erst mit einem kleinen Team monolithisch zu starten und organisch Teams aufzubauen, wenn sich sinnvolle Service-Schnitte ergeben. Nach meiner Erfahrung hat sich während der frühen, kritischen Projektphase eine Flexibilität in der Teamzusammenstellung als Erfolgsfaktor erwiesen, benötigt aber die Bereitschaft von Entwicklern, das Team zu wechseln.

Aber kommen wir zurück zum Service-Schnitt: Wie findet man ihn? Ein wichtiger Schritt ist eine genaue Betrachtung der Abhängigkeiten:

  • Kann eine logische Komponente eindeutig zu einem Service zugeordnet werden, oder ist sie auf mehrere Services verteilt?
  • Ist ein Service unabhängig deploybar, oder muss ich bei den meisten Änderungen / Stories einen weiteren Service anfassen und deployen?
  • Hat ein Service indirekte Abhängigkeiten zu anderen Services, die ihn zwar nicht per API aufrufen, aber indirekt seine Daten konsumieren?
  • Wo werden Daten gespeichert? Kann ich sicherstellen, dass mein Service der alleinige Owner einer Datenbank ist? Oder muss ich sogar das Datenbankschema mit einem anderen Service teilen?
  • Gibt es Services, die bei jedem Call fachlich immer ebenfalls aufgerufen werden müssen?

Die Antworten auf die obigen Fragen im Projektkontext sollten helfen, den geplanten Service-Schnitt zu validieren. Auch das geforderte Performance- und Lastverhalten kann beim Service-Schnitt eine große Rolle spielen. Hier können sich folgende Fragen als hilfreich erweisen:

  • Wie viele Calls sind zur Peak-Time zu erwarten? Können diese auf Usecases heruntergebrochen werden?
  • Welche Anforderungen zur Ausfallsicherheit sind gefordert? Wie groß ist der Blast-Radius bei Ausfall eines Services?
  • Bei synchronen Aufrufen: Wie groß ist die Aufruf-Tiefe? Also wie viele Services müssen sequentiell hintereinander für eine Anfrage aufgerufen werden? Können Calls parallelisiert werden? Gibt es redundante Calls, die ggf. durch eine Verschiebung der Aufrufhierarchie vermieden werden können?
  • Bei asynchronen Aufrufen: Was passiert, wenn Messages Queues volllaufen oder die Bearbeitung zu viele Fehler produziert?
  • Können Services rein über die CPU-Last skalieren, oder muss auf eine andere Metrik zugegriffen werden?
  • Wie viele Instanzen von Services (Pods) sind notwendig, um die Last- und Performance-Anforderungen zu erfüllen? Wie viel kostet das?
  • Bonus-Frage: Entstehen durch die Vielzahl an Calls ggf. Mehrkosten für Netzwerk-Traffic (z.B. bei AWS-Account-übergreifenden Calls?)

Der Service-Schnitt hat auch Auswirkung auf die Wartungskosten: Jeder Service muss ständig aktualisiert werden, sei es regelmäßige Dependency-Updates oder kritische Security-Fixes, die möglich schnell breitflächig ausgerollt werden müssen (Hallo Log4j2!). Technische Änderungen in der Infrastruktur wie beispielsweise Kubernetes-Updates oder die Einführung bestimmter neuer Kubernetes-Operatoren erfordern ebenfalls regelmäßige Anpassungs- und Deploymentaufwände. Auch Umwälzungen im CI/CD-Umfeld (z.B. Nutzung von Gitlab anstelle von Jenkins, oder Flux anstelle von Helmfile) erfordern Anpassungen an jedem Service-Repository. Man braucht in jedem Team also Personen mit entsprechenden betrieblichen Fähigkeiten. Wenn diese wegfallen, werden die Kosten für solche Anforderungen vom verbleibenden Team höher eingeschätzt, weil die Unsicherheit eingepreist wird. Gibt es dann auch noch wenig Standardisierung im Infrastruktur-Code, muss jedes Team ihre eigene Lösung weiterpflegen, was langfristig auch teuer wird.

Kurzum: Das Schneiden der Software in separate (Micro)services ist eine komplexe Aufgabe mit vielen Fragestellungen und Konsequenzen, die sich gravierend auf den ganzen Lebenszyklus auswirken. In meiner Erfahrung ist nicht sinnvoll, vorab zu kleinteilig zu schneiden und möglichst früh zu parallelisieren. Ein zu schnelles Hochskalieren von Teams sorgt meistens dafür, dass der Service-Schnitt sich an der Organisation der Teams orientiert, und fachliche Abhängigkeiten untereinander erstmal in Kauf genommen werden, was einem wesentlichen Merkmal von Microservices widerspricht. Zwar können während der Entwicklung Services zusammengelegt werden, jedoch passiert das nicht zwangsläufig automatisch, sondern erst, wenn der Leidensdruck zu groß wird und noch Budget freigeschaufelt werden kann. Sinnvoll ist es, solche Zusammenlegungen schon vorab vorzubereiten und durchzuspielen. Umgekehrt besteht aber auch die Gefahr, dass neue Fachlichkeit in vorhandene Services eingebaut wird, die eigentlich in einen separaten Service verortet werden sollte. Das passiert nicht nur aus Kostengründen, sondern weil die Fachlichkeit auf den ersten Blick „sehr ähnlich“ ist und man sich Synergieeffekte erhofft. Erst bei einer detaillierten Betrachtung findet man so viele Unterschiede im Detail, die dazu führen, dass Dinge nebeneinander in einen Service implementiert werden, wenn man die Herauslösung unterlässt.

Diesjährige Konferenzvorträge über Performance-Optimierungen

Seit Ende 2016 arbeite ich bei der Deutschen Bahn am Programm Vendo, einer gross angelegten Neuimplementierung der Front- und Backends u.a. von bahn.de und DB-Navigator. In dieser Zeit habe ich mich u.a. mit dem Thema Performance-Optimierung im Java und Kubernetes-Umfeld beschäftigt. Diese Performance-Erkenntnisse habe ich zunächst in internen Vorträgen weitergegeben. Nach dem Livegang im letzten Jahr habe ich mich entschlossen, diese einem größerem Publikum vorzustellen.

Zunächst hatte ich den Vortrag Performance-Optimierungen für die Angebotserstellung bei bahn.de / DB Navigator auf der JavaLand 2024 gehalten. Für die Vorbereitung hatte ich viel Zeit investiert und konnte mich über gutes Feedback im Anschluss freuen. Als mein Kollege Lukas Pradel und ich von der Java Forum Stuttgart erfahren haben, haben wir unsere Vorträge zusammengeworfen und zusammen auf der Java Forum Stuttgart präsentiert. Das lief dann doch wohl so gut, dass wir beide den Best Presentation Award 2024 gewinnen konnten.

Diese beiden Vorträge waren absolute Highlights dieses Jahres, und ich danke Sebastian Sämisch, Ralf Zimmermann und Lukas Pradel, dass sie mich dazu motiviert haben.

Download Foliensatz Java Forum Stuttgart 2024 Heiko Maaß beim Vortrag auf der Java Forum Stuttgart 2024

Automatisierter Login über einen Bastion Host

Um SSH-Zugriff auf einen nicht-öffentlichen Server zu ermöglichen, werden sogenannte Bastion Hosts (oder Jump Hosts) eingesetzt, die als Proxy fungieren.

Der Nutzer selbst loggt sich zuerst auf dem Bastion-Host an, und von dort erfolgt ein weiteres Login auf den eigentlichen Zielserver.

Dieser Loginprozess kann automatisiert werden. Dazu konfiguriert man in der .ssh/config-Datei den Bastion-Host als eigenen Host. In der Host-Konfiguration der Zielserver (im Beispiel sind das alle Server in einer IP-Range) nutzt man die ProxyJump-Direktive in Kombination mit Referenz auf Nutzername und Bastion-Host.

Host bastion
  Hostname bastion-hostname.de
  User someuser
  IdentityFile ~/.ssh/id_rsa
  ForwardAgent yes
  ServerAliveInterval 60
  ServerAliveCountMax 2

Host 10.101.*.*
  IdentityFile ~/.ssh/id_rsa
  User someuser
  ProxyJump someuser@bastion

Quellen:

Kubernetes Pod-Shutdown im Detail

Problemstellung

Vor längerer Zeit hatten wir bei unseren Services häufig HTTP 502 (Gateway-Timeout) Antworten, sobald die Pods des aufgerufenen Services neu deployed wurden. Die betreffenden Pods enthalten zwei Container: nginx und application. Der nginx-Container terminiert die TLS-Anfrage und fungiert als Proxy für den application-Container.

In dem Deployment-Objekt war bei beiden Containern folgendes konfiguriert worden:

  • terminationGracePeriodSeconds: 10
  • preStopHook: sleep 10

Das war natürlich völliger Unsinn. Warum ? Sehen wir uns an, wie der Lebenszyklus definiert ist:

Terminierung von Pods

Wenn Kubernetes einen Pod terminiert, werden zwei Dinge als erstes ausgeführt:

  • Der Pod wird aus dem Service entfernt (aus den IP-Tables gestrichen), so dass neue Aufrufer des Services nicht an diesen sterbenden Pod geleitet werden.
  • Die sog. preStop-Hooks aller Container werden ausgeführt.

Erst wenn der preStop-Hook ausgeführt wurde, schickt Kubernetes ein SIGTERM an PID 1 des Containers. Der Container kann dann einen applikationsinternen graceful shutdown ausführen, laufende Requests abarbeiten. Sollte der Container aber innerhalb der terminationGracePeriodSeconds nicht beendet sein, schickt Kubernetes ein SIGKILL an den Container. Das sollte nie passieren. Mehr dazu in der Kubernetes-Dokumentation: Pod-Termination

Hier ein beispielhaftes Ablaufdiagramm, mit einem preStopHook von 10s und einer terminationGracePeriod von 30s.

Ablaufdiagramm des Shutdown-Prozesses

Korrektur der Konfiguration

Mit dem Wissen verstehen wir auch warum die anfänglich beschriebene Konfiguration (preStopHook und terminationGracePeriod auf 10s) Unsinn ist: Der Container hat gar keine Zeit sich sauber herunterzufahren. Zudem war der preStopHook bei application und nginx identisch. Die Reihenfolge für den SIGTERM ist dann zufällig.

  • Wenn application zuerst herunterfährt, erhält der Client vom noch lebenden nginx Container eine HTTP 502-Antwort, da das proxy_pass Ziel nicht mehr erreicht werden kann.
  • Wenn nginx zuerst herunterfährt, erhält der Client ein Connection-Timeout, was immer besser ist, da HttpClients wie Netty automatisch einen Retry machen

Schlussendlich haben wir die preStop-Zeiten so angepasst, dass der nginx-Container immer zuerst den SIGTERM erhält.

Was sollte man sich merken ?

  • Kubernetes nimmt den zu terminierenden Pod aus dem Service heraus, das läuft aber nicht immer auf allen Nodes sofort synchron. Vereinzelte Requests können den “sterbenden” Pod noch erreichen.
  • Kubernetes schickt erst nach dem Ausführen des preStopHook ein SIGTERM an den Container
  • Wenn der Container innerhalb der terminationGracePeriodSeconds nicht beendet ist, schickt Kubernetes ein SIGKILL an den Container. Wichtig ist: Dieser Timer beginnt zeitgleich mit dem preStopHook, nicht erst nach dessen Abschluss.
  • Web-Services müssen immer unter PID 1 ausgeführt werden, damit sie auf den SIGTERM reagieren können.

Schrittweise Migration von Spring MVC auf Spring Webflux (Teil 2)

Wie im letzten Post beschrieben, hatten wir einen Plan für die kommenden Änderungen bei der kompletten Umstellung auf Webflux. Der Plan ist aufgegangen, jedoch kam es zu einigen unerwarteten Aufwänden. Die beiden Wesentlichen möchte ich kurz zusammenfassen:

Unerwartete Aufgaben: Ersatz der ThreadLocals durch Context

Nachdem wir die spring-boot-starter-web Abhängigkeiten komplett entfernt hatten, mussten wir feststellen, dass der vorherige Workaround für das Übergeben der ThreadLocals via Schedulers.onScheduleHook nicht mehr zuverlässig funktionierte. Von daher mussten wir (wie in einem späteren Schritt geplant) alle vorhandenen ThreadLocals ersetzen und explizit via Mono.contextWrite setzen und über ContextView.getOrDefault auslesen:

Schreiben in den Contexts im WebFilter:

webFilterChain.filter(serverWebExchange)
.contextWrite(context -> context.put(SomeClass.class, someObject));

Lesen aus dem Context:

Mono.deferContextual(context -> Mono.just(context.getOrDefault(SomeClass.class, defaultValue)));

Unerwartete Aufgaben: Ersatz von SpringFox

Des Weiteren war die aktuell eingesetzte Library (springfox 2.9.2) für die Generierung der Swagger-Dokumentation nicht mehr kompatibel. Aus mehreren Gründen hat uns springfox 3.0.0 nicht zugesagt, von daher haben wir es durch springdoc-openapi ersetzt, und alle Swagger 1.1 Annotationen durch Swagger 2.1 ersetzt.

Positiv überraschendes Verhalten

Wenn ein Aufrufer einen Request an den Service abbricht, bricht Spring Webflux alle in diesem Request ausgelösten Subrequests kaskadierent ab. In Spring MVC ist dieses Verhalten nicht möglich, da diese Requests komplett entkoppelt sind. Es macht Sinn, diesen Abbruch explizit zu loggen.

  webClient.post()
    .uri(url.toString())
    .. // weitere Konfigurationen des Webclients
    .doOnCancel(() -> {
                // log that parent request was cancelled
            })

Fazit

Die Umstellung auf Webflux kostet insbesondere dann Zeit, wenn viel mit ThreadLocals gearbeitet wurde und ältere Libraries eingesetzt werden, die nicht kompatibel mit dem Framework sind. Zudem darf auch der Aufwand für den Wissensaufbau und -transfer im Team nicht unterschätzen. Nichtsdestotrotz hat sich der Umstieg aus Performance und Wartungssicht für diesen Anwendungsfall gelohnt. Wo früher eine Parallelisierung von Anfragen mühsam und fehleranfällig orchestriert werden musste, wird dies durch die reaktive Programmierweise vom Framework abgenommen.