Breve introdución a BPF e eBPF

Ola, Habr! Queremos informarvos de que estamos preparando un libro para a súa publicación".Observabilidade de Linux con BPF".

Breve introdución a BPF e eBPF
Dado que a máquina virtual BPF segue evolucionando e utilízase activamente na práctica, traducimos para vostede un artigo que describe as súas principais capacidades e o seu estado actual.

Nos últimos anos, as ferramentas e técnicas de programación fixéronse cada vez máis populares para compensar as limitacións do núcleo de Linux nos casos nos que se require un procesamento de paquetes de alto rendemento. Unha das técnicas máis populares deste tipo chámase bypass do núcleo (bypass do núcleo) e permite, ignorando a capa de rede do núcleo, realizar todo o procesamento de paquetes desde o espazo do usuario. Omitir o núcleo tamén implica controlar a tarxeta de rede desde espazo de usuario. Noutras palabras, cando traballamos cunha tarxeta de rede, confiamos no controlador espazo de usuario.

Ao transferir o control total da tarxeta de rede a un programa de espazo de usuario, reducimos a sobrecarga do núcleo (cambio de contexto, procesamento da capa de rede, interrupcións, etc.), o que é moi importante cando se executa a velocidades de 10 Gb/s ou superior. Bypass do núcleo máis unha combinación doutras características (procesamento por lotes) e axuste coidadoso do rendemento (Contabilidade NUMA, Illamento da CPU, etc.) corresponden aos fundamentos do procesamento de rede de alto rendemento no espazo do usuario. Quizais sexa un exemplo exemplar deste novo enfoque para o procesamento de paquetes DPDK de Intel (Kit de desenvolvemento do plano de datos), aínda que existen outras ferramentas e técnicas coñecidas, entre elas o VPP (Vector Packet Processing) de Cisco, Netmap e, por suposto, Snabb.

Organizar as interaccións de rede no espazo do usuario ten unha serie de desvantaxes:

  • O núcleo do SO é unha capa de abstracción para recursos de hardware. Dado que os programas espaciais dos usuarios teñen que xestionar os seus recursos directamente, tamén teñen que xestionar o seu propio hardware. Isto moitas veces significa ter que programar os seus propios controladores.
  • Debido a que renunciamos por completo ao espazo do núcleo, tamén renunciamos a toda a funcionalidade de rede proporcionada polo núcleo. Os programas espaciais de usuario deben reimplementar funcións que xa poden proporcionar o núcleo ou o sistema operativo.
  • Os programas funcionan en modo sandbox, o que limita seriamente a súa interacción e impide que se integren con outras partes do sistema operativo.

En esencia, cando se conecta en rede no espazo do usuario, conséguense ganancias de rendemento movendo o procesamento de paquetes do núcleo ao espazo do usuario. XDP fai exactamente o contrario: move os programas de rede do espazo do usuario (filtros, resolutores, enrutamento, etc.) á área do núcleo. XDP permítenos realizar unha función de rede tan pronto como un paquete chega a unha interface de rede e antes de que comece a subir ao subsistema de rede do núcleo. Como resultado, a velocidade de procesamento de paquetes aumenta significativamente. Non obstante, como permite o kernel ao usuario executar os seus programas no espazo do kernel? Antes de responder a esta pregunta, vexamos o que é BPF.

BPF e eBPF

A pesar do nome confuso, BPF (Berkeley Packet Filtering) é, de feito, un modelo de máquina virtual. Esta máquina virtual foi deseñada orixinalmente para xestionar o filtrado de paquetes, de aí o nome.

Unha das ferramentas máis famosas que usan BPF é tcpdump. Ao capturar paquetes usando tcpdump o usuario pode especificar unha expresión para filtrar paquetes. Só se capturarán os paquetes que coincidan con esta expresión. Por exemplo, a expresión "tcp dst port 80” refírese a todos os paquetes TCP que chegan ao porto 80. O compilador pode acurtar esta expresión converténdoa en 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 fai basicamente o programa anterior:

  • Instrución (000): carga o paquete no offset 12, como unha palabra de 16 bits, no acumulador. O offset 12 corresponde ao etertipo do paquete.
  • Instrución (001): compara o valor do acumulador con 0x86dd, é dicir, co valor ethertype para IPv6. Se o resultado é verdadeiro, entón o contador do programa pasa á instrución (002), e se non, a (006).
  • Instrución (006): compara o valor con 0x800 (valor ethertype para IPv4). Se a resposta é certa, entón o programa pasa a (007), se non, entón a (015).

E así ata que o programa de filtrado de paquetes devolva un resultado. Isto xeralmente é un booleano. Devolver un valor distinto de cero (instrución (014)) significa que o paquete foi aceptado, e devolver un valor cero (instrución (015)) significa que o paquete non foi aceptado.

A máquina virtual BPF e o seu código de bytes foron propostos por Steve McCann e Van Jacobson a finais de 1992 cando se publicou o seu artigo. Filtro de paquetes BSD: nova arquitectura para a captura de paquetes a nivel de usuario, esta tecnoloxía presentouse por primeira vez na conferencia de Usenix no inverno de 1993.

Como BPF é unha máquina virtual, define o ambiente no que se executan os programas. Ademais do bytecode, tamén define o modelo de memoria por lotes (as instrucións de carga aplícanse implícitamente ao lote), os rexistros (A e X; rexistros de acumulador e índice), o almacenamento de memoria scratch e un contador de programa implícito. Curiosamente, o código de bytes BPF foi modelado despois do Motorola 6502 ISA. Como recordaba Steve McCann no seu informe plenario no Sharkfest '11, estaba familiarizado coa compilación 6502 da programación dos seus días de secundaria no Apple II, e este coñecemento influíu no seu traballo de deseño do bytecode BPF.

O soporte BPF está implementado no núcleo de Linux nas versións v2.5 e superiores, engadido principalmente polos esforzos de Jay Schullist. O código BPF permaneceu inalterado ata 2011, cando Eric Dumaset redeseñou o intérprete BPF para executalo en modo JIT (Fonte: JIT para filtros de paquetes). Despois diso, o núcleo, en lugar de interpretar o bytecode BPF, podería converter directamente os programas BPF á arquitectura de destino: x86, ARM, MIPS, etc.

Máis tarde, en 2014, Alexey Starovoitov propuxo un novo mecanismo JIT para BPF. De feito, este novo JIT converteuse nunha nova arquitectura baseada en BPF e chamouse eBPF. Creo que ambas máquinas virtuales coexistiron durante algún tempo, pero actualmente o filtrado de paquetes está implementado baseándose en eBPF. De feito, en moitos exemplos de documentación moderna, BPF enténdese como eBPF, e BPF clásico coñécese hoxe como cBPF.

eBPF estende a clásica máquina virtual BPF de varias maneiras:

  • Baseado en arquitecturas modernas de 64 bits. eBPF usa rexistros de 64 bits e aumenta o número de rexistros dispoñibles de 2 (acumulador e X) a 10. eBPF tamén ofrece códigos de operación adicionais (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Desconectado do subsistema da capa de rede. BPF estivo vinculado ao modelo de datos por lotes. Dado que se utilizaba para o filtrado de paquetes, o seu código estaba situado no subsistema que proporciona comunicacións de rede. Non obstante, a máquina virtual eBPF xa non está vinculada ao modelo de datos e pódese utilizar para calquera propósito. Entón, agora o programa eBPF pódese conectar a tracepoint ou kprobe. Isto abre o camiño para a instrumentación eBPF, análise de rendemento e moitos outros casos de uso no contexto doutros subsistemas do núcleo. Agora o código eBPF está situado no seu propio camiño: kernel/bpf.
  • Almacéns de datos globais chamados Maps. Os mapas son almacéns de clave-valor que permiten o intercambio de datos entre o espazo de usuario e o espazo do núcleo. eBPF ofrece varios tipos de mapas.
  • Funcións secundarias. En particular, para reescribir un paquete, calcular unha suma de verificación ou clonar un paquete. Estas funcións execútanse dentro do núcleo e non son programas de espazo de usuario. Tamén pode facer chamadas ao sistema desde programas eBPF.
  • Finalizar chamadas. O tamaño do programa en eBPF está limitado a 4096 bytes. A función de chamada de cola permite que un programa eBPF transfera o control a un novo programa eBPF e, polo tanto, eludir esta limitación (pódense vincular ata 32 programas deste xeito).

eBPF: exemplo

Hai varios exemplos de eBPF nas fontes do núcleo de Linux. Están dispoñibles en samples/bpf/. Para compilar estes exemplos, simplemente introduza:

$ sudo make samples/bpf/

Non vou escribir un novo exemplo para eBPF, pero usarei unha das mostras dispoñibles en samples/bpf/. Vou ver algunhas partes do código e explicarei como funciona. Como exemplo, escollín o programa tracex4.

En xeral, cada un dos exemplos de samples/bpf/ consta de dous ficheiros. Neste caso:

  • tracex4_kern.c, contén o código fonte que se vai executar no núcleo como bytecode eBPF.
  • tracex4_user.c, contén un programa do espazo de usuario.

Neste caso, necesitamos compilar tracex4_kern.c ao código de bytes eBPF. Actualmente en gcc non hai backend para eBPF. Afortunadamente, clang pode emitir o código de bytes eBPF. Makefile usos clang para compilación tracex4_kern.c ao ficheiro obxecto.

Mencionei anteriormente que unha das características máis interesantes de eBPF son os mapas. tracex4_kern define un 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 é un dos moitos tipos de tarxetas que ofrece eBPF. Neste caso, é só un hash. Tamén podes ter notado un anuncio SEC("maps"). SEC é unha macro utilizada para crear unha nova sección dun ficheiro binario. En realidade, no exemplo tracex4_kern defínense dúas seccións máis:

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 dúas funcións permítenche eliminar unha entrada do mapa (kprobe/kmem_cache_free) e engade unha nova entrada ao mapa (kretprobe/kmem_cache_alloc_node). Todos os nomes de función escritos en maiúsculas corresponden a macros definidas en bpf_helpers.h.

Se volco as seccións do ficheiro obxecto, debería ver que estas novas seccións xa están 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

Tamén hai tracex4_user.c, programa principal. Basicamente, este programa escoita eventos kmem_cache_alloc_node. Cando se produce tal evento, execútase o código eBPF correspondente. O código garda o atributo IP do obxecto nun mapa e, a continuación, o obxecto pasa a través do 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 están relacionados un programa de espazo de usuario e un programa eBPF? Na inicialización tracex4_user.c carga un ficheiro obxecto tracex4_kern.o utilizando a función 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;
}

Mentres fai load_bpf_file engádense as sondas definidas no ficheiro eBPF /sys/kernel/debug/tracing/kprobe_events. Agora escoitamos estes eventos e o noso programa pode facer algo cando ocorren.

$ 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 demais programas en sample/bpf/ están estruturados de xeito similar. Sempre conteñen dous ficheiros:

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

O programa eBPF identifica mapas e funcións asociadas a unha sección. Cando o núcleo emite un evento de certo tipo (por exemplo, tracepoint), execútanse as funcións vinculadas. As tarxetas proporcionan comunicación entre o programa do núcleo e o programa do espazo do usuario.

Conclusión

Este artigo discutiu BPF e eBPF en termos xerais. Sei que hoxe hai moita información e recursos sobre eBPF, así que recomendarei algúns recursos máis para seguir estudiando

Recomendo ler:

Fonte: www.habr.com

Engadir un comentario