O Problema com Mock de Chamadas de Sistema
Bibliotecas de injeção de falhas se dividem em dois grandes grupos. O primeiro grupo faz mock na camada da aplicação: mock de repositório, mock de cliente HTTP, retorno de exceção. Limpo. Rápido. E completamente desconectado do que o ambiente operacional real faz quando degrada.
O segundo grupo recorre a ferramentas de infraestrutura: Toxiproxy para falhas de proxy TCP, tc netem para moldagem de pacotes no nível do kernel, Pumba ou Chaos Monkey para matar contêineres. Poderoso, mas requer configuração externa, conflita com o isolamento de testes e frequentemente roda em um processo separado do qual a aplicação não tem conhecimento.
Ambos os grupos ignoram algo: a fronteira da libc. A camada onde sua aplicação, independentemente da linguagem ou runtime, passa o controle para o sistema operacional. Onde `read()` realmente acontece. Onde `getaddrinfo()` resolve nomes. Onde `mmap()` aloca memória. Onde `clock_gettime()` retorna o tempo.
Se você intercepta aqui, você intercepta tudo. E se as falhas que você injeta nessa camada são indistinguíveis das falhas que o kernel produz — porque usam os mesmos códigos errno, as mesmas convenções de valor de retorno, as mesmas características de temporização — então o que você construiu não é um mock. É uma simulação. Uma simulação real ao nível do kernel.
Por Que a Fronteira da libc é a Camada Certa
LD_PRELOAD é um recurso do linker dinâmico do Linux que insere objetos compartilhados no link-map de um processo antes de qualquer outra biblioteca — incluindo a libc. Quando uma aplicação chama `connect()`, a resolução de símbolos do linker dinâmico percorre o link-map em ordem. Se uma biblioteca pré-carregada exporta um símbolo chamado `connect`, esse símbolo vence.
Isso nos dá interposição: a capacidade de envolver qualquer função da libc sem modificar o código chamador ou recompilar qualquer coisa. E está disponível desde antes de a maioria dos sistemas de produção atuais terem sido projetados.
As propriedades fundamentais que tornam esta a camada certa:
**Universalidade independente de linguagem.** O `socket.connect()` do Python passa pelo glibc. O `net.connect()` do Node.js passa pelo glibc. O `Socket.connect()` do Java — via JNI — passa pelo glibc. Uma única biblioteca pré-carregada intercepta todos eles.
**Falhas reais do kernel.** Retornamos valores errno reais. O código chamador não consegue distinguir um ECONNREFUSED real do nosso ECONNREFUSED injetado. O circuit breaker vê uma falha real, não uma simulada com consciência de teste.
**Zero alterações na aplicação.** Sem agentes. Sem importações de API. Sem necessidade de integração com framework de testes na camada da aplicação. A aplicação roda normalmente; as falhas aparecem abaixo dela.
**Escopo de processo.** As falhas têm escopo para um único processo. Execute seu processo de teste com um conjunto de falhas; execute sua dependência em um contêiner separado sem nenhuma falha. Sem contaminação cruzada.
Seis Domínios de Falhas
O conjunto de bibliotecas cobre seis domínios de falhas independentes, cada um como um objeto compartilhado separado. Eles podem ser carregados individualmente ou compostos — a composição é segura porque cada biblioteca possui um conjunto disjunto de símbolos (o Teorema de Propriedade de Símbolos, documentado em detalhes na documentação de arquitetura).
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) Interposição de Símbolos: Como Funciona na Prática
O mecanismo é simples em princípio, mas cuidadoso na execução.
**Passo 1 — Inicialização da biblioteca.** Quando o `.so` pré-carregado é carregado, seu construtor é executado. Ele chama `dlsym(RTLD_NEXT, "connect")` para encontrar o `connect` real da libc. RTLD_NEXT pula o DSO chamador no link-map e retorna o próximo símbolo correspondente — a implementação real da libc. Esse ponteiro é armazenado em cache em uma variável estática. Sem dlsym por chamada no caminho quente.
**Passo 2 — Interceptação de chamada.** Quando a aplicação chama `connect()`, o linker dinâmico encontra nosso símbolo primeiro (ordem do link-map). Nosso wrapper é executado: ele lê o arquivo de configuração (ou um snapshot em cache), avalia se uma falha deve ser disparada com base no seletor de endpoint e na probabilidade, e então injeta a falha ou passa a chamada para a função real da libc via o ponteiro em cache.
**Passo 3 — Invariante de propriedade de símbolo.** Se duas bibliotecas pré-carregadas tentam ambas envolver `read()`, uma delas receberá RTLD_NEXT apontando para a outra, causando dupla injeção. Evitamos isso com atribuição estrita de símbolos: cada símbolo da libc pertence a exatamente uma biblioteca no conjunto. `libchaos-net.so` não possui `read()` — símbolos de I/O pertencem a `libchaos-io.so`. As bibliotecas se compõem com segurança porque foram projetadas para isso.
# 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 A Interface do Arquivo de Configuração
Não existe API. Não existe header público. Não existe biblioteca para linkar. A interface inteira é um arquivo de configuração em texto simples.
Issa foi uma escolha deliberada. Um arquivo de configuração pode ser escrito por um script shell, um job de CI, um harness de testes em qualquer linguagem ou um humano no terminal. Não requer integração com sistema de build. Pode ser trocado em quente enquanto o processo está em execução. Pode ser versionado junto com dados de teste. E cria zero acoplamento entre a biblioteca de caos e a aplicação sob teste.
A sintaxe de configuração é `selector:operation:effect:value:probability`. Alguns exemplos representativos:
# 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 Recarga de Configuração Lock-Free em Runtime
Um dos desafios de engenharia menos óbvios é a recarga de configuração em quente em um processo multi-thread. A abordagem ingênua — ler o arquivo a cada chamada — é cara demais. A abordagem ingênua baseada em lock — manter um mutex enquanto atualiza o snapshot de configuração — bloqueia cada syscall interceptada durante a recarga.
Usamos uma abordagem lock-free baseada em compare-and-swap atômico:
**Dois buffers de snapshot.** Um está ativo; o outro está disponível para escrita. Um slot atômico de 64 bits armazena o mtime da configuração ativa no momento.
**Detecção do vencedor da recarga.** Cada interceptação verifica o mtime do arquivo de configuração em relação ao valor em cache. Em caso de divergência, uma thread vence a corrida CAS para iniciar a recarga. As perdedoras veem o estado RELOADING e aguardam brevemente.
**Troca atômica.** O vencedor escreve a nova configuração no buffer inativo e, em seguida, troca atomicamente o índice ativo. A partir desse ponto, todas as threads enxergam a nova configuração. O buffer antigo fica disponível para o próximo ciclo de recarga.
A janela de recarga tipicamente fica abaixo de 15 microssegundos. Leitores nunca bloqueiam em escritores no estado estacionário. Isso torna prático alterar taxas de falha entre métodos de teste sem reiniciar o processo.
Integração com CI: Três Linhas de YAML
A integração em um pipeline de CI é intencionalmente mínima. Binários pré-compilados para quatro combinações de plataforma (glibc/musl × amd64/arm64) estão disponíveis. Nenhuma etapa de build é necessária — baixe o binário correto para o OS e a arquitetura do seu runner de CI, escreva um arquivo de configuração e defina 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 Internos Thread-Local: Sem Estado Global
A corretude multi-thread é garantida por meio de armazenamento thread-local em vez de locks sempre que possível.
**PRNG por thread.** Cada thread tem seu próprio estado de gerador de números aleatórios. As sequências de falhas são independentes por thread — sem gargalo de aleatoriedade global, sem contenção em estado compartilhado. Isso também significa que as sequências de falhas são determinísticas por thread dado o mesmo seed, o que importa para reproduzir falhas de teste.
**Guarda de recursão.** Quando o código de caos chama internals da libc (stat para verificar mtime, read para ler o arquivo de configuração), essas chamadas seriam elas próprias interceptadas, causando recursão infinita. Uma variável `__thread int guard` previne isso: se a profundidade de recursão for diferente de zero, passamos diretamente para a função real.
**Cache FD-para-caminho.** O interceptor de I/O de arquivo precisa mapear um descritor de arquivo para um caminho (para correspondência de regras por prefixo de caminho). Ler `/proc/self/fd/<fd>` a cada chamada de I/O é caro demais. Mantemos um cache thread-local de mapeamento direto com 32 slots (fd % 32), preenchido no `open()` e invalidado no `close()`. A taxa de cache miss para cargas de trabalho reais é inferior a 3%.
O Que Pode e Não Pode Ser Interceptado
Documentamos os limites explicitamente em vez de deixar os usuários descobri-los.
**Não é possível interceptar:**
- **Syscalls diretas.** O runtime do Go emite syscalls diretamente via o pacote `syscall`, contornando completamente a libc. Programas Go compilados sem CGO são imunes ao caos via LD_PRELOAD em seu I/O de rede. (Java via JNI funciona; JNI passa pelo glibc.) - **Binários estáticos.** LD_PRELOAD é um mecanismo do linker dinâmico. Binários estáticos não possuem linker dinâmico; o pré-carregamento não tem efeito. - **Binários setuid/setgid.** O kernel remove LD_PRELOAD para binários setuid (semântica AT_SECURE). Esse é um recurso de segurança no qual dependemos corretamente. - **Clocks com caminho rápido vDSO.** Algumas chamadas de clock_gettime passam pelo vDSO, não pelo glibc. Nossa biblioteca de tempo intercepta no nível do wrapper glibc, que fica downstream do vDSO. Na prática, isso significa que a falha é disparada na chamada glibc antes da consulta vDSO — o efeito líquido é o mesmo, mas o clock interno do kernel não é afetado.
**É possível interceptar:** - Qualquer processo linkado dinamicamente no Linux (glibc ou musl) - Java via JNI — incluindo todas as chamadas de socket, arquivo, DNS e tempo no JDK - Python, Node.js, Ruby, PHP — todos usam glibc para I/O de sistema - Binários Rust que usam std (que usa glibc) ou bibliotecas C com ponte CGO
- Java (caminho JNI): cobertura completa — rede, DNS, arquivo, clock, memória
- Python / Node.js / Ruby: cobertura completa via glibc
- Go (CGO=1 ou bibliotecas C): cobertura parcial — chamadas CGO interceptadas, syscalls Go puras contornam
- Go (puro, CGO=0): sem cobertura — ABI de syscall direto, LD_PRELOAD não tem efeito
- Binários estáticos: sem cobertura por definição
Desempenho: Os Números de Overhead
Medimos o overhead de passthrough com microbenchmarks serializados por rdtsc para obter números de pior caso. O caminho quente para uma chamada de passagem (sem falha configurada):
- **Cache hit de configuração (sem recarga necessária):** ~20–40 ns de overhead além do custo real da syscall. Isso é uma verificação de mtime via stat (em cache em um slot atômico), uma varredura de regras (linear sobre N regras, tipicamente N < 16) e uma chamada pelo ponteiro de função em cache. - **Cache miss (arquivo de configuração recarregado):** Uma syscall stat() mais uma read() do arquivo de configuração — tipicamente 150–300 ns no total, e somente na primeira chamada após uma alteração de configuração. - **Falha injetada:** O overhead da falha em si (dormir para LATENCY, definir errno para ERRNO) domina; o custo do wrapper é desprezível.
Para uma suíte de testes bem configurada com um pequeno número de regras, o overhead de passthrough fica abaixo do nível de ruído de uma chamada de rede. Você paga dezenas de nanossegundos sobre operações que custam microssegundos a milissegundos.
Key Takeaways
A biblioteca de caos C99 com LD_PRELOAD é como a injeção de falhas real do kernel se parece quando é projetada a partir de primeiros princípios, em vez de montada a partir de ferramentas existentes.
Seis domínios de falhas independentes. Propriedade de símbolos disjunta para que se componham com segurança. Uma interface de configuração em texto simples que funciona a partir de qualquer linguagem ou toolchain. Recarga em quente lock-free para que as taxas de falha possam mudar entre métodos de teste. PRNG thread-local para que as sequências de falhas sejam independentes e determinísticas. Binários pré-compilados para glibc e musl em amd64 e arm64.
E 100% de cobertura de linhas aplicada no CI, porque já entregamos software suficiente em cartuchos sem botão de patch para saber que cada linha não coberta é um incidente de produção esperando para acontecer.
O código-fonte está disponível em chaos-testing-libraries. O framework de testes Java que injeta essas bibliotecas em seus contêineres Docker automaticamente está em chaos-testing.
Engineering Team
Senior Solutions Architects
Construímos sistemas distribuídos desde antes de 'microsserviços' existir como conceito. Nossas cicatrizes contam histórias.