Una breve introducción a BPF y eBPF

¡Hola, Habr! Les informamos que nos estamos preparando para lanzar un libro”Observabilidad de Linux con BPF".

Una breve introducción a BPF y eBPF
A medida que la máquina virtual BPF continúa evolucionando y se usa activamente en la práctica, hemos traducido un artículo para usted que describe sus características principales y su estado actual.

En los últimos años, las herramientas y técnicas de programación han ganado popularidad para compensar las limitaciones del kernel de Linux en los casos en que se requiere un procesamiento de paquetes de alto rendimiento. Uno de los métodos más populares de este tipo se llama derivación de núcleo (bypass del kernel) y permite, saltándose la capa de red del kernel, realizar todo el procesamiento de paquetes desde el espacio del usuario. Pasar por alto el kernel también implica administrar la tarjeta de red desde espacio de usuario. En otras palabras, cuando trabajamos con una tarjeta de red, confiamos en el controlador espacio de usuario.

Al transferir el control total de la tarjeta de red a un programa de espacio de usuario, reducimos la sobrecarga causada por el kernel (cambios de contexto, procesamiento de capa de red, interrupciones, etc.), lo cual es bastante importante cuando se ejecuta a velocidades de 10 Gb/s o más alto. Omitir el kernel más una combinación de otras características (procesamiento por lotes) y un cuidadoso ajuste del rendimiento (contabilidad NUMA, aislamiento de la CPU, etc.) se ajustan a los conceptos básicos de redes de espacio de usuario de alto rendimiento. Quizás un ejemplo ejemplar de este nuevo enfoque para el procesamiento de paquetes es DPDK de Intel (Kit de desarrollo del plano de datos), aunque existen otras herramientas y técnicas muy conocidas, como VPP de Cisco (Vector Packet Processing), Netmap y, por supuesto, snabb.

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

  • Un kernel de sistema operativo es una capa de abstracción para los recursos de hardware. Debido a que los programas de espacio de usuario tienen que administrar sus recursos directamente, también deben administrar su propio hardware. Esto a menudo significa programar sus propios controladores.
  • Dado que estamos renunciando por completo al espacio del kernel, también estamos renunciando a toda la funcionalidad de red proporcionada por el kernel. Los programas de espacio de usuario tienen que volver a implementar funciones que el kernel o el sistema operativo ya pueden proporcionar.
  • Los programas funcionan en modo sandbox, lo que limita seriamente su interacción y evita que se integren con otras partes del sistema operativo.

En esencia, cuando se conecta en red en el espacio del usuario, las ganancias de rendimiento se logran al mover el procesamiento de paquetes del kernel al espacio del usuario. XDP hace exactamente lo contrario: mueve los programas de red desde el espacio del usuario (filtros, convertidores, enrutamiento, etc.) al área del núcleo. XDP nos permite ejecutar la función de red tan pronto como el paquete llega a la interfaz de red y antes de que comience a viajar hacia el subsistema de red del kernel. Como resultado, la velocidad de procesamiento de paquetes aumenta significativamente. Sin embargo, ¿cómo permite el kernel que el usuario ejecute sus programas en el espacio del kernel? Antes de responder a esta pregunta, veamos qué es BPF.

BPF y eBPF

A pesar del nombre no del todo claro, BPF (Packet Filtering, Berkeley) es, de hecho, un modelo de máquina virtual. Esta máquina virtual se diseñó originalmente para manejar el filtrado de paquetes, de ahí el nombre.

Una de las herramientas más conocidas que utilizan BPF es tcpdump. Al capturar paquetes con tcpdump el usuario puede especificar una expresión para el filtrado de 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 convirtié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 en el desplazamiento 12, como una palabra de 16 bits, en el acumulador. El desplazamiento 12 corresponde al tipo ether 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, entonces el contador del programa pasa a la instrucción (002), y si no, a (006).
  • Instrucción (006): compara el valor con 0x800 (valor ethertype para IPv4). Si la respuesta es verdadera, entonces el programa va a (007), si no, entonces a (015).

Y así sucesivamente, hasta que el programa de filtrado de paquetes devuelva un resultado. Por lo general, es booleano. Devolver un valor distinto de cero (instrucción (014)) significa que el paquete coincidió y devolver cero (instrucción (015)) significa que el paquete no coincidió.

La máquina virtual BPF y su código de bytes fueron propuestos por Steve McCann y Van Jacobson a fines de 1992 cuando salió su artículo. BSD Packet Filter: nueva arquitectura para la captura de paquetes a nivel de usuario, por primera vez esta tecnología se presentó en la conferencia de Usenix en el invierno de 1993.

Debido a que BPF es una máquina virtual, define el entorno en el que se ejecutan los programas. Además del código de bytes, también define un modelo de memoria de paquetes (las instrucciones de carga se aplican implícitamente a un paquete), registros (A y X; acumuladores y registros de índice), almacenamiento de memoria temporal y un contador de programa implícito. Curiosamente, el código de bytes BPF se modeló a partir del Motorola 6502 ISA. Como recordaba Steve McCann en su informe plenario en Sharkfest '11, estaba familiarizado con la compilación 6502 desde la escuela secundaria cuando programaba en Apple II, y este conocimiento influyó en su trabajo de diseño del código de bytes BPF.

El soporte de BPF está implementado en el kernel de Linux en la versión v2.5 y posteriores, agregado principalmente por Jay Schullist. El código BPF se mantuvo sin cambios hasta 2011, cuando Eric Dumaset rediseñó el intérprete BPF para que funcionara en modo JIT (Fuente: JIT para filtros de paquetes). Después de eso, en lugar de interpretar el código de bytes BPF, el núcleo podría convertir directamente los programas BPF a la arquitectura de destino: x86, ARM, MIPS, etc.

Posteriormente, en 2014, Alexei Starovoitov propuso un nuevo mecanismo JIT para BPF. De hecho, este nuevo JIT se convirtió en una nueva arquitectura basada en BPF y se denominó eBPF. Creo que ambas máquinas virtuales coexistieron durante algún tiempo, pero actualmente se implementa el filtrado de paquetes además de eBPF. De hecho, en muchos ejemplos de documentación moderna, BPF se conoce como eBPF, y el BPF clásico se conoce hoy como cBPF.

eBPF amplía la máquina virtual BPF clásica de varias formas:

  • Se basa en arquitecturas modernas de 64 bits. eBPF utiliza registros de 64 bits y aumenta el número 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…).
  • Separado del subsistema de capa de red. BPF estaba vinculado al modelo de datos por lotes. Dado que se usaba para filtrar paquetes, su código estaba en el subsistema que proporcionaba interacciones de red. Sin embargo, la máquina virtual eBPF ya no está vinculada a un modelo de datos y se puede utilizar para cualquier propósito. Entonces, ahora el programa eBPF se puede conectar a tracepoint o kprobe. Esto abre la puerta a la instrumentación eBPF, el análisis de rendimiento y muchos otros casos de uso en el contexto de otros subsistemas del núcleo. Ahora el código eBPF se encuentra en su propia ruta: kernel/bpf.
  • Almacenes de datos globales llamados Maps. Los mapas son almacenes de clave-valor que proporcionan intercambio de datos entre el espacio del usuario y el espacio del núcleo. eBPF proporciona varios tipos de tarjetas.
  • Funciones secundarias. En particular, para sobrescribir un paquete, calcular una suma de verificación o clonar un paquete. Estas funciones se ejecutan dentro del kernel y no pertenecen a programas de espacio de usuario. Además, las llamadas al sistema se pueden realizar desde programas eBPF.
  • Finalizar llamadas. El tamaño del programa en eBPF está limitado a 4096 bytes. La función de finalización de llamada permite que un programa eBPF transfiera el control a un nuevo programa eBPF y, por lo tanto, eluda esta limitación (se pueden encadenar hasta 32 programas de esta manera).

ejemplo de eBPF

Hay varios ejemplos de eBPF en las fuentes del kernel de Linux. Están disponibles en samples/bpf/. Para compilar estos ejemplos, simplemente escriba:

$ sudo make samples/bpf/

No escribiré un nuevo ejemplo para eBPF, pero usaré una de las muestras disponibles en samples/bpf/. Examinaré algunas partes del código y explicaré cómo funciona. Como ejemplo, elegí el programa tracex4.

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

  • tracex4_kern.c, contiene el código fuente que se ejecutará en el núcleo como código de bytes eBPF.
  • tracex4_user.c, contiene un programa del espacio de usuario.

En este caso, tenemos que compilar tracex4_kern.c al código de bytes eBPF. en este momento en gcc no hay una parte del servidor para eBPF. Afortunadamente, clang puede producir código de bytes eBPF. Makefile usos clang compilar tracex4_kern.c al archivo de objeto.

Mencioné anteriormente 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 tarjetas que ofrece eBPF. En este caso, es solo un hash. Es posible que también hayas notado el anuncio. SEC("maps"). SEC es una macro utilizada para crear una nueva sección de un archivo binario. En realidad, en el ejemplo tracex4_kern se definen dos secciones más:

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 dos funciones le permiten eliminar una entrada del mapa (kprobe/kmem_cache_free) y agregue una nueva entrada al mapa (kretprobe/kmem_cache_alloc_node). Todos los nombres de funciones escritos en mayúsculas corresponden a macros definidas en bpf_helpers.h.

Si descargo las secciones del archivo del objeto, debería ver que estas nuevas secciones ya 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

Todavia tengo tracex4_user.c, programa principal. Básicamente, este programa escucha eventos kmem_cache_alloc_node. Cuando ocurre tal evento, se ejecuta el código eBPF correspondiente. El código guarda el atributo IP del objeto en un mapa y luego el objeto pasa por el programa principal. Ejemplo:

$ 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

¿Cómo se relacionan el programa de espacio de usuario y el programa eBPF? En la inicialización tracex4_user.c carga el archivo de objeto tracex4_kern.o usando la 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;
}

Al realizar load_bpf_file las sondas definidas en el archivo eBPF se agregan a /sys/kernel/debug/tracing/kprobe_events. Ahora escuchamos estos eventos y nuestro programa puede hacer algo cuando suceden.

$ 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 los mapas y funciones asociadas a una sección. Cuando el kernel emite un evento de cierto tipo (por ejemplo, tracepoint), se ejecutan las funciones enlazadas. Los mapas proporcionan comunicación entre un programa kernel y un programa de espacio de usuario.

Conclusión

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

Recomiendo leer:

Fuente: habr.com

Añadir un comentario