Il Livello che Toxiproxy Non Può Raggiungere
Toxiproxy è eccellente. tc netem è eccellente. Le librerie di chaos basate su LD_PRELOAD sono eccellenti. Tutte operano al livello di rete o del sistema operativo — intercettando connessioni TCP, la consegna dei pacchetti e le system call.
Ma ecco ciò che nessuna di esse riesce a vedere: cosa succede all'interno del tuo HikariPool quando tenta di ottenere una connessione e va in timeout. Cosa succede all'interno del tuo ScheduledExecutorService quando è saturo. Cosa succede all'interno dell'implementazione SSL del JDK quando incontra una ritrasmissione. Cosa succede all'interno del tuo thread pool quando i task si accumulano più velocemente di quanto i worker riescano a smaltirli.
Queste sono modalità di fallimento interne alla JVM. Non si manifestano come errori di rete a livello TCP. Si manifestano come eccezioni lanciate dalle classi JDK — `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` — e come pattern di esaurimento delle risorse che diventano visibili solo quando si strumentano i call site giusti all'interno della JVM stessa.
Questo è il gap che il JVM chaos agent colma. Non sostituisce il livello LD_PRELOAD — si compone con esso. Insieme, offrono l'iniezione di fault sia al confine del sistema operativo sia al confine interno della JVM: la superficie completa delle modalità di fallimento di un'applicazione Java.
62 Call Site JDK: L'Intera Superficie
L'agent strumenta 62 call site JDK specifici — non scelti a caso, ma quelli che contano per i workload backend enterprise. La selezione è stata guidata dall'analisi degli incidenti nei fallimenti dei sistemi distribuiti:
**Threading & Executor** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, operazioni su BlockingQueue (put, offer, poll, take), submission su ForkJoinPool, ciclo di vita dei virtual thread
**Rete & I/O** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, operazioni su DatagramChannel, ServerSocket.accept
**DNS & Risoluzione dei Nomi** InetAddress.getByName/getAllByName — il percorso di risoluzione interno del JDK, prima che venga chiamato il resolver del sistema operativo
**JDBC & Accesso ai Dati** DataSource.getConnection (acquisizione dalla connection pool), Statement.execute/executeQuery, operazioni su PreparedStatement — al livello in cui passano tutti: Hikari, DBCP e c3p0
**Client HTTP** HttpURLConnection, HttpClient (JDK 11+) — send, sendAsync
**SSL/TLS** SSLEngine.wrap/unwrap, SSLSocket.startHandshake
**Tempo** System.currentTimeMillis(), System.nanoTime() — con effetti FIXED offset, DRIFT e FREEZE
**Internals JVM** System.gc(), caricamento classi, reflection, serializzazione/deserializzazione di oggetti, ThreadLocal.get/set, lookup JNDI, operazioni JMX, caricamento di librerie native, decompressione ZIP
// 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: Come Funziona
Strumentare le classi JDK non è banale. Le classi JDK sono caricate dal bootstrap classloader — il classloader radice che non ha genitore. Il codice dell'agent risiede nell'agent classloader, che il bootstrap classloader non riesce a vedere per nome. Collegare correttamente questo gap richiede un uso attento di JVMTI e del Java Memory Model.
**Passo 1 — Premain o agentmain.** L'agent si aggancia all'avvio (tramite `-javaagent:`) oppure dinamicamente durante il test (tramite la JDK Attach API). Per l'aggancio dinamico, lo Spring Boot test starter chiama `VirtualMachine.attach(processId)` e inietta il jar dell'agent.
**Passo 2 — Iniezione nel bootstrap.** Una classe `BootstrapDispatcher` viene estratta dal jar dell'agent e scritta in un JAR temporaneo. Questo JAR viene aggiunto al bootstrap classpath tramite `Instrumentation.appendToBootstrapClassLoaderSearch()`. Il bootstrap classloader può ora vedere `BootstrapDispatcher`.
**Passo 3 — Cablaggio dei MethodHandle.** Viene costruito un array `MethodHandle[]` di 62 slot, uno per ogni target di intercettazione, che punta all'implementazione nell'agent classloader. L'array e un oggetto `delegate` vengono pubblicati in `BootstrapDispatcher` come campi `volatile`. Il JMM garantisce che qualsiasi thread che osservi `delegate != null` veda anche l'array `handles` completamente inizializzato.
**Passo 4 — Inlining dell'advice bytecode.** Il meccanismo `@Advice` di ByteBuddy copia il bytecode dell'advice direttamente nel corpo del metodo JDK strumentato. Non c'è virtual dispatch, nessuna chiamata a interfaccia — il bytecode intessuto è inline. Dopo la compilazione JIT al warm-up (~10.000 invocazioni), l'intera catena di dispatch viene compilata in codice nativo.
// 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 Isolamento di Sessione: Ogni Test Ha il Suo Chaos
Il problema ingegneristico più difficile in un chaos agent a livello JVM non è la strumentazione del bytecode — è l'isolamento. Più test JUnit vengono eseguiti in concorrenza nella stessa JVM. Se il test A inietta fallimenti nelle connessioni JDBC, il test B non può essere esposto a quei fallimenti.
La soluzione è `ChaosSession`, supportata da `ThreadLocal<UUID>`.
Ogni metodo di test (o classe di test, a seconda del ciclo di vita) ottiene un ID di sessione univoco. Gli scenari di chaos possono essere registrati come JVM-scoped (influenza tutti i thread) o session-scoped (influenza solo i thread che portano l'ID di sessione di quel test). Il `ThreadLocal` fornisce il binding.
Per i task sottomessi agli executor, l'ID di sessione deve propagarsi dal thread mittente al thread worker. L'agent strumenta `ThreadPoolExecutor.execute()` per avvolgere il `Runnable` sottomesso con un decorator che cattura l'ID di sessione corrente e lo ripristina sul thread worker prima dell'esecuzione del task. Questa propagazione avviene in modo trasparente — il codice applicativo non ne è consapevole.
@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 e 4: Cablaggio Zero-Config
I test starter per Spring Boot esistono perché il cablaggio dovrebbe essere invisibile. Non dovresti dover capire ByteBuddy, il bootstrap classloader o JVMTI per eseguire il tuo primo chaos test. Lo starter si occupa di:
- Impostare programmaticamente `jdk.attach.allowAttachSelf=true` prima che la JVM elabori questo flag (usando un inizializzatore statico nella classe di auto-configurazione dello starter) - Rilevare se l'agent è già stato agganciato (idempotente — sicuro in esecuzione parallela di classi di test) - Agganciare l'agent tramite `VirtualMachine.attach("0")` (self-attach) se non è già presente - Registrare `ChaosControlPlane` e `ChaosSession` come Spring bean nel test ApplicationContext - Fornire la risoluzione dei parametri JUnit 5 per gli argomenti del costruttore `ChaosSession` e `ChaosControlPlane`
Lo starter runtime (per il deployment non di test) espone inoltre `/actuator/chaos` come endpoint Actuator protetto per la gestione live degli scenari.
<!-- 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> La Pipeline di Attivazione a Otto Gate
Ogni valutazione di uno scenario di chaos passa attraverso otto gate in sequenza. Tutti i gate devono passare affinché l'effetto venga applicato. I gate sono veloci — valutati sul thread chiamante senza I/O e con il minimo di locking.
**Gate 1 — Verifica avvio.** Lo scenario è stato attivato? (Lettura di un flag economica.)
**Gate 2 — Corrispondenza ID di sessione.** Se session-scoped, il `ThreadLocal` del thread corrente porta l'UUID corretto? (Lettura thread-local, nessun lock.)
**Gate 3 — Corrispondenza del selector.** Questo call site corrisponde al selector dello scenario? (Confronto enum, O(1).)
**Gate 4 — Finestra di attivazione.** L'ora corrente è all'interno della finestra start/end configurata per lo scenario? (Due confronti di long.)
**Gate 5 — Contatore di warm-up.** Questo scenario ha trovato corrispondenza almeno N volte prima di iniziare ad applicarsi? (Lettura di AtomicLong.)
**Gate 6 — Rate limit.** Il token bucket a finestra scorrevole ha capacità disponibile? (Blocco sincronizzato; percorso non conteso ~5 ns.)
**Gate 7 — Probabilità.** Estrazione casuale rispetto alla probabilità configurata. (SplittableRandom con seed basato sull'ID dello scenario + contatore di corrispondenze — deterministico per riprodurre i fallimenti.)
**Gate 8 — Numero massimo di applicazioni.** Loop CAS su AtomicLong per applicare un limite massimo. (Loop compareAndSet; corretto sotto contesa — a differenza di un semplice incrementAndGet che può sforare.)
Tutti e otto i gate passano → applica l'effetto. Un gate fallisce → passa direttamente al metodo JDK reale senza modifiche.
Stressor in Background: Oltre i Fault sul Percorso delle Richieste
Oltre agli effetti inline sul percorso delle richieste (delay, rejection, iniezione di eccezioni), l'agent supporta gli stressor in background — thread con ciclo di vita vincolato che applicano pressione sulle risorse in modo continuo, indipendentemente dal traffico.
Gli stressor simulano le modalità di fallimento a combustione lenta che non vengono innescate da operazioni specifiche ma si accumulano nel tempo:
**Stressor di memoria:** Pressione sull'heap (trattenere chunk di byte[]), pressione sul GC (churn di allocazione), pressione sul Metaspace (definizioni sintetiche di classi), pressione sui buffer diretti (ByteBuffer off-heap), flooding dell'intern pool delle stringhe
**Stressor JVM:** Pressione sulla code cache (flood di classi generate da ByteBuddy per il thrashing del JIT), backlog del finalizer (flood della coda di phantom-reference), flooding della coda di riferimento, safepoint storm (retrasformazione periodica per innescare pause stop-the-world)
**Stressor di thread:** Thread leak (thread parcheggiati in modo permanente che consumano memoria dello stack), ThreadLocal leak (entry su thread pooled che si accumulano), iniezione di deadlock (vero deadlock su monitor JVM tra N thread — verificato con ThreadMXBean), monitor contention (thread in background in competizione per un lock condiviso), keep-alive thread (impediscono lo shutdown della JVM)
Gli stressor vengono avviati quando uno scenario viene attivato e fermati quando viene chiuso. Si compongono con gli effetti inline: puoi avere simultaneamente rejection JDBC sul percorso delle richieste e uno stressor sulla code cache attivi, testando se la tua applicazione si riprende dai fallimenti di connessione mentre è già sotto pressione di compilazione JIT.
- Pressione heap: trattenere MB/s configurabili in allocazioni byte[] per forzare pressione sul GC
- Pressione Metaspace: definire classi sintetiche in un ClassLoader isolato per consumare permgen/metaspace
- Pressione code cache: generare classi ByteBuddy per saturare il compilatore JIT
- Safepoint storm: forzare la retrasformazione periodica per innescare pause stop-the-world
- Deadlock reale: due thread acquisiscono i monitor in ordine inverso — rilevato da ThreadMXBean
- Thread leak: parcheggiare thread in modo permanente per esaurire la memoria stack -Xss nel tempo
Esempio: Validare il Comportamento SLA sotto Esaurimento JDBC
Uno scenario di test concreto che mette alla prova il valore centrale dell'agent: dimostrare che il tuo servizio risponde correttamente a un pagamento rispettando il target SLA quando il connection pool del database è sotto pressione.
@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 e Osservabilità
L'agent espone un JMX MBean a `com.macstab.chaos.jvm:type=ChaosDiagnostics` che fornisce uno snapshot in tempo reale di tutti gli scenari attivi — il loro stato corrente, i contatori di corrispondenza e i contatori di applicazione — senza richiedere modifiche al codice o configurazioni aggiuntive.
Questa funzionalità è utile durante il debug dei test: se un chaos test sta fallendo in modo inatteso, puoi collegare JConsole o VisualVM alla JVM di test e ispezionare esattamente quali scenari sono attivi e quante volte sono scattati.
L'observability bus consente la pubblicazione degli eventi di chaos su sistemi di metriche esterni. Se stai eseguendo chaos test in un ambiente di integrazione condiviso e vuoi correlare gli eventi di chaos con dashboard Grafana o trace distribuite, il bus fornisce il punto di integrazione.
- JMX MBean: elenco degli scenari attivi, contatori di corrispondenza, contatori di applicazione, stato corrente per scenario
- API di snapshot in-process: interrogabile dal codice di test per asserzioni sul comportamento del chaos
- Debug dump: rappresentazione testuale di tutti gli scenari e dello stato — utile nell'output dei fallimenti di test
- Observability bus: publisher di eventi collegabile per l'integrazione con metriche/tracing
Key Takeaways
Il JVM chaos agent colma il gap tra l'iniezione di fault a livello infrastrutturale e le reali modalità di fallimento interne alla JVM. 62 call site JDK, isolamento di sessione per test, auto-wiring Spring Boot 3/4, stressor in background per i fallimenti a combustione lenta e un percorso di dispatch ottimizzato dal JIT che costa ~60 ns per chiamata nel caso a zero scenari.
L'agent si compone con la libreria C99 LD_PRELOAD per una copertura fault full-stack: fallimenti a livello di syscall sotto la JVM, fallimenti a livello di bytecode al suo interno. Entrambi sono orchestrati dal sistema di annotazioni del framework di testing Java — un solo test, entrambi i livelli attivi, zero sovrapposizione di configurazione.
Il chaos engineering come gate CI è pronto. L'unica domanda è quale modalità di fallimento il tuo circuit breaker non stia ancora gestendo.
Engineering Team
Senior Solutions Architects
Costruiamo sistemi distribuiti da prima che 'microservizi' fosse un termine comune. Le nostre cicatrici raccontano storie.