Die Schicht, die Toxiproxy nicht erreicht
Toxiproxy ist ausgezeichnet. tc netem ist ausgezeichnet. Die LD_PRELOAD-Chaos-Bibliotheken sind ausgezeichnet. Sie alle operieren an der Netzwerk- oder OS-Grenze – sie fangen TCP-Verbindungen, Paketauslieferung und Systemaufrufe ab.
Aber folgendes können sie alle nicht sehen: Was passiert innerhalb Ihres HikariPool, wenn er versucht, eine Verbindung auszuleihen und auf einen Timeout trifft. Was passiert innerhalb Ihres ScheduledExecutorService, wenn er gesättigt ist. Was passiert innerhalb der SSL-Implementierung des JDK, wenn es eine Neuübertragung erkennt. Was passiert innerhalb Ihres Thread-Pools, wenn Aufgaben schneller einlaufen, als Arbeiter sie abarbeiten können.
Das sind JVM-interne Fehlermodi. Sie äußern sich nicht als Netzwerkfehler auf der TCP-Ebene. Sie äußern sich als Exceptions, die von JDK-Klassen geworfen werden – `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` – und als Muster der Ressourcenerschöpfung, die erst sichtbar werden, wenn man die richtigen Aufrufstellen innerhalb der JVM instrumentiert.
Das ist die Lücke, die der JVM-Chaos-Agent füllt. Er ersetzt nicht die LD_PRELOAD-Schicht – er ergänzt sie. Zusammen bieten sie Fehlerinjektion sowohl an der OS-Grenze als auch an der JVM-internen Grenze: die vollständige Angriffsfläche der Fehlermodi einer Java-Anwendung.
62 JDK-Aufrufstellen: Die vollständige Angriffsfläche
Der Agent instrumentiert 62 spezifische JDK-Aufrufstellen – nicht zufällige, sondern solche, die für Enterprise-Backend-Workloads relevant sind. Die Auswahl wurde durch Incident-Analysen über verteilte Systemausfälle gesteuert:
**Threading & Executors** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, BlockingQueue-Operationen (put, offer, poll, take), ForkJoinPool-Einreichungen, Virtual-Thread-Lebenszyklus
**Netzwerk & I/O** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, DatagramChannel-Operationen, ServerSocket.accept
**DNS & Namensauflösung** InetAddress.getByName/getAllByName – der JDK-interne Auflösungspfad, bevor der OS-Resolver aufgerufen wird
**JDBC & Datenzugriff** DataSource.getConnection (Connection-Pool-Ausleihe), Statement.execute/executeQuery, PreparedStatement-Operationen – auf der Ebene, durch die Hikari, DBCP und c3p0 alle durchlaufen
**HTTP-Clients** HttpURLConnection, HttpClient (JDK 11+) – send, sendAsync
**SSL/TLS** SSLEngine.wrap/unwrap, SSLSocket.startHandshake
**Zeit** System.currentTimeMillis(), System.nanoTime() – mit FIXED-Offset-, DRIFT- und FREEZE-Effekten
**JVM-Interna** System.gc(), Klassenladung, Reflection, Objekt-Serialisierung/Deserialisierung, ThreadLocal.get/set, JNDI-Lookups, JMX-Operationen, Laden nativer Bibliotheken, ZIP-Inflation
// 62 interception targets across the JDK surface
Sealed interface ChaosSelector permits
ThreadSelector, // Thread.start, pool operations
ExecutorSelector, // ThreadPoolExecutor, ForkJoinPool
QueueSelector, // BlockingQueue operations
FutureSelector, // CompletableFuture
SocketSelector, // Socket, ServerSocket
NioSelector, // SocketChannel, Selector
JdbcSelector, // DataSource.getConnection, Statement
HttpSelector, // HttpURLConnection, HttpClient
DnsSelector, // InetAddress resolution
SslSelector, // SSLEngine, SSLSocket
ClockSelector, // System.currentTimeMillis, nanoTime
GcSelector, // System.gc
ClassloadingSelector, // ClassLoader.loadClass
SerializationSelector,// ObjectInputStream, ObjectOutputStream
ThreadLocalSelector, // ThreadLocal.get, set
VirtualThreadSelector;// VirtualThread lifecycle ByteBuddy + Bootstrap Bridge: Funktionsweise
Die Instrumentierung von JDK-Klassen ist nicht trivial. JDK-Klassen werden vom Bootstrap-Classloader geladen – dem Root-Classloader, der kein Elternteil hat. Agent-Code lebt im Agent-Classloader, den der Bootstrap-Classloader nicht namentlich sehen kann. Das korrekte Überbrücken dieser Lücke erfordert sorgfältigen Einsatz von JVMTI und des Java Memory Model.
**Schritt 1 – Premain oder agentmain.** Der Agent wird entweder beim Start (via `-javaagent:`) oder dynamisch zur Testlaufzeit (via die JDK Attach API) angehängt. Beim dynamischen Anhängen ruft der Spring Boot Test Starter `VirtualMachine.attach(processId)` auf und injiziert das Agent-Jar.
**Schritt 2 – Bootstrap-Injektion.** Eine `BootstrapDispatcher`-Klasse wird aus dem Agent-Jar extrahiert und in ein temporäres JAR geschrieben. Dieses JAR wird über `Instrumentation.appendToBootstrapClassLoaderSearch()` an den Bootstrap-Classpath angehängt. Der Bootstrap-Classloader kann `BootstrapDispatcher` jetzt sehen.
**Schritt 3 – MethodHandle-Verdrahtung.** Ein `MethodHandle[]`-Array mit 62 Slots wird aufgebaut, ein Handle pro Interceptionziel, das in die Implementierung des Agent-Classloaders zeigt. Das Array und ein `delegate`-Objekt werden als `volatile`-Felder in `BootstrapDispatcher` veröffentlicht. Das JMM garantiert, dass jeder Thread, der `delegate != null` beobachtet, auch das vollständig initialisierte `handles`-Array sieht.
**Schritt 4 – Bytecode-Advice-Inlining.** ByteBuddy's `@Advice`-Mechanismus kopiert den Advice-Bytecode direkt in den instrumentierten JDK-Methodenrumpf. Es gibt keinen virtuellen Dispatch, keinen Interface-Aufruf – der eingewobene Bytecode ist inline. Nach JIT-Kompilierung beim Aufwärmen (~10.000 Aufrufe) kompiliert die gesamte Dispatch-Kette zu nativem Code.
// Hot path after JIT compilation — effectively just:
public static void connect(Socket socket, SocketAddress endpoint, int timeout) {
Object delegate = BootstrapDispatcher.delegate; // volatile read
if (delegate != null) { // null check — fast path
BootstrapDispatcher.handles[SOCKET_CONNECT] // MethodHandle
.invoke(delegate, socket, endpoint); // → ChaosRuntime
}
originalConnect(socket, endpoint, timeout); // real JDK call
}
// Zero-scenario overhead: one volatile read + one null check + one untaken branch
// Measured: ~60 ns per call in the uncontested case after warm-up Session-Isolation: Jeder Test bekommt sein eigenes Chaos
Das schwierigste Engineeringproblem bei einem JVM-level Chaos-Agent ist nicht die Bytecode-Instrumentierung – es ist die Isolation. Mehrere JUnit-Tests laufen gleichzeitig in derselben JVM. Wenn Test A JDBC-Verbindungsfehler injiziert, darf Test B diese Fehler nicht sehen.
Die Lösung ist `ChaosSession`, gestützt auf `ThreadLocal<UUID>`.
Jede Testmethode (oder Testklasse, abhängig vom Lebenszyklus) bekommt eine eindeutige Session-ID. Chaos-Szenarien können entweder als JVM-weit (betrifft alle Threads) oder als session-weit (betrifft nur Threads, die die Session-ID dieses Tests tragen) registriert werden. Das `ThreadLocal` stellt die Bindung her.
Für von Executoren eingereichte Aufgaben muss die Session-ID vom einreichenden Thread in den Worker-Thread propagiert werden. Der Agent instrumentiert `ThreadPoolExecutor.execute()`, um das eingereichte `Runnable` in einen Dekorator zu hüllen, der die aktuelle Session-ID erfasst und sie im Worker-Thread wiederherstellt, bevor die Aufgabe ausgeführt wird. Diese Propagation geschieht transparent – der Anwendungscode weiß nichts davon.
@ChaosTest // @SpringBootTest composed annotation
class OrderServiceChaosTest {
@Test
void retryLogic_handles_transient_jdbc_failures(ChaosSession session) {
var scenario = ChaosScenario.builder("transient-db")
.selector(ChaosSelector.jdbc(OperationType.JDBC_CONNECTION_ACQUIRE))
.effect(ChaosEffect.reject("chaos: simulated pool timeout"))
.activationPolicy(ActivationPolicy.builder()
.probability(1.0)
.maxApplications(2) // First 2 borrows fail, 3rd succeeds
.build())
.build();
try (var handle = session.activate(scenario)) {
try (var scope = session.bind()) {
// This thread and any executor tasks it submits
// will see the JDBC failures — nothing else will
var result = orderService.placeOrder(testOrder);
assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED);
}
}
// Chaos cleaned up — next test is unaffected
}
} Spring Boot 3 und 4: Zero-Config-Verdrahtung
Die Spring Boot Test Starter existieren, weil die Verdrahtung unsichtbar sein sollte. Sie müssen ByteBuddy, den Bootstrap-Classloader oder JVMTI nicht verstehen, um Ihren ersten Chaos-Test auszuführen. Der Starter übernimmt:
- Programmatisches Setzen von `jdk.attach.allowAttachSelf=true` bevor die JVM dieses Flag verarbeitet (mittels eines statischen Initializers in der Auto-Konfigurationsklasse des Starters) - Erkennung, ob der Agent bereits angehängt ist (idempotent – sicher bei paralleler Testklassenausführung) - Anhängen des Agents via `VirtualMachine.attach("0")` (Self-Attach), falls noch nicht vorhanden - Registrierung von `ChaosControlPlane` und `ChaosSession` als Spring-Beans im Test-ApplicationContext - Bereitstellung von JUnit 5 Parameter-Auflösung für `ChaosSession`- und `ChaosControlPlane`-Konstruktorargumente
Der Runtime-Starter (für Nicht-Test-Deployments) stellt zusätzlich `/actuator/chaos` als geschützten Actuator-Endpunkt für das Live-Szenariomanagement bereit.
<!-- Spring Boot 3 — add to pom.xml testImplementation -->
<dependency>
<groupId>com.macstab.chaos</groupId>
<artifactId>chaos-agent-spring-boot3-test-starter</artifactId>
<version>${chaos-agent.version}</version>
<scope>test</scope>
</dependency>
<!-- That's it. No -javaagent flag. No JVM args. No beans to define. -->
<!-- Spring Boot 4 -->
<dependency>
<groupId>com.macstab.chaos</groupId>
<artifactId>chaos-agent-spring-boot4-test-starter</artifactId>
<version>${chaos-agent.version}</version>
<scope>test</scope>
</dependency> Die Acht-Tor-Aktivierungspipeline
Jede Chaos-Szenarienauswertung durchläuft der Reihe nach acht Tore. Alle Tore müssen passiert werden, damit der Effekt ausgelöst wird. Die Tore sind schnell – sie werden im aufrufenden Thread ohne I/O und mit minimaler Sperrung ausgewertet.
**Tor 1 – Gestartet-Prüfung.** Wurde das Szenario aktiviert? (Günstige Flag-Leseoperation.)
**Tor 2 – Session-ID-Abgleich.** Falls session-weit: Trägt das `ThreadLocal` des aktuellen Threads die richtige UUID? (ThreadLocal-Leseoperation, keine Sperre.)
**Tor 3 – Selektor-Abgleich.** Stimmt diese Aufrufstelle mit dem Selektor des Szenarios überein? (Enum-Vergleich, O(1).)
**Tor 4 – Aktivierungsfenster.** Liegt die aktuelle Zeit innerhalb des konfigurierten Start-/End-Fensters des Szenarios? (Zwei Long-Vergleiche.)
**Tor 5 – Aufwärm-Zähler.** Hat dieses Szenario mindestens N mal zugeschlagen, bevor es zu wirken beginnt? (AtomicLong-Leseoperation.)
**Tor 6 – Ratenbegrenzung.** Hat der Sliding-Window-Token-Bucket Kapazität? (Synchronisierter Block; unbestrittener Pfad ~5 ns.)
**Tor 7 – Wahrscheinlichkeit.** Zufallsziehung gegen die konfigurierte Wahrscheinlichkeit. (SplittableRandom, mit Szenario-ID + Trefferzähler geseedet – deterministisch für die Reproduktion von Fehlern.)
**Tor 8 – Maximale Anwendungen.** CAS-Schleife auf AtomicLong zur Durchsetzung eines harten Limits. (compareAndSet-Schleife; korrekt unter Konkurrenz – anders als ein einfaches incrementAndGet, das überschießen kann.)
Alle acht Tore bestanden → Effekt anwenden. Ein Tor schlägt fehl → Weitergabe an die echte JDK-Methode unverändert.
Hintergrund-Stressoren: Jenseits von Request-Pfad-Fehlern
Zusätzlich zu inline Request-Pfad-Effekten (Verzögerung, Ablehnung, Exception-Injektion) unterstützt der Agent Hintergrund-Stressoren – lebenszyklus-gebundene Threads, die unabhängig vom Verkehr kontinuierlich Ressourcendruck erzeugen.
Stressoren simulieren die schleichenden Fehlermodi, die nicht durch spezifische Operationen ausgelöst werden, sondern sich über die Zeit ansammeln:
**Speicher-Stressoren:** Heap-Druck (byte[]-Chunks zurückhalten), GC-Druck (Allokationswirbel), Metaspace-Druck (synthetische Klassendefinitionen), Direktpuffer-Druck (Off-Heap ByteBuffer), String-Intern-Flutung
**JVM-Stressoren:** Code-Cache-Druck (von ByteBuddy generierte Klassenflut für JIT-Thrashing), Finalizer-Rückstand (Phantom-Referenz-Queue-Flutung), Referenz-Queue-Flutung, Safepoint-Stürme (periodische Reinstrumentierung, um Stop-the-World-Pausen auszulösen)
**Thread-Stressoren:** Thread-Leak (dauerhaft geparkte Threads, die Thread-Stack-Speicher verbrauchen), ThreadLocal-Leak (Einträge auf gepoolten Threads, die sich ansammeln), Deadlock-Injektion (echter JVM-Monitor-Deadlock zwischen N Threads – verifiziert mit ThreadMXBean), Monitor-Konkurrenz (Hintergrund-Threads konkurrieren um eine gemeinsame Sperre), Keep-Alive-Threads (verhindern JVM-Shutdown)
Stressoren werden gestartet, wenn ein Szenario aktiviert wird, und gestoppt, wenn es geschlossen wird. Sie kombinieren sich mit Inline-Effekten: Sie können gleichzeitig Request-Pfad-JDBC-Ablehnungen und einen Code-Cache-Stressor aktiv haben und so testen, ob Ihre Anwendung sich von Verbindungsfehlern erholt, während sie bereits unter JIT-Kompilierungsdruck steht.
- Heap-Druck: konfigurierbare MB/s in byte[]-Allokationen zurückhalten, um GC-Druck zu erzwingen
- Metaspace-Druck: synthetische Klassen in einen isolierten ClassLoader definieren, um Permgen/Metaspace aufzubrauchen
- Code-Cache-Druck: ByteBuddy-Klassen generieren, um den JIT-Compiler zu sättigen
- Safepoint-Sturm: periodische Reinstrumentierung erzwingen, um Stop-the-World-Pausen auszulösen
- Echter Deadlock: zwei Threads erwerben Monitore in umgekehrter Reihenfolge – erkannt durch ThreadMXBean
- Thread-Leak: Threads dauerhaft parken, um -Xss-Stack-Speicher über die Zeit zu erschöpfen
Beispiel: SLA-Verhalten unter JDBC-Erschöpfung validieren
Ein konkretes Testszenario, das den Kernwert des Agents demonstriert: der Beweis, dass Ihr Service korrekt auf eine Zahlung mit einem SLA-Ziel antwortet, wenn der Datenbankverbindungspool unter Druck steht.
@SpringBootTest
@ChaosTest
class PaymentSLAChaosTest {
@Autowired PaymentService paymentService;
@Test
void payment_meets_sla_under_intermittent_jdbc_pressure(ChaosSession session) {
// Activate 20% JDBC connection failure — simulates pool under load
var jdbcChaos = ChaosScenario.builder("jdbc-pressure")
.selector(ChaosSelector.jdbc(OperationType.JDBC_CONNECTION_ACQUIRE))
.effect(ChaosEffect.delay(Duration.ofMillis(150))) // Not rejection — delay
.activationPolicy(ActivationPolicy.probability(0.20))
.build();
// Also activate a background heap stressor
var heapStressor = ChaosScenario.builder("heap-pressure")
.effect(ChaosEffect.stressor(StressorType.HEAP_PRESSURE)
.retainMbPerSecond(50)
.build())
.build();
try (var h1 = session.activate(jdbcChaos);
var h2 = session.activate(heapStressor)) {
try (var scope = session.bind()) {
long start = System.nanoTime();
var result = paymentService.process(testPayment);
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
assertThat(result.status()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(elapsedMs).isLessThan(500); // SLA: p99 < 500ms
}
}
}
} JMX und Observability
Der Agent stellt ein JMX MBean unter `com.macstab.chaos.jvm:type=ChaosDiagnostics` bereit, das eine Live-Momentaufnahme aller aktiven Szenarien liefert – deren aktuellen Zustand, Trefferzähler und Anwendungszähler – ohne Codeänderungen oder zusätzliche Konfiguration zu erfordern.
Das ist beim Test-Debugging nützlich: Wenn ein Chaos-Test unerwartet fehlschlägt, können Sie JConsole oder VisualVM mit der Test-JVM verbinden und genau prüfen, welche Szenarien aktiv sind und wie oft sie ausgelöst wurden.
Der Observability-Bus ermöglicht das Veröffentlichen von Chaos-Ereignissen in externe Metriksysteme. Wenn Sie Chaos-Tests in einer gemeinsamen Integrationsumgebung ausführen und Chaos-Ereignisse mit Grafana-Dashboards oder verteilten Traces korrelieren möchten, bietet der Bus den Integrationspunkt.
- JMX MBean: aktive Szenarienliste, Trefferzähler, Anwendungszähler, aktueller Zustand pro Szenario
- In-Process-Snapshot-API: aus Testcode abfragbar für Assertions über Chaos-Verhalten
- Debug-Dump: Textdarstellung aller Szenarien und Zustände – nützlich in der Testfehlerausgabe
- Observability-Bus: steckbarer Event-Publisher für Metriken-/Tracing-Integration
Key Takeaways
Der JVM-Chaos-Agent füllt die Lücke zwischen infrastruktureller Fehlerinjektion und echten JVM-internen Fehlermodi. 62 JDK-Aufrufstellen, Isolation pro Test-Session, Spring Boot 3/4 Auto-Wiring, Hintergrund-Stressoren für schleichende Fehler und ein JIT-optimierter Dispatch-Pfad, der im Zero-Szenario-Fall ~60 ns pro Aufruf kostet.
Der Agent kombiniert sich mit der C99 LD_PRELOAD-Bibliothek für Full-Stack-Fehlerabdeckung: syscall-level-Fehler unterhalb der JVM, bytecode-level-Fehler innerhalb von ihr. Beide werden durch das Annotationssystem des Java-Testframeworks orchestriert – ein Test, beide Schichten aktiv, kein Konfigurationskonflikt.
Chaos Engineering als CI-Gate ist bereit. Die einzige Frage ist, welchen Fehlermodus Ihr Circuit Breaker noch nicht behandelt.
Engineering Team
Senior Solutions Architects
Wir bauen verteilte Systeme, seit lange bevor 'Microservices' ein Begriff war. Unsere Narben erzählen Geschichten.