Una breve introduzione a BPF e eBPF

Ehi Habr! Vi informiamo che ci stiamo preparando a pubblicare un libro"Osservabilità di Linux con BPF".

Una breve introduzione a BPF e eBPF
Poiché la macchina virtuale BPF continua a evolversi e viene utilizzata attivamente nella pratica, abbiamo tradotto per te un articolo che ne descrive le caratteristiche principali e lo stato attuale.

Negli ultimi anni, gli strumenti e le tecniche di programmazione hanno guadagnato popolarità per compensare i limiti del kernel Linux nei casi in cui è richiesta l'elaborazione di pacchetti ad alte prestazioni. Viene chiamato uno dei metodi più popolari di questo tipo bypass del nucleo (kernel bypass) e permette, saltando il livello di rete del kernel, di eseguire tutta l'elaborazione dei pacchetti dallo spazio utente. Bypassare il kernel comporta anche la gestione della scheda di rete da spazio utente. In altre parole, quando lavoriamo con una scheda di rete, ci affidiamo al driver spazio utente.

Trasferendo il pieno controllo della scheda di rete a un programma in spazio utente, riduciamo l'overhead causato dal kernel (commutazioni di contesto, elaborazione del livello di rete, interruzioni, ecc.), che è abbastanza importante quando si esegue a velocità di 10 Gb / so più alto. Bypassare il kernel più una combinazione di altre funzionalità (elaborazione in lotti) e un'attenta messa a punto delle prestazioni (Contabilità NUMA, Isolamento della CPU, ecc.) si adattano alle basi del networking dello spazio utente ad alte prestazioni. Forse un esempio esemplare di questo nuovo approccio all'elaborazione dei pacchetti è DPK dell'Intel (Kit di sviluppo del piano dati), sebbene esistano altri strumenti e tecniche ben noti, tra cui VPP di Cisco (Vector Packet Processing), Netmap e, naturalmente, snab.

L'organizzazione delle interazioni di rete nello spazio utente presenta una serie di svantaggi:

  • Un kernel del sistema operativo è un livello di astrazione per le risorse hardware. Poiché i programmi in spazio utente devono gestire direttamente le proprie risorse, devono anche gestire il proprio hardware. Questo spesso significa programmare i propri driver.
  • Dato che stiamo rinunciando completamente allo spazio del kernel, stiamo anche rinunciando a tutte le funzionalità di rete fornite dal kernel. I programmi in spazio utente devono reimplementare funzionalità che potrebbero essere già fornite dal kernel o dal sistema operativo.
  • I programmi funzionano in modalità sandbox, il che limita seriamente la loro interazione e impedisce loro di integrarsi con altre parti del sistema operativo.

In sostanza, quando si lavora in rete nello spazio utente, si ottengono miglioramenti delle prestazioni spostando l'elaborazione dei pacchetti dal kernel allo spazio utente. XDP fa esattamente l'opposto: sposta i programmi di rete dallo spazio utente (filtri, convertitori, routing, ecc.) all'area del kernel. XDP ci consente di eseguire la funzione di rete non appena il pacchetto colpisce l'interfaccia di rete e prima che inizi a viaggiare fino al sottosistema di rete del kernel. Di conseguenza, la velocità di elaborazione dei pacchetti aumenta notevolmente. Tuttavia, in che modo il kernel consente all'utente di eseguire i propri programmi nello spazio del kernel? Prima di rispondere a questa domanda, diamo un'occhiata a cos'è il BPF.

BPF e eBPF

Nonostante il nome non del tutto chiaro, BPF (Packet Filtering, Berkeley) è, infatti, un modello di macchina virtuale. Questa macchina virtuale è stata originariamente progettata per gestire il filtraggio dei pacchetti, da cui il nome.

Uno degli strumenti più noti che utilizzano BPF è tcpdump. Quando si catturano i pacchetti con tcpdump l'utente può specificare un'espressione per il filtraggio dei pacchetti. Verranno catturati solo i pacchetti che corrispondono a questa espressione. Ad esempio, l'espressione "tcp dst port 80” si riferisce a tutti i pacchetti TCP in arrivo sulla porta 80. Il compilatore può abbreviare questa espressione convertendola in 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

Questo è fondamentalmente ciò che fa il programma sopra:

  • Istruzione (000): Carica il pacchetto all'offset 12, come una parola di 16 bit, nell'accumulatore. L'offset 12 corrisponde all'ethertype del pacchetto.
  • Istruzione (001): confronta il valore nell'accumulatore con 0x86dd, ovvero con il valore ethertype per IPv6. Se il risultato è vero, il contatore del programma passa all'istruzione (002) e, in caso contrario, a (006).
  • Istruzione (006): confronta il valore con 0x800 (valore ethertype per IPv4). Se la risposta è vera, il programma va a (007), in caso contrario, a (015).

E così via, finché il programma di filtraggio dei pacchetti non restituisce un risultato. Di solito è booleano. Restituire un valore diverso da zero (istruzione (014)) significa che il pacchetto corrisponde e restituire zero (istruzione (015)) significa che il pacchetto non corrisponde.

La macchina virtuale BPF e il suo bytecode furono proposti da Steve McCann e Van Jacobson alla fine del 1992, quando uscì il loro articolo. BSD Packet Filter: nuova architettura per l'acquisizione di pacchetti a livello utente, per la prima volta questa tecnologia è stata presentata alla conferenza Usenix nell'inverno del 1993.

Poiché BPF è una macchina virtuale, definisce l'ambiente in cui vengono eseguiti i programmi. Oltre al bytecode, definisce anche un modello di memoria a pacchetto (le istruzioni di caricamento vengono applicate implicitamente a un pacchetto), i registri (A e X; registri accumulatore e indice), l'archiviazione della memoria scratch e un contatore di programma implicito. È interessante notare che il bytecode BPF è stato modellato sul Motorola 6502 ISA. Come ha ricordato Steve McCann nel suo relazione plenaria allo Sharkfest '11, conosceva la build 6502 dal liceo durante la programmazione su Apple II, e questa conoscenza ha influenzato il suo lavoro di progettazione del bytecode BPF.

Il supporto BPF è implementato nel kernel Linux nella versione v2.5 e successive, aggiunto principalmente da Jay Schullist. Il codice BPF è rimasto invariato fino al 2011, quando Eric Dumaset ha riprogettato l'interprete BPF per lavorare in modalità JIT (Fonte: JIT per i filtri di pacchetto). Successivamente, invece di interpretare il bytecode BPF, il kernel potrebbe convertire direttamente i programmi BPF nell'architettura di destinazione: x86, ARM, MIPS, ecc.

Successivamente, nel 2014, Alexei Starovoitov ha proposto un nuovo meccanismo JIT per BPF. In effetti, questo nuovo JIT è diventato una nuova architettura basata su BPF ed è stato chiamato eBPF. Penso che entrambe le macchine virtuali siano coesistite per un po 'di tempo, ma il filtraggio dei pacchetti è attualmente implementato su eBPF. Infatti, in molti esempi di documentazione moderna, la BPF è indicata come eBPF e la BPF classica è conosciuta oggi come cBPF.

eBPF estende la classica macchina virtuale BPF in diversi modi:

  • Si basa su moderne architetture a 64 bit. eBPF utilizza registri a 64 bit e aumenta il numero di registri disponibili da 2 (accumulatore e X) a 10. eBPF fornisce anche opcode aggiuntivi (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Distaccato dal sottosistema del livello di rete. BPF era legato al modello di dati batch. Poiché veniva utilizzato per filtrare i pacchetti, il suo codice si trovava nel sottosistema che forniva le interazioni di rete. Tuttavia, la macchina virtuale eBPF non è più associata a un modello di dati e può essere utilizzata per qualsiasi scopo. Quindi, ora il programma eBPF può essere connesso a tracepoint oa kprobe. Questo apre le porte alla strumentazione eBPF, all'analisi delle prestazioni e a molti altri casi d'uso nel contesto di altri sottosistemi del kernel. Ora il codice eBPF si trova nel proprio percorso: kernel/bpf.
  • Archivi di dati globali chiamati Maps. Le mappe sono negozi di valori-chiave che forniscono lo scambio di dati tra lo spazio utente e lo spazio del kernel. eBPF fornisce diversi tipi di carte.
  • Funzioni secondarie. In particolare, per sovrascrivere un pacchetto, calcolare un checksum o clonare un pacchetto. Queste funzioni vengono eseguite all'interno del kernel e non appartengono ai programmi in spazio utente. Inoltre, le chiamate di sistema possono essere effettuate dai programmi eBPF.
  • Termina le chiamate. La dimensione del programma in eBPF è limitata a 4096 byte. La funzione di fine chiamata consente a un programma eBPF di trasferire il controllo a un nuovo programma eBPF e quindi aggirare questa limitazione (fino a 32 programmi possono essere concatenati in questo modo).

esempio eBPF

Ci sono diversi esempi per eBPF nei sorgenti del kernel Linux. Sono disponibili su samples/bpf/. Per compilare questi esempi basta digitare:

$ sudo make samples/bpf/

Non scriverò personalmente un nuovo esempio per eBPF, ma utilizzerò uno degli esempi disponibili in samples/bpf/. Esaminerò alcune parti del codice e spiegherò come funziona. Ad esempio, ho scelto il programma tracex4.

In generale, ciascuno degli esempi in samples/bpf/ consiste di due file. In questo caso:

  • tracex4_kern.c, contiene il codice sorgente da eseguire nel kernel come bytecode eBPF.
  • tracex4_user.c, contiene un programma dallo spazio utente.

In questo caso, dobbiamo compilare tracex4_kern.c al bytecode eBPF. Al momento in gcc non esiste una parte server per eBPF. Fortunatamente, clang può produrre bytecode eBPF. Makefile usi clang compilare tracex4_kern.c al file oggetto.

Ho menzionato sopra che una delle caratteristiche più interessanti di eBPF sono le mappe. tracex4_kern definisce una mappa:

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 è uno dei tanti tipi di carte offerti da eBPF. In questo caso, è solo un hash. Potresti anche aver notato l'annuncio SEC("maps"). SEC è una macro utilizzata per creare una nuova sezione di un file binario. In realtà, nell'esempio tracex4_kern sono definite altre due sezioni:

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;
}   

Queste due funzioni consentono di rimuovere una voce dalla mappa (kprobe/kmem_cache_free) e aggiungi una nuova voce alla mappa (kretprobe/kmem_cache_alloc_node). Tutti i nomi delle funzioni scritti in maiuscolo corrispondono alle macro definite in bpf_helpers.h.

Se eseguo il dump delle sezioni del file oggetto, dovrei vedere che queste nuove sezioni sono già definite:

$ 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

Ancora tracex4_user.c, programma principale. Fondamentalmente, questo programma ascolta gli eventi kmem_cache_alloc_node. Quando si verifica un tale evento, viene eseguito il codice eBPF corrispondente. Il codice salva l'attributo IP dell'oggetto in una mappa, quindi l'oggetto viene ripetuto attraverso il programma principale. Esempio:

$ 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

Come sono correlati il ​​programma in spazio utente e il programma eBPF? All'inizializzazione tracex4_user.c carica il file oggetto tracex4_kern.o utilizzando la funzione 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;
}

Durante l'esecuzione load_bpf_file vengono aggiunte le sonde definite nel file eBPF /sys/kernel/debug/tracing/kprobe_events. Ora ascoltiamo questi eventi e il nostro programma può fare qualcosa quando accadono.

$ 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

Tutti gli altri programmi in sample/bpf/ sono strutturati in modo simile. Contengono sempre due file:

  • XXX_kern.c: programma eBPF.
  • XXX_user.c: programma principale.

Il programma eBPF definisce le mappe e le funzioni associate a una sezione. Quando il kernel emette un evento di un certo tipo (ad esempio, tracepoint), vengono eseguite le funzioni associate. Le mappe forniscono la comunicazione tra un programma del kernel e un programma in spazio utente.

conclusione

In questo articolo, BPF ed eBPF sono stati discussi in termini generali. So che oggi ci sono molte informazioni e risorse sull'eBPF, quindi consiglierò qualche altro materiale per ulteriori studi.

Raccomando di leggere:

Fonte: habr.com

Aggiungi un commento