Introdução ao BPF e eBPF

Olá, Habr! Informamos que estamos prestes a lançar o livro "Linux Observability with BPF".

Introdução ao BPF e eBPF
Como a máquina virtual BPF continua a evoluir e é amplamente utilizada na prática, traduzimos para você um artigo que descreve suas principais características e seu estado atual.

Nos últimos anos, ferramentas de programação e técnicas para compensar as limitações do núcleo Linux têm ganhado popularidade em casos onde é necessária uma processamento de pacotes de alto desempenho. Uma das técnicas mais populares desse tipo é chamada de bypass do núcleo (kernel bypass) e permite que toda a manipulação de pacotes seja realizada no espaço do usuário, pulando o nível de rede do núcleo. O bypass do núcleo também implica o controle da placa de rede a partir do espaço do usuário. Em outras palavras, ao trabalhar com a placa de rede, confiamos no driver espaço do usuário.

Ao dar controle total da placa de rede a um programa do espaço do usuário, reduzimos os custos decorrentes do funcionamento do núcleo (mudança de contexto, processamento do nível de rede, interrupções, etc.), o que é bastante importante para operações em velocidades de 10Gb/s ou superiores. O bypass do núcleo, combinado com outras funcionalidades (processamento em lote) e ajustes cuidadosos de desempenho (consideração de NUMA, isolamento de CPU, etc.) corresponde aos fundamentos do processamento de rede de alto desempenho no espaço do usuário. Um exemplo emblemático desse novo método de processamento de pacotes é o DPDK da Intel (Data Plane Development Kit), embora existam outras ferramentas e técnicas amplamente conhecidas, incluindo o VPP da Cisco (Vector Packet Processing), Netmap e, claro, Snabb.

A organização das interações de rede no espaço do usuário possui uma série de desvantagens:

  • O núcleo do SO é um nível de abstração para recursos de hardware. Como os programas do espaço do usuário devem gerenciar seus recursos diretamente, eles também precisam lidar com seu próprio hardware. Muitas vezes, isso significa que é necessário programar drivers próprios.
  • Como estamos totalmente abrindo mão do espaço do núcleo, também estamos renunciando a toda a funcionalidade de rede fornecida pelo núcleo. Os programas do espaço de usuário precisam reinventar funcionalidades que podem já ser fornecidas pelo núcleo ou sistema operacional.
  • Os programas operam em modo sandbox, o que limita severamente suas capacidades de interação e dificulta a integração com outras partes do sistema operacional.

Essencialmente, ao organizar interações de rede no espaço de usuário, o aumento de desempenho é alcançado ao transferir o processamento de pacotes do núcleo para o espaço de usuário. O XDP faz exatamente o contrário: move programas de rede do espaço de usuário (filtros, transformadores, roteamento, etc.) para a área do núcleo. O XDP nos permite executar a função de rede assim que o pacote chega à interface de rede e antes de começar a subir na subsistema de rede do núcleo. Como resultado, a velocidade de processamento dos pacotes aumenta significativamente. No entanto, como o núcleo permite que o usuário execute seus programas no espaço do núcleo? Antes de responder a essa pergunta, vamos entender o que é o BPF.

BPF e eBPF

Apesar do nome não muito claro BPF (Filtragem de Pacotes de Berkeley) – é, na verdade, um modelo de máquina virtual. Essa máquina virtual foi projetada originalmente para o processamento de filtragem de pacotes, daí o nome.

Uma das ferramentas mais conhecidas que usam BPF é tcpdump. Ao capturar pacotes com tcpdump o usuário pode definir uma expressão para filtrar pacotes. Apenas pacotes correspondentes a essa expressão serão capturados. Por exemplo, a expressão “tcp dst port 80” refere-se a todos os pacotes TCP que chegam à porta 80. O compilador pode otimizar essa expressão, transformando-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

É isso que, basicamente, o programa acima faz:

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

E assim por diante, até que o programa de filtragem de pacotes retorne um resultado. Normalmente, isso é um booleano. O retorno de um valor não nulo (instrução (014)) significa que o pacote passou, enquanto o retorno nulo (instrução (015)) indica que o pacote não passou.

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

Uma vez que o BPF é uma máquina virtual, ele define o ambiente onde os programas são executados. Além do bytecode, também define o modelo de memória de pacotes (as instruções de carregamento são aplicadas implicitamente ao pacote), registradores (A e X; registradores acumulador e índice), armazenamento de memória temporária e um contador de programa implícito. Curiosamente, o bytecode BPF foi modelado com base na arquitetura Motorola 6502 ISA. Como Steve McCanne lembrou em sua apresentação plenária no Sharkfest '11, ele havia trabalhado com o 6502 desde os tempos de escola, quando programava no Apple II, e esse conhecimento influenciou seu trabalho no design do bytecode BPF.

O suporte ao BPF foi implementado no núcleo Linux na versão v2.5 e superior, principalmente graças aos esforços de Jay Schulist. O código BPF permaneceu sem mudanças significativas até 2011, quando Eric Dumazet reformulou o interpretador BPF para operar em modo JIT (Fonte: JIT para filtros de pacotes). Após isso, o núcleo poderia, em vez de interpretar o bytecode BPF, converter diretamente os programas BPF para a arquitetura de destino: x86, ARM, MIPS, etc.

Mais tarde, em 2014, Alexey Starovoitov propôs um novo mecanismo JIT para o BPF. De fato, esse novo JIT tornou-se uma nova arquitetura baseada no BPF e recebeu o nome de eBPF. Acredito que, por um certo tempo, ambas as máquinas virtuais coexistiram, mas atualmente a filtragem de pacotes é implementada com base no eBPF. De fato, em muitos exemplos da documentação moderna, BPF se refere a eBPF, enquanto o BPF clássico hoje é conhecido como cBPF.

O eBPF expande a máquina virtual clássica BPF em vários aspectos:

  • Baseia-se em arquiteturas modernas de 64 bits. O eBPF utiliza registradores de 64 bits e aumenta o número de registradores disponíveis de 2 (acumulador e X) para 10. O eBPF também fornece códigos de operação adicionais (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Desacoplado da subsistema de nível de rede. O BPF estava ligado ao modelo de dados orientado a pacotes. Como era usado para filtrar pacotes, seu código estava na subsistema que provia interações de rede. No entanto, a máquina virtual eBPF não está mais ligada ao modelo de dados e pode ser utilizada para quaisquer fins. Agora, o programa eBPF pode ser conectado a tracepoints ou kprobes. Isso abre caminho para instrumentação eBPF, análise de desempenho e muitas outras aplicações no contexto de outras subsistemas do núcleo. Agora, o código eBPF está em seu próprio caminho: kernel/bpf.
  • Armazenamentos de dados globais chamados Mapas. Os Mapas são armazenamentos do tipo 'chave-valor', que permitem a troca de dados entre o espaço do usuário e o espaço do núcleo. O eBPF fornece mapas de vários tipos.
  • Funções auxiliares. Em particular, para reescrever pacotes, calcular somas de verificação ou clonar pacotes. Essas funções são executadas dentro do núcleo e não se referem a programas do espaço do usuário. Além disso, chamadas de sistema podem ser realizadas a partir de programas eBPF.
  • Chamadas finais. O tamanho do programa em eBPF é limitado a 4096 bytes. A possibilidade de chamada final permite que o programa eBPF transfira o controle para um novo programa eBPF, contornando assim essa limitação (dessa forma, é possível encadear até 32 programas).

eBPF: exemplo

Nos códigos-fonte do núcleo Linux, existem vários exemplos de eBPF. Eles estão disponíveis em samples/bpf/. Para compilar esses exemplos, basta digitar:

$ sudo make samples/bpf/

Não vou escrever um novo exemplo para eBPF, mas vou usar um dos exemplos disponíveis em samples/bpf/. Analisarei algumas partes do código e explicarei como ele funciona. Como exemplo, escolhi o programa tracex4.

Na verdade, cada um dos exemplos em samples/bpf/ consiste em dois arquivos. Neste caso:

  • tracex4_kern.c, contém o código fonte que deve ser executado no núcleo como bytecode eBPF.
  • tracex4_user.c, contém o programa do espaço do usuário.

Nesse caso, precisamos compilar tracex4_kern.c para o bytecode eBPF. Atualmente, não há gcc parte do servidor para eBPF. Felizmente, clang pode gerar bytecode eBPF. Makefile usa clang para compilação tracex4_kern.c em um arquivo objeto.

Mencionei antes que uma das funcionalidades 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 muitos tipos de mapas oferecidos pelo eBPF. Neste caso, é apenas um hash. Você também deve ter notado a declaração SEC("maps"). SEC é um macro usado para criar uma nova seção no arquivo binário. De fato, no exemplo tracex4_kern duas seções adicionais 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;

    // obtemos o endereço IP do chamador de 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;
}   

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

Se eu fizer um dump das seções do arquivo objeto, devo ver que essas novas seções já estão definidas:

$ objdump -h tracex4_kern.o

tracex4_kern.o: formato de arquivo elf64-pequeno

Seções:
Idx Nome Tamanho VMA LMA Offset do arquivo Alinhamento
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTEÚDO, ALOCADO, CARREGADO, SOMENTE_LEITURA, CÓDIGO
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTEÚDO, ALOCADO, CARREGADO, RELOC, SOMENTE_LEITURA, CÓDIGO
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTEÚDO, ALOCADO, CARREGADO, RELOC, SOMENTE_LEITURA, CÓDIGO
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTEÚDO, ALOCADO, CARREGADO, DADOS
4 licença 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTEÚDO, ALOCADO, CARREGADO, DADOS
5 versão 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTEÚDO, ALOCADO, CARREGADO, DADOS
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTEÚDO, ALOCADO, CARREGADO, RELOC, SOMENTE_LEITURA, DADOS

Também existe tracex4_user.c, o programa principal. Basicamente, este programa escuta eventos kmem_cache_alloc_node. Quando tal evento ocorre, o código eBPF correspondente é executado. O código armazena o atributo IP do objeto no mapa e, em seguida, esse objeto é continuamente apresentado no programa principal. Exemplo:

$ sudo ./tracex4
obj 0xffff8d6430f60a00 está 2s velho, foi alocado em ip ffffffff9891ad90
obj 0xffff8d6062ca5e00 está 23s velho, foi alocado em ip ffffffff98090e8f
obj 0xffff8d5f80161780 está 6s velho, foi alocado em ip ffffffff98090e8f

Como um programa de espaço do usuário e um programa eBPF estão relacionados? Ao inicializar, tracex4_user.c carrega o arquivo objeto tracex4_kern.o utilizando a função carregar_arquivo_bpf.

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 executar carregar_arquivo_bpf sondas definidas no arquivo eBPF, são adicionadas a /sys/kernel/debug/tracing/kprobe_events. Agora estamos ouvindo esses eventos, e nosso programa pode fazer algo quando eles ocorrem.

$ 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 maneira semelhante. Sempre há dois arquivos:

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

O programa eBPF define mapas e funções ligadas à seção. Quando o núcleo gera um evento de um tipo específico (por exemplo, tracepoint), as funções ligadas são executadas. Os mapas permitem a troca de dados entre o programa do núcleo e o programa do espaço do usuário.

Conclusão

Neste artigo, abordamos brevemente o BPF e o eBPF. Sei que hoje existe muita informação e recursos sobre o eBPF, então recomendo mais alguns materiais para estudo adicional.

Recomendo a leitura:

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster