JVM-Monitoring für Denodo auf ECS Fargate mit JMX Exporter und CloudWatch
Heute gibt es eine Challenge aus meinem beruflichen Alltag, die ich ziemlich spannend fand. Und auf die Lösung bin ich auch ein bisschen stolz.
Das Problem
Wenn man eine Java-Applikation wie Denodo betreibt, reichen irgendwann die Standard-ECS-Metriken nicht mehr aus. CPU und Memory sind nett, aber was passiert in der JVM? Heap-Nutzung, GC-Verhalten, Thread-Counts, Denodo-spezifische Kennzahlen wie aktive Verbindungen und wartende Requests. Dafür braucht man JMX.
Unser Setup: Denodo Virtual Dataport läuft als containerisierter Java-Prozess auf AWS ECS Fargate. Für Monitoring nutzen wir Grafana Cloud, das über eine CloudWatch-Datasource an AWS angebunden ist. Die Monitoring-Lösung und die Services sind also physikalisch voneinander getrennt. Der Plan war simpel: JMX-Metriken nach CloudWatch schieben, Grafana liest sie von dort.
Die naheliegende Lösung wäre die native jmx-Sektion des CloudWatch Agent. Die funktioniert hier aber nicht:
- Das offizielle CW Agent Image basiert auf
FROM scratch. Kein Java, keine Shell. Diejmx-Sektion startet intern einen separaten Java-Prozess (jmx-collector.jar), der ohne JRE nicht laufen kann. - Selbst mit Java wären nur vordefinierte Metriken (JVM, Kafka, Tomcat) verfügbar. Denodo-spezifische MBeans sind damit nicht abdeckbar.
Also: Plan B.
Die Lösung: JMX Exporter als Java Agent
Der Prometheus JMX Exporter löst beide Probleme elegant:
- Läuft in der Denodo-JVM selbst (Java ist ja schon da)
- Hat direkten Zugriff auf alle MBeans (keine Remote-JMX-Auth nötig)
- Exponiert Metriken als HTTP-Endpunkt auf
localhost:8081/metrics - Der CloudWatch Agent scrapt diesen Endpunkt per Prometheus-Protokoll
Architektur
Implementierung
Config-Dateien ohne neues Docker Image
Bevor es an den JMX Exporter geht, kurz ein Exkurs zu einem Pattern, das ich hier eingesetzt habe und das mir seitdem immer wieder begegnet.
Das Problem: Normalerweise leben Docker-Images (die Java-App) und die Infrastrukturbeschreibung in unterschiedlichen Repos. Konfigurationen gehören meiner Meinung nach zum IaC-Teil, weil man z.B. andere Settings pro Environment haben möchte. Alles in ein Docker-Image zu packen wirkt unsauber. Für jede Config-Änderung ein neues Image bauen? Nein danke.
Meine Lösung: Ein generisches Preinit-Skript, das beim Container-Start Config-Dateien aus Umgebungsvariablen auf die Festplatte schreibt. Das Skript kommt einmal ins Image, und im Infrastruktur-Teil steuert man über Umgebungsvariablen, welche Dateien wo landen.
Ein Hinweis zur Limitierung: ECS Task Definitions haben ein Größenlimit für Umgebungsvariablen. Für kleine Configs wie die JMX Exporter YAML ist das kein Problem, aber bei größeren Dateien stößt man irgendwann an die Grenze. In dem Fall wäre S3 + ein Init-Script oder der AWS Systems Manager Parameter Store die bessere Wahl. Für unseren Use Case reicht das Env-Var-Pattern aber locker aus.
Denodo Extensions über EFS
Denodo braucht diverse Extensions (JDBC-Treiber, Custom Wrappers, etc.), die nicht im Base-Image enthalten sind. Auf ECS Fargate kann man keine lokalen Volumes persistent halten, also mounten wir ein EFS-Filesystem in den Container. Die Extensions liegen dort zentral und werden beim Start direkt verfügbar. Das hat den Vorteil, dass man Extensions aktualisieren kann ohne ein neues Image zu deployen oder den Container neu zu starten.
JMX Exporter aktivieren
Der JMX Exporter wird über die JVM-Opts als Java Agent geladen:
-javaagent:/path/to/jmx_prometheus_javaagent.jar=8081:/tmp/jmx-exporter-config.yaml
Die Config-Datei wird zur Laufzeit über das Preinit-Skript (write_config_files.sh) aus einer Umgebungsvariable geschrieben. Kein zusätzliches Volume, kein Init-Container nötig:
write_config_files.sh:
#!/bin/bash
# Generic preinit script: writes config files from environment variables to the filesystem.
#
# Usage: Set WRITE_FILES as a JSON array of objects with "path" and "env" keys.
# Each entry reads the content from the named environment variable and writes it to the specified path.
#
# Example:
# WRITE_FILES='[{"path":"/tmp/jmx-exporter-config.yaml","env":"JMX_EXPORTER_CONFIG"}]'
#
# This avoids creating a new preinit script for every config file.
if [ -z "${WRITE_FILES:-}" ]; then
exit 0
fi
if ! command -v jq &>/dev/null; then
echo "[write-config] ERROR: jq is required but not installed" >&2
exit 1
fi
errors=0
while IFS= read -r entry; do
path=$(echo "$entry" | jq -r '.path // empty') || { echo "[write-config] ERROR: failed to parse path from: $entry" >&2; errors=$((errors+1)); continue; }
env_name=$(echo "$entry" | jq -r '.env // empty') || { echo "[write-config] ERROR: failed to parse env from: $entry" >&2; errors=$((errors+1)); continue; }
if [ -z "$path" ] || [ -z "$env_name" ]; then
echo "[write-config] WARN: skipping entry with missing path or env: $entry" >&2
continue
fi
content=$(printenv "$env_name" 2>/dev/null) || true
if [ -n "$content" ]; then
mkdir -p "$(dirname "$path")"
printf '%s\n' "$content" > "$path"
echo "[write-config] Written $path from \$$env_name"
else
echo "[write-config] WARN: \$$env_name is empty or not set, skipping $path" >&2
fi
done < <(echo "$WRITE_FILES" | jq -c '.[]')
if [ $errors -gt 0 ]; then
echo "[write-config] ERROR: $errors entries failed to process" >&2
exit 1
fi
Im CDK/Terraform-Code sieht das dann so aus:
environment: {
JMX_EXPORTER_CONFIG: jmxExporterConfig,
WRITE_FILES: JSON.stringify([
{ path: '/tmp/jmx-exporter-config.yaml', env: 'JMX_EXPORTER_CONFIG' }
]),
}
CloudWatch Agent Sidecar
Der CW Agent läuft als Sidecar-Container in derselben Task. Da ECS Fargate mit awsvpc Networking arbeitet, teilen sich alle Container das Netzwerk-Interface. localhost:8081 ist direkt erreichbar, ohne Service Discovery oder extra Networking.
CW Agent Config (cloudwatch-agent-config.json):
{
"logs": {
"metrics_collected": {
"prometheus": {
"cluster_name": "${CLUSTER_NAME}",
"log_group_name": "/ecs/denodo/prometheus",
"prometheus_config_path": "env:PROMETHEUS_CONFIG_CONTENT",
"emf_processor": {
"metric_namespace": "Denodo/VirtualDataport",
"metric_declaration": [
{
"source_labels": ["job"],
"label_matcher": "^jmx-exporter$",
"dimensions": [["service_name"]],
"metric_selectors": [".*"]
}
]
}
}
}
}
}
Prometheus Scrape Config (prometheus-config.yaml):
global:
scrape_interval: 60s
scrape_timeout: 10s
scrape_configs:
- job_name: jmx-exporter
static_configs:
- targets:
- localhost:8081
labels:
service_name: ${SERVICE_NAME}
Das service_name Label wird pro Environment dynamisch gesetzt (virtual-dataport-dev, -tst, -prd), damit die Metriken in CloudWatch nicht vermischt werden.
Stolpersteine
Natürlich hat nicht alles auf Anhieb funktioniert. Hier die Fallen, in die ich getappt bin.
CW Agent Version 1.300067.x und das Prometheus-Config-Format
Diese Agent-Version hat intern zwei Parser:
- Target Allocator (OTel-basiert): Versucht die Prometheus-YAML als
prometheusreceiver.Configzu parsen und warnt:WARN: 'prometheusreceiver.Config' has invalid keys: global, scrape_configs - Prometheus Scrape Manager: Nutzt das Standard-Format und funktioniert korrekt
Die Warnung ist harmlos. Der Scrape Manager lädt die Config danach erfolgreich. Wir haben das verifiziert, indem wir den Agent lokal in Docker gestartet haben:
docker run --rm \
-e AWS_REGION=eu-central-1 \
-e CW_CONFIG_CONTENT='...' \
-e PROMETHEUS_CONFIG_CONTENT='...' \
amazon/cloudwatch-agent:1.300067.0b1404
Trotzdem: Die erste halbe Stunde habe ich diese Warnung für den Fehler gehalten. War sie nicht.
Fehlende Pflichtfelder
Der Agent braucht log_group_name und cluster_name in der Config. Ohne log_group_name bricht die Config-Translation ab:
prometheus does not have log group name
In ECS wird cluster_name normalerweise auto-detected, aber explizit setzen ist sicherer.
Metriken nicht unterscheidbar
Ohne zusätzliches Label haben alle VDP-Instanzen instance=localhost:8081 als einzige Dimension. Logisch, wenn man drüber nachdenkt. Die Lösung: ein statisches service_name Label in der Prometheus static_configs setzen und als CloudWatch-Dimension verwenden.
Exit Code 137 bei Deployments
Bei Deployments stoppt ECS die alte Task. Denodo braucht lange zum Herunterfahren (Lizenz-Release, DB-Connections schließen). Wenn der stopTimeout (default 30s) überschritten wird, kommt SIGKILL (Exit 137). Das ist kein Crash. Ein stopTimeout: 120s gibt Denodo genug Zeit für einen sauberen Shutdown. Wer das zum ersten Mal sieht, bekommt trotzdem kurz Panik.
Ergebnis
Nach dem Setup landen alle JVM- und Denodo-Metriken im CloudWatch Namespace Denodo/VirtualDataport, sauber getrennt nach Environment:
| Metrik-Kategorie | Beispiele |
|---|---|
| Denodo Server | denodo_server_activerequests, denodo_server_waitingrequests, denodo_server_activeconnections |
| JVM Heap | jvm_heap_memory_used_bytes, jvm_heap_memory_max_bytes |
| GC | jvm_gc_collection_seconds_sum, jvm_gc_collection_seconds_count |
| Threads | jvm_threads_current, jvm_threads_deadlocked |
Die Metriken können direkt in CloudWatch-Dashboards visualisiert oder über eine CloudWatch-Datasource in Grafana Cloud eingebunden werden.
Empfohlene Alarme
| Alarm | Bedingung | Warum |
|---|---|---|
| Heap Memory High | > 85% von max über 5 min | OOM-Risiko |
| GC Thrashing | GC-Zeit > 10% der Wall-Clock-Time | Performance-Degradation |
| Thread Deadlock | jvm_threads_deadlocked > 0 | Sofort kritisch |
| Waiting Requests | > 10 über 3 min | Überlastung |
| JMX Scrape Error | = 1 über 2 min | Monitoring selbst kaputt |
Fazit
Der Umweg über JMX Exporter + Prometheus Scrape ist auf ECS Fargate der einzige zuverlässige Weg für JVM-Monitoring. Die native JMX-Integration des CW Agent funktioniert nur auf EC2 oder in Containern mit eigener JRE. Der Mehraufwand (Sidecar-Container, Config-Management) zahlt sich durch vollständige Sichtbarkeit in die JVM und Denodo-Internals aus.
Das Preinit-Skript-Pattern hat sich übrigens seitdem in mehreren anderen Services bewährt. Einmal geschrieben, überall wiederverwendbar.