Introduzione breve a BPF e eBPF

Ciao, Habr! Ti informiamo che sta per uscire il libro "Linux Observability with BPF".

Introduzione breve a BPF e eBPF
Poiché la macchina virtuale BPF continua a evolversi ed è ampiamente utilizzata nella pratica, abbiamo tradotto per te un articolo che descrive le sue principali funzionalità e il suo stato attuale.

Negli ultimi anni, si sono diffusi strumenti di programmazione e tecniche destinate a compensare le limitazioni del kernel Linux in situazioni in cui è necessaria un'elaborazione di pacchetti ad alte prestazioni. Una delle più diffuse tecniche di questo tipo è chiamata bypass del kernel (kernel bypass) e consente, saltando il livello di rete del kernel, di eseguire l'intera elaborazione dei pacchetti dallo spazio utente. Il bypass del kernel implica anche la gestione della scheda di rete dallo spazio utente. In altre parole, durante l'interazione con la scheda di rete, ci si basa sul driver spazio utente.

Trasferendo il controllo completo della scheda di rete a un programma dallo spazio utente, riduciamo i costi dovuti all'attività del kernel (commutazione del contesto, elaborazione del livello di rete, interruzioni, ecc.), che è molto importante quando si opera a velocità di 10 Gb/s o superiori. Il bypass del kernel, insieme ad altre caratteristiche (elaborazione dei pacchetti) e una corretta ottimizzazione delle prestazioni (considerazione NUMA, isolamento della CPU, ecc.) costituiscono le basi per un'elaborazione di rete ad alte prestazioni nello spazio utente. Un esempio esemplare di questo nuovo approccio all'elaborazione dei pacchetti è il DPDK di Intel (Data Plane Development Kit), anche se ci sono altri strumenti e tecniche ampiamente noti, tra cui VPP di Cisco (Vector Packet Processing), Netmap e, naturalmente, Snabb.

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

  • Il kernel OS è un livello di astrazione per le risorse hardware. Poiché i programmi dello spazio utente devono gestire direttamente le proprie risorse, devono anche gestire il proprio hardware. Ciò spesso significa dover programmare i propri driver.
  • Poiché rinunciamo completamente allo spazio del kernel, rinunciamo anche a tutte le funzionalità di rete fornite dal kernel. I programmi dello spazio utente devono implementare nuovamente le funzioni che, forse, sono già fornite dal kernel o dal sistema operativo.
  • I programmi operano in modalità sandbox, il che limita seriamente le loro capacità di interazione e ostacola l'integrazione con altre parti del sistema operativo.

In sostanza, nell'organizzazione delle interazioni di rete nello spazio utente, il miglioramento delle prestazioni viene raggiunto trasferendo l'elaborazione dei pacchetti dal kernel allo spazio utente. XDP fa esattamente il contrario: sposta i programmi di rete dallo spazio utente (filtri, trasformatori, instradamento, ecc.) nell'area del kernel. XDP ci consente di eseguire una funzione di rete non appena il pacchetto entra nell'interfaccia di rete e prima che inizi a muoversi verso l'alto nel sottosistema di rete del kernel. Di conseguenza, la velocità di elaborazione dei pacchetti aumenta notevolmente. Tuttavia, come fa il kernel a consentire agli utenti di eseguire i propri programmi nello spazio del kernel? Prima di rispondere a questa domanda, esaminiamo cos'è il BPF.

BPF e eBPF

Nonostante il nome poco chiaro BPF (Berkeley Packet Filtering), si tratta, in effetti, di 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ù conosciuti che utilizza il BPF è tcpdump. Durante la cattura dei pacchetti con tcpdump l'utente può specificare un'espressione per filtrare i pacchetti. Saranno 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ò semplificare 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

Ecco cosa fa, in sostanza, il programma sopra citato:

  • Istruzione (000): carica il pacchetto con uno offset di 12, come parola a 16 bit nell'accumulatore. L'offset 12 corrisponde all'ethertype del pacchetto.
  • Istruzione (001): confronta il valore nell'accumulatore con 0x86dd, cioè, con il valore ethertype per IPv6. Se il risultato è true, il contatore del programma passa all'istruzione (002), altrimenti passa a (006).
  • Istruzione (006): confronta il valore con 0x800 (valore ethertype per IPv4). Se la risposta è vera, il programma passa all'istruzione (007), altrimenti all'istruzione (015).

E così via, finché il programma di filtraggio dei pacchetti non restituisce un risultato. Di solito, questo è un valore booleano. Il ritorno di un valore non nullo (istruzione (014)) significa che il pacchetto è passato, mentre il ritorno di zero (istruzione (015)) significa che il pacchetto non è passato.

La macchina virtuale BPF e il suo bytecode sono stati proposti da Steve McCanne e Van Jacobson alla fine del 1992, quando è uscito il loro articolo Filtraggio pacchetti BSD: una nuova architettura per la cattura dei pacchetti a livello utente, questa tecnologia è stata presentata per la prima volta alla conferenza Usenix nell'inverno del 1993.

Poiché BPF è una macchina virtuale, essa definisce l'ambiente in cui vengono eseguiti i programmi. Oltre al bytecode, definisce anche il modello di memoria dei pacchetti (le istruzioni di caricamento vengono applicate implicitamente ai pacchetti), i registri (A e X; registri dell'accumulatore e dell'indice), uno spazio di memoria temporanea e un contatore di programma implicito. È interessante notare che il bytecode BPF è stato modellato su quello dell'ISA Motorola 6502. Come ricordava Steve McCanne nel suo intervento principale a Sharkfest '11, conosceva l'assemblaggio del 6502 dai tempi delle superiori, quando programmava su Apple II, e queste conoscenze hanno influenzato il suo lavoro di progettazione del bytecode BPF.

Il supporto per BPF è stato implementato nel kernel Linux nella versione v2.5 e successive, aggiunto soprattutto grazie agli sforzi di Jay Schullist. Il codice BPF è rimasto senza modifiche significative fino al 2011, quando Eric Dumazet ha riscritto l'interprete BPF per funzionare in modalità JIT (Fonte: JIT per i filtri dei pacchetti). Dopo di che, il kernel poteva invece elaborare direttamente i programmi BPF per l'architettura di destinazione: x86, ARM, MIPS, ecc.

Successivamente, nel 2014, Alexey Starovoitov ha proposto un nuovo meccanismo JIT per BPF. In effetti, questo nuovo JIT è diventato una nuova architettura basata su BPF e ha preso il nome di eBPF. Penso che per un certo periodo entrambe le macchine virtuali siano coesistite, ma attualmente il filtraggio dei pacchetti è implementato sulla base di eBPF. Di fatto, in molti campioni della documentazione moderna, per BPF si intende eBPF, mentre il classico BPF è oggi conosciuto come cBPF.

eBPF amplia la tradizionale macchina virtuale BPF sotto molti aspetti:

  • Si basa su moderne architetture a 64 bit. L'eBPF utilizza registri a 64 bit e aumenta il numero di registri disponibili da 2 (accumulatore e X) a 10. In eBPF vengono forniti anche codici operativi aggiuntivi (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Slegato dal sottosistema di rete. BPF era legato al modello di dati a pacchetto. Poiché era utilizzato per filtrare i pacchetti, il suo codice si trovava all'interno del sottosistema che gestiva le interazioni di rete. Tuttavia, la macchina virtuale eBPF non è più legata al modello di dati e può essere utilizzata per qualsiasi scopo. Ora, il programma eBPF può essere collegato a un tracepoint o a un kprobe. Questo apre la strada all'strumentazione eBPF, all'analisi delle prestazioni e a molte altre opzioni di utilizzo nel contesto di altri sottosistemi del kernel. Ora, il codice eBPF è collocato lungo un proprio percorso: kernel/bpf.
  • Archivi di dati globali chiamati Mappe. Le mappe sono archivi di tipo "chiave-valore" che consentono lo scambio di dati tra lo spazio utente e lo spazio kernel. In eBPF sono disponibili mappe di diversi tipi.
  • Funzioni ausiliarie. In particolare, per la riscrittura di pacchetti, il calcolo di checksum o la clonazione di pacchetti. Queste funzioni vengono eseguite all'interno del kernel e non si riferiscono ai programmi dello spazio utente. Inoltre, dalle applicazioni eBPF è possibile eseguire chiamate di sistema.
  • Chiamate finali. La dimensione del programma in eBPF è limitata a 4096 byte. La possibilità di chiamata finale consente al programma eBPF di trasferire il controllo a un nuovo programma eBPF e quindi di aggirare questa limitazione (in questo modo è possibile concatenare fino a 32 programmi).

eBPF: esempio

Nelle sorgenti del kernel Linux ci sono diversi esempi per eBPF. Sono disponibili all'indirizzo samples/bpf/. Per compilare questi esempi, è sufficiente digitare:

$ sudo make samples/bpf/

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

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

  • tracex4_kern.c, contiene il codice sorgente che deve essere eseguito nel kernel come bytecode eBPF.
  • tracex4_user.c, contiene la programmazione dallo spazio utente.

In tal caso, dobbiamo compilarlo tracex4_kern.c in bytecode eBPF. Attualmente, nel gcc non esiste una parte server per eBPF. Fortunatamente, clang può generare bytecode eBPF. Makefile utilizza clang per la compilazione tracex4_kern.c in un 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 molti tipi di mappa offerti da eBPF. In questo caso, si tratta semplicemente di un hash. Potresti anche notare la dichiarazione SEC("maps"). SEC è una macro utilizzata per creare una nuova sezione nel file binario. Infatti, nell'esempio tracex4_kern vengono 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;

    // otteniamo l'indirizzo IP del chiamante 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 permettono di rimuovere una registrazione dalla mappa (kprobe/kmem_cache_free) e di aggiungere una nuova registrazione nella mappa (kretprobe/kmem_cache_alloc_node). Tutti i nomi delle funzioni scritti in lettere maiuscole corrispondono alle macro definite in bpf_helpers.h.

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

$ objdump -h tracex4_kern.o

tracex4_kern.o: formato file elf64-little

Sezioni:
Idx Nome Dimensione VMA LMA File off Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTENUTO, ALLOCAZIONE, CARICAMENTO, SOLO_LETTURA, CODICE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENUTO, ALLOCAZIONE, CARICAMENTO, RICOLLOCAZIONE, SOLO_LETTURA, CODICE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENUTO, ALLOCAZIONE, CARICAMENTO, RICOLLOCAZIONE, SOLO_LETTURA, CODICE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENUTO, ALLOCAZIONE, CARICAMENTO, DATI
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENUTO, ALLOCAZIONE, CARICAMENTO, DATI
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENUTO, ALLOCAZIONE, CARICAMENTO, DATI
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENUTO, ALLOCAZIONE, CARICAMENTO, RICOLLOCAZIONE, SOLO_LETTURA, DATI

C'è anche tracex4_user.c, il programma principale. In linea di massima, 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 nella mappa, e poi questo oggetto viene ciclicamente riportato nel programma principale. Esempio:

$ sudo .\/tracex4
l'oggetto 0xffff8d6430f60a00 ha 2 secondi ed è stato allocato all'ip ffffffff9891ad90
l'oggetto 0xffff8d6062ca5e00 ha 23 secondi ed è stato allocato all'ip ffffffff98090e8f
l'oggetto 0xffff8d5f80161780 ha 6 secondi ed è stato allocato all'ip ffffffff98090e8f

Come sono collegate il programma dell'utente e il programma eBPF? Durante l'inizializzazione tracex4_user.c carica il file oggetto tracex4_kern.o utilizzando la funzione carica_file_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;
}

Durante l'esecuzione carica_file_bpf i sonde definite nel file eBPF vengono aggiunte a /sys/kernel/debug/tracing/kprobe_events. Ora stiamo ascoltando questi eventi e il nostro programma può far qualcosa quando si verificano.

$ 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. Ci sono sempre due file:

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

Il programma eBPF definisce mappe e funzioni collegate a una sezione. Quando il kernel emette un evento di un certo tipo (ad esempio, tracepoint), le funzioni collegate vengono eseguite. Le mappe offrono uno scambio di dati tra il programma del kernel e il programma dello spazio utente.

Conclusione

In questo articolo sono stati trattati, in generale, BPF e eBPF. So che al giorno d'oggi ci sono molte informazioni e risorse su eBPF, quindi consiglio ulteriori materiali per approfondire

Consiglio di leggere:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster