Introducción breve a BPF y eBPF

¡Hola, Habr! Les informamos que estamos preparando el lanzamiento del libro "Linux Observability with BPF".

Introducción breve a BPF y eBPF
Dado que la máquina virtual BPF sigue evolucionando y se utiliza activamente en la práctica, hemos traducido para ustedes un artículo que describe sus principales capacidades y su estado actual.

En los últimos años, han ganado popularidad las herramientas y técnicas de programación diseñadas para compensar las limitaciones del núcleo de Linux en casos donde se requiere un procesamiento de paquetes de alto rendimiento. Una de las técnicas más populares de este tipo se llama bypass del núcleo (kernel bypass) y permite, al eludir el nivel de red del núcleo, realizar todo el procesamiento de paquetes desde el espacio de usuario. El bypass del núcleo también implica la gestión de la tarjeta de red desde el espacio de usuario. En otras palabras, al trabajar con la tarjeta de red, confiamos en el controlador espacio de usuario.

Al transferir el control total de la tarjeta de red a un programa en el espacio de usuario, reducimos los costos asociados con la operación del núcleo (cambio de contexto, procesamiento del nivel de red, interrupciones, etc.), lo cual es bastante importante al trabajar a velocidades de 10 Gb/s o superiores. El bypass del núcleo, junto con una combinación de otras capacidades (procesamiento por lotes) y un ajuste cuidadoso del rendimiento (teniendo en cuenta NUMA, aislamiento de CPU, etc.) son fundamentales para el procesamiento de red de alto rendimiento en el espacio de usuario. Un ejemplo modelo de este nuevo enfoque al procesamiento de paquetes es el DPDK de Intel (Kit de Desarrollo de Plano de Datos), aunque existen otras herramientas y técnicas ampliamente conocidas, entre las que se encuentran VPP de Cisco (Vector Packet Processing), Netmap y, por supuesto, Snabb.

La organización de las interacciones de red en el espacio de usuario tiene una serie de desventajas:

  • El núcleo del sistema operativo es un nivel de abstracción para los recursos de hardware. Dado que los programas de espacio de usuario deben gestionar sus propios recursos directamente, también deben gestionar su propio hardware. A menudo, esto significa que necesitan programar sus propios controladores.
  • Dado que renunciamos por completo al espacio del kernel, también renunciamos a toda la funcionalidad de red proporcionada por el kernel. Las aplicaciones en el espacio de usuario deben volver a implementar aquellas funciones que, posiblemente, ya son proporcionadas por el kernel o el sistema operativo.
  • Las aplicaciones funcionan en modo sandbox, lo que limita gravemente sus capacidades de interacción y dificulta su integración con otras partes del sistema operativo.

En esencia, al organizar interacciones de red en el espacio de usuario, el aumento del rendimiento se logra al trasladar el procesamiento de paquetes del kernel al espacio de usuario. XDP hace lo contrario: mueve programas de red del espacio de usuario (filtros, transformadores, enrutamiento, etc.) al área del kernel. XDP nos permite ejecutar funciones de red tan pronto como el paquete llega a la interfaz de red y antes de que comience a moverse hacia arriba en la subestructura de red del kernel. Como resultado, la velocidad de procesamiento de paquetes aumenta significativamente. Sin embargo, ¿cómo permite el kernel a los usuarios ejecutar sus programas en el espacio del kernel? Antes de responder a esta pregunta, revisemos qué es BPF.

BPF y eBPF

A pesar de su nombre algo confuso, BPF (Filtración de Paquetes de Berkeley) es, de hecho, un modelo de máquina virtual. Esta máquina virtual fue diseñada originalmente para el procesamiento de la filtración de paquetes, de ahí su nombre.

Uno de los instrumentos más conocidos que utiliza BPF es tcpdump. Al capturar paquetes mediante tcpdump el usuario puede establecer una expresión para filtrar paquetes. Solo se capturarán los paquetes que coincidan con esta expresión. Por ejemplo, la expresión “tcp dst port 80” se refiere a todos los paquetes TCP que llegan al puerto 80. El compilador puede acortar esta expresión transformándola en código de bytes 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

Esto es básicamente lo que hace el programa anterior:

  • Instrucción (000): carga el paquete con un desplazamiento de 12, como una palabra de 16 bits en el acumulador. El desplazamiento 12 corresponde al ethertype del paquete.
  • Instrucción (001): compara el valor en el acumulador con 0x86dd, es decir, con el valor ethertype para IPv6. Si el resultado es verdadero, el contador del programa pasa a la instrucción (002), y si no, a la (006).
  • Instrucción (006): compara el valor con 0x800 (valor de ethertype para IPv4). Si la respuesta es verdadera, el programa pasa a (007), de lo contrario, a (015).

Y así sucesivamente, hasta que el programa de filtrado de paquetes devuelva un resultado. Normalmente es un booleano. Devolver un valor no nulo (instrucción (014)) significa que el paquete ha sido aceptado, mientras que devolver nulo (instrucción (015)) significa que el paquete no ha sido aceptado.

La máquina virtual BPF y su bytecode fueron propuestas por Steve McCanne y Van Jacobson a finales de 1992, cuando publicaron su artículo. Filtro de paquetes BSD: Una nueva arquitectura para capturar paquetes a nivel de usuario., esta tecnología fue presentada por primera vez en la conferencia Usenix en el invierno de 1993.

Dado que BPF es una máquina virtual, define el entorno en el que se ejecutan los programas. Además del bytecode, también define el modelo de memoria de paquetes (las instrucciones de carga se aplican implícitamente al paquete), los registros (A y X; registros de acumulador e índice), almacenamiento de memoria de trabajo y un contador de programas implícito. Curiosamente, el bytecode de BPF fue modelado según la ISA de Motorola 6502. Como recordó Steve McCanne en su conferencia plenaria en Sharkfest '11, él estaba familiarizado con el ensamblador de 6502 desde la secundaria, cuando programaba en Apple II, y este conocimiento influyó en su trabajo en el diseño del bytecode de BPF.

El soporte para BPF fue implementado en el núcleo de Linux en la versión v2.5 y superiores, principalmente gracias a los esfuerzos de Jay Schulist. El código de BPF permaneció sin cambios significativos hasta 2011, cuando Eric Dumazet reescribió el intérprete de BPF para que funcionara en modo JIT (Fuente: JIT para filtros de paquetes). Después de esto, el núcleo, en lugar de interpretar el bytecode de BPF, pudo convertir directamente los programas de BPF a la arquitectura de destino: x86, ARM, MIPS, etc.

Más tarde, en 2014, Alexey Starovoytov propuso un nuevo mecanismo JIT para BPF. De hecho, este nuevo JIT se convirtió en una nueva arquitectura basada en BPF y recibió el nombre de eBPF. Creo que durante un tiempo ambas máquinas virtuales coexistieron, pero actualmente la filtración de paquetes se realiza sobre la base de eBPF. De hecho, en muchas muestras de la documentación moderna, lo que se entiende por BPF es eBPF, y la BPF clásica hoy se conoce como cBPF.

eBPF expande la máquina virtual clásica BPF en varios aspectos:

  • Se basa en arquitecturas modernas de 64 bits. eBPF utiliza registros de 64 bits y aumenta la cantidad de registros disponibles de 2 (acumulador y X) a 10. eBPF también proporciona códigos de operación adicionales (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Desacoplada del subsistema de red. BPF estaba atada a un modelo de datos de paquetes. Dado que se utilizaba para filtrar paquetes, su código residía en el subsistema que manejaba interacciones de red. Sin embargo, la máquina virtual eBPF ya no está ligada al modelo de datos y puede usarse con cualquier propósito. Así, ahora el programa eBPF puede conectarse a un tracepoint o a un kprobe. Esto abre la puerta a la instrumentación de eBPF, análisis de rendimiento y muchas otras aplicaciones en el contexto de otros subsistemas del núcleo. Ahora el código eBPF se encuentra en su propio camino: kernel/bpf.
  • Almacenes de datos globales llamados Mapas. Los mapas son almacenamientos tipo 'clave-valor' que permiten el intercambio de datos entre el espacio de usuario y el espacio del núcleo. En eBPF, se proporcionan mapas de varios tipos.
  • Funciones auxiliares. En particular, para reescribir un paquete, calcular una suma de verificación o clonar un paquete. Estas funciones se ejecutan dentro del núcleo y no se refieren a los programas del espacio de usuario. Además, desde los programas eBPF pueden realizarse llamadas al sistema.
  • Llamadas finales. El tamaño del programa en eBPF está limitado a 4096 bytes. La capacidad de llamada final permite que un programa eBPF transfiera el control a un nuevo programa eBPF y así eludir esta limitación (de este modo se pueden encadenar hasta 32 programas).

eBPF: ejemplo

En las fuentes del núcleo de Linux hay varios ejemplos para eBPF. Están disponibles en samples/bpf/. Para compilar estos ejemplos, simplemente ingrese:

$ sudo make samples/bpf/

No voy a escribir un nuevo ejemplo para eBPF, sino que usaré uno de los ejemplos disponibles en samples/bpf/. Examinaré algunas secciones del código y explicaré cómo funciona. Como ejemplo, elegí el programa tracex4.

En general, cada uno de los ejemplos en samples/bpf/ consta de dos archivos. En este caso:

  • tracex4_kern.c, contiene el código fuente que debe ejecutarse en el núcleo como bytecode eBPF.
  • tracex4_user.c, contiene el programa del espacio de usuario.

En este caso, necesitamos compilar tracex4_kern.c a bytecode eBPF. En este momento, gcc no hay una parte del servidor para eBPF. Afortunadamente, clang puede emitir bytecode eBPF. Makefile utiliza clang para compilar tracex4_kern.c en un archivo objeto.

Antes mencioné que una de las características más interesantes de eBPF son los 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 es uno de los muchos tipos de mapas ofrecidos por eBPF. En este caso, simplemente es un hash. También puedes notar la declaración SEC("maps"). SEC es un macro utilizado para crear una nueva sección del archivo binario. En el ejemplo, tracex4_kern se definen otras dos secciones:

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;

    // obtenemos la dirección IP de la llamada a 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 dos funciones permiten eliminar una entrada del mapa (kprobe/kmem_cache_free) y agregar una nueva entrada al mapa (kretprobe/kmem_cache_alloc_node). Todos los nombres de funciones, escritos en mayúsculas, corresponden a macros definidos en bpf_helpers.h.

Si realizo un volcado de secciones del archivo objeto, debo ver que estas nuevas secciones ya están definidas:

$ objdump -h tracex4_kern.o

tracex4_kern.o: formato de archivo elf64-little

Secciones:
Idx Nombre Tamaño VMA LMA Offset de archivo Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTENIDO, ALOJAR, CARGAR, SOLO_LECTURA, CÓDIGO
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENIDO, ALOJAR, CARGAR, RELOCALIZAR, SOLO_LECTURA, CÓDIGO
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENIDO, ALOJAR, CARGAR, RELOCALIZAR, SOLO_LECTURA, CÓDIGO
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENIDO, ALOJAR, CARGAR, DATOS
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENIDO, ALOJAR, CARGAR, DATOS
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENIDO, ALOJAR, CARGAR, DATOS
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENIDO, ALOJAR, CARGAR, RELOCALIZAR, SOLO_LECTURA, DATOS

También existe tracex4_user.c, el programa principal. En principio, este programa escucha eventos kmem_cache_alloc_node. Cuando ocurre tal evento, se ejecuta el código correspondiente de eBPF. El código guarda el atributo IP del objeto en el mapa, y luego este objeto se muestra continuamente en el programa principal. Ejemplo:

$ sudo ./tracex4
obj 0xffff8d6430f60a00 tiene 2 segundos de antigüedad, fue asignado en ip ffffffff9891ad90
obj 0xffff8d6062ca5e00 tiene 23 segundos de antigüedad, fue asignado en ip ffffffff98090e8f
obj 0xffff8d5f80161780 tiene 6 segundos de antigüedad, fue asignado en ip ffffffff98090e8f

¿Cómo se relacionan el programa de espacio usuario y el programa eBPF? Al inicializar, tracex4_user.c carga el archivo objeto tracex4_kern.o mediante la función cargar_archivo_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;
}

Al ejecutar cargar_archivo_bpf los sondas definidas en el archivo eBPF, se añaden a /sys/kernel/debug/tracing/kprobe_events. Ahora estamos escuchando esos eventos, y nuestro programa puede hacer algo cuando ocurren.

$ 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 los demás programas en sample/bpf/ están estructurados de manera similar. Siempre contienen dos archivos:

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

El programa eBPF define mapas y funciones vinculadas a la sección. Cuando el núcleo emite un evento de cierto tipo (por ejemplo, tracepoint), las funciones vinculadas se ejecutan. Los mapas permiten el intercambio de datos entre el programa del núcleo y el programa del espacio del usuario.

Conclusión

En este artículo se han discutido en términos generales BPF y eBPF. Sé que hoy hay mucha información y recursos sobre eBPF, por lo que recomendaría algunos materiales adicionales para un estudio más profundo

Recomiendo leer:

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster