La Couche que Toxiproxy Ne Peut Pas Atteindre
Toxiproxy est excellent. tc netem est excellent. Les bibliothèques de chaos LD_PRELOAD sont excellentes. Elles opèrent toutes au niveau du réseau ou de la frontière OS — en interceptant les connexions TCP, la livraison de paquets et les appels système.
Mais voici ce qu'aucune d'elles ne peut voir : ce qui se passe à l'intérieur de votre HikariPool lorsqu'il tente d'emprunter une connexion et rencontre un timeout. Ce qui se passe à l'intérieur de votre ScheduledExecutorService lorsqu'il est saturé. Ce qui se passe à l'intérieur de l'implémentation SSL du JDK lorsqu'il rencontre une retransmission. Ce qui se passe à l'intérieur de votre pool de threads lorsque les tâches s'accumulent plus vite que les workers ne peuvent les traiter.
Ce sont des modes de défaillance internes à la JVM. Ils ne se manifestent pas comme des échecs réseau au niveau de la couche TCP. Ils se manifestent comme des exceptions levées par des classes JDK — `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` — et comme des schémas d'épuisement des ressources qui ne deviennent visibles que lorsqu'on instrumente les bons sites d'appel à l'intérieur de la JVM elle-même.
C'est le vide que l'agent de chaos JVM comble. Il ne remplace pas la couche LD_PRELOAD — il se compose avec elle. Ensemble, ils permettent l'injection de fautes à la fois à la frontière OS et à la frontière interne de la JVM : la surface complète des modes de défaillance d'une application Java.
62 Sites d'Appel JDK : La Surface Complète
L'agent instrumente 62 sites d'appel JDK spécifiques — non pas des sites aléatoires, mais ceux qui comptent pour les charges de travail backend d'entreprise. La sélection a été guidée par l'analyse d'incidents survenus dans des systèmes distribués en défaillance :
**Threading & Executors** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, opérations BlockingQueue (put, offer, poll, take), soumissions ForkJoinPool, cycle de vie des threads virtuels
**Réseau & E/S** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, opérations DatagramChannel, ServerSocket.accept
**DNS & Résolution de Noms** InetAddress.getByName/getAllByName — le chemin de résolution interne du JDK avant que le résolveur OS soit appelé
**JDBC & Accès aux Données** DataSource.getConnection (emprunt depuis le pool de connexions), Statement.execute/executeQuery, opérations PreparedStatement — au niveau où Hikari, DBCP et c3p0 transitent tous
**Clients HTTP** HttpURLConnection, HttpClient (JDK 11+) — send, sendAsync
**SSL/TLS** SSLEngine.wrap/unwrap, SSLSocket.startHandshake
**Temps** System.currentTimeMillis(), System.nanoTime() — avec des effets FIXED offset, DRIFT et FREEZE
**Internes JVM** System.gc(), chargement de classes, réflexion, sérialisation/désérialisation d'objets, ThreadLocal.get/set, lookups JNDI, opérations JMX, chargement de bibliothèques natives, décompression 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 : Fonctionnement
L'instrumentation des classes JDK n'est pas triviale. Les classes JDK sont chargées par le classloader bootstrap — le classloader racine qui n'a pas de parent. Le code de l'agent réside dans le classloader de l'agent, que le classloader bootstrap ne peut pas voir par son nom. Combler ce fossé correctement nécessite une utilisation soigneuse de JVMTI et du modèle mémoire Java.
**Étape 1 — Premain ou agentmain.** L'agent s'attache soit au démarrage (via `-javaagent:`) soit dynamiquement au moment du test (via l'API JDK Attach). Lors d'un attachement dynamique, le starter de test Spring Boot appelle `VirtualMachine.attach(processId)` et injecte le jar de l'agent.
**Étape 2 — Injection bootstrap.** Une classe `BootstrapDispatcher` est extraite du jar de l'agent et écrite dans un JAR temporaire. Ce JAR est ajouté au classpath bootstrap via `Instrumentation.appendToBootstrapClassLoaderSearch()`. Le classloader bootstrap peut désormais voir `BootstrapDispatcher`.
**Étape 3 — Câblage MethodHandle.** Un tableau `MethodHandle[]` de 62 emplacements est construit, un handle par cible d'interception, pointant vers l'implémentation dans le classloader de l'agent. Le tableau et un objet `delegate` sont publiés dans `BootstrapDispatcher` en tant que champs `volatile`. Le JMM garantit que tout thread observant `delegate != null` voit également le tableau `handles` entièrement initialisé.
**Étape 4 — Inlining du bytecode advice.** Le mécanisme `@Advice` de ByteBuddy copie le bytecode de l'advice directement dans le corps de la méthode JDK instrumentée. Il n'y a pas de dispatch virtuel, pas d'appel d'interface — le bytecode tissé est inline. Après la compilation JIT au préchauffage (~10 000 invocations), toute la chaîne de dispatch se compile en code natif.
// 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 Isolation de Session : Chaque Test Reçoit Son Propre Chaos
Le problème d'ingénierie le plus difficile dans un agent de chaos au niveau JVM n'est pas l'instrumentation bytecode — c'est l'isolation. Plusieurs tests JUnit s'exécutent de façon concurrente dans la même JVM. Si le test A injecte des échecs de connexion JDBC, le test B ne doit pas pouvoir voir ces échecs.
La solution est `ChaosSession`, soutenu par `ThreadLocal<UUID>`.
Chaque méthode de test (ou classe de test, selon le cycle de vie) reçoit un identifiant de session unique. Les scénarios de chaos peuvent être enregistrés soit avec une portée JVM (affecte tous les threads) soit avec une portée session (affecte uniquement les threads portant l'ID de session de ce test). Le `ThreadLocal` fournit la liaison.
Pour les tâches soumises à des executors, l'ID de session doit se propager du thread soumettant vers le thread worker. L'agent instrumente `ThreadPoolExecutor.execute()` pour envelopper le `Runnable` soumis avec un décorateur qui capture l'ID de session courant et le restaure sur le thread worker avant que la tâche s'exécute. Cette propagation se produit de façon transparente — le code applicatif ne sait pas qu'elle se produit.
@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 et 4 : Câblage Sans Configuration
Les starters de test Spring Boot existent parce que le câblage doit être invisible. Vous ne devriez pas avoir besoin de comprendre ByteBuddy, le classloader bootstrap, ou JVMTI pour exécuter votre premier test de chaos. Le starter gère :
- La définition programmatique de `jdk.attach.allowAttachSelf=true` avant que la JVM traite ce flag (en utilisant un initialiseur statique dans la classe d'auto-configuration du starter) - La détection de si l'agent est déjà attaché (idempotent — sûr en cas d'exécution parallèle de classes de test) - L'attachement de l'agent via `VirtualMachine.attach("0")` (auto-attachement) s'il n'est pas encore présent - L'enregistrement de `ChaosControlPlane` et `ChaosSession` en tant que beans Spring dans le ApplicationContext de test - La résolution de paramètres JUnit 5 pour les arguments de constructeur `ChaosSession` et `ChaosControlPlane`
Le starter runtime (pour le déploiement hors test) expose également `/actuator/chaos` en tant qu'endpoint Actuator protégé pour la gestion de scénarios en direct.
<!-- 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> Le Pipeline d'Activation à Huit Portes
Chaque évaluation de scénario de chaos passe par huit portes en séquence. Toutes les portes doivent passer pour que l'effet se déclenche. Les portes sont rapides — évaluées sur le thread appelant sans E/S et avec un verrouillage minimal.
**Porte 1 — Vérification démarrée.** Le scénario a-t-il été activé ? (Lecture de flag bon marché.)
**Porte 2 — Correspondance ID de session.** Si portée session, est-ce que le `ThreadLocal` du thread courant porte le bon UUID ? (Lecture thread-local, sans verrou.)
**Porte 3 — Correspondance sélecteur.** Ce site d'appel correspond-il au sélecteur du scénario ? (Comparaison enum, O(1).)
**Porte 4 — Fenêtre d'activation.** L'heure courante est-elle dans la fenêtre de début/fin configurée du scénario ? (Deux comparaisons long.)
**Porte 5 — Compteur de préchauffage.** Ce scénario a-t-il correspondu au moins N fois avant de commencer à s'appliquer ? (Lecture AtomicLong.)
**Porte 6 — Limite de débit.** Le token bucket à fenêtre glissante a-t-il de la capacité ? (Bloc synchronisé ; chemin non contesté ~5 ns.)
**Porte 7 — Probabilité.** Tirage aléatoire contre la probabilité configurée. (SplittableRandom initialisé avec l'ID de scénario + compteur de correspondances — déterministe pour reproduire les défaillances.)
**Porte 8 — Nombre maximum d'applications.** Boucle CAS sur AtomicLong pour imposer un plafond strict. (Boucle compareAndSet ; correcte sous contention — contrairement à un simple incrementAndGet qui peut dépasser.)
Toutes les huit portes passent → appliquer l'effet. Une porte échoue → transmettre l'appel à la vraie méthode JDK sans modification.
Stresseurs de Fond : Au-delà des Fautes sur le Chemin des Requêtes
En plus des effets inline sur le chemin des requêtes (délai, rejet, injection d'exception), l'agent prend en charge des stresseurs de fond — des threads liés au cycle de vie qui appliquent en continu une pression sur les ressources, indépendamment du trafic.
Les stresseurs simulent les modes de défaillance à combustion lente qui ne sont pas déclenchés par des opérations spécifiques mais s'accumulent avec le temps :
**Stresseurs mémoire :** Pression heap (conserver des blocs byte[]), pression GC (churn d'allocation), pression Metaspace (définitions de classes synthétiques), pression de buffers directs (ByteBuffer off-heap), inondation d'interns de chaînes
**Stresseurs JVM :** Pression du cache de code (inondation de classes générées par ByteBuddy pour le thrashing JIT), backlog du finaliseur (inondation de la queue de références fantômes), inondation de la queue de références, tempêtes de safepoint (retransformation périodique pour déclencher des pauses stop-the-world)
**Stresseurs de threads :** Fuite de threads (threads définitivement parqués consommant de la mémoire de pile), fuite ThreadLocal (entrées sur des threads poolés qui s'accumulent), injection de deadlock (vrai deadlock de moniteur JVM entre N threads — vérifié avec ThreadMXBean), contention de moniteur (threads de fond en compétition pour un verrou partagé), threads keep-alive (prévenir l'arrêt de la JVM)
Les stresseurs démarrent quand un scénario est activé et s'arrêtent quand il est fermé. Ils se composent avec les effets inline : vous pouvez simultanément avoir des rejections JDBC sur le chemin des requêtes et un stresseur de cache de code actif, en testant si votre application se remet des échecs de connexion tout en étant déjà sous pression de compilation JIT.
- Pression heap : conserver des allocations byte[] configurables en Mo/s pour forcer la pression GC
- Pression Metaspace : définir des classes synthétiques dans un ClassLoader isolé pour consommer permgen/metaspace
- Pression du cache de code : générer des classes ByteBuddy pour saturer le compilateur JIT
- Tempête de safepoint : forcer une retransformation périodique pour déclencher des pauses stop-the-world
- Vrai deadlock : deux threads acquièrent des moniteurs dans l'ordre inverse — détecté par ThreadMXBean
- Fuite de threads : parker des threads définitivement pour épuiser la mémoire de pile -Xss au fil du temps
Exemple : Validation du Comportement SLA sous Épuisement JDBC
Un scénario de test concret qui exploite la valeur fondamentale de l'agent : prouver que votre service répond correctement à un paiement avec une cible SLA lorsque le pool de connexions à la base de données est sous pression.
@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 et Observabilité
L'agent expose un MBean JMX à `com.macstab.chaos.jvm:type=ChaosDiagnostics` qui fournit un instantané en direct de tous les scénarios actifs — leur état courant, les compteurs de correspondances et les compteurs d'applications — sans nécessiter de modification de code ni de configuration supplémentaire.
Cela est utile lors du débogage des tests : si un test de chaos échoue de façon inattendue, vous pouvez connecter JConsole ou VisualVM à la JVM de test et inspecter exactement quels scénarios sont actifs et combien de fois ils se sont déclenchés.
Le bus d'observabilité permet de publier des événements de chaos vers des systèmes de métriques externes. Si vous exécutez des tests de chaos dans un environnement d'intégration partagé et souhaitez corréler les événements de chaos avec des tableaux de bord Grafana ou des traces distribuées, le bus fournit le point d'intégration.
- MBean JMX : liste des scénarios actifs, compteurs de correspondances, compteurs d'applications, état courant par scénario
- API d'instantané in-process : interrogeable depuis le code de test pour des assertions sur le comportement du chaos
- Dump de débogage : représentation textuelle de tous les scénarios et états — utile dans la sortie d'échec de test
- Bus d'observabilité : éditeur d'événements enfichable pour l'intégration métriques/traçage
Key Takeaways
L'agent de chaos JVM comble le fossé entre l'injection de fautes au niveau de l'infrastructure et les vrais modes de défaillance internes à la JVM. 62 sites d'appel JDK, isolation de session par test, auto-câblage Spring Boot 3/4, stresseurs de fond pour les défaillances à combustion lente, et un chemin de dispatch optimisé JIT qui coûte ~60 ns par appel dans le cas zéro scénario.
L'agent se compose avec la bibliothèque LD_PRELOAD C99 pour une couverture complète des fautes : défaillances au niveau syscall sous la JVM, défaillances au niveau bytecode à l'intérieur. Les deux sont orchestrés par le système d'annotation du framework de test Java — un seul test, les deux couches actives, zéro chevauchement de configuration.
Le chaos engineering comme porte de CI est prêt. La seule question est quel mode de défaillance votre disjoncteur ne gère pas encore.
Engineering Team
Senior Solutions Architects
Nous construisons des systèmes distribués depuis avant que « microservices » ne soit un terme courant. Nos cicatrices racontent des histoires.