Iniezione di Guasti Reale a Livello Kernel: Una Libreria Chaos C99 con LD_PRELOAD per Qualsiasi Processo
Ingegneria category.testing 26 giugno 2026

Iniezione di Guasti Reale a Livello Kernel: Una Libreria Chaos C99 con LD_PRELOAD per Qualsiasi Processo

Sei domini di guasto, zero modifiche al codice applicativo, qualsiasi binario ELF. Una libreria chaos in C99 che intercetta le chiamate libc a livello di linker dinamico — consegnando guasti reali a livello kernel a Java, Go, Python, Node.js e tutto il resto.

E
Engineering Team
Senior Solutions Architects
16 min di lettura

Il Problema con il Mock delle Chiamate di Sistema

Le librerie di iniezione di guasti si dividono in due grandi categorie. La prima categoria esegue il mock a livello applicativo: si fa il mock di un repository, di un client HTTP, si restituisce un'eccezione. Pulito. Veloce. E completamente disconnesso da ciò che il vero ambiente operativo fa quando si degrada.

La seconda categoria ricorre a strumenti di infrastruttura: Toxiproxy per i guasti del proxy TCP, tc netem per la modellatura dei pacchetti a livello kernel, Pumba o Chaos Monkey per l'eliminazione dei container. Potenti, ma richiedono una configurazione esterna, confliggono con l'isolamento dei test e spesso girano in un processo separato di cui l'applicazione non può essere consapevole.

Entrambe le categorie tralasciano qualcosa: il confine libc. Il livello in cui la tua applicazione, indipendentemente dal linguaggio o dal runtime, passa il controllo al sistema operativo. Dove avviene davvero `read()`. Dove `getaddrinfo()` risolve i nomi. Dove `mmap()` alloca la memoria. Dove `clock_gettime()` restituisce il tempo.

Se intercetti qui, intercetti tutto. E se i guasti che inietti a questo livello sono indistinguibili da quelli prodotti dal kernel — perché usano gli stessi codici errno, le stesse convenzioni sui valori di ritorno, le stesse caratteristiche di temporizzazione — allora ciò che hai costruito non è un mock. È una simulazione. Una simulazione reale a livello kernel.

Perché il Confine libc è il Livello Giusto

LD_PRELOAD è una funzionalità del linker dinamico Linux che inserisce oggetti condivisi nella link-map di un processo prima di qualsiasi altra libreria — inclusa libc. Quando un'applicazione chiama `connect()`, la risoluzione dei simboli del linker dinamico percorre la link-map in ordine. Se una libreria precaricata esporta un simbolo chiamato `connect`, quel simbolo ha la precedenza.

Questo ci fornisce l'interposizione: la capacità di avvolgere qualsiasi funzione libc senza modificare il codice chiamante né ricompilare nulla. Ed è disponibile da prima che la maggior parte degli attuali sistemi in produzione fossero progettati.

Le proprietà chiave che rendono questo il livello giusto:

**Universalità indipendente dal linguaggio.** Il `socket.connect()` di Python passa attraverso glibc. Il `net.connect()` di Node.js passa attraverso glibc. Il `Socket.connect()` di Java — tramite JNI — passa attraverso glibc. Una singola libreria precaricata le intercetta tutte.

**Guasti reali a livello kernel.** Restituiamo veri valori errno. Il codice chiamante non riesce a distinguere un vero ECONNREFUSED dal nostro ECONNREFUSED iniettato. Il circuit breaker vede un vero fallimento, non uno simulato consapevole del test.

**Zero modifiche all'applicazione.** Nessun agente. Nessuna importazione di API. Nessuna integrazione con il framework di test richiesta a livello applicativo. L'applicazione gira normalmente; i guasti appaiono al di sotto di essa.

**Scope di processo.** I guasti sono circoscritti a un singolo processo. Esegui il tuo processo di test con un insieme di guasti; esegui la tua dipendenza in un container separato senza alcun guasto. Nessuna contaminazione incrociata.

Sei Domini di Guasto

L'insieme di librerie copre sei domini di guasto indipendenti, ciascuno come oggetto condiviso separato. Possono essere caricati individualmente o composti — la composizione è sicura perché ogni libreria possiede un insieme disgiunto di simboli (il Teorema di Proprietà dei Simboli, documentato in dettaglio nella documentazione architetturale).

text
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)

Interposizione dei Simboli: Come Funziona Davvero

Il meccanismo è semplice nei principi, ma attento nell'esecuzione.

**Passo 1 — Inizializzazione della libreria.** Quando il `.so` precaricato viene caricato, esegue il suo costruttore. Chiama `dlsym(RTLD_NEXT, "connect")` per trovare il vero `connect` di libc. RTLD_NEXT salta il DSO chiamante nella link-map e restituisce il simbolo corrispondente successivo — la vera implementazione di libc. Questo puntatore è memorizzato in una variabile statica. Nessun dlsym per ogni chiamata nel percorso caldo.

**Passo 2 — Intercettazione della chiamata.** Quando l'applicazione chiama `connect()`, il linker dinamico trova prima il nostro simbolo (ordine della link-map). Il nostro wrapper viene eseguito: legge il file di configurazione (o uno snapshot memorizzato nella cache), valuta se un guasto deve scattare in base al selettore dell'endpoint e alla probabilità, e poi inietta il guasto oppure chiama la vera funzione libc tramite il puntatore memorizzato.

**Passo 3 — Invariante di proprietà dei simboli.** Se due librerie precaricate tentano entrambe di avvolgere `read()`, una di esse otterrà RTLD_NEXT che punta all'altra, causando una doppia iniezione. Preveniamo questo con un'assegnazione rigorosa dei simboli: ogni simbolo libc è di proprietà di esattamente una libreria nell'insieme. `libchaos-net.so` non possiede `read()` — i simboli I/O appartengono a `libchaos-io.so`. Le librerie si compongono in sicurezza perché sono progettate per farlo.

bash
# 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

L'Interfaccia del File di Configurazione

Non c'è alcuna API. Non c'è alcun header pubblico. Non c'è alcuna libreria a cui collegarsi. L'intera interfaccia è un semplice file di configurazione in testo normale.

Questa è stata una scelta deliberata. Un file di configurazione può essere scritto da uno script shell, da un job CI, da un test harness in qualsiasi linguaggio, o da un essere umano in un terminale. Non richiede integrazione con il sistema di build. Può essere sostituito a caldo mentre il processo è in esecuzione. Può essere versionato insieme ai dati di test. E crea un accoppiamento zero tra la libreria chaos e l'applicazione sotto test.

La sintassi di configurazione è `selettore:operazione:effetto:valore:probabilità`. Alcuni esempi rappresentativi:

text
# 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

Ricarica della Configurazione Lock-Free a Runtime

Una delle sfide ingegneristiche meno ovvie è il ricaricamento a caldo della configurazione in un processo multi-threaded. L'approccio ingenuo — leggere il file ad ogni chiamata — è troppo costoso. L'approccio ingenuo basato su lock — tenere un mutex mentre si aggiorna lo snapshot della configurazione — blocca ogni syscall intercettata durante il ricaricamento.

Usiamo un approccio lock-free basato su compare-and-swap atomico:

**Due buffer snapshot.** Uno è attivo; l'altro è disponibile per la scrittura. Uno slot atomico a 64 bit mantiene l'mtime della configurazione attualmente attiva.

**Rilevamento del vincitore del ricaricamento.** Ogni intercettazione confronta l'mtime del file di configurazione con il valore memorizzato nella cache. In caso di mismatch, un thread vince la gara CAS per iniziare il ricaricamento. I perdenti vedono lo stato RELOADING e attendono brevemente.

**Scambio atomico.** Il vincitore scrive la nuova configurazione nel buffer inattivo, poi scambia atomicamente l'indice attivo. Da questo momento, tutti i thread vedono la nuova configurazione. Il vecchio buffer è disponibile per il prossimo ciclo di ricaricamento.

La finestra di ricaricamento è tipicamente inferiore a 15 microsecondi. I lettori non si bloccano mai sugli scrittori in stato stazionario. Questo rende pratico modificare le percentuali di guasto tra i metodi di test senza riavviare il processo.

Integrazione CI: Tre Righe di YAML

L'integrazione in una pipeline CI è intenzionalmente minimale. Sono disponibili binari precompilati per quattro combinazioni di piattaforme (glibc/musl × amd64/arm64). Nessun passaggio di build necessario — scarica il binario giusto per il sistema operativo e l'architettura del tuo runner CI, scrivi un file di configurazione, imposta LD_PRELOAD.

yaml
# 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

Internals Thread-Local: Nessuno Stato Globale

La correttezza multi-threaded è garantita tramite lo storage thread-local piuttosto che con i lock ove possibile.

**PRNG per thread.** Ogni thread ha il proprio stato del generatore di numeri casuali. Le sequenze di guasti sono indipendenti per thread — nessun collo di bottiglia globale della casualità, nessuna contesa sullo stato condiviso. Questo significa anche che le sequenze di guasti sono deterministiche per thread dato lo stesso seed, il che è importante per riprodurre i fallimenti dei test.

**Guardia alla ricorsione.** Quando il codice chaos chiama gli interni di libc (stat per controllare l'mtime, read per leggere il file di configurazione), quelle chiamate verrebbero esse stesse intercettate, causando una ricorsione infinita. Una variabile `__thread int guard` lo previene: se la profondità di ricorsione è diversa da zero, chiamiamo direttamente la funzione reale.

**Cache da FD a path.** L'intercettore di I/O su file deve associare un file descriptor a un path (per la corrispondenza delle regole per prefisso di path). Leggere `/proc/self/fd/<fd>` ad ogni chiamata I/O è troppo costoso. Manteniamo una cache thread-local a mappatura diretta di 32 slot (fd % 32) inizializzata su `open()` e invalidata su `close()`. Il tasso di cache miss per carichi di lavoro reali è inferiore al 3%.

Cosa Può e Non Può Essere Intercettato

Documentiamo i limiti esplicitamente piuttosto che lasciare che gli utenti li scoprano da soli.

**Non può intercettare:**

- **Syscall dirette.** Il runtime di Go emette syscall direttamente tramite il pacchetto `syscall`, bypassando completamente libc. I programmi Go compilati senza CGO sono immuni al chaos LD_PRELOAD sul loro I/O di rete. (Java tramite JNI va bene; JNI passa attraverso glibc.) - **Binari statici.** LD_PRELOAD è un meccanismo del linker dinamico. I binari statici non hanno un linker dinamico; il precaricamento non ha alcun effetto. - **Binari setuid/setgid.** Il kernel rimuove LD_PRELOAD per i binari setuid (semantiche AT_SECURE). Questa è una funzionalità di sicurezza su cui ci affidiamo correttamente. - **Clock vDSO fast-path.** Alcune chiamate clock_gettime passano attraverso il vDSO, non glibc. La nostra libreria time intercetta al livello del wrapper glibc, che si trova a valle del vDSO. In pratica, questo significa che il guasto scatta sulla chiamata glibc prima della ricerca vDSO — l'effetto netto è lo stesso, ma l'orologio interno del kernel non è influenzato.

**Può intercettare:** - Qualsiasi processo collegato dinamicamente su Linux (glibc o musl) - Java tramite JNI — incluse tutte le chiamate socket, file, DNS e time nel JDK - Python, Node.js, Ruby, PHP — tutti usano glibc per I/O di sistema - Binari Rust che usano std (che usa glibc) o librerie C collegate tramite CGO

  • Java (percorso JNI): copertura completa — rete, DNS, file, clock, memoria
  • Python / Node.js / Ruby: copertura completa tramite glibc
  • Go (CGO=1 o librerie C): copertura parziale — le chiamate CGO vengono intercettate, le syscall pure Go le aggirano
  • Go (puro, CGO=0): nessuna copertura — ABI syscall diretta, LD_PRELOAD non ha effetto
  • Binari statici: nessuna copertura per definizione

Prestazioni: I Numeri dell'Overhead

Misuriamo l'overhead in passthrough con microbenchmark serializzati tramite rdtsc per ottenere i numeri peggiori. Il percorso caldo per una chiamata che passa (nessun guasto configurato):

- **Cache hit della configurazione (nessun ricaricamento necessario):** ~20–40 ns di overhead oltre al costo effettivo della syscall. Questo è un controllo mtime con stat (memorizzato in uno slot atomico), una scansione delle regole (lineare su N regole, tipicamente N < 16), e una chiamata attraverso il puntatore di funzione memorizzato. - **Cache miss (file di configurazione ricaricato):** Una syscall stat() più un read() del file di configurazione — tipicamente 150–300 ns in totale, e solo alla prima chiamata dopo una modifica della configurazione. - **Guasto iniettato:** L'overhead del guasto stesso (sleep per LATENCY, impostazione di errno per ERRNO) domina; il costo del wrapper è trascurabile.

Per una suite di test ben configurata con un piccolo numero di regole, l'overhead in passthrough è al di sotto del livello di rumore di una chiamata di rete. Si pagano decine di nanosecondi in aggiunta a operazioni che costano da microsecondi a millisecondi.

Key Takeaways

La libreria chaos C99 con LD_PRELOAD è ciò che appare l'iniezione di guasti reale a livello kernel quando viene progettata a partire da principi fondamentali piuttosto che assemblata da strumenti esistenti.

Sei domini di guasto indipendenti. Proprietà disgiunta dei simboli così che si compongano in sicurezza. Un'interfaccia di configurazione in testo normale che funziona da qualsiasi linguaggio o toolchain. Ricaricamento a caldo lock-free così che le percentuali di guasto possano cambiare tra i metodi di test. PRNG thread-local così che le sequenze di guasti siano indipendenti e deterministiche. Binari precompilati per glibc e musl su amd64 e arm64.

E copertura al 100% delle righe applicata in CI, perché abbiamo distribuito abbastanza software su cartucce senza pulsanti di patch da sapere che ogni riga non coperta è un incidente in produzione in attesa di accadere.

Il sorgente è disponibile su chaos-testing-libraries. Il framework di test Java che inietta queste librerie nei tuoi container Docker automaticamente è disponibile su chaos-testing.

#chaos-engineering #c99 #ldpreload #elf #libc #fault-injection #ci-cd #linux #resilience
E

Engineering Team

Senior Solutions Architects

Costruiamo sistemi distribuiti da prima che 'microservizi' fosse una parola. Le nostre cicatrici raccontano storie.