Van Productie-incidenten naar JUnit 5 Annotaties: Een Drielaags Chaos-testframework
Engineering category.testing 26 juni 2026

Van Productie-incidenten naar JUnit 5 Annotaties: Een Drielaags Chaos-testframework

448 L1 syscall-primitieven. 92 L2 foutcomposieten. 64 L3 productie-incidentscenario's gecodeerd als enkele annotaties. Een JUnit 5 extensieframework dat post-mortems omzet in CI-gates voor Spring Boot, Quarkus en Micronaut.

E
Engineering Team
Senior Solutions Architects
20 min lezen

Één Annotatie. Jouw Laatste Productie-incident. Gereproduceerd.

Ergens in de Slack-geschiedenis van je team staat een post-mortem. Er is een sectie met de naam "Hoofdoorzaak" die beschrijft hoe een Kubernetes rolling update een venster van 30 seconden creëerde waarbij iptables de verwijdering van de nieuwe pod nog niet had doorgevoerd, en ECONNRESET werd teruggegeven op actieve verbindingen met de oude pod. Er is een sectie "Mitigatie" die zegt dat je retry-with-backoff hebt geconfigureerd. Er is een sectie "Actiepunten" met een selectievakje naast "Voeg chaostest toe voor dit scenario."

Dat selectievakje is nooit aangevinkt.

Niet omdat het team het niet belangrijk vindt. Maar omdat het schrijven van een chaostest voor dat specifieke scenario vereist dat je begrijpt hoe iptables-vertraging zich vertaalt naar ECONNRESET-percentages, welke libc-aanroep wordt onderschept, wat de juiste kans is, en hoe je de fout injecteert in een Docker-container die in een CI-omgeving draait.

Dat is veel voorkennis voor een selectievakje.

`@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)` is één import en één annotatie. Dat is het aanvinken van het selectievakje.

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);
}

De Drielaagse Annotatiehiërarchie

Het framework organiseert chaos-scenario's in drie lagen, waarbij elke laag een ander niveau van expertise en gebruiksgeval aanpakt:

**L1 — Ruwe Primitieven (448 annotaties):** Directe syscall-niveau controle. Één annotatie komt overeen met één foutregel: één errno, één type operatie, één kans. Dit is de laag voor engineers die precies weten welke kernel-aanroep ze willen verstoren en met welke frequentie. Volledige expressiekracht, geen abstractie.

**L2 — Benoemde Composieten (92 annotaties):** Gedocumenteerde foutfamilies, waarbij elke composiet 2–6 L1-regels combineert tot een benoemd patroon. Dit is de laag voor veelvoorkomende foutscenario's die een goed begrepen vorm hebben maar niet hoeven te herleiden naar een specifiek incident. De standaardwaarden zijn gekalibreerd op basis van echte foutanalyses.

**L3 — Incidentscenario's (64 annotaties):** Echte productie-incidenten gecodeerd als multi-domein composities. Elke L3-annotatie bevat een Severity-beoordeling, een verwijzing naar het oorspronkelijke incident of de industriebron, en een Composer-klasse die weet hoe het scenario moet worden omgezet in de juiste mix van L1-regels over meerdere domeinen. Dit is de laag voor "reproduceer wat er in de post-mortem is gebeurd."

De lagen zijn niet exclusief — je kunt L1-, L2- en L3-annotaties combineren op dezelfde testklasse of -methode, en ze stapelen correct. Annotaties op methodeniveau overschrijven annotaties op klasseniveau voor de duur van de testmethode, waarna de regels op klasseniveau worden hersteld wanneer de methode eindigt.

L1: Ruwe Syscall-primitieven — 448 Annotaties

De L1-laag geeft je directe controle over individuele syscall-foutregels. Elke annotatie volgt hetzelfde patroon: doeloperatie + errno of effect + kans + optioneel host- of padpatroon.

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: Benoemde Foutcomposieten — 92 Gedocumenteerde Patronen

De L2-laag codeert gedocumenteerde foutpatronen met gekalibreerde standaardwaarden. Elke composiet vertegenwoordigt een foutvorm die een naam heeft in incident-post-mortems en een bekende, voorspelbare impact heeft op het gedrag van de applicatie.

  • @CompositeChaosConnectionRefused — ECONNREFUSED bij elke connect(), simuleert een onbereikbare downstream
  • @CompositeChaosTransientDnsFailure — EAI_AGAIN bij 15% van de getaddrinfo()-aanroepen, simuleert CoreDNS-overbelasting
  • @CompositeChaosLowMemoryPressure — ENOMEM bij 0,5% van de mmap()-aanroepen, simuleert geheugencontentie
  • @CompositeChaosNetworkFlap — afwisselend ECONNRESET en succes, simuleert een instabiele netwerkverbinding
  • @CompositeChaosSlowDisk — 50–200ms latentie bij write(), simuleert I/O-verzadigde opslag
  • @CompositeChaosClockDrift — ±5 seconden gesigneerde klokafwijking, simuleert NTP-drift
  • @CompositeChaosConnectionTimeout — ETIMEDOUT bij 5% van connect() met 3s latentie, simuleert firewalltime-out
  • @CompositeChaosShortWrite — TORN bij 10% van schrijfbewerkingen, simuleert bestandssysteem dat korte tellingen teruggeeft

L3: Echte Productie-incidenten — 64 Geannoteerde Scenario's

De L3-laag is waar chaos-engineering institutioneel geheugen wordt. Elke annotatie codeert een echte foutmodus — gedocumenteerd in productie-incidentrapporten, Kubernetes issue trackers, of bekende gedistribueerde systemen foutanalyses.

Het Composer-patroon verzorgt de decompositie: elke L3-annotatie verwijst naar een Composer-klasse die weet hoe een beschrijving op hoog niveau van een incident vertaald moet worden naar de juiste mix van L1-regels over meerdere foutdomeinen.

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 op Elke Productie-image

Voor tests die applicatiecode uitvoeren binnen Docker-containers (Testcontainers-patroon), injecteert het framework automatisch de C99 LD_PRELOAD-bibliotheken in de container voordat deze start.

Het injectiemechanisme maakt gebruik van de Docker API tar-stream — niet via shell exec, niet via volumes, niet via init-containers:

1. De JUnit-extensie detecteert het basis-besturingssysteem van de container-image (Alpine → musl, Debian/RHEL/Ubuntu → glibc). 2. Het selecteert het juiste voorgecompileerde binaire bestand (glibc/musl × amd64/arm64 — vier varianten, meegeleverd in de JAR). 3. Het kopieert het `.so`-bestand naar de container via `DockerClient.copyArchiveToContainerCmd()` vóór `container.start()`. 4. Het stelt `LD_PRELOAD=/chaos/libchaos-net.so` (en andere zoals gedeclareerd) in de containeromgeving in.

Dit betekent dat chaos-testing werkt op `distroless`-images, `scratch`-gebaseerde images, UBI minimal, Alpine — elke image die gebruikmaakt van een standaard ELF-dynamische linker. Geen Dockerfile-aanpassing, geen sidecar, geen extra infrastructuur.

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);
    }
}

Framework-integraties: Spring Boot, Quarkus, Micronaut

Elk groot Java-framework krijgt een eigen integratiemodule die zowel de testorkestratie als het optionele runtime chaos-controlevlak afhandelt.

**Spring Boot 3 en 4:** De test-starter biedt `@ChaosTest` als samengestelde annotatie, registreert `ChaosControlPlane` en `ChaosSession` als Spring-beans, en biedt JUnit 5 parameterresolutie voor beide. Het componeert met `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest` en elke andere Spring test-slice-annotatie.

De runtime-starter stelt `/actuator/chaos` beschikbaar als beveiligd Actuator-eindpunt voor live scenario-beheer in niet-productieomgevingen.

**Quarkus:** De Quarkus-extensie biedt `@QuarkusChaosTest` als samengestelde Quarkus-testannotatie met CDI-injectie van `ChaosControlPlane`. Integreert met `@QuarkusTest`, `@QuarkusIntegrationTest` en native image-tests.

**Micronaut:** De Micronaut-integratie biedt `@MicronautChaosTest` met `@Inject`-compatibele `ChaosControlPlane`- en `ChaosSession`-beans. Werkt met `@MicronautTest` in zowel JVM- als native testmodi.

  • Spring Boot 3/4: @ChaosTest samengestelde annotatie, ChaosControlPlane als Spring-bean, /actuator/chaos eindpunt
  • Quarkus: @QuarkusChaosTest, CDI-injectie, werkt met @QuarkusIntegrationTest en native
  • Micronaut: @MicronautChaosTest, @Inject-compatibele beans, JVM- en native testondersteuning
  • Gewone JUnit 5: @ExtendWith(ChaosTestingExtension.class) met handmatige ChaosControlPlane — geen framework vereist
  • Gradle en Maven compatibel: alle modules gepubliceerd naar Maven Central met standaard coördinaten

Resources en Beperkingen: @Resources-annotatie

Naast foutinjectie ondersteunt het framework het testen van resourcebeperkingen via `@Resources`. Dit declareert CPU- en geheugenlimieten die de JUnit-extensie toepast op de Docker-container voordat deze start — zonder de containerdefinitie in de testcode te wijzigen.

Dit maakt een klasse van tests mogelijk die vaak wordt overgeslagen: "gedraagt mijn service zich correct wanneer de resources beperkt zijn?" Dit is een realistisch scenario in Kubernetes, waar pods draaien met CPU-limieten en geheugenlimieten, en waarbij het bereiken van die limieten throttling en OOM kills veroorzaakt.

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
    }
}

De Plugin-architectuur: Één Extensie, Alle Containers

Vroege versies van het framework hadden aparte JUnit-extensies voor elk containertype: één voor Redis, één voor PostgreSQL, één voor Kafka, enzovoort. Elke extensie had 200+ regels nagenoeg identieke lifecycle-code. Uitbreiding naar een nieuw containertype betekende kopiëren en aanpassen.

De huidige architectuur gebruikt een `ChaosPlugin` SPI die wordt ontdekt via `ServiceLoader.load(ChaosPlugin.class)`. Elk containertype registreert één implementatie. De enkelvoudige `ChaosTestingExtension` orkestreert ze allemaal.

Het SPI-contract is minimaal: ontdek containers in de testklasse, verstrek verbindingsinformatie, verwerk resource-toepassing en zorg voor chaos-setup vóór het starten. De universele extensie handelt al het overige af: annotatieverwerking, lifecycle-beheer, sessie-isolatie, parameterresolutie.

Dit elimineert ~8.000 regels duplicaat code en maakt het framework uitbreidbaar naar nieuwe containertypen zonder de kern aan te passen.

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 Domeinen, Één Mentaal Model

Het framework is georganiseerd over 43 Gradle-modules die 11 foutdomeinen beslaan. Hier is het volledige overzicht:

**Kerninfrastructuur:** chaos-core (JUnit 5 extensie, SPI, lifecycle), chaos-spi (plugin-contract), chaos-api (selector- en effecttypen)

**Foutdomein-testpakketten:** testpacks-connection (TCP/UDP), testpacks-dns (DNS-resolutie), testpacks-memory (malloc/mmap), testpacks-process (fork/exec/signalen), testpacks-time (klok, tijdafwijking), testpacks-filesystem (bestand I/O)

**JVM-integratie:** chaos-java (JVM bytecode-transport, container-side bedrading)

**L3-incidentpakketten:** testpacks-l3-kubernetes (rolling updates, DNS-stormen), testpacks-l3-feign (retry-amplificatie), testpacks-l3-spring (transactionele deadlock, OSIV-uithongering), testpacks-l3-redis (failover-stormen), testpacks-l3-kafka (broker-failover), testpacks-l3-grpc (GOAWAY-stormen)

**Framework-integraties:** java-spring-boot3, java-spring-boot3-test, java-spring-boot4, java-spring-boot4-test, java-quarkus, java-micronaut

  • 448 L1-annotaties: ruwe syscall-primitieven over 6 OS-niveau foutdomeinen
  • 92 L2-composieten: benoemde foutpatronen met gekalibreerde, productie-afgeleide standaardwaarden
  • 64 L3-incidenten: echte post-mortems als enkele annotaties met Severity-beoordelingen
  • 43 Gradle-modules: onafhankelijk geversioneerd, geen verplichte transitieve afhankelijkheden
  • 4 binaire varianten: glibc-amd64, glibc-arm64, musl-amd64, musl-arm64 — meegeleverd in JAR
  • Spring Boot 3/4, Quarkus, Micronaut: volledige framework-integratie out of the box

Key Takeaways

Het framework bestaat omdat incident-post-mortems actiepunten bevatten die nooit worden uitgevoerd. Niet omdat engineers lui zijn, maar omdat de kloof tussen "we zouden deze foutmodus moeten testen" en "hier is een test die deze foutmodus test" te groot was.

448 L1-annotaties voor chirurgische controle. 92 L2-composieten voor gedocumenteerde foutfamilies. 64 L3-incidentscenario's waarmee je een Slack-post-mortem in vijf minuten kunt omzetten in een falende CI-test.

De chaoslaag vervangt geen unit-tests of integratietests. Het completeert het beeld. Fase 4 is de fase waarin je CI-pipeline leert productie-incidenten te reproduceren voordat ze opnieuw plaatsvinden.

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

Engineering Team

Senior Solutions Architects

We bouwen al gedistribueerde systemen voordat 'microservices' een begrip was. Onze littekens vertellen verhalen.