Des Incidents de Production aux Annotations JUnit 5 : Un Framework de Chaos Testing à Trois Niveaux
Ingénierie category.testing 26 juin 2026

Des Incidents de Production aux Annotations JUnit 5 : Un Framework de Chaos Testing à Trois Niveaux

448 primitives syscall de niveau L1. 92 composites de fautes de niveau L2. 64 scénarios d'incidents de production encodés sous forme d'annotations uniques. Un framework d'extension JUnit 5 qui transforme les post-mortems en portes CI pour Spring Boot, Quarkus et Micronaut.

E
Engineering Team
Senior Solutions Architects
20 min de lecture

Une Annotation. Votre Dernier Incident de Production. Reproduit.

Quelque part dans l'historique Slack de votre équipe se trouve un post-mortem. Il contient une section intitulée « Cause Racine » qui décrit comment une mise à jour progressive Kubernetes a créé une fenêtre de 30 secondes pendant laquelle iptables n'avait pas encore propagé la suppression du nouveau pod, et ECONNRESET était renvoyé sur les connexions actives vers l'ancien pod. Il contient une section « Mitigation » indiquant que vous avez configuré un retry avec backoff. Il contient une section « Actions » avec une case à cocher à côté de « Ajouter un test de chaos pour ce scénario ».

Cette case n'a jamais été cochée.

Non pas parce que l'équipe s'en fiche. Mais parce qu'écrire un test de chaos pour ce scénario précis nécessite de comprendre comment le décalage iptables se traduit en taux ECONNRESET, quel appel libc est intercepté, quelle est la bonne probabilité, et comment injecter la faute dans un conteneur Docker s'exécutant dans un environnement CI.

C'est beaucoup de connaissances préalables pour cocher une case.

`@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)`, c'est un import et une annotation. C'est la case cochée.

java
// The post-mortem's action item, as a test:
@Test
@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)
void service_survives_rolling_update_rst_storm() {
    // 30% of RECV calls return ECONNRESET
    // This is exactly what a Kubernetes rolling update looks like
    assertThat(callService("/api/health")).isEqualTo(200);
}

La Hiérarchie d'Annotations à Trois Niveaux

Le framework organise les scénarios de chaos en trois niveaux, chacun s'adressant à un degré d'expertise et à un cas d'usage différents :

**L1 — Primitives Brutes (448 annotations) :** Contrôle direct au niveau des appels système. Une annotation correspond à une règle de faute : un errno, un type d'opération, une probabilité. C'est le niveau pour les ingénieurs qui savent exactement quel appel noyau ils souhaitent provoquer en erreur et à quel taux. Pouvoir expressif maximal, aucune abstraction.

**L2 — Composites Nommés (92 annotations) :** Familles de fautes documentées, chacune composant 2 à 6 règles L1 en un modèle nommé. C'est le niveau pour les scénarios de défaillance courants qui ont une forme bien comprise mais n'ont pas besoin d'être rattachés à un incident spécifique. Les valeurs par défaut sont calibrées à partir d'analyses de défaillances réelles.

**L3 — Scénarios d'Incidents (64 annotations) :** De vrais incidents de production encodés sous forme de compositions multi-domaines. Chaque annotation L3 porte une note de Sévérité, une référence à l'incident original ou à la source industrielle, et une classe Composer qui sait décomposer le scénario en la bonne combinaison de règles L1 sur plusieurs domaines. C'est le niveau pour « reproduire ce qui s'est passé dans le post-mortem ».

Les niveaux ne sont pas exclusifs — vous pouvez mélanger des annotations L1, L2 et L3 sur la même classe ou méthode de test, et elles se combinent correctement. Les annotations de portée méthode remplacent les annotations de portée classe pendant la durée de la méthode de test, puis restaurent les règles de portée classe lorsque la méthode se termine.

L1 : Primitives Syscall Brutes — 448 Annotations

Le niveau L1 vous donne un contrôle direct sur les règles de faute individuelles au niveau des appels système. Chaque annotation suit le même modèle : opération cible + errno ou effet + probabilité + motif optionnel de host ou de chemin.

java
// Network — connect/recv/send
@ChaosConnectEconnrefused(probability = 0.10)
@ChaosConnectEtimedout(probability = 0.03)
@ChaosRecvEconnreset(probability = 0.05)
@ChaosSendEpipe(probability = 0.02)

// DNS — getaddrinfo
@ChaosDnsGetaddrinfoEaiAgain(hostPattern = "*.internal", probability = 0.20)
@ChaosDnsGetaddrinfoEaiFail(hostPattern = "legacy.svc", probability = 1.0)

// File I/O — writes and reads
@ChaosWriteTorn(pathPrefix = "/data/wal", probability = 0.05)
@ChaosReadEio(pathPrefix = "/etc/app", probability = 0.01)
@ChaosWriteEnospc(pathPrefix = "/data", probability = 0.001)

// Memory
@ChaosMmapEnomem(probability = 0.005)

// Timing — breaks JWT expiry checks, caching TTLs
@ChaosClock(effect = ClockEffect.OFFSET, offsetMs = 30_000)  // +30 second skew

// All annotations support:
//   probability  — 0.0 (never) to 1.0 (always)
//   id           — target a specific container by ID
//   onMissingEnv — ERROR (fail test) or ABORT (skip test) if chaos env unavailable

L2 : Composites de Fautes Nommés — 92 Modèles Documentés

Le niveau L2 encode des modèles de défaillance documentés avec des valeurs par défaut calibrées. Chaque composite représente une forme de défaillance qui a un nom dans les post-mortems d'incidents et un impact connu et prévisible sur le comportement de l'application.

  • @CompositeChaosConnectionRefused — ECONNREFUSED sur chaque connect(), simulant un service aval mort
  • @CompositeChaosTransientDnsFailure — EAI_AGAIN sur 15% des appels getaddrinfo(), simulant une surcharge CoreDNS
  • @CompositeChaosLowMemoryPressure — ENOMEM sur 0,5% des appels mmap(), simulant une contention mémoire
  • @CompositeChaosNetworkFlap — alternance de ECONNRESET et de succès, simulant un lien réseau instable
  • @CompositeChaosSlowDisk — latence de 50 à 200 ms sur write(), simulant un stockage saturé en I/O
  • @CompositeChaosClockDrift — décalage d'horloge signé de ±5 secondes, simulant une dérive NTP
  • @CompositeChaosConnectionTimeout — ETIMEDOUT sur 5% des connect() avec une latence de 3s, simulant un timeout de pare-feu
  • @CompositeChaosShortWrite — TORN sur 10% des writes, simulant un système de fichiers retournant des comptes courts

L3 : Vrais Incidents de Production — 64 Scénarios Annotés

Le niveau L3 est là où le chaos engineering devient mémoire institutionnelle. Chaque annotation encode un vrai mode de défaillance — documenté dans des rapports d'incidents de production, des trackers d'issues Kubernetes, ou des analyses de défaillances de systèmes distribués bien connues.

Le pattern Composer gère la décomposition : chaque annotation L3 référence une classe Composer qui sait traduire une description d'incident de haut niveau en la bonne combinaison de règles L1 sur plusieurs domaines de fautes.

java
// L3 Incident: Kubernetes Rolling Update RST Storm
// Source: Kubernetes GitHub Issue #56903, multiple production incidents
// Severity: CRITICAL
// What happens: During rolling updates, iptables rules update asynchronously.
// Old pods receive connection attempts for ~15-30s after replacement.
// Active connections to old pods get ECONNRESET when the pod terminates.
@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)

// L3 Incident: Feign Retry Amplification Storm
// Source: Documented in multiple high-traffic Java service incidents
// Severity: CRITICAL
// What happens: Feign default retry (5x) × replicas (3) × backends (N)
// creates multiplicative amplification. A 50% failure rate becomes 9x load.
@IncidentChaosFeignRetryAmplification(toxicity = 0.5)

// L3 Incident: Spring @Transactional(REQUIRES_NEW) Deadlock
// Source: Spring Framework known issue pattern
// Severity: SEVERE
// What happens: REQUIRES_NEW suspends outer transaction and borrows new
// connection. Under load, pool exhausts — outer tx holds one connection,
// REQUIRES_NEW waits for another, deadlock.
@IncidentChaosSpringTransactionalPoolDeadlock

// L3 Incident: Kubernetes DNS ndots=5 Storm
// Source: CoreDNS performance analysis, ndots search path behavior
// Severity: SEVERE
// What happens: With ndots:5 (K8s default), each hostname generates 5+
// DNS queries (search path expansion). Under CoreDNS load, EAI_AGAIN
// spikes. Services that don't handle DNS transient failures cascade.
@IncidentChaosK8sDnsNdots5Storm

// L3 Incident: JVM Code Cache Full
// Source: JVM JIT compilation documentation, production profiling
// Severity: SEVERE
// What happens: When code cache fills, JIT stops compiling new methods.
// Throughput drops 10–50x as hot paths deoptimize to interpreted mode.
@IncidentChaosJvmCodeCacheFull

// L3 Incident: Redis Sentinel Failover Storm
// Source: Redis Sentinel documentation, observed failover cascades
// Severity: MEDIUM
// What happens: Sentinel failover triggers connection flap on all clients.
// Applications that don't handle LOADING errors loop reconnecting,
// overwhelming the new master.
@IncidentChaosRedisNetworkFlap

Docker + LD_PRELOAD : Chaos sur N'importe Quelle Image de Production

Pour les tests qui exécutent du code applicatif dans des conteneurs Docker (pattern Testcontainers), le framework injecte automatiquement les bibliothèques C99 LD_PRELOAD dans le conteneur avant son démarrage.

Le mécanisme d'injection utilise le flux tar de l'API Docker — pas de shell exec, pas de volumes, pas de conteneurs init :

1. L'extension JUnit détecte l'OS de base de l'image du conteneur (Alpine → musl, Debian/RHEL/Ubuntu → glibc). 2. Elle sélectionne le binaire pré-compilé correct (glibc/musl × amd64/arm64 — quatre variantes, incluses dans le JAR). 3. Elle copie le fichier `.so` dans le conteneur via `DockerClient.copyArchiveToContainerCmd()` avant `container.start()`. 4. Elle définit `LD_PRELOAD=/chaos/libchaos-net.so` (et d'autres selon la déclaration) dans l'environnement du conteneur.

Cela signifie que le chaos testing fonctionne sur les images `distroless`, les images basées sur `scratch`, UBI minimal, Alpine — toute image qui utilise un éditeur de liens dynamique ELF standard. Aucune modification de Dockerfile, aucun sidecar, aucune infrastructure supplémentaire.

java
// @SyscallLevelChaos declares which libraries to inject
// The framework handles the rest: image detection, binary selection, injection

@Testcontainers
@ExtendWith(ChaosTestingExtension.class)
@SyscallLevelChaos({LibchaosLib.NET, LibchaosLib.DNS})  // Inject both
class InventoryServiceChaosTest {

    @Container @AppContainer
    static GenericContainer<?> inventory =
        new GenericContainer<>("inventory-service:latest")  // Any image works
            .withExposedPorts(8080);

    @Container
    static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:16");

    @Test
    @CompositeChaosTransientDnsFailure  // L2: 15% EAI_AGAIN on DNS
    void inventory_lookup_resilient_to_dns_hiccups(String inventoryUrl) {
        // libchaos-dns.so is active inside the inventory-service container
        // DNS calls from the service will see 15% EAI_AGAIN failures
        var response = given().get(inventoryUrl + "/api/product/123");
        assertThat(response.statusCode()).isEqualTo(200);
    }
}

Intégrations Framework : Spring Boot, Quarkus, Micronaut

Chaque grand framework Java dispose d'un module d'intégration dédié qui gère à la fois l'orchestration côté test et le plan de contrôle de chaos optionnel à l'exécution.

**Spring Boot 3 et 4 :** Le starter de test fournit `@ChaosTest` comme annotation composée, enregistre `ChaosControlPlane` et `ChaosSession` en tant que beans Spring, et assure la résolution de paramètres JUnit 5 pour les deux. Il se compose avec `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest`, et toute autre annotation de tranche de test Spring.

Le starter runtime expose `/actuator/chaos` comme endpoint Actuator protégé pour la gestion de scénarios en direct dans les environnements hors production.

**Quarkus :** L'extension Quarkus fournit `@QuarkusChaosTest` comme annotation de test Quarkus composée avec injection CDI de `ChaosControlPlane`. S'intègre avec `@QuarkusTest`, `@QuarkusIntegrationTest`, et les tests d'image native.

**Micronaut :** L'intégration Micronaut fournit `@MicronautChaosTest` avec des beans `ChaosControlPlane` et `ChaosSession` compatibles `@Inject`. Fonctionne avec `@MicronautTest` en modes de test JVM et natif.

  • Spring Boot 3/4 : annotation composée @ChaosTest, ChaosControlPlane comme bean Spring, endpoint /actuator/chaos
  • Quarkus : @QuarkusChaosTest, injection CDI, compatible avec @QuarkusIntegrationTest et le mode natif
  • Micronaut : @MicronautChaosTest, beans compatibles @Inject, support des tests JVM et natif
  • JUnit 5 seul : @ExtendWith(ChaosTestingExtension.class) avec ChaosControlPlane manuel — aucun framework requis
  • Compatible Gradle et Maven : tous les modules publiés sur Maven Central avec des coordonnées standard

Ressources et Contraintes : L'Annotation @Resources

Au-delà de l'injection de fautes, le framework prend en charge les tests de contraintes de ressources via `@Resources`. Cela déclare des limites de CPU et de mémoire que l'extension JUnit applique au conteneur Docker avant son démarrage — sans modifier la définition du conteneur dans le code de test.

Cela permet une catégorie de tests souvent ignorée : « mon service se comporte-t-il correctement lorsqu'il est contraint en ressources ? » C'est un scénario réaliste dans Kubernetes, où les pods s'exécutent avec des limites de CPU et de mémoire, et où atteindre ces limites déclenche une limitation (throttling) et des kill OOM.

java
// Test your service under the same resource constraints as production
@Testcontainers
@ExtendWith(ChaosTestingExtension.class)
@SyscallLevelChaos(LibchaosLib.NET)
@Resources(cpuQuota = 0.5, memoryMb = 256)  // Same limits as production K8s pod
class ResourceConstrainedChaosTest {

    @Container @AppContainer
    static GenericContainer<?> app =
        new GenericContainer<>("my-service:latest")
            .withExposedPorts(8080);

    @Test
    @IncidentChaosJvmCodeCacheFull  // Code cache fills → JIT stops
    void service_degrades_gracefully_under_resource_pressure(String appUrl) {
        // CPU-throttled + code cache pressure = realistic production stress test
        var response = given().get(appUrl + "/api/health");
        // Service should return 200 or 503 — not hang forever
        assertThat(response.statusCode()).isIn(200, 503);
        assertThat(response.time()).isLessThan(5000);  // Under 5s even degraded
    }
}

L'Architecture Plugin : Une Extension, Tous les Conteneurs

Les premières versions du framework disposaient d'extensions JUnit séparées pour chaque type de conteneur : une pour Redis, une pour PostgreSQL, une pour Kafka, et ainsi de suite. Chaque extension comportait plus de 200 lignes de code de cycle de vie quasi identiques. Étendre le support à un nouveau type de conteneur impliquait de copier et de modifier.

L'architecture actuelle utilise un SPI `ChaosPlugin` découvert via `ServiceLoader.load(ChaosPlugin.class)`. Chaque type de conteneur enregistre une implémentation. La `ChaosTestingExtension` unique les orchestre toutes.

Le contrat SPI est minimal : découvrir les conteneurs dans la classe de test, fournir les informations de connexion, gérer l'application des ressources, et gérer la configuration du chaos avant le démarrage. L'extension universelle gère tout le reste : le traitement des annotations, la gestion du cycle de vie, l'isolation des sessions, la résolution des paramètres.

Cela élimine environ 8 000 lignes de duplication et rend le framework extensible à de nouveaux types de conteneurs sans modifier le cœur.

java
// Registering a custom container type
public class MyServiceChaosPlugin implements ChaosPlugin {

    @Override
    public boolean supportsContainer(GenericContainer<?> container) {
        return container.getDockerImageName().startsWith("my-service:");
    }

    @Override
    public ConnectionInfo extractConnectionInfo(GenericContainer<?> container,
                                               Annotation annotation) {
        return ConnectionInfo.of(
            "http://" + container.getHost() + ":" + container.getMappedPort(8080)
        );
    }

    @Override
    public Class<? extends Annotation> containerAnnotation() {
        return AppContainer.class;  // @AppContainer marks this container
    }
}
// META-INF/services/com.macstab.chaos.core.spi.ChaosPlugin:
// com.example.chaos.MyServiceChaosPlugin

43 Modules, 11 Domaines, Un Seul Modèle Mental

Le framework est organisé en 43 modules Gradle couvrant 11 domaines de fautes. Voici le tableau complet :

**Infrastructure centrale :** chaos-core (extension JUnit 5, SPI, cycle de vie), chaos-spi (contrat plugin), chaos-api (types de sélecteurs et d'effets)

**Packs de tests par domaine de faute :** testpacks-connection (TCP/UDP), testpacks-dns (résolution DNS), testpacks-memory (malloc/mmap), testpacks-process (fork/exec/signaux), testpacks-time (horloge, décalage temporel), testpacks-filesystem (I/O fichier)

**Intégration JVM :** chaos-java (transport bytecode JVM, câblage côté conteneur)

**Packs d'incidents L3 :** testpacks-l3-kubernetes (mises à jour progressives, tempêtes DNS), testpacks-l3-feign (amplification de retry), testpacks-l3-spring (deadlock transactionnel, famine OSIV), testpacks-l3-redis (tempêtes de failover), testpacks-l3-kafka (failover de broker), testpacks-l3-grpc (tempêtes GOAWAY)

**Intégrations framework :** java-spring-boot3, java-spring-boot3-test, java-spring-boot4, java-spring-boot4-test, java-quarkus, java-micronaut

  • 448 annotations L1 : primitives syscall brutes sur 6 domaines de fautes au niveau OS
  • 92 composites L2 : modèles de fautes nommés avec des valeurs par défaut calibrées issues de la production
  • 64 incidents L3 : de vrais post-mortems comme annotations uniques avec des niveaux de Sévérité
  • 43 modules Gradle : versionnés indépendamment, sans dépendances transitives obligatoires
  • 4 variantes binaires : glibc-amd64, glibc-arm64, musl-amd64, musl-arm64 — incluses dans le JAR
  • Spring Boot 3/4, Quarkus, Micronaut : intégration complète des frameworks prête à l'emploi

Key Takeaways

Le framework existe parce que les post-mortems d'incidents ont des actions qui ne sont jamais mises en œuvre. Non pas parce que les ingénieurs sont paresseux, mais parce que l'écart d'outillage entre « nous devrions tester ce mode de défaillance » et « voici un test qui teste ce mode de défaillance » était trop grand.

448 annotations L1 pour un contrôle chirurgical. 92 composites L2 pour les familles de défaillances documentées. 64 scénarios d'incidents L3 qui vous permettent de transformer un post-mortem Slack en un test CI en échec en cinq minutes.

La couche de chaos ne remplace pas les tests unitaires ou les tests d'intégration. Elle complète le tableau. La Phase 4 est la phase où votre pipeline CI apprend à reproduire les incidents de production avant qu'ils ne se reproduisent.

#chaos-engineering #java #junit5 #spring-boot #quarkus #micronaut #testcontainers #resilience #ci-cd #annotations
E

Engineering Team

Senior Solutions Architects

Nous construisons des systèmes distribués depuis avant que le terme « microservices » n'existe. Nos cicatrices ont des histoires à raconter.