Uma breve introdução ao BPF e eBPF

Olá, Habr! Gostaríamos de informar que estamos preparando um livro para lançamento."Observabilidade Linux com BPF".

Uma breve introdução ao BPF e eBPF
Como a máquina virtual BPF continua a evoluir e é usada ativamente na prática, traduzimos para você um artigo que descreve suas principais capacidades e estado atual.

Nos últimos anos, ferramentas e técnicas de programação tornaram-se cada vez mais populares para compensar as limitações do kernel Linux nos casos em que é necessário processamento de pacotes de alto desempenho. Uma das técnicas mais populares deste tipo é chamada desvio de kernel (kernel bypass) e permite, contornando a camada de rede do kernel, realizar todo o processamento de pacotes do espaço do usuário. Ignorar o kernel também envolve controlar a placa de rede de espaço do usuário. Em outras palavras, ao trabalhar com uma placa de rede, contamos com o driver espaço do usuário.

Ao transferir o controle total da placa de rede para um programa no espaço do usuário, reduzimos a sobrecarga do kernel (troca de contexto, processamento da camada de rede, interrupções, etc.), o que é muito importante quando executado em velocidades de 10 Gb/s ou superiores. Desvio de kernel mais uma combinação de outros recursos (processamento em lote) e ajuste cuidadoso de desempenho (Contabilidade NUMA, Isolamento da CPU, etc.) correspondem aos fundamentos do processamento de rede de alto desempenho no espaço do usuário. Talvez um exemplo exemplar desta nova abordagem ao processamento de pacotes seja DPDK da Intel (Kit de desenvolvimento de plano de dados), embora existam outras ferramentas e técnicas bem conhecidas, incluindo o VPP (Vector Packet Processing) da Cisco, Netmap e, claro, agarrar.

Organizar interações de rede no espaço do usuário tem uma série de desvantagens:

  • O kernel do sistema operacional é uma camada de abstração para recursos de hardware. Como os programas de espaço do usuário precisam gerenciar seus recursos diretamente, eles também precisam gerenciar seu próprio hardware. Isso geralmente significa ter que programar seus próprios drivers.
  • Como estamos abrindo mão totalmente do espaço do kernel, também estamos abrindo mão de toda a funcionalidade de rede fornecida pelo kernel. Os programas de espaço do usuário devem reimplementar recursos que já podem ser fornecidos pelo kernel ou sistema operacional.
  • Os programas operam no modo sandbox, o que limita seriamente sua interação e impede sua integração com outras partes do sistema operacional.

Em essência, quando se trabalha em rede no espaço do usuário, os ganhos de desempenho são obtidos movendo o processamento de pacotes do kernel para o espaço do usuário. O XDP faz exatamente o oposto: move programas de rede do espaço do usuário (filtros, resolvedores, roteamento, etc.) para o espaço do kernel. O XDP nos permite executar uma função de rede assim que um pacote atinge uma interface de rede e antes de começar a subir para o subsistema de rede do kernel. Como resultado, a velocidade de processamento de pacotes aumenta significativamente. Entretanto, como o kernel permite que o usuário execute seus programas no espaço do kernel? Antes de responder a esta pergunta, vejamos o que é BPF.

BPF e eBPF

Apesar do nome confuso, BPF (Berkeley Packet Filtering) é, na verdade, um modelo de máquina virtual. Esta máquina virtual foi originalmente projetada para lidar com filtragem de pacotes, daí o nome.

Uma das ferramentas mais famosas usando BPF é tcpdump. Ao capturar pacotes usando tcpdump o usuário pode especificar uma expressão para filtrar pacotes. Somente pacotes que correspondam a esta expressão serão capturados. Por exemplo, a expressão “tcp dst port 80”refere-se a todos os pacotes TCP que chegam na porta 80. O compilador pode encurtar esta expressão convertendo-a em bytecode BPF.

$ sudo tcpdump -d "tcp dst port 80"
(000) ldh [12] (001) jeq #0x86dd jt 2 jf 6
(002) ldb [20] (003) jeq #0x6 jt 4 jf 15
(004) ldh [56] (005) jeq #0x50 jt 14 jf 15
(006) jeq #0x800 jt 7 jf 15
(007) ldb [23] (008) jeq #0x6 jt 9 jf 15
(009) ldh [20] (010) jset #0x1fff jt 15 jf 11
(011) ldxb 4*([14]&0xf)
(012) ldh [x + 16] (013) jeq #0x50 jt 14 jf 15
(014) ret #262144
(015) ret #0

Isto é o que o programa acima basicamente faz:

  • Instrução (000): Carrega o pacote no deslocamento 12, como uma palavra de 16 bits, no acumulador. O deslocamento 12 corresponde ao tipo ether do pacote.
  • Instrução (001): compara o valor do acumulador com 0x86dd, ou seja, com o valor ethertype para IPv6. Se o resultado for verdadeiro, então o contador do programa vai para a instrução (002), e se não, então para (006).
  • Instrução (006): compara o valor com 0x800 (valor ethertype para IPv4). Se a resposta for verdadeira, então o programa vai para (007), caso contrário, então para (015).

E assim por diante até que o programa de filtragem de pacotes retorne um resultado. Geralmente é um booleano. Retornar um valor diferente de zero (instrução (014)) significa que o pacote foi aceito, e retornar um valor zero (instrução (015)) significa que o pacote não foi aceito.

A máquina virtual BPF e seu bytecode foram propostos por Steve McCann e Van Jacobson no final de 1992, quando seu artigo foi publicado Filtro de pacotes BSD: nova arquitetura para captura de pacotes em nível de usuário, esta tecnologia foi apresentada pela primeira vez na conferência Usenix no inverno de 1993.

Como o BPF é uma máquina virtual, ele define o ambiente no qual os programas são executados. Além do bytecode, ele também define o modelo de memória do lote (instruções de carregamento são aplicadas implicitamente ao lote), registradores (A e X; acumuladores e registradores de índice), armazenamento de memória temporária e um contador de programa implícito. Curiosamente, o bytecode BPF foi modelado a partir do Motorola 6502 ISA. Como Steve McCann lembrou em seu relatório plenário no Sharkfest '11, ele estava familiarizado com o build 6502 desde seus tempos de colégio, programando no Apple II, e esse conhecimento influenciou seu trabalho de design do bytecode BPF.

O suporte BPF é implementado no kernel Linux nas versões v2.5 e superiores, adicionado principalmente pelos esforços de Jay Schullist. O código BPF permaneceu inalterado até 2011, quando Eric Dumaset redesenhou o interpretador BPF para rodar em modo JIT (Fonte: JIT para filtros de pacotes). Depois disso, o kernel, em vez de interpretar o bytecode BPF, poderia converter diretamente os programas BPF para a arquitetura alvo: x86, ARM, MIPS, etc.

Mais tarde, em 2014, Alexey Starovoitov propôs um novo mecanismo JIT para o BPF. Na verdade, este novo JIT tornou-se uma nova arquitetura baseada em BPF e foi denominado eBPF. Acho que as duas VMs coexistiram por algum tempo, mas atualmente a filtragem de pacotes é implementada com base em eBPF. Na verdade, em muitos exemplos de documentação moderna, o BPF é entendido como eBPF, e o BPF clássico é hoje conhecido como cBPF.

O eBPF estende a máquina virtual BPF clássica de várias maneiras:

  • Baseado em arquiteturas modernas de 64 bits. O eBPF usa registros de 64 bits e aumenta o número de registros disponíveis de 2 (acumulador e X) para 10. O eBPF também fornece opcodes adicionais (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Separado do subsistema da camada de rede. O BPF estava vinculado ao modelo de dados em lote. Por ser utilizado para filtragem de pacotes, seu código estava localizado no subsistema que fornece comunicação de rede. Porém, a máquina virtual eBPF não está mais vinculada ao modelo de dados e pode ser utilizada para qualquer finalidade. Portanto, agora o programa eBPF pode ser conectado ao tracepoint ou kprobe. Isso abre caminho para instrumentação eBPF, análise de desempenho e muitos outros casos de uso no contexto de outros subsistemas do kernel. Agora o código eBPF está localizado em seu próprio caminho: kernel/bpf.
  • Armazenamentos de dados globais chamados Mapas. Mapas são armazenamentos de valores-chave que permitem a troca de dados entre o espaço do usuário e o espaço do kernel. O eBPF fornece vários tipos de mapas.
  • Funções secundárias. Em particular, para reescrever um pacote, calcular uma soma de verificação ou clonar um pacote. Essas funções são executadas dentro do kernel e não são programas no espaço do usuário. Você também pode fazer chamadas de sistema a partir de programas eBPF.
  • Encerrar chamadas. O tamanho do programa no eBPF é limitado a 4096 bytes. O recurso de chamada final permite que um programa eBPF transfira o controle para um novo programa eBPF e, assim, contorne essa limitação (até 32 programas podem ser vinculados dessa forma).

eBPF: exemplo

Existem vários exemplos de eBPF nas fontes do kernel Linux. Eles estão disponíveis em samples/bpf/. Para compilar esses exemplos, basta digitar:

$ sudo make samples/bpf/

Não escreverei um novo exemplo para eBPF, mas usarei uma das amostras disponíveis em samples/bpf/. Examinarei algumas partes do código e explicarei como funciona. Como exemplo, escolhi o programa tracex4.

Em geral, cada um dos exemplos em samples/bpf/ consiste em dois arquivos. Nesse caso:

  • tracex4_kern.c, contém o código fonte a ser executado no kernel como bytecode eBPF.
  • tracex4_user.c, contém um programa do espaço do usuário.

Neste caso, precisamos compilar tracex4_kern.c para bytecode eBPF. Atualmente em gcc não há back-end para eBPF. Felizmente, clang pode gerar bytecode eBPF. Makefile usa clang para compilação tracex4_kern.c para o arquivo objeto.

Mencionei acima que uma das características mais interessantes do eBPF são os mapas. tracex4_kern define um mapa:

struct pair {
    u64 val;
    u64 ip;
};  

struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(long),
    .value_size = sizeof(struct pair),
    .max_entries = 1000000,
};

BPF_MAP_TYPE_HASH é um dos diversos tipos de cartões oferecidos pelo eBPF. Neste caso, é apenas um hash. Você também deve ter notado um anúncio SEC("maps"). SEC é uma macro usada para criar uma nova seção de um arquivo binário. Na verdade, no exemplo tracex4_kern mais duas seções são definidas:

SEC("kprobe/kmem_cache_free")
int bpf_prog1(struct pt_regs *ctx)
{   
    long ptr = PT_REGS_PARM2(ctx);

    bpf_map_delete_elem(&my_map, &ptr); 
    return 0;
}
    
SEC("kretprobe/kmem_cache_alloc_node") 
int bpf_prog2(struct pt_regs *ctx)
{
    long ptr = PT_REGS_RC(ctx);
    long ip = 0;

    // получаем ip-адрес вызывающей стороны kmem_cache_alloc_node() 
    BPF_KRETPROBE_READ_RET_IP(ip, ctx);

    struct pair v = {
        .val = bpf_ktime_get_ns(),
        .ip = ip,
    };
    
    bpf_map_update_elem(&my_map, &ptr, &v, BPF_ANY);
    return 0;
}   

Estas duas funções permitem eliminar uma entrada do mapa (kprobe/kmem_cache_free) e adicione uma nova entrada ao mapa (kretprobe/kmem_cache_alloc_node). Todos os nomes de funções escritos em letras maiúsculas correspondem a macros definidas em bpf_helpers.h.

Se eu despejar as seções do arquivo objeto, verei que essas novas seções já estão definidas:

$ objdump -h tracex4_kern.o

tracex4_kern.o: file format elf64-little

Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENTS, ALLOC, LOAD, DATA
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENTS, ALLOC, LOAD, DATA
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENTS, ALLOC, LOAD, DATA
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA

Ainda tem tracex4_user.c, programa principal. Basicamente, este programa escuta eventos kmem_cache_alloc_node. Quando tal evento ocorre, o código eBPF correspondente é executado. O código salva o atributo IP do objeto em um mapa e o objeto é então executado em loop pelo programa principal. Exemplo:

$ sudo ./tracex4
obj 0xffff8d6430f60a00 is 2sec old was allocated at ip ffffffff9891ad90
obj 0xffff8d6062ca5e00 is 23sec old was allocated at ip ffffffff98090e8f
obj 0xffff8d5f80161780 is 6sec old was allocated at ip ffffffff98090e8f

Como um programa de espaço de usuário e um programa eBPF estão relacionados? Na inicialização tracex4_user.c carrega um arquivo objeto tracex4_kern.o usando a função load_bpf_file.

int main(int ac, char **argv)
{
    struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
    char filename[256];
    int i;

    snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);

    if (setrlimit(RLIMIT_MEMLOCK, &r)) {
        perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
        return 1;
    }

    if (load_bpf_file(filename)) {
        printf("%s", bpf_log_buf);
        return 1;
    }

    for (i = 0; ; i++) {
        print_old_objects(map_fd[1]);
        sleep(1);
    }

    return 0;
}

Ao fazer load_bpf_file probes definidos no arquivo eBPF são adicionados ao /sys/kernel/debug/tracing/kprobe_events. Agora estamos atentos a estes eventos e o nosso programa pode fazer algo quando eles acontecem.

$ sudo cat /sys/kernel/debug/tracing/kprobe_events
p:kprobes/kmem_cache_free kmem_cache_free
r:kprobes/kmem_cache_alloc_node kmem_cache_alloc_node

Todos os outros programas em sample/bpf/ são estruturados de forma semelhante. Eles sempre contêm dois arquivos:

  • XXX_kern.c: programa eBPF.
  • XXX_user.c: programa principal.

O programa eBPF identifica mapas e funções associadas a uma seção. Quando o kernel emite um evento de um determinado tipo (por exemplo, tracepoint), as funções vinculadas são executadas. Os cartões fornecem comunicação entre o programa kernel e o programa espacial do usuário.

Conclusão

Este artigo discutiu BPF e eBPF em termos gerais. Eu sei que há muitas informações e recursos sobre o eBPF hoje, então vou recomendar mais alguns recursos para um estudo mais aprofundado

Eu recomendo ler:

Fonte: habr.com

Adicionar um comentário