Von Produktionsvorfällen zu JUnit 5-Annotationen: Ein dreistufiges Chaos-Testing-Framework
Engineering category.testing 26. Juni 2026

Von Produktionsvorfällen zu JUnit 5-Annotationen: Ein dreistufiges Chaos-Testing-Framework

448 L1-Syscall-Primitive. 92 L2-Fault-Composites. 64 L3-Produktionsvorfallszenarien, kodiert als einzelne Annotationen. Ein JUnit 5-Erweiterungsframework, das Post-Mortems in CI-Gates für Spring Boot, Quarkus und Micronaut verwandelt.

E
Engineering Team
Senior Solutions Architects
20 Min. Lesezeit

Eine Annotation. Ihr letzter Produktionsvorfall. Reproduziert.

Irgendwo im Slack-Verlauf Ihres Teams gibt es ein Post-Mortem. Es enthält einen Abschnitt namens Grundursache, der beschreibt, wie ein Kubernetes Rolling Update ein 30-Sekunden-Fenster erzeugte, in dem iptables die Entfernung des neuen Pods noch nicht propagiert hatte und ECONNRESET bei aktiven Verbindungen zum alten Pod zurückgegeben wurde. Es gibt einen Abschnitt Abhilfemaßnahme, in dem steht, dass Sie Retry-with-Backoff konfiguriert haben. Es gibt einen Abschnitt Maßnahmen mit einem Kontrollkästchen neben Chaos-Test für dieses Szenario hinzufügen.

Dieses Kontrollkästchen wurde nie angehakt.

Nicht weil dem Team etwas daran liegt. Sondern weil das Schreiben eines Chaos-Tests für dieses spezifische Szenario erfordert zu verstehen, wie iptables-Verzögerung auf ECONNRESET-Raten abbildet, welcher libc-Aufruf abgefangen wird, welche Wahrscheinlichkeit richtig ist und wie der Fehler in einen Docker-Container injiziert wird, der in einer CI-Umgebung läuft.

Das ist eine Menge Vorwissen für ein Kontrollkästchen.

`@IncidentChaosK8sRollingUpdateRst(toxicity = 0.3)` ist ein Import und eine Annotation. Das ist das angehakte Kontrollkästchen.

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

Die dreistufige Annotationshierarchie

Das Framework organisiert Chaos-Szenarien in drei Stufen, die jeweils ein unterschiedliches Expertenniveau und verschiedene Anwendungsfälle ansprechen:

**L1 — Rohe Primitive (448 Annotationen):** Direkte Kontrolle auf Syscall-Ebene. Eine Annotation entspricht einer Fehlerregel: ein Errno, ein Operationstyp, eine Wahrscheinlichkeit. Dies ist die Ebene für Ingenieure, die genau wissen, welchen Kernel-Aufruf sie mit welcher Rate fehlschlagen lassen wollen. Volle Ausdruckskraft, keine Abstraktion.

**L2 — Benannte Composites (92 Annotationen):** Dokumentierte Fehlerfamilien, die jeweils 2–6 L1-Regeln zu einem benannten Muster kombinieren. Dies ist die Ebene für häufige Fehlerszenarien mit klar verstandener Form, die nicht auf einen spezifischen Vorfall zurückgeführt werden müssen. Die Standardwerte sind aus realen Fehleranalysen kalibriert.

**L3 — Vorfallszenarien (64 Annotationen):** Echte Produktionsvorfälle, kodiert als mehrbereichige Kompositionen. Jede L3-Annotation trägt eine Schweregradbewertung, einen Verweis auf den ursprünglichen Vorfall oder eine Branchenquelle und eine Composer-Klasse, die weiß, wie das Szenario in die richtige Mischung aus L1-Regeln über mehrere Domänen hinweg zerlegt wird. Dies ist die Ebene für „reproduziere, was im Post-Mortem passiert ist.

Die Stufen schließen sich nicht gegenseitig aus — Sie können L1-, L2- und L3-Annotationen auf derselben Testklasse oder -methode kombinieren, und sie stapeln sich korrekt. Methodenbezogene Annotationen überschreiben klassenbezogene Annotationen für die Dauer der Testmethode und stellen anschließend die klassenbezogenen Regeln wieder her.

L1: Rohe Syscall-Primitive — 448 Annotationen

Die L1-Stufe gibt Ihnen direkte Kontrolle über einzelne Syscall-Fehlerregeln. Jede Annotation folgt demselben Muster: Zieloperation + Errno oder Effekt + Wahrscheinlichkeit + optionales Host- oder Pfadmuster.

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: Benannte Fault-Composites — 92 dokumentierte Muster

Die L2-Stufe kodiert dokumentierte Fehlermuster mit kalibrierten Standardwerten. Jedes Composite stellt eine Fehlerform dar, die in Incident-Post-Mortems einen Namen hat und einen bekannten, vorhersehbaren Einfluss auf das Anwendungsverhalten besitzt.

  • @CompositeChaosConnectionRefused — ECONNREFUSED bei jedem connect(), simuliert einen ausgefallenen Downstream
  • @CompositeChaosTransientDnsFailure — EAI_AGAIN bei 15 % der getaddrinfo()-Aufrufe, simuliert CoreDNS-Überlastung
  • @CompositeChaosLowMemoryPressure — ENOMEM bei 0,5 % der mmap()-Aufrufe, simuliert Speicherkonkurrenz
  • @CompositeChaosNetworkFlap — abwechselndes ECONNRESET und Erfolg, simuliert eine instabile Netzwerkverbindung
  • @CompositeChaosSlowDisk — 50–200 ms Latenz bei write(), simuliert I/O-gesättigten Speicher
  • @CompositeChaosClockDrift — ±5 Sekunden vorzeichenbehaftete Taktabweichung, simuliert NTP-Drift
  • @CompositeChaosConnectionTimeout — ETIMEDOUT bei 5 % der connect()-Aufrufe mit 3 s Latenz, simuliert Firewall-Timeout
  • @CompositeChaosShortWrite — TORN bei 10 % der Schreibvorgänge, simuliert ein Dateisystem, das kurze Zählungen zurückgibt

L3: Echte Produktionsvorfälle — 64 annotierte Szenarien

Die L3-Stufe ist der Ort, an dem Chaos Engineering zum institutionellen Gedächtnis wird. Jede Annotation kodiert einen echten Fehlermodus — dokumentiert in Produktionsvorfallberichten, Kubernetes Issue-Trackern oder bekannten Fehleranalysen verteilter Systeme.

Das Composer-Muster übernimmt die Zerlegung: Jede L3-Annotation referenziert eine Composer-Klasse, die weiß, wie eine übergeordnete Vorfallsbeschreibung in die richtige Mischung aus L1-Regeln über mehrere Fehlerdomänen hinweg übersetzt wird.

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 auf jedem Produktions-Image

Bei Tests, die Anwendungscode innerhalb von Docker-Containern ausführen (Testcontainers-Muster), injiziert das Framework automatisch die C99-LD_PRELOAD-Bibliotheken in den Container, bevor er startet.

Der Injektionsmechanismus verwendet den Docker-API-Tar-Stream — kein Shell-Exec, keine Volumes, keine Init-Container:

1. Die JUnit-Erweiterung erkennt das Basis-Betriebssystem des Container-Images (Alpine → musl, Debian/RHEL/Ubuntu → glibc). 2. Sie wählt die korrekte vorkompilierte Binärdatei (glibc/musl × amd64/arm64 — vier Varianten, im JAR gebündelt). 3. Sie kopiert die `.so`-Datei via `DockerClient.copyArchiveToContainerCmd()` in den Container, bevor `container.start()` aufgerufen wird. 4. Sie setzt `LD_PRELOAD=/chaos/libchaos-net.so` (und weitere, wie deklariert) in der Container-Umgebung.

Das bedeutet, dass Chaos-Tests auf `distroless`-Images, `scratch`-basierten Images, UBI Minimal, Alpine — jedem Image, das einen Standard-ELF-Dynamic-Linker verwendet — funktionieren. Keine Dockerfile-Änderung, kein Sidecar, keine zusätzliche Infrastruktur.

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-Integrationen: Spring Boot, Quarkus, Micronaut

Jedes große Java-Framework erhält ein dediziertes Integrationsmodul, das sowohl die testseitige Orchestrierung als auch die optionale Laufzeit-Chaos-Steuerungsebene übernimmt.

**Spring Boot 3 und 4:** Der Test-Starter stellt `@ChaosTest` als zusammengesetzte Annotation bereit, registriert `ChaosControlPlane` und `ChaosSession` als Spring-Beans und bietet JUnit 5-Parameterauflösung für beide. Es kombiniert sich mit `@SpringBootTest`, `@DataJpaTest`, `@WebMvcTest` und jeder anderen Spring-Test-Slice-Annotation.

Der Laufzeit-Starter stellt `/actuator/chaos` als geschützten Actuator-Endpunkt für die Live-Szenarienverwaltung in Nicht-Produktionsumgebungen bereit.

**Quarkus:** Die Quarkus-Erweiterung stellt `@QuarkusChaosTest` als zusammengesetzte Quarkus-Testannotation mit CDI-Injektion von `ChaosControlPlane` bereit. Integriert sich mit `@QuarkusTest`, `@QuarkusIntegrationTest` und nativen Image-Tests.

**Micronaut:** Die Micronaut-Integration stellt `@MicronautChaosTest` mit `@Inject`-kompatiblen `ChaosControlPlane`- und `ChaosSession`-Beans bereit. Funktioniert mit `@MicronautTest` sowohl im JVM- als auch im nativen Testmodus.

  • Spring Boot 3/4: @ChaosTest als zusammengesetzte Annotation, ChaosControlPlane als Spring-Bean, /actuator/chaos-Endpunkt
  • Quarkus: @QuarkusChaosTest, CDI-Injektion, funktioniert mit @QuarkusIntegrationTest und nativ
  • Micronaut: @MicronautChaosTest, @Inject-kompatible Beans, JVM- und native Testunterstützung
  • Reines JUnit 5: @ExtendWith(ChaosTestingExtension.class) mit manuellem ChaosControlPlane — kein Framework erforderlich
  • Gradle und Maven kompatibel: alle Module auf Maven Central mit Standardkoordinaten veröffentlicht

Ressourcen und Einschränkungen: Die @Resources-Annotation

Über die Fehlerinjektion hinaus unterstützt das Framework das Testen von Ressourceneinschränkungen über `@Resources`. Dies deklariert CPU- und Arbeitsspeicherlimits, die die JUnit-Erweiterung auf den Docker-Container anwendet, bevor er startet — ohne die Container-Definition im Testcode zu ändern.

Dies ermöglicht eine Klasse von Tests, die häufig übersprungen wird: „Verhält sich mein Dienst korrekt, wenn er ressourcenbeschränkt ist? Dies ist ein realistisches Szenario in Kubernetes, wo Pods mit CPU-Limits und Arbeitsspeicherlimits laufen und wo das Erreichen dieser Limits Drosselung und OOM-Kills auslöst.

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

Die Plugin-Architektur: Eine Erweiterung, alle Container

Frühe Versionen des Frameworks hatten separate JUnit-Erweiterungen für jeden Container-Typ: eine für Redis, eine für PostgreSQL, eine für Kafka und so weiter. Jede Erweiterung hatte 200+ Zeilen nahezu identischen Lifecycle-Codes. Die Erweiterung auf einen neuen Container-Typ bedeutete Kopieren und Anpassen.

Die aktuelle Architektur verwendet ein `ChaosPlugin`-SPI, das über `ServiceLoader.load(ChaosPlugin.class)` gefunden wird. Jeder Container-Typ registriert eine Implementierung. Die einzige `ChaosTestingExtension` orchestriert alle.

Der SPI-Vertrag ist minimal: Container in der Testklasse finden, Verbindungsinformationen bereitstellen, Ressourcenanwendung handhaben und die Chaos-Einrichtung vor dem Start übernehmen. Die universelle Erweiterung übernimmt alles andere: Annotationsverarbeitung, Lifecycle-Management, Session-Isolierung, Parameterauflösung.

Dies eliminiert ~8.000 Zeilen Duplikation und macht das Framework erweiterbar für neue Container-Typen, ohne den Kern zu verändern.

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 Module, 11 Domänen, ein mentales Modell

Das Framework ist auf 43 Gradle-Module verteilt und deckt 11 Fehlerdomänen ab. Hier ist das vollständige Bild:

**Kerninfrastruktur:** chaos-core (JUnit 5-Erweiterung, SPI, Lifecycle), chaos-spi (Plugin-Vertrag), chaos-api (Selektor- und Effekttypen)

**Fehlerdomänen-Testpakete:** testpacks-connection (TCP/UDP), testpacks-dns (DNS-Auflösung), testpacks-memory (malloc/mmap), testpacks-process (fork/exec/Signale), testpacks-time (Takt, Zeitversatz), testpacks-filesystem (Datei-I/O)

**JVM-Integration:** chaos-java (JVM-Bytecode-Transport, containerseitige Verdrahtung)

**L3-Vorfallspakete:** testpacks-l3-kubernetes (Rolling Updates, DNS-Stürme), testpacks-l3-feign (Retry-Amplifikation), testpacks-l3-spring (Transaktions-Deadlock, OSIV-Auslastung), testpacks-l3-redis (Failover-Stürme), testpacks-l3-kafka (Broker-Failover), testpacks-l3-grpc (GOAWAY-Stürme)

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

  • 448 L1-Annotationen: rohe Syscall-Primitive über 6 Betriebssystem-Fehlerdomänen
  • 92 L2-Composites: benannte Fehlermuster mit kalibrierten, produktionsabgeleiteten Standardwerten
  • 64 L3-Vorfälle: echte Post-Mortems als einzelne Annotationen mit Schweregradbewertungen
  • 43 Gradle-Module: unabhängig versioniert, keine obligatorischen transitiven Abhängigkeiten
  • 4 Binärvarianten: glibc-amd64, glibc-arm64, musl-amd64, musl-arm64 — im JAR gebündelt
  • Spring Boot 3/4, Quarkus, Micronaut: vollständige Framework-Integration sofort einsatzbereit

Key Takeaways

Das Framework existiert, weil Incident-Post-Mortems Maßnahmen enthalten, die nie umgesetzt werden. Nicht weil Ingenieure faul sind, sondern weil die Werkzeug-Lücke zwischen „wir sollten diesen Fehlermodus testen und hier ist ein Test, der diesen Fehlermodus testet zu groß war.

448 L1-Annotationen für chirurgische Kontrolle. 92 L2-Composites für dokumentierte Fehlerfamilien. 64 L3-Vorfallszenarien, mit denen Sie ein Slack-Post-Mortem in fünf Minuten in einen fehlschlagenden CI-Test verwandeln können.

Die Chaos-Schicht ersetzt keine Unit-Tests oder Integrationstests. Sie vervollständigt das Bild. Phase 4 ist die Phase, in der Ihre CI-Pipeline lernt, Produktionsvorfälle zu reproduzieren, bevor sie erneut auftreten.

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

Engineering Team

Senior Solutions Architects

Wir bauen verteilte Systeme, seit bevor Microservices ein Begriff war. Unsere Narben erzählen Geschichten.