Das Release ist durch, das Change Advisory Board hat zugestimmt, und am nächsten Morgen meldet der Fachbereich, dass Schadenmeldungen hängen. In Elasticsearch liegen zwar Logs, aber die Quarkus-Services schreiben sie anders als die Spring-Boot-Services, und keine Zeile verrät die Version. Wer Quarkus und Spring Boot parallel betreibt, braucht deshalb gemeinsame Konventionen für Logs, Metriken und Versionsangaben. Wir zeigen am Beispiel eines Versicherers, wie beide Frameworks ihre Daten nach Elasticsearch liefern und Grafana daraus ein prüfbares Kriterium für jedes Release macht.
Ausgangslage: zwei Java-Frameworks, ein Elasticsearch
Die Landschaft, die wir bei Versicherern antreffen, ist gewachsen. Ältere Fachservices für Vertragsverwaltung und Bestandsführung laufen auf Spring Boot, neuere Services für Schadenmeldung oder Tarifrechner auf Quarkus. Beides läuft in Containern auf Kubernetes oder OpenShift. Elasticsearch ist als zentraler Log-Speicher gesetzt und von der Informationssicherheit abgenommen, Grafana dient als Oberfläche für den Betrieb.
Releases laufen in festen Fenstern. Jede Änderung geht durch ein Change Advisory Board, kurz CAB: ein Gremium, das Risiko und Rückfallplan eines Deployments bewertet, bevor es in Produktion darf. Das CAB prüft heute Unterlagen, aber keine Messwerte aus dem laufenden Betrieb.
Dazu kommt die Regulierung. Seit Januar 2025 gilt für Versicherer die EU-Verordnung DORA. Ihr Artikel 10 verlangt Mechanismen, die anomale Aktivitäten und Leistungsprobleme von IKT-Systemen zeitnah erkennen (Verordnung (EU) 2022/2554). Ein Monitoring, das erst über den Fachbereich anschlägt, erfüllt diesen Anspruch nicht.
Warum Quarkus und Spring Boot im Betrieb auseinanderlaufen
Beide Frameworks bringen gute Werkzeuge mit, nutzen sie aber mit unterschiedlichen Voreinstellungen. Spring Boot schreibt Textlogs mit eigenem Muster, Quarkus über JBoss Logging ein anderes. Filebeat liest beides als unstrukturierten Text, und in Elasticsearch landet ein Feld message, in dem Zeitstempel, Level und Inhalt vermischt sind. Eine Suche nach allen Fehlern eines Services in einer bestimmten Version ist damit nicht möglich.
Bei den Metriken ist die Lage ähnlich. Beide Frameworks messen über Micrometer, die gemeinsame Metrik-Bibliothek der Java-Welt, und stellen die Werte im Prometheus-Textformat bereit. Quarkus tut das unter /q/metrics, Spring Boot über Actuator unter /actuator/prometheus. Abgeholt werden die Werte oft von niemandem, weil ein eigener Prometheus eine weitere Plattform wäre, die betrieben und abgenommen werden muss.
Das dritte Problem betrifft die Sicherheit. Die Endpunkte für Metriken und Health Checks hängen standardmäßig am selben Port wie die fachliche API. Wer die API erreicht, erreicht damit auch die Betriebsdaten. Für einen DevSecOps-Ansatz, also Sicherheit als festen Teil von Entwicklung und Betrieb, ist das ein Befund.
Schließlich enthalten Versicherungslogs personenbezogene Daten. Versicherungsnummern, Namen oder bei Kranken- und Lebensversicherung Gesundheitsangaben rutschen über Debug-Ausgaben in die Logs. Sobald diese Daten in Elasticsearch liegen, gelten für den Index dieselben Schutzanforderungen wie für das Bestandssystem.
Quarkus und Spring Boot auf dieselben Konventionen bringen
Das Zielbild ist einfach: Beide Frameworks schreiben Logs im selben Schema, stellen Metriken auf einem getrennten Port bereit und tragen die Version in jedem Datensatz. Elasticsearch speichert Logs und Metriken, Grafana zeigt beides nebeneinander und prüft das Release. Die Konfigurationen sind für Spring Boot 3.5, Quarkus 3.20, Elasticsearch, Filebeat und Metricbeat 8.18 sowie Grafana 12.1 geschrieben.
flowchart LR
Q[Quarkus-Service] -- ECS-JSON auf stdout --> F[Filebeat]
S[Spring-Boot-Service] -- ECS-JSON auf stdout --> F
Q -- Port 9000 /q/metrics --> M[Metricbeat]
S -- Port 8081 /actuator/prometheus --> M
F --> E[Elasticsearch]
M --> E
E --> G[Grafana]
G --> R[Release-Gate im Rollout]
Schritt 1: Quarkus und Spring Boot gleich konfigurieren
In Quarkus trennt die Management-Schnittstelle die Betriebsendpunkte vom Fachverkehr. Das ist ein zweiter HTTP-Server, der Health, Metriken und Info-Endpunkte auf Port 9000 bereitstellt (Quarkus Management Interface). Benötigt werden die Extensions quarkus-micrometer-registry-prometheus, quarkus-smallrye-health und quarkus-logging-json. Das ECS-Format liefert Quarkus direkt (Quarkus Logging).
# application.properties (Quarkus)
quarkus.application.name=schadenmeldung
quarkus.management.enabled=true
quarkus.log.console.json.enabled=true
quarkus.log.console.json.log-format=ecs
quarkus.log.console.json.mdc.flat-fields=true
%dev.quarkus.log.console.json.enabled=false
%test.quarkus.log.console.json.enabled=false
In Spring Boot übernimmt management.server.port die Trennung, Voraussetzung sind spring-boot-starter-actuator und micrometer-registry-prometheus. Structured Logging im ECS-Format gibt es seit Spring Boot 3.4 (Spring Boot Logging). Die Version setzen wir ausdrücklich aus dem Maven-Build, damit sie nicht vom Manifest abhängt.
# application.yaml (Spring Boot)
spring:
application:
name: vertragsverwaltung
management:
server:
port: 8081
endpoints:
web:
exposure:
include: health,prometheus
logging:
structured:
format:
console: ecs
ecs:
service:
version: "@project.version@"
environment: production
ECS steht für Elastic Common Schema, ein festes Feldschema, in dem log.level, service.name und service.version in beiden Frameworks gleich heißen. Die Network Policy im Cluster lässt die Ports 9000 und 8081 nur für Metricbeat zu.
Schritt 2: Fachliche Kennzahlen mit Micrometer
HTTP-Statuscodes zeigen technische Fehler, aber keinen fachlichen. Eine Schadenmeldung, die an der Plausibilitätsprüfung scheitert, liefert trotzdem Status 200. Solche Ereignisse zählen die Entwicklungsteams selbst. Der Code ist in beiden Frameworks identisch, nur die Bean-Annotation unterscheidet sich: @ApplicationScoped in Quarkus, @Component in Spring Boot.
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class SchadenmeldungMetriken {
private final Counter abgelehnt;
public SchadenmeldungMetriken(MeterRegistry registry) {
this.abgelehnt = Counter.builder("schadenmeldung.abgelehnt")
.description("Schadenmeldungen, die die Plausibilitätsprüfung nicht bestehen")
.tag("sparte", "kfz")
.register(registry);
}
public void abgelehnt() {
abgelehnt.increment();
}
}
Im Prometheus-Format heißt der Zähler schadenmeldung_abgelehnt_total. Tags wie sparte bleiben bei wenigen festen Werten. Eine Versicherungsnummer als Tag würde die Zahl der Zeitreihen sprengen und personenbezogene Daten in den Metrikspeicher tragen.
Schritt 3: Logs mit Filebeat nach Elasticsearch
Filebeat liest die Container-Logs, zerlegt die JSON-Zeilen und reichert sie mit Kubernetes-Metadaten an (Filestream Input).
# filebeat.yml
filebeat.inputs:
- type: filestream
id: java-services
paths:
- /var/log/containers/*.log
parsers:
- container:
stream: all
format: auto
- ndjson:
target: ""
overwrite_keys: true
expand_keys: true
add_error_key: true
processors:
- add_kubernetes_metadata:
host: ${NODE_NAME}
matchers:
- logs_path:
logs_path: "/var/log/containers/"
- drop_fields:
fields: ["<FELD_MIT_PERSONENBEZUG>"]
ignore_missing: true
output.elasticsearch:
hosts: ["https://<ELASTICSEARCH_HOST>:9200"]
api_key: "${ES_API_KEY}"
ssl.certificate_authorities: ["/etc/filebeat/certs/ca.crt"]
Der Prozessor drop_fields ist die letzte Sicherung, nicht die eigentliche Maßnahme. Personenbezogene Daten gehören gar nicht erst ins Log. Im MDC steht deshalb nur eine technische Vorgangs-ID. Der MDC (Mapped Diagnostic Context) ist der Kontextspeicher des Loggers, dessen Felder jede Logzeile eines Requests mitträgt.
Schritt 4: Metriken mit Metricbeat nach Elasticsearch
Statt eines eigenen Prometheus holt Metricbeat die Endpunkte ab. Das Prometheus-Modul liest das Textformat, und Autodiscover erkennt die Pods über ein Label framework (Prometheus Collector). Mit use_types und rate_counters berechnet Metricbeat für jeden Zähler den Zuwachs seit dem letzten Abruf, bei einem Intervall von 60 Sekunden also den Zuwachs pro Minute.
# metricbeat.yml
metricbeat.autodiscover:
providers:
- type: kubernetes
node: ${NODE_NAME}
templates:
- condition:
equals:
kubernetes.labels.framework: "quarkus"
config:
- module: prometheus
metricsets: ["collector"]
period: 60s
hosts: ["${data.host}:9000"]
metrics_path: /q/metrics
use_types: true
rate_counters: true
- condition:
equals:
kubernetes.labels.framework: "spring-boot"
config:
- module: prometheus
metricsets: ["collector"]
period: 60s
hosts: ["${data.host}:8081"]
metrics_path: /actuator/prometheus
use_types: true
rate_counters: true
output.elasticsearch:
hosts: ["https://<ELASTICSEARCH_HOST>:9200"]
api_key: "${ES_API_KEY}"
ssl.certificate_authorities: ["/etc/metricbeat/certs/ca.crt"]
In Elasticsearch steht der HTTP-Zähler danach im Feld prometheus.http_server_requests_seconds_count.rate, der Statuscode in prometheus.labels.status. Die Version kommt aus dem Pod-Label und landet in kubernetes.labels.app_kubernetes_io/version.
Schritt 5: Das Release-Gate in Grafana
Grafana liest mit einem eigenen API-Schlüssel, der nur Leserechte auf die beiden Datenströme hat. Den Schlüssel legt man in den Kibana Dev Tools mit POST /_security/api_key und diesem Inhalt an.
{
"name": "grafana-lesen",
"role_descriptors": {
"grafana_lesen": {
"indices": [
{
"names": ["filebeat-*", "metricbeat-*"],
"privileges": ["read", "view_index_metadata"]
}
]
}
}
}
Die Datenquellen werden per Provisioning angelegt, damit sie in jeder Umgebung gleich aussehen und der Schlüssel nicht von Hand im Browser landet.
# grafana/provisioning/datasources/elasticsearch.yaml
apiVersion: 1
datasources:
- name: Elasticsearch Logs
type: elasticsearch
access: proxy
url: https://<ELASTICSEARCH_HOST>:9200
jsonData:
index: "filebeat-*"
timeField: "@timestamp"
logMessageField: message
logLevelField: log.level
httpHeaderName1: Authorization
secureJsonData:
httpHeaderValue1: "ApiKey <API_KEY>"
- name: Elasticsearch Metriken
type: elasticsearch
access: proxy
url: https://<ELASTICSEARCH_HOST>:9200
jsonData:
index: "metricbeat-*"
timeField: "@timestamp"
httpHeaderName1: Authorization
secureJsonData:
httpHeaderValue1: "ApiKey <API_KEY>"
Die Alertregel für das Gate besteht aus zwei Abfragen und einer Rechnung. Die Abfrage Fehler summiert die Serverfehler je Version und Minute, die Abfrage Anfragen dasselbe ohne Statusfilter, ein Math-Ausdruck $Fehler / $Anfragen ergibt die Fehlerquote. So sieht die erste Abfrage im Query-Modell von Grafana aus.
{
"refId": "Fehler",
"query": "kubernetes.container.name:\"<CONTAINER>\" AND prometheus.labels.status:5*",
"metrics": [
{ "id": "1", "type": "sum", "field": "prometheus.http_server_requests_seconds_count.rate" }
],
"bucketAggs": [
{ "id": "2", "type": "terms", "field": "kubernetes.labels.app_kubernetes_io/version", "settings": { "size": "5", "order": "desc", "orderBy": "_count", "min_doc_count": "1" } },
{ "id": "3", "type": "date_histogram", "field": "@timestamp", "settings": { "interval": "1m" } }
],
"timeField": "@timestamp"
}
Neben der Quote steht im Dashboard ein Logpanel mit der Abfrage log.level:ERROR AND service.name:"<SERVICE>", gruppiert nach service.version. Das CAB bekommt damit ein Kriterium, das es prüfen kann: Das Release gilt als abgenommen, wenn die neue Version über das vereinbarte Beobachtungsfenster unter dem Schwellwert bleibt.
Was sich im Betrieb ändert
Wir nennen hier bewusst keine Vorher-nachher-Zahlen, weil sie stark von Landschaft und Release-Takt abhängen. Ein fehlerhaftes Release fällt auf, solange erst ein Teil der Pods die neue Version trägt, und nicht erst am nächsten Morgen. Die Fehlersuche beginnt bei einer Version und einer Logzeile mit festem Schema statt bei einer Volltextsuche.
Für den Betrieb gibt es keinen Unterschied mehr zwischen Quarkus und Spring Boot. Beide liefern dieselben Felder und dieselben Kennzahlen, und beide trennen ihren Betriebsport vom Fachverkehr. Für die DORA-Dokumentation lässt sich die Erkennungsfähigkeit an konkreten Regeln und Dashboards zeigen.
Für die Entwicklungsteams sinkt der Aufwand pro Service. Die Konventionen stehen in einer gemeinsamen Vorlage, etwa als Parent-POM für Spring Boot oder als firmeneigene Quarkus-Extension. Ein neuer Service bringt Betriebsport, ECS-Logs und Versionsangabe damit von Anfang an mit.
Wo der Ansatz nicht trägt
Elasticsearch ist kein spezialisierter Metrikspeicher. Wer Latenz-Quantile aus Histogrammen braucht oder komplexe Abfragen über viele Zeitreihen stellt, kommt mit PromQL in Prometheus oder Grafana Mimir weiter. Metriken in Elasticsearch belegen außerdem mehr Speicher pro Messwert, deshalb halten wir das Abrufintervall bei 60 Sekunden und die Zahl der Tags klein. In der beschriebenen Ausgangslage wiegt schwerer, dass keine zweite Plattform abgenommen werden muss.
Das Gate setzt voraus, dass die Version an zwei Stellen gleich gepflegt wird: im Pod-Label app.kubernetes.io/version und im Build, aus dem service.version stammt. Steht im Label latest, trennt die Abfrage nichts. Die Deployment-Pipeline sollte beide Werte deshalb aus derselben Build-Nummer setzen.
Das Gate braucht außerdem Verkehr. Ein Service, der nachts nur wenige Anfragen bekommt, liefert keine belastbare Quote. Ein einzelner Fehler bei drei Anfragen ergibt eine hohe Fehlerquote, obwohl nichts kaputt ist. Für solche Services helfen synthetische Anfragen im Rollout oder ein Gate auf absolute Fehlerzahlen.
Fachliche Fehler erkennt das Setup nur, soweit die Teams sie mit Micrometer zählen. Wer Abläufe über mehrere Services verfolgen will, ergänzt Distributed Tracing mit OpenTelemetry; der APM Server von Elastic nimmt OpenTelemetry-Daten direkt an.
Fazit: gemeinsame Konventionen machen Releases prüfbar
Quarkus und Spring Boot unterscheiden sich im Betrieb weniger, als es die Voreinstellungen vermuten lassen. Mit ECS-Logs, getrennten Management-Ports und einer Versionsangabe in jedem Datensatz werden Elasticsearch und Grafana zur gemeinsamen Grundlage für die Release-Freigabe.
Die Lets Scale IT AG setzt solche Stacks gemeinsam mit Entwicklungs- und Betriebsteams auf. Wir haben dabei auch Projekte bei Versicherern in Hannover begleitet.