Das Problem mit dem Mocking von Systemaufrufen
Fehlerinjektions-Bibliotheken lassen sich in zwei große Lager einteilen. Das erste Lager mockt auf der Anwendungsschicht: ein Repository mocken, einen HTTP-Client mocken, eine Exception zurückgeben. Sauber. Schnell. Und völlig losgelöst davon, was die reale Betriebsumgebung tut, wenn sie degradiert.
Das zweite Lager greift zu Infrastruktur-Werkzeugen: Toxiproxy für TCP-Proxy-Fehler, tc netem für kernel-seitiges Paket-Shaping, Pumba oder Chaos Monkey für das Beenden von Containern. Mächtig, aber mit externem Setup-Aufwand, Konflikten mit Test-Isolation und oft als separatem Prozess, den die Anwendung nicht wahrnehmen kann.
Beide Lager verpassen etwas: die libc-Grenze. Die Schicht, an der Ihre Anwendung — unabhängig von Sprache oder Laufzeit — an das Betriebssystem übergibt. Wo `read()` tatsächlich passiert. Wo `getaddrinfo()` Namen auflöst. Wo `mmap()` Speicher allokiert. Wo `clock_gettime()` die Zeit zurückgibt.
Wenn man hier abfängt, fängt man alles ab. Und wenn die Fehler, die man auf dieser Schicht injiziert, von den Fehlern, die der Kernel erzeugt, nicht zu unterscheiden sind — weil sie dieselben errno-Codes, dieselben Rückgabewert-Konventionen, dieselben Timing-Charakteristiken verwenden — dann ist das, was man gebaut hat, kein Mock. Es ist eine Simulation. Eine kernel-echte Simulation.
Warum die libc-Grenze die richtige Schicht ist
LD_PRELOAD ist eine Linux-Funktion des dynamischen Linkers, die gemeinsam genutzte Objekte in die Link-Map eines Prozesses vor allen anderen Bibliotheken einfügt — einschließlich libc. Wenn eine Anwendung `connect()` aufruft, durchläuft die Symbolauflösung des dynamischen Linkers die Link-Map der Reihe nach. Wenn eine vorgeladene Bibliothek ein Symbol namens `connect` exportiert, gewinnt dieses Symbol.
Das gibt uns Interposition: die Fähigkeit, beliebige libc-Funktionen zu umhüllen, ohne den aufrufenden Code zu ändern oder irgendetwas neu zu kompilieren. Und das ist verfügbar, seit bevor die meisten aktuellen Produktionssysteme entworfen wurden.
Die wesentlichen Eigenschaften, die dies zur richtigen Schicht machen:
**Sprachagnostische Universalität.** Pythons `socket.connect()` läuft über glibc. Node.js's `net.connect()` läuft über glibc. Javas `Socket.connect()` — via JNI — läuft über glibc. Eine einzige vorgeladene Bibliothek fängt alle davon ab.
**Kernel-echte Fehler.** Wir geben echte errno-Werte zurück. Der aufrufende Code kann einen echten ECONNREFUSED nicht von unserem injizierten ECONNREFUSED unterscheiden. Der Circuit Breaker sieht einen echten Fehler, keinen testbewussten simulierten.
**Null Anwendungsänderungen.** Keine Agenten. Keine API-Importe. Keine Test-Framework-Integration auf Anwendungsebene erforderlich. Die Anwendung läuft normal; die Fehler erscheinen darunter.
**Prozessbereich.** Fehler sind auf einen einzelnen Prozess begrenzt. Führen Sie Ihren Testprozess mit einem Satz von Fehlern aus; führen Sie Ihre Abhängigkeit in einem separaten Container ohne Fehler aus. Keine Kreuzkontamination.
Sechs Fehlerdomänen
Der Bibliothekssatz umfasst sechs unabhängige Fehlerdomänen, jede als separates Shared Object. Sie können einzeln geladen oder kombiniert werden — die Kombination ist sicher, weil jede Bibliothek eine disjunkte Menge von Symbolen besitzt (Symbol Ownership Theorem, im Detail in den Architekturdokumenten dokumentiert).
libchaos-io.so — File I/O
Hooks: open/openat, read/readv, write/writev, pread/preadv, pwrite/pwritev,
close, fsync/fdatasync, ftruncate, fallocate, unlinkat, renameat,
sendfile, copy_file_range
Effects: ERRNO (EIO, ENOSPC, EDQUOT, ...), LATENCY,
TORN writes (short return < requested), CORRUPT reads (bit flip)
libchaos-net.so — Network Sockets
Hooks: socket/socketpair, bind, listen, connect, accept,
send/sendto/sendmsg/sendmmsg, recv/recvfrom/recvmsg/recvmmsg,
poll, select, epoll_wait/epoll_pwait
Effects: ERRNO (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ...), LATENCY, CORRUPT
libchaos-dns.so — DNS Resolution
Hooks: getaddrinfo, getnameinfo
Effects: Name rewrites, address synthesis, EAI_AGAIN, EAI_FAIL, EAI_NONAME,
EAI_MEMORY, answer shuffling, family filtering, result limiting
libchaos-time.so — Clock & Timing
Hooks: clock_gettime (all POSIX clocks), nanosleep, usleep
Effects: ERRNO, LATENCY, OFFSET (signed millisecond time skew)
libchaos-memory.so — Memory Allocation
Hooks: mmap, munmap, mprotect, madvise
Effects: ERRNO (ENOMEM, ENFILE), LATENCY
libchaos-process.so — Process Lifecycle
Hooks: pthread_create, fork, posix_spawn/posix_spawnp, execve/execveat,
waitpid
Effects: ERRNO, LATENCY, FAIL_AFTER (allow N calls then fail permanently) Symbol-Interposition: Wie es wirklich funktioniert
Der Mechanismus ist im Prinzip einfach, in der Ausführung sorgfältig.
**Schritt 1 — Bibliotheks-Init.** Wenn das vorgeladene `.so` geladen wird, läuft sein Konstruktor. Er ruft `dlsym(RTLD_NEXT, "connect")` auf, um das echte libc `connect` zu finden. RTLD_NEXT überspringt das aufrufende DSO in der Link-Map und gibt das nächste passende Symbol zurück — die echte libc-Implementierung. Dieser Zeiger wird in einer statischen Variable gecacht. Kein dlsym pro Aufruf im Hot Path.
**Schritt 2 — Aufruf-Abfang.** Wenn die Anwendung `connect()` aufruft, findet der dynamische Linker zuerst unser Symbol (Link-Map-Reihenfolge). Unser Wrapper läuft: Er liest die Konfigurationsdatei (oder einen gecachten Snapshot), wertet aus, ob ein Fehler ausgelöst werden soll, basierend auf dem Endpunkt-Selektor und der Wahrscheinlichkeit, und injiziert entweder den Fehler oder ruft über den gecachten Zeiger die echte libc-Funktion auf.
**Schritt 3 — Symbol-Ownership-Invariante.** Wenn zwei vorgeladene Bibliotheken beide `read()` umhüllen wollen, zeigt RTLD_NEXT bei einer von ihnen auf die andere, was zu doppelter Injektion führt. Wir verhindern das durch strikte Symbolzuweisung: Jedes libc-Symbol gehört genau einer Bibliothek im Satz. `libchaos-net.so` besitzt nicht `read()` — I/O-Symbole gehören zu `libchaos-io.so`. Die Bibliotheken kombinieren sicher, weil sie dafür ausgelegt sind.
# Compose safely — net + dns + io loaded simultaneously, no double-injection
LD_PRELOAD=libchaos-net.so:libchaos-dns.so:libchaos-io.so ./your-service
# Each library reads its own config file independently
/tmp/.chaos-net.conf
/tmp/.chaos-dns.conf
/tmp/.chaos-io.conf Die Konfigurationsdatei-Schnittstelle
Es gibt keine API. Es gibt keinen öffentlichen Header. Es gibt keine Bibliothek, gegen die man linken muss. Die gesamte Schnittstelle ist eine Klartextkonfigurationsdatei.
Das war eine bewusste Entscheidung. Eine Konfigurationsdatei kann von einem Shell-Skript, einem CI-Job, einem Test-Harness in beliebiger Sprache oder einem Menschen im Terminal geschrieben werden. Sie erfordert keine Build-System-Integration. Sie kann ausgetauscht werden, während der Prozess läuft. Sie kann zusammen mit Testdaten versioniert werden. Und sie schafft keinerlei Kopplung zwischen der Chaos-Bibliothek und der zu testenden Anwendung.
Die Konfigurationssyntax lautet `selector:operation:effect:value:probability`. Einige repräsentative Beispiele:
# libchaos-net.conf — network fault examples
#
# ECONNREFUSED on 10% of connects to the payments service
tcp4://payments.internal:8080:connect:ECONNREFUSED:0.10
#
# ECONNRESET on 5% of recv() on any TCP socket
tcp4://*:*:recv:ECONNRESET:0.05
#
# 200ms latency on 100% of connects to the analytics backend
tcp4://analytics.internal:9000:connect:LATENCY:200
#
# Corrupt 1% of sent packets to any endpoint (single bit flip)
tcp4://*:*:send:CORRUPT:0.01
# libchaos-dns.conf — DNS fault examples
#
# Transient failure for all *.internal lookups
dns://*.internal:EAI_AGAIN:0.20
#
# Permanent failure for a specific service
dns://legacy-payments.svc.cluster.local:EAI_FAIL:1.0
# libchaos-io.conf — filesystem fault examples
#
# Torn writes (short return) on WAL file
/data/wal:write:TORN:0.05
#
# EIO on 1% of reads from the config directory
/etc/app:read:EIO:0.01 Lockfreies Neuladen der Konfiguration zur Laufzeit
Eine der weniger offensichtlichen Engineering-Herausforderungen ist das Hot-Reload der Konfiguration in einem Multi-Thread-Prozess. Der naive Ansatz — die Datei bei jedem Aufruf lesen — ist zu teuer. Der naive Mutex-basierte Ansatz — einen Mutex halten, während der Konfigurations-Snapshot aktualisiert wird — blockiert jeden abgefangenen Syscall während des Neuladens.
Wir verwenden einen lockfreien Ansatz auf Basis von atomarem Compare-and-Swap:
**Zwei Snapshot-Puffer.** Einer ist aktiv; der andere steht zum Schreiben bereit. Ein 64-Bit-Atomic-Slot hält die mtime der aktuell aktiven Konfiguration.
**Erkennung des Reload-Gewinners.** Jeder Abfang prüft die mtime der Konfigurationsdatei gegen den gecachten Wert. Bei Abweichung gewinnt ein Thread das CAS-Rennen zum Start des Neuladens. Verlierer sehen den RELOADING-Zustand und warten kurz.
**Atomares Umschalten.** Der Gewinner schreibt die neue Konfiguration in den inaktiven Puffer und tauscht dann atomar den aktiven Index. Ab diesem Punkt sehen alle Threads die neue Konfiguration. Der alte Puffer steht für den nächsten Reload-Zyklus bereit.
Das Reload-Fenster liegt typischerweise unter 15 Mikrosekunden. Leser werden im Steady State niemals durch Schreiber blockiert. Das macht es praktisch, Fehlerraten zwischen Testmethoden zu ändern, ohne den Prozess neu zu starten.
CI-Integration: Drei Zeilen YAML
Die Integration in eine CI-Pipeline ist bewusst minimal gehalten. Vorkompilierte Binärdateien für vier Plattformkombinationen (glibc/musl × amd64/arm64) sind verfügbar. Kein Build-Schritt erforderlich — laden Sie die richtige Binärdatei für Ihr CI-Runner-Betriebssystem und Ihre Architektur herunter, schreiben Sie eine Konfigurationsdatei, setzen Sie LD_PRELOAD.
# GitHub Actions example: inject network chaos for integration tests
- name: Download libchaos
run: |
curl -fsSL https://github.com/macstab/chaos-testing-libraries/releases/latest/\
download/libchaos-net-glibc-amd64.so -o /tmp/libchaos-net.so
- name: Write chaos config
run: |
cat > /tmp/.chaos-net.conf << 'EOF'
tcp4://db.internal:5432:connect:ECONNREFUSED:0.10
tcp4://cache.internal:6379:connect:ETIMEDOUT:0.05
dns://*.internal:EAI_AGAIN:0.15
EOF
- name: Run integration tests with chaos
env:
LD_PRELOAD: /tmp/libchaos-net.so
run: ./gradlew integrationTest Thread-lokale Interna: Kein globaler Zustand
Multi-Thread-Korrektheit wird durch thread-lokalen Speicher statt Sperren durchgesetzt, wo immer dies möglich ist.
**PRNG pro Thread.** Jeder Thread hat seinen eigenen Zufallszahlengenerator-Zustand. Fehlersequenzen sind pro Thread unabhängig — kein globaler Zufallsengpass, kein Streit um gemeinsamen Zustand. Das bedeutet auch, dass Fehlersequenzen pro Thread bei gleichem Seed deterministisch sind, was für die Reproduktion von Testfehlern wichtig ist.
**Rekursionsschutz.** Wenn der Chaos-Code libc-Interna aufruft (stat zum Prüfen der mtime, read zum Lesen der Konfigurationsdatei), würden diese Aufrufe selbst abgefangen, was unendliche Rekursion verursacht. Eine `__thread int guard`-Variable verhindert das: Wenn die Rekursionstiefe ungleich null ist, rufen wir sofort die echte Funktion auf.
**FD-zu-Pfad-Cache.** Der Datei-I/O-Interceptor muss einen Dateideskriptor einem Pfad zuordnen (für Pfad-Präfix-Regel-Matching). `/proc/self/fd/<fd>` bei jedem I/O-Aufruf zu lesen ist zu teuer. Wir pflegen einen 32-Slot-Direct-Mapped-Thread-lokalen Cache (fd % 32), der bei `open()` befüllt und bei `close()` invalidiert wird. Die Cache-Miss-Rate für reale Workloads liegt unter 3%.
Was abgefangen werden kann und was nicht
Wir dokumentieren die Grenzen explizit, anstatt die Nutzer diese selbst entdecken zu lassen.
**Kann nicht abgefangen werden:**
- **Direkte Syscalls.** Die Go-Laufzeitumgebung gibt Syscalls direkt über das `syscall`-Paket aus und umgeht dabei libc vollständig. Go-Programme, die ohne CGO kompiliert wurden, sind gegen LD_PRELOAD-Chaos bei ihrem Netzwerk-I/O immun. (Java via JNI ist in Ordnung; JNI läuft über glibc.) - **Statische Binärdateien.** LD_PRELOAD ist ein Mechanismus des dynamischen Linkers. Statische Binärdateien haben keinen dynamischen Linker; Vorladen hat keine Wirkung. - **Setuid/Setgid-Binärdateien.** Der Kernel entfernt LD_PRELOAD für Setuid-Binärdateien (AT_SECURE-Semantik). Das ist eine Sicherheitsfunktion, auf die wir uns korrekt verlassen. - **vDSO-Fast-Path-Clocks.** Einige clock_gettime-Aufrufe laufen über das vDSO, nicht über glibc. Unsere Zeitbibliothek fängt auf der glibc-Wrapper-Ebene ab, die downstream vom vDSO liegt. In der Praxis bedeutet das, dass der Fehler beim glibc-Aufruf vor der vDSO-Suche ausgelöst wird — der Nettoeffekt ist derselbe, aber die interne Uhr des Kernels ist nicht betroffen.
**Kann abgefangen werden:** - Jeder dynamisch gelinkte Prozess unter Linux (glibc oder musl) - Java via JNI — einschließlich aller Socket-, Datei-, DNS- und Zeit-Aufrufe im JDK - Python, Node.js, Ruby, PHP — alle verwenden glibc für System-I/O - Rust-Binärdateien, die std verwenden (was glibc verwendet) oder CGO-überbrückte C-Bibliotheken
- Java (JNI-Pfad): vollständige Abdeckung — Netzwerk, DNS, Datei, Clock, Speicher
- Python / Node.js / Ruby: vollständige Abdeckung via glibc
- Go (CGO=1 oder C-Bibliotheken): teilweise Abdeckung — CGO-Aufrufe abgefangen, reine Go-Syscalls umgehen den Interceptor
- Go (rein, CGO=0): keine Abdeckung — direktes Syscall-ABI, LD_PRELOAD hat keine Wirkung
- Statische Binärdateien: per Definition keine Abdeckung
Performance: Die Overhead-Zahlen
Wir messen den Passthrough-Overhead mit rdtsc-serialisierten Microbenchmarks, um Worst-Case-Zahlen zu erhalten. Der Hot Path für einen durchlaufenden Aufruf (kein Fehler konfiguriert):
- **Config-Cache-Treffer (kein Reload erforderlich):** ~20–40 ns Overhead über die eigentlichen Syscall-Kosten hinaus. Das ist eine mtime-stat-Prüfung (in einem Atomic-Slot gecacht), ein Regel-Scan (linear über N Regeln, typischerweise N < 16) und ein Aufruf über den gecachten Funktionszeiger. - **Cache-Miss (Konfigurationsdatei neu geladen):** Ein stat()-Syscall plus ein read() der Konfigurationsdatei — typischerweise 150–300 ns insgesamt, und nur beim ersten Aufruf nach einer Konfigurationsänderung. - **Injizierter Fehler:** Der Fehler-Overhead selbst (Schlafen für LATENCY, errno setzen für ERRNO) dominiert; die Wrapper-Kosten sind vernachlässigbar.
Für eine gut konfigurierte Testsuite mit einer kleinen Anzahl von Regeln liegt der Passthrough-Overhead unterhalb des Rauschpegels eines Netzwerkaufrufs. Sie zahlen Dutzende Nanosekunden zusätzlich zu Operationen, die Mikrosekunden bis Millisekunden kosten.
Key Takeaways
Die C99 LD_PRELOAD-Chaos-Bibliothek ist das Ergebnis, wenn kernel-echte Fehlerinjektion von Grund auf nach ersten Prinzipien entworfen wird, anstatt aus bestehenden Werkzeugen zusammengestückelt zu werden.
Sechs unabhängige Fehlerdomänen. Disjunkte Symbol-Ownership, damit sie sicher kombiniert werden können. Eine Klartextkonfigurations-Schnittstelle, die aus jeder Sprache oder Toolchain funktioniert. Lockfreies Hot-Reload, damit Fehlerraten zwischen Testmethoden geändert werden können. Thread-lokaler PRNG, damit Fehlersequenzen unabhängig und deterministisch sind. Vorkompilierte Binärdateien für glibc und musl auf amd64 und arm64.
Und 100% Zeilentestabdeckung, die in CI durchgesetzt wird, weil wir genug Software auf Cartridges ohne Patch-Buttons ausgeliefert haben, um zu wissen, dass jede ungetestete Zeile ein Produktionsvorfall ist, der nur darauf wartet zu passieren.
Der Quellcode ist unter chaos-testing-libraries verfügbar. Das Java-Test-Framework, das diese Bibliotheken automatisch in Ihre Docker-Container injiziert, befindet sich unter chaos-testing.
Engineering Team
Senior Solutions Architects
Wir bauen verteilte Systeme, seit bevor 'Microservices' ein Begriff war. Unsere Narben erzählen Geschichten.