JVM-Monitoring für Denodo auf ECS Fargate mit JMX Exporter und CloudWatch

Aktualisiert 12. Mai 2026

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:

  1. Das offizielle CW Agent Image basiert auf FROM scratch. Kein Java, keine Shell. Die jmx-Sektion startet intern einen separaten Java-Prozess (jmx-collector.jar), der ohne JRE nicht laufen kann.
  2. 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.Config zu 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-KategorieBeispiele
Denodo Serverdenodo_server_activerequests, denodo_server_waitingrequests, denodo_server_activeconnections
JVM Heapjvm_heap_memory_used_bytes, jvm_heap_memory_max_bytes
GCjvm_gc_collection_seconds_sum, jvm_gc_collection_seconds_count
Threadsjvm_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

AlarmBedingungWarum
Heap Memory High> 85% von max über 5 minOOM-Risiko
GC ThrashingGC-Zeit > 10% der Wall-Clock-TimePerformance-Degradation
Thread Deadlockjvm_threads_deadlocked > 0Sofort kritisch
Waiting Requests> 10 über 3 minÜberlastung
JMX Scrape Error= 1 über 2 minMonitoring 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.