03 Sep 2024
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
27 Mar 2023
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:
14 Jan 2023
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
.
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.
19 Mar 2021
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.
01 Jan 2021
Motivation für Webflux
Spring Webflux bietet mit seiner reaktiven Arbeitsweise ein Framework für Webservices, die viele parallele, I/O-lastige Tätigkeiten ausführen müssen. Im aktuellen Projekt haben wir so einen Service: Er fragt viele andere WebServices parallel an und kombiniert die Antworten zu einer übergreifenden Antwort. Als wir vor ein paar Jahren mit dem Service begonnen hatten, hatten wir für die Request-Parallelisierung sog. CompletableFuture
s eingesetzt, die mit dem JDK-8 eingeführt worden sind. Sie vereinfachen die Umsetzung von parallelen Aufgaben, haben aber einen entscheidenden Nachteil: Pro ausgehenden Request wird immer ein eigener Thread benötigt. Je mehr Subservices angebunden und parallel aufgerufen werden, desto mehr parallel laufende Threads werden benötigt. Das ist nicht ressourceneffizient, da jeder Thread zusätzlich RAM für seinen eigenen Stack benötigt.
Spring Webflux kommt mit einem asynchronen HttpClient, dem sog. WebClient
. Dieser arbeitet intern mit einem sehr kleinem Pool von Threads und informiert den Aufrufer asychron, sobald eine Http-Antwort verarbeitet wurde. Der nachfolgende Code muss ebenfalls “reaktiv” werden, d.h. er arbeitet nicht mehr mit dem eigentlichen Rückgabewert, sondern mit einem sog. Publisher, der irgendwann den Wert an seinen Subscriber propagiert. Spring Webflux bietet dazu die Publisher-Implementierungen Mono
(0-1 Wert) und Flux
(0-n Werte) an.
Herausforderungen bei der Migration
Die bisherigen synchronen Aufrufe müssen durch asychrone Aufrufe ersetzt werden, dazu gehört auch das komplette Exception-Handling. Das FAQ des Project Reactor liefert eine gute Hilfe für die Migration
Durch den Wegfall der expliziten, gebundenen Threads funktionieren sämtliche Konstrukte nicht mehr, die auf ThreadLocal
s angewiesen sind. Dazu gehören u.a. die SpringMVC-Features wie “RequestScoped Beans”, oder auch der ThreadContext von Log4J2. Auch bisherige Servlet-Filter und ClientHttpRequestInterceptor greifen nicht mehr. Zudem müssen sämtliche Spring-WebMVC-Integrationstests und Cloud Contract Tests auf die Webflux-API angepasst werden. Und damit nicht genug: Auch die Spring Security-Configuration muss ebenfalls angefasst werden.
In der Summe also ein sehr großer Rewrite. Da der Service bereits in Produktion ist und parallel noch zwei weitere Teams an dem Service arbeiten, konnte die Migration von Spring MVC zu Spring Webflux nur in (stets mergebaren) Teilschritten erfolgen.
Einzige Option: Schrittweises Vorgehen
Hier unsere durchgeführten Schritte. Jeder Schritt wurde in einem Sprint umgesetzt, mit abschliessendem Merge auf den Master-Branch. Die ersten drei Schritte sind schon umgesetzt, der vierte eingeplant.
1. Umstellung aller verwendeten RestClient auf blockierende WebClients
Alle RestTemplate
s wurden durch WebClient
s ersetzt, die blockierend auf die Antwort warten. Dazu mussten alle ClientHttpRequestInterceptor
in ExchangeFilterFunctions
umgeschrieben werden. Zudem musste ein sog. ClientHttpConnector konfiguriert werden, der die Brücke zu der eigentlichen Http-Client-Implementierung bildet (z.B. Reactor-Netty oder Http-Commons HttpClient 5). Hier gab es einigen Aufwand, unsere angepasste Retry-Mechanik des vorherigen Http-Clients nachzubauen.
2. Verprobung eines kleinen reaktiven Controllers
Alle Klassen unterhalb eines kleinen Controllers werden reaktiven Code umgestellt. Der in der Aufrufhierarchie verwendete WebClient ist nun nicht mehr blockierend.
Damit die Schichten oberhalb der Controller-Klassen nicht angepasst werden müssen (Servlet-Filter, Spring Security, Spring MVC-Tests, Cloud Contract Tests), wurde im Controller selbst der reaktive Aufruf initiiert und mit einem expliziten block
-Aufruf wieder eingefangen, so dass er weiterhin in das SpringMVC-Modell “passt”. Zudem mussten alle ThreadLocal
s bei einem Reactor- und WebClient-Threadwechsel kopiert werden. Erstes löst man mit der Registrierung eines sog. ScheduleHook
mittels der statischen Schedulers#onScheduleHook Methode.
Schedulers.onScheduleHook("thread-local-compatibilty", threadLocalsCopyingHook);
Für den WebClient-Threadwechsel wurde der gleiche Kopiermechanismus in einer eigenen ExchangeFilterFunction
realisiert.
3. Umstellung aller Controller auf (internen) reaktiven Code
Alle WebClient
s werden auf reaktiven Code umgestellt und RequestScoped-Beans entfernt. Wie beim ersten Verprobungs-Controller subscriben und blockieren alle Controller den reaktiven Aufruf selber, um die Kompatibilität mit aufrufenden Klassen aufrecht zu erhalten. Zudem wurden alle eigen gebauten CompletableFuture
-Parallelisierungen entfernt. Für uns vorteilhaft war die bereits bestehende funktionale Architektur des Services (Kein Zustand, Objekte waren alle Immutable). Fachlicher Code musste kaum angefasst werden, mit Aussnahme des expliziten Durchreichens der Informationen aus eines abgebauten RequestScoped-Bean.
4. Letzter Schritt: Rückbau SpringMVC
Als letzten Schritt, der noch offen ist, sollen die reaktiven Typen (Mono oder Flux) in die Signatur der Controller-Methoden aufgenommen und der block
-Aufruf entfernt werden. Zudem wird die Annotation @SpringBootTest durch @WebFluxTest ersetzt, sowie @EnableWebSecurity durch @EnableWebFluxSecurity.
Zusammenfassung
Eine Migration eines bestehenden Services von klassischem SpringMVC auf Spring Webflux ist mit einigem Aufwand verbunden, da alle Schichten betroffen sind und bestimmte Mechanismen (z.B. RequestScoped-Beans) nicht mehr funktionieren. Die Migration konnte in unserem Fall jedoch in Teilschritten und schichtweise erfolgen. Von Vorteil war die bereits die funktionale Architektur des Services (Immutable-Objects, keinen Zustand), so dass nur wenig fachlicher Code umgeschrieben werden musste. Wir versprechen uns davon einen deutlich ressourceneffizienteren WebService, den wir auch über auschliesslich über die CPU-Last skalieren können.