httpinspect: Como resolvi um problema de verifier BPF num servidor Kimsufi
O compilador sabia que cap >= 16. O verifier precisava de uma prova explícita de que cap > 0. O compilador eliminou-lhe a prova. Ninguém ficou contente. Como um volatile resolveu um conflito entre clang e o BPF verifier.
O compilador sabia que cap >= 16. O verifier precisava de uma prova explícita de que cap > 0. O compilador eliminou-lhe a prova. Ninguém ficou contente.
Há dias decidi montar um monitor de tráfego HTTP com eBPF num servidor OVH Kimsufi KS-4. A ideia era simples: usar TCX (Traffic Control eXpress) para observar passivamente pedidos HTTP a passar pela interface de rede, extrair as linhas de request e as status lines de response, e mandar tudo para um ring buffer no userspace.
O que parecia uma tarde de trabalho transformou-se numa viagem de várias horas pelo mundo do BPF verifier, otimizações de compilador, e uma interação inesperada entre o clang e o kernel que me fez aprender mais sobre eBPF do que qualquer tutorial.
Terminal com erro do BPF verifier
O que é o httpinspect
O httpinspect é um programa eBPF que se liga a todas as interfaces de rede (ingress e egress) via TCX e filtra tráfego HTTP diretamente no kernel:
- Captura linhas de request HTTP (GET, POST, PUT, etc.) e status lines de response
- Filtra no kernel: só passam segments cujo payload começa com um token HTTP conhecido. ACKs, SYN, FIN, e tráfego não-HTTP nunca saem do kernel
- Lida com IPv4 e IPv6, Ethernet e raw-IP
- Usa bpf_skb_load_bytes para ler o payload de forma segura (funciona com GSO e paged frags)
- Envia eventos via ring buffer (8 MB) para o userspace
O userspace, escrito em JavaScript com o runtime yeet, recebe os eventos e agrega por endpoint (method + host + path).
O servidor: Kimsufi KS-4
O servidor onde isto corre não é propriamente um monstro. É um Atom N2800 lançado em 2012, dois cores físicos com Hyper-Threading. É o tipo de CPU que encontras num NAS antigo. A RAM está no limite, o swap está completamente saturado. Cada operação que aloca memória causa page faults para o disco SATA a 66 MB/s.
| Componente | Especificação |
|---|---|
| CPU | Intel Atom N2800 @ 1.86GHz (2 cores / 4 threads) |
| RAM | 3.8 GB DDR3 |
| Swap | 512 MB (completamente cheio) |
| Disco | SATA 1.8 TB (~66 MB/s leitura sequencial) |
| Kernel | Debian 13, 6.12.90 |
| Stack | clang 19.1.7, bpftool 7.5.0, libbpf 1.5, yeet 0.19.2 |
Não é o ideal para compilar programas BPF com -O2, mas a vida é feita de escolhas.
O problema
Depois de escrever o programa e compilar, ao carregar o BPF object com o yeet:
O verifier do kernel rejeitou o programa. A instrução bpf_skb_load_bytes recebia um tamanho [0, 510] e zero não é permitido.
O código problemático era este:
Eu tinha um if (cap == 0). O verifier pedia um check explícito de que cap > 0. O check estava lá. O que se passava?
A raiz do problema
O compilador clang, com -O2, é inteligente. Talvez demasiado inteligente.
Depois do check if (plen < MIN_REQ), o compilador sabe que plen >= 16. Depois, quando fazemos cap = plen e limitamos a 511, o compilador infere que cap >= min(16, 511) = 16. Portanto cap == 0 é impossível. O compilador elimina o branch if (cap == 0) como dead code.
Lá para o raciocínio dele está correto: se chegamos aquele ponto do código, cap nunca pode ser zero. O problema é que o BPF verifier não faz essa análise de ranges. Ele vê cap como um escalar com range [0, 510] (do mask) e exige uma prova explícita de que cap > 0 antes de chamar bpf_skb_load_bytes.
O compilador removeu-lhe a prova. O verifier chumbou. Ninguém fica contente.
A caça ao bug: 6 tentativas falhadas
Tentativa 1: Check explícito de range
Substituir o mask por um range check explícito:
Resultado: Falhou. O compilador vê que cap >= 16 e elimina o zero check na mesma.
Tentativa 2: Calcular cap depois do ringbuf_reserve
Mover o cálculo de cap para depois do ringbuf_reserve para quebrar a ligação ao check anterior.
Resultado: Falhou. O compilador ainda sabe que plen >= 16 e propaga esse range.
Tentativa 3: Fresh computation
Resultado: Falhou. O clang faz Common Subexpression Elimination (CSE) e apercebe-se que skb->len - poff já foi calculado em plen. Liga o range ao plen anterior.
Tentativa 4: asm volatile
Resultado: Falhou, e de forma espetacular. O compilador gerou dois registos diferentes para a mesma variável: R1 (narrowed para [1,510] pelo zero check) e R4 (ainda [0,510]). O bpf_skb_load_bytes usou R4, o que não foi narrowed.
Tentativa 5: Função noinline
Resultado: Falhou. O clang faz análise inter-procedural e vê que a função retorna um valor sempre >= 16. Elimina o zero check no caller.
Tentativa 6: Compilar com -O1
Resultado: Falhou. Mesmo com -O1, o clang mantém a análise de ranges e elimina o zero check. A análise de ranges é uma otimização "básica" que não está nos passes de -O2.
Neste ponto comecei a pensar que talvez o problema não tivesse solução. Seis tentativas, todas falhadas.
A solução: volatile
A sétima tentativa foi a que resultou. Usar a palavra-chave volatile para forçar o compilador a tratar a variável como opaca.
O volatile diz ao compilador: "confia em mim, este valor pode mudar a qualquer momento, não faças suposições sobre ele." O compilador não pode propagar o range de plen para vcap, porque volatile implica que o valor é lido da stack de cada vez.
O código final:
Porque funciona:
- volatile quebra a propagação de ranges: o compilador não sabe nada sobre vcap
- O zero check é mantido: o compilador não pode provar que cap >= 16
- O verifier vê o check: pode narrow o range de [0, limit] para [1, limit]
- Nenhuma penalidade real: o volatile só impede a propagação de ranges
O patch final: 14 linhas adicionadas, 7 removidas. O Makefile ficou inalterado, tudo compila com -O2.
O programa carrega, o verifier aceita.
Lições aprendidas
O compilador não é teu amigo (no BPF). Em C normal, ter o compilador a eliminar dead code é ótimo. Em BPF, onde o verifier precisa de provas explícitas, pode ser um problema. É preciso pensar nas duas fases: o que o compilador faz, e o que o verifier precisa.
volatile é a última barreira. Depois de tentar asm, noinline, -O1, a única coisa que realmente quebrou o tracking do compilador foi volatile. Não há otimização que ignore volatile.
O verifier é estúpido (por design). O verifier BPF é intencionalmente conservador. Não faz análise sofisticada de ranges porque tem de ser rápido e seguro. Cabe ao programador dar-lhe as provas de que precisa, de forma que o compilador não remova.
Hardware velho, problemas novos. O Atom N2800 não tem nada de especial, mas corre um kernel 6.12 com BPF moderno. É possível fazer coisas interessantes em servidores antigos.
CSE é mais agressivo do que parece. Mesmo com uma conta "fresca" (skb->len - poff), o clang liga expressões equivalentes. Só o volatile quebra essa ligação.
Conclusão
O httpinspect está funcional no Kimsufi. O patch volatile é mínimo e resolve um problema subtil que cruzou otimizações de compilador com regras de verificação de segurança do kernel.
O programa compila com -O2 sem alterações ao Makefile, carrega e anexa as interfaces, e filtra tráfego HTTP no kernel com overhead mínimo.
Se estiveres a ler isto porque o teu programa BPF foi rejeitado com um erro de zero-sized read e já tentaste de tudo, experimenta volatile. Às vezes a ferramenta mais simples é a que resolve.
Recursos adicionais
Partilhar
Comentários 0
Ainda sem comentários — sê o primeiro.