Injection de Pannes au Niveau Noyau : Une Bibliothèque de Chaos C99 LD_PRELOAD pour Tout Processus
Ingénierie category.testing 26 juin 2026

Injection de Pannes au Niveau Noyau : Une Bibliothèque de Chaos C99 LD_PRELOAD pour Tout Processus

Six domaines de pannes, zéro modification du code applicatif, tout binaire ELF. Une bibliothèque de chaos C99 qui intercepte les appels libc au niveau du linker dynamique — injectant des pannes réelles du noyau dans Java, Go, Python, Node.js, et tout le reste.

E
Engineering Team
Senior Solutions Architects
16 min de lecture

Le Problème du Mocking des Appels Système

Les bibliothèques d'injection de pannes se divisent en deux grandes catégories. La première catégorie effectue des mocks au niveau de la couche applicative : mocker un repository, mocker un client HTTP, retourner une exception. Propre. Rapide. Et totalement déconnecté de ce que fait réellement l'environnement d'exploitation quand il se dégrade.

La deuxième catégorie se tourne vers les outils d'infrastructure : Toxiproxy pour les pannes de proxy TCP, tc netem pour le façonnage de paquets au niveau noyau, Pumba ou Chaos Monkey pour tuer des conteneurs. Puissants, mais ils nécessitent une configuration externe, entrent en conflit avec l'isolation des tests, et s'exécutent souvent dans un processus séparé dont l'application ne peut pas avoir connaissance.

Les deux catégories ratent quelque chose : la frontière libc. La couche où votre application, quel que soit le langage ou le runtime, délègue au système d'exploitation. Là où `read()` se produit vraiment. Là où `getaddrinfo()` résout les noms. Là où `mmap()` alloue la mémoire. Là où `clock_gettime()` retourne l'heure.

Si vous interceptez ici, vous interceptez tout. Et si les pannes que vous injectez à cette couche sont indiscernables de celles que produit le noyau — parce qu'elles utilisent les mêmes codes errno, les mêmes conventions de valeur de retour, les mêmes caractéristiques temporelles — alors ce que vous avez construit n'est pas un mock. C'est une simulation. Une simulation réelle au niveau noyau.

Pourquoi la Frontière libc est la Bonne Couche

LD_PRELOAD est une fonctionnalité du linker dynamique Linux qui insère des objets partagés dans la link-map d'un processus avant toute autre bibliothèque — y compris libc. Lorsqu'une application appelle `connect()`, la résolution de symboles du linker dynamique parcourt la link-map dans l'ordre. Si une bibliothèque préchargée exporte un symbole nommé `connect`, ce symbole l'emporte.

Cela nous donne l'interposition : la capacité d'envelopper n'importe quelle fonction libc sans modifier le code appelant ni recompiler quoi que ce soit. Et c'est disponible depuis avant que la plupart des systèmes de production actuels aient été conçus.

Les propriétés clés qui font de cette couche le bon choix :

**Universalité indépendante du langage.** Le `socket.connect()` de Python passe par glibc. Le `net.connect()` de Node.js passe par glibc. Le `Socket.connect()` de Java — via JNI — passe par glibc. Une seule bibliothèque préchargée les intercepte tous.

**Pannes réelles du noyau.** Nous retournons de vraies valeurs errno. Le code appelant ne peut pas distinguer un vrai ECONNREFUSED de notre ECONNREFUSED injecté. Le circuit breaker voit une vraie défaillance, pas une simulée consciente du contexte de test.

**Zéro modification applicative.** Pas d'agents. Pas d'imports d'API. Aucune intégration de framework de test requise au niveau applicatif. L'application s'exécute normalement ; les pannes apparaissent en dessous.

**Portée processus.** Les pannes sont limitées à un seul processus. Exécutez votre processus de test avec un ensemble de pannes ; exécutez votre dépendance dans un conteneur séparé sans aucune panne. Pas de contamination croisée.

Six Domaines de Pannes

La suite de bibliothèques couvre six domaines de pannes indépendants, chacun sous forme d'objet partagé distinct. Ils peuvent être chargés individuellement ou composés — la composition est sûre car chaque bibliothèque possède un ensemble disjoint de symboles (le Théorème de Propriété des Symboles, documenté en détail dans la documentation d'architecture).

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)

Interposition de Symboles : Comment Ça Fonctionne Réellement

Le mécanisme est simple en principe, soigneux dans son exécution.

**Étape 1 — Initialisation de la bibliothèque.** Lorsque le `.so` préchargé est chargé, son constructeur s'exécute. Il appelle `dlsym(RTLD_NEXT, "connect")` pour trouver le vrai `connect` de libc. RTLD_NEXT saute le DSO appelant dans la link-map et retourne le symbole correspondant suivant — la vraie implémentation libc. Ce pointeur est mis en cache dans une variable statique. Pas de dlsym par appel dans le chemin critique.

**Étape 2 — Interception des appels.** Lorsque l'application appelle `connect()`, le linker dynamique trouve notre symbole en premier (ordre de la link-map). Notre wrapper s'exécute : il lit le fichier de configuration (ou un instantané mis en cache), évalue si une panne doit se déclencher en fonction du sélecteur de point de terminaison et de la probabilité, et soit injecte la panne, soit appelle la vraie fonction libc via le pointeur mis en cache.

**Étape 3 — Invariant de propriété des symboles.** Si deux bibliothèques préchargées essaient toutes deux d'envelopper `read()`, l'une d'elles obtiendra RTLD_NEXT pointant vers l'autre, provoquant une double injection. Nous prévenons cela avec une attribution stricte des symboles : chaque symbole libc appartient exactement à une bibliothèque de la suite. `libchaos-net.so` ne possède pas `read()` — les symboles d'E/S appartiennent à `libchaos-io.so`. Les bibliothèques se composent en toute sécurité car elles sont conçues pour cela.

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'Interface Fichier de Configuration

Il n'y a pas d'API. Il n'y a pas de header public. Il n'y a pas de bibliothèque à lier. L'intégralité de l'interface est un fichier de configuration en texte brut.

C'était un choix délibéré. Un fichier de configuration peut être écrit par un script shell, un job CI, un harnais de test dans n'importe quel langage, ou un humain dans un terminal. Il ne nécessite aucune intégration au système de build. Il peut être remplacé à chaud pendant que le processus tourne. Il peut être versionné aux côtés des données de test. Et il crée un couplage nul entre la bibliothèque de chaos et l'application en cours de test.

La syntaxe de configuration est `sélecteur:opération:effet:valeur:probabilité`. Quelques exemples représentatifs :

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

Rechargement de Configuration Lock-Free à l'Exécution

L'un des défis d'ingénierie les moins évidents est le rechargement de configuration à chaud dans un processus multi-threadé. L'approche naïve — lire le fichier à chaque appel — est trop coûteuse. L'approche naïve basée sur les verrous — tenir un mutex pendant la mise à jour de l'instantané de configuration — bloque chaque appel système intercepté pendant le rechargement.

Nous utilisons une approche lock-free basée sur le compare-and-swap atomique :

**Deux tampons d'instantané.** L'un est actif ; l'autre est disponible pour l'écriture. Un slot atomique 64 bits contient le mtime de la configuration actuellement active.

**Détection du gagnant du rechargement.** Chaque interception vérifie le mtime du fichier de configuration par rapport à la valeur mise en cache. En cas de discordance, un thread gagne la course CAS pour commencer le rechargement. Les perdants voient l'état RELOADING et tournent brièvement.

**Basculement atomique.** Le gagnant écrit la nouvelle configuration dans le tampon inactif, puis échange atomiquement l'index actif. À partir de ce moment, tous les threads voient la nouvelle configuration. L'ancien tampon est disponible pour le prochain cycle de rechargement.

La fenêtre de rechargement est typiquement inférieure à 15 microsecondes. Les lecteurs ne sont jamais bloqués par les écrivains en état stable. Cela rend pratique le changement des taux de pannes entre les méthodes de test sans redémarrer le processus.

Intégration CI : Trois Lignes de YAML

L'intégration dans un pipeline CI est intentionnellement minimale. Des binaires précompilés pour quatre combinaisons de plateformes (glibc/musl × amd64/arm64) sont disponibles. Aucune étape de compilation n'est nécessaire — téléchargez le bon binaire pour l'OS et l'architecture de votre runner CI, écrivez un fichier de configuration, définissez 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 : Pas d'État Global

La cohérence multi-threadée est assurée par le stockage thread-local plutôt que par des verrous autant que possible.

**PRNG par thread.** Chaque thread possède son propre état de générateur de nombres aléatoires. Les séquences de pannes sont indépendantes par thread — pas de goulot d'étranglement de hasard global, pas de contention sur l'état partagé. Cela signifie également que les séquences de pannes sont déterministes par thread pour une même graine, ce qui compte pour reproduire les échecs de tests.

**Garde contre la récursion.** Lorsque le code de chaos appelle les internes de libc (stat pour vérifier le mtime, read pour lire le fichier de configuration), ces appels seraient eux-mêmes interceptés, provoquant une récursion infinie. Une variable `__thread int guard` prévient cela : si la profondeur de récursion est non nulle, nous appelons directement la fonction réelle.

**Cache FD-vers-chemin.** L'intercepteur d'E/S de fichiers doit faire correspondre un descripteur de fichier à un chemin (pour la correspondance de règles par préfixe de chemin). Lire `/proc/self/fd/<fd>` à chaque appel d'E/S est trop coûteux. Nous maintenons un cache thread-local à correspondance directe de 32 emplacements (fd % 32), alimenté à l'ouverture `open()` et invalidé à la fermeture `close()`. Le taux de défaut de cache pour les charges de travail réelles est inférieur à 3 %.

Ce Qui Peut et Ne Peut Pas Être Intercepté

Nous documentons les limites explicitement plutôt que de laisser les utilisateurs les découvrir par eux-mêmes.

**Ne peut pas intercepter :**

- **Appels système directs.** Le runtime Go émet des appels système directement via le package `syscall`, contournant libc entièrement. Les programmes Go compilés sans CGO sont immunisés contre le chaos LD_PRELOAD sur leurs E/S réseau. (Java via JNI est correct ; JNI passe par glibc.) - **Binaires statiques.** LD_PRELOAD est un mécanisme du linker dynamique. Les binaires statiques n'ont pas de linker dynamique ; le préchargement n'a aucun effet. - **Binaires setuid/setgid.** Le noyau supprime LD_PRELOAD pour les binaires setuid (sémantique AT_SECURE). C'est une fonctionnalité de sécurité sur laquelle nous nous appuyons correctement. - **Horloges fast-path vDSO.** Certains appels clock_gettime passent par le vDSO, pas par glibc. Notre bibliothèque de temps intercepte au niveau du wrapper glibc, qui se situe en aval du vDSO. En pratique, cela signifie que la panne se déclenche sur l'appel glibc avant la recherche vDSO — l'effet net est le même, mais l'horloge interne du noyau n'est pas affectée.

**Peut intercepter :** - Tout processus lié dynamiquement sur Linux (glibc ou musl) - Java via JNI — incluant tous les appels socket, fichier, DNS et heure dans le JDK - Python, Node.js, Ruby, PHP — tous utilisent glibc pour les E/S système - Les binaires Rust qui utilisent std (qui utilise glibc) ou des bibliothèques C pontées par CGO

  • Java (chemin JNI) : couverture complète — réseau, DNS, fichier, horloge, mémoire
  • Python / Node.js / Ruby : couverture complète via glibc
  • Go (CGO=1 ou bibliothèques C) : couverture partielle — appels CGO interceptés, les appels système Go purs contournent
  • Go (pur, CGO=0) : aucune couverture — ABI syscall direct, LD_PRELOAD n'a aucun effet
  • Binaires statiques : aucune couverture par définition

Performance : Les Chiffres de Surcoût

Nous mesurons le surcoût en mode passthrough avec des microbenchmarks sérialisés par rdtsc pour obtenir les chiffres dans le pire cas. Le chemin critique pour un appel passant (aucune panne configurée) :

- **Succès de cache de configuration (pas de rechargement nécessaire) :** ~20–40 ns de surcoût au-delà du coût réel du syscall. C'est une vérification de mtime par stat (mise en cache dans un slot atomique), un scan de règles (linéaire sur N règles, typiquement N < 16), et un appel via le pointeur de fonction mis en cache. - **Défaut de cache (fichier de configuration rechargé) :** Un syscall stat() plus un read() du fichier de configuration — typiquement 150–300 ns au total, et seulement lors du premier appel après un changement de configuration. - **Panne injectée :** Le surcoût de la panne elle-même (attendre pour LATENCY, définir errno pour ERRNO) domine ; le coût du wrapper est négligeable.

Pour une suite de tests bien configurée avec un petit nombre de règles, le surcoût en mode passthrough est en dessous du plancher de bruit d'un appel réseau. Vous payez des dizaines de nanosecondes en plus d'opérations qui coûtent des microsecondes à des millisecondes.

Key Takeaways

La bibliothèque de chaos C99 LD_PRELOAD, c'est ce à quoi ressemble l'injection de pannes réelle au niveau noyau quand elle est conçue à partir des premiers principes plutôt qu'assemblée à la hâte à partir d'outils existants.

Six domaines de pannes indépendants. Une propriété de symboles disjointe pour qu'ils se composent en toute sécurité. Une interface de configuration en texte brut qui fonctionne depuis n'importe quel langage ou chaîne d'outils. Rechargement à chaud lock-free pour que les taux de pannes puissent changer entre les méthodes de test. PRNG thread-local pour que les séquences de pannes soient indépendantes et déterministes. Binaires précompilés pour glibc et musl sur amd64 et arm64.

Et une couverture de ligne à 100 % imposée en CI, parce que nous avons livré suffisamment de logiciels sur cartouches sans bouton de correction pour savoir que chaque ligne non couverte est un incident de production qui attend de se produire.

La source est disponible sur chaos-testing-libraries. Le framework de test Java qui injecte ces bibliothèques dans vos conteneurs Docker automatiquement est sur chaos-testing.

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

Engineering Team

Senior Solutions Architects

Nous construisons des systèmes distribués depuis avant que « microservices » ne soit un terme courant. Nos cicatrices ont des histoires à raconter.