Introduzione a BPF ed eBPF

Ciao, Habr! Siamo lieti di annunciare che stiamo preparando il libro "Linux Observability with BPF".

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

Negli ultimi anni, sono diventati sempre più popolari gli strumenti di programmazione e le tecniche progettate per compensare le limitazioni del kernel Linux nei casi in cui è necessaria un'elaborazione dei pacchetti ad alte prestazioni. Una delle tecniche più popolari di questo tipo è chiamata bypass del kernel (kernel bypass) e consente, saltando il livello di rete del kernel, di gestire 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, quando lavoriamo con la scheda di rete, facciamo affidamento sul driver. spazio utente.

Affidando il pieno controllo della scheda di rete a un programma nello spazio utente, riduciamo i costi associati alle operazioni del kernel (cambio di contesto, gestione del livello di rete, interruzioni, ecc.), un aspetto cruciale quando si lavora a velocità di 10 Gb/s o superiori. L'evitamento del kernel, unito a una combinazione di altre funzionalità (elaborazione batch) e un'attenta ottimizzazione delle prestazioni (considerazione NUMA, isolamento CPU, ecc.) rappresentano le basi dell'elaborazione di rete ad alte prestazioni nello spazio utente. Un esempio esemplare di questo nuovo approccio all'elaborazione dei pacchetti è DPDK di Intel (Data Plane Development Kit), sebbene esistano anche altri strumenti e tecniche ampiamente riconosciuti, tra cui VPP di Cisco (Vector Packet Processing), Netmap e, naturalmente, Snabb.

La gestione delle interazioni di rete nello spazio utente presenta diversi svantaggi:

  • Il kernel del sistema operativo è 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ò significa spesso la necessità di programmare driver personalizzati.
  • Poiché rinunciamo completamente allo spazio del kernel, ci allontaniamo anche da tutte le funzionalità di rete fornite dal kernel. I programmi dello spazio utente devono implementare nuovamente quelle funzioni che potrebbero già essere fornite dal kernel o dal sistema operativo.
  • I programmi funzionano in modalità sandbox, il che limita significativamente le loro capacità di interazione e impedisce loro di integrarsi con altre parti del sistema operativo.

In sostanza, nell'organizzazione delle interazioni di rete nello spazio utente, il miglioramento delle prestazioni si ottiene trasferendo l'elaborazione dei pacchetti dallo spazio del kernel allo spazio utente. L'XDP fa esattamente il contrario: sposta i programmi di rete dallo spazio utente (filtri, trasformatori, routing, ecc.) nell'area del kernel. L'XDP ci consente di eseguire una funzione di rete non appena un pacchetto raggiunge l'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 all'utente di eseguire i propri programmi nello spazio del kernel? Prima di rispondere a questa domanda, diamo un'occhiata a cosa sia il BPF.

BPF ed eBPF

Nonostante il nome non molto chiaro BPF (Berkeley Packet Filter) – in realtà, si tratta di un modello di macchina virtuale. Questa macchina virtuale è stata originariamente progettata per la filtrazione dei pacchetti, da cui il nome.

Uno degli strumenti più noti che utilizzano il BPF è tcpdump. Durante la cattura dei pacchetti utilizzando 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 trasformandola 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, fondamentalmente, il programma sopra riportato:

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

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

La macchina virtuale BPF e il suo bytecode sono stati proposti da Steve McCanne e Van Jacobson alla fine del 1992, quando è uscita la loro articolo. Filtro pacchetti BSD: una nuova architettura per la cattura dei pacchetti a livello utente., una tecnologia presentata per la prima volta 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 il modello di memoria del pacchetto (le istruzioni di caricamento si applicano implicitamente al pacchetto), registri (A e X; i registri dell'accumulatore e dell'indice), memoria scratch e un contatore di programma implicito. È interessante notare che il bytecode BPF è stato modellato sullo schema dell'ISA Motorola 6502. Come ricordava Steve McCanne nel suo discorso plenaria allo Sharkfest ‘11, era già familiare con l'assemblatore 6502 dalle superiori, quando programmava su Apple II, e queste conoscenze hanno influenzato il suo lavoro nel progettare il bytecode BPF.

Il supporto BPF è stato implementato nel kernel Linux a partire dalla versione v2.5, principalmente grazie agli sforzi di Jay Schulist. Il codice BPF è rimasto sostanzialmente invariato fino al 2011, quando Eric Dumazet ha riscritto l'interprete BPF per funzionare in modalità JIT (Fonte: JIT per i filtri dei pacchetti). Da allora, il kernel ha potuto convertire direttamente i programmi BPF nella propria architettura target: x86, ARM, MIPS, ecc.

Successivamente, nel 2014, Alexey Starovoitov ha proposto un nuovo meccanismo JIT per BPF. In effetti, questo nuovo JIT ha rappresentato una nuova architettura basata su BPF, nota come eBPF. Penso che per un certo periodo entrambe le macchine virtuali siano coesistite, ma attualmente la filtrazione dei pacchetti è realizzata sulla base di eBPF. Infatti, in molti esempi di documentazione moderna, con BPF si intende eBPF, mentre il BPF classico è oggi noto come cBPF.

eBPF espande la classica macchina virtuale BPF in diversi aspetti:

  • 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. In eBPF sono forniti anche codici operativi aggiuntivi (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Slegato dal sottosistema di rete. BPF era legato al modello dei pacchetti. Poiché veniva utilizzato per filtrare i pacchetti, il suo codice si trovava nel sottosistema che gestiva le interazioni di rete. Tuttavia, la macchina virtuale eBPF non è più vincolata al modello dei dati e può essere utilizzata per qualsiasi scopo. Ora, un programma eBPF può essere collegato a un tracepoint o a un kprobe. Questo apre la strada all'implementazione di eBPF, all'analisi delle prestazioni e a molte altre opzioni di utilizzo nel contesto di altri sottosistemi del kernel. Ora il codice eBPF ha un percorso proprio: kernel/bpf.
  • Archiviazioni globali dei dati chiamate Mappe. Le Mappe sono archivi di tipo 'chiave-valore' che consentono lo scambio di dati tra spazio utente e spazio kernel. In eBPF sono disponibili mappe di diversi tipi.
  • Funzioni ausiliarie. In particolare, per la riscrittura dei pacchetti, il calcolo del checksum o la clonazione dei pacchetti. Queste funzioni vengono eseguite all'interno del kernel e non appartengono ai programmi dello spazio utente. Inoltre, dai programmi eBPF possono essere effettuate chiamate di sistema.
  • Chiamate finali. La dimensione di un 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, aggirando così questo limite (in questo modo è possibile concatenare fino a 32 programmi).

eBPF: esempio

Nel codice sorgente del kernel Linux ci sono diversi esempi per eBPF. Sono disponibili all'indirizzo samples/bpf/. Per compilare questi esempi, basta digitare:

$ sudo make samples/bpf/

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

In generale, ciascuno degli esempi in samples/bpf/ è composto da 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 il programma dallo spazio utente.

In tal caso, dobbiamo compilare tracex4_kern.c in bytecode eBPF. Attualmente, non è presente gcc il supporto server per eBPF. Fortunatamente, clang può generare bytecode eBPF. Makefile utilizza clang per la compilazione tracex4_kern.c in un file oggetto.

In precedenza ho menzionato che una delle funzionalità 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 mappe offerti da eBPF. In questo caso, è semplicemente un hash. Potreste anche aver notato la dichiarazione SEC("maps"). SEC è una macro utilizzata per creare una nuova sezione del file binario. Nel nostro 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 della parte 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 un record dalla mappa (kprobe/kmem_cache_free) e aggiungere una nuova voce alla mappa (kretprobe/kmem_cache_alloc_node). Tutti i nomi delle funzioni, scritti in maiuscolo, corrispondono a macro definite in bpf_helpers.h.

Se stampo il 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
CONTENUTI, ALLOC, CARICARE, SOLA LETTURA, CODICE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENUTI, ALLOC, CARICARE, RICOLLOCAZIONE, SOLA LETTURA, CODICE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENUTI, ALLOC, CARICARE, RICOLLOCAZIONE, SOLA LETTURA, CODICE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENUTI, ALLOC, CARICARE, DATI
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENUTI, ALLOC, CARICARE, DATI
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENUTI, ALLOC, CARICARE, DATI
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENUTI, ALLOC, CARICARE, RICOLLOCAZIONE, SOLA LETTURA, DATI

C'è anche tracex4_user.c, il programma principale. In sostanza, 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 stampato nel programma principale. Esempio:

$ sudo ./tracex4
oggetto 0xffff8d6430f60a00 è vecchio di 2 secondi, allocato all'indirizzo ip ffffffff9891ad90
oggetto 0xffff8d6062ca5e00 è vecchio di 23 secondi, allocato all'indirizzo ip ffffffff98090e8f
oggetto 0xffff8d5f80161780 è vecchio di 6 secondi, allocato all'indirizzo ip ffffffff98090e8f

Come sono collegati il programma nello spazio 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 sondaggi, definiti nel file eBPF, vengono aggiunti a /sys/kernel/debug/tracing/kprobe_events. Ora stiamo ascoltando questi eventi, e il nostro programma può agire 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 nella cartella 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 sezioni. Quando il kernel genera un evento di un certo tipo (per esempio, tracepoint), le funzioni collegate vengono eseguite. Le mappe garantiscono lo scambio di dati tra il programma del kernel e il programma utente.

Conclusione

In questo articolo, abbiamo esaminato in generale BPF ed eBPF. So che ci sono molte informazioni e risorse su eBPF, quindi consiglierò anche alcuni materiali per ulteriori approfondimenti.

Raccomando 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