Introducere scurtă în BPF și eBPF

Salut, Habr! Vă informăm că pregătim lansarea cărții "Linux Observability with BPF".

Introducere scurtă în BPF și eBPF
Pe măsură ce mașina virtuală BPF continuă să evolueze și este aplicată activ în practică, am tradus pentru voi un articol care descrie principalele sale capacități și starea actuală.

În ultimii ani, au câștigat popularitate uneltele de programare și tehnicile destinate să compenseze limitările nucleului Linux în cazul în care este necesară o procesare de pachete de înaltă performanță. Una dintre cele mai populare tehnici de acest tip se numește o ocolire a nucleului (kernel bypass) și permite, ocolind nivelul de rețea al nucleului, să execute întreaga procesare a pachetelor din spațiul utilizatorului. O ocolire a nucleului implică, de asemenea, gestionarea plăcii de rețea din spațiul utilizatorului. Cu alte cuvinte, atunci când lucrăm cu placa de rețea, ne bazăm pe un driver spațiul utilizatorului.

Oferind controlul complet asupra plăcii de rețea unui program din spațiul utilizatorului, reduc costurile implicate de funcționarea nucleului (comutarea de context, procesarea nivelului de rețea, întreruperi etc.), ceea ce este destul de important atunci când lucrăm la viteze de 10Gb/s sau mai mari. Ocolirea nucleului, plus combinația altor capabilități (procesarea pachetelor) și ajustarea fină a performanței (considerarea NUMA, izolarea CPU, etc.) corespund bazelor procesării rețea de înaltă performanță în spațiul utilizatorului. Un exemplu exemplar al unei astfel de noi abordări pentru procesarea pachetelor este DPDK de la Intel (Data Plane Development Kit), deși există și alte unelte și tehnici bine cunoscute, printre care VPP de la Cisco (Vector Packet Processing), Netmap și, bineînțeles, Snabb.

Organizarea interacțiunilor de rețea în spațiul utilizatorului are o serie de neajunsuri:

  • Nucleul OS este un nivel de abstractizare pentru resursele hardware. Deoarece programele din spațiul utilizatorului trebuie să își gestioneze direct resursele, ele trebuie să se ocupe și de propriile echipamente hardware. Adesea, aceasta înseamnă necesitatea de a programa drivere proprii.
  • Deoarece renunțăm complet la spațiul kernel-ului, renunțăm și la întreaga funcționalitate de rețea oferită de kernel. Programele din spațiul utilizatorului trebuie să implementeze din nou funcțiile care, poate, deja sunt furnizate de kernel sau de sistemul de operare.
  • Programele funcționează în modul sand-box, ceea ce limitează grav posibilitățile lor de interacțiune și împiedică integrarea cu alte părți ale sistemului de operare.

În esență, în organizarea interacțiunilor de rețea în spațiul utilizatorului, creșterea performanței se realizează prin mutarea prelucrării pachetelor din kernel în spațiul utilizatorului. XDP face exact inversul: mută programele de rețea din spațiul utilizatorului (filtre, convertoare, rutare etc.) în zona kernel-ului. XDP ne permite să executăm funcția de rețea de fiecare dată când un pachet ajunge pe interfața de rețea, înainte ca acesta să înceapă să se miște în sus în subsistemul de rețea al kernel-ului. Ca rezultat, viteza de procesare a pachetelor crește semnificativ. Totuși, cum permite kernel-ul utilizatorului să își ruleze programele în spațiul kernel-ului? Înainte de a răspunde la această întrebare, să examinăm ce este BPF.

BPF și eBPF

În ciuda numelui nu foarte clar BPF (Filtrare de pachete, Berkeley) – aceasta este, de fapt, un model de mașină virtuală. Această mașină virtuală a fost inițial proiectată pentru a trata filtrarea pachetelor, de aici și denumirea.

Unul dintre cele mai cunoscute instrumente care utilizează BPF este tcpdump. Atunci când capturează pachete, utilizatorul poate specifica o expresie pentru filtrarea pachetelor. Vor fi capturate doar pachetele care corespund acestei expresii. De exemplu, expresia “ tcpdump tcp dst port 80” se referă la toate pachetele TCP care ajung la portul 80. Compilatorul poate simplifica această expresie, transformând-o în byte-code BPF.Iată ce face, în principiu, programul de mai sus:

$ 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

Instrucțiunea (000): încarcă pachetul cu un offset de 12, ca un cuvânt de 16 biți în acumulatoare. Offset-ul 12 corespunde ethertype-ului pachetului.

  • Instrucțiunea (001): compară valoarea din acumulatoare cu 0x86dd, adică cu valoarea ethertype pentru IPv6. Dacă rezultatul este adevărat, atunci contorul programului trece la instrucțiunea (002), iar dacă nu – la (006).
  • Instrucțiunea (001): compară valoarea din acumulator cu 0x86dd, adică, cu valoarea ethertype pentru IPv6. Dacă rezultat este adevărat, contorul programului trece la instrucțiunea (002), iar dacă nu – la (006).
  • Instrucțiunea (006): compară valoarea cu 0x800 (valoarea ethertype pentru IPv4). Dacă răspunsul este adevărat, programul trece la (007), altfel la (015).

Și tot așa, până când programul de filtrare a pachetelor returnează un rezultat. De obicei, este un boolean. Returnarea unei valori non-zero (instrucțiunea (014)) înseamnă că pachetul a fost acceptat, iar returnarea valorii zero (instrucțiunea (015)) înseamnă că pachetul nu a fost acceptat.

Mașina virtuală BPF și codul său bytecode au fost propuse de Steve McCan și Van Jacobson la sfârșitul anului 1992, când a apărut articolul lor. Filtrul de pachete BSD: O nouă arhitectură pentru captarea pachetelor la nivel de utilizator., pentru prima dată această tehnologie a fost prezentată la conferința Usenix iarna anului 1993.

Deoarece BPF este o mașină virtuală, ea definește mediul în care rulează programele. Pe lângă bytecode, ea definește și modelul de memorie pentru pachete (instrucțiunile de încărcare se aplică implicit pachetului), registrele (A și X; registrele acumulatorului și indexului), stocarea de scratch și un contor implicit pentru programe. Interesant este că bytecode-ul BPF a fost modelat după Motorola 6502 ISA. Așa cum își amintea Steve McCan în prezentarea sa. plenară la Sharkfest ‘11, el era familiarizat cu asamblarea 6502 încă din liceu, când programa pe Apple II, iar aceste cunoștințe au influențat munca sa la proiectarea bytecode-ului BPF.

Suportul pentru BPF a fost implementat în nucleul Linux în versiunea v2.5 și versiuni ulterioare, adăugat în principal prin eforturile lui Jay Schulist. Codul BPF a rămas fără modificări semnificative până în 2011, când Eric Dumazet a refăcut interpretul BPF pentru a funcționa în modul JIT (Sursa: JIT pentru filtrele de pachete). După aceea, nucleul, în loc să interpreteze bytecode-ul BPF, putea să convertească direct programele BPF pentru arhitectura țintă: x86, ARM, MIPS, etc.

Mai târziu, în 2014, Alexey Starovoitov a propus un nou mecanism JIT pentru BPF. De fapt, acest nou JIT a devenit o nouă arhitectură bazată pe BPF și a fost denumit eBPF. Cred că pentru o perioadă de timp ambele mașini virtuale au coexistat, dar în prezent, filtrarea pachetelor se realizează pe baza eBPF. De fapt, în multe exemple din documentația modernă, prin BPF se înțelege eBPF, iar BPF clasic este cunoscută astăzi sub numele de cBPF.

eBPF extinde în mai multe moduri mașina virtuală clasică BPF:

  • Se bazează pe arhitecturi moderne de 64 de biți. eBPF utilizează registre de 64 de biți și mărește numărul registrelor disponibile de la 2 (acumulator și X) la 10. De asemenea, eBPF oferă coduri de operațiuni suplimentare (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Dezîncătușat de subsistemul nivelului rețea. BPF era legat de modelul de date bazat pe pachete. Deoarece era utilizat pentru filtrarea pachetelor, codul său se afla în subsistemul care asigura interacțiunile de rețea. Cu toate acestea, mașina virtuală eBPF nu mai este legată de modelul de date și poate fi utilizată în orice scop. Astfel, acum programul eBPF poate fi conectat la tracepoint sau la kprobe. Acest lucru deschide calea pentru instrumentarea eBPF, analiza performanței și multe alte utilizări în contextul altor subsisteme ale nucleului. Acum, codul eBPF își are propriul său drum: kernel/bpf.
  • Stocări globale de date numite Mape. Mape sunt stocări de tip «cheie-valoare», asigurând schimbul de date între spațiul utilizatorului și spațiul nucleului. În eBPF sunt disponibile mape de mai multe tipuri.
  • Funcții auxiliare. În special, pentru rescrierea pachetului, calcularea sumei de control sau clonarea pachetului. Aceste funcții sunt executate în interiorul nucleului și nu se referă la programele din spațiul utilizatorului. În plus, din programele eBPF pot fi efectuate apeluri sistem.
  • Apeluri finale. Dimensiunea programului în eBPF este limitată la 4096 de octeți. Posibilitatea apelului final permite programului eBPF să transfere controlul unei noi programe eBPF și astfel să ocolească această limitare (se pot lega până la 32 de programe).

eBPF: exemplu

În sursele nucleului Linux există câteva exemple pentru eBPF. Acestea sunt disponibile la adresa samples/bpf/. Pentru a compila aceste exemple, introduceți pur și simplu:

$ sudo make samples/bpf/

Nu voi scrie eu un exemplu nou pentru eBPF, ci voi folosi unul dintre modelele disponibile în samples/bpf/. Voi analiza câteva porțiuni din cod și voi explica cum funcționează. Ca exemplu, am ales programul tracex4.

În general, fiecare dintre exemplele din samples/bpf/ constă din două fișiere. În acest caz:

  • tracex4_kern.c, conține codul sursă care trebuie să fie executat în nucleu ca bytecode eBPF.
  • tracex4_user.c, conține programul din spațiul utilizatorului.

În acest caz, trebuie să compilăm tracex4_kern.c în bytecode eBPF. În prezent, în gcc lipsesc părțile serverului pentru eBPF. Din fericire, clang poate genera bytecode eBPF. Makefile folosește clang pentru compilare tracex4_kern.c în fișierul obiect.

Mai sus am menționat că una dintre cele mai interesante caracteristici ale eBPF sunt hărțile. tracex4_kern definește o hartă:

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 – este unul dintre multe tipuri de hărți oferite de eBPF. În acest caz, este pur și simplu un hash. De asemenea, ați putea observa declarația SEC("maps"). SEC este un macro folosit pentru a crea o nouă secțiune în fișierul binar. De fapt, în exemplu, tracex4_kern definește încă două secțiuni:

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;

    // obținem adresa IP a apelantului 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;
}   

Aceste două funcții permit ștergerea unei înregistrări din hartă (kprobe/kmem_cache_free) și adăugarea unei noi înregistrări în hartă (kretprobe/kmem_cache_alloc_node). Toate numele funcțiilor, scrise cu litere mari, corespund macro-urilor definite în bpf_helpers.h.

Dacă aș extrage un dump al secțiunilor fișierului obiect, ar trebui să văd că aceste noi secțiuni sunt deja definite:

$ objdump -h tracex4_kern.o

tracex4_kern.o: format de fișier elf64-little

Secțiuni:
Idx Nume Dimensiune VMA LMA Off fișier Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONȚINUT, ALOCAT, ÎNCĂRCAT, DOAR CITIRE, COD
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONȚINUT, ALOCAT, ÎNCĂRCAT, RELOCAT, DOAR CITIRE, COD
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONȚINUT, ALOCAT, ÎNCĂRCAT, RELOCAT, DOAR CITIRE, COD
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONȚINUT, ALOCAT, ÎNCĂRCAT, DATE
4 licență 00000004 0000000000000000 0000000000000000 00000164 2**0
CONȚINUT, ALOCAT, ÎNCĂRCAT, DATE
5 versiune 00000004 0000000000000000 0000000000000000 00000168 2**2
CONȚINUT, ALOCAT, ÎNCĂRCAT, DATE
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONȚINUT, ALOCAT, ÎNCĂRCAT, RELOCAT, DOAR CITIRE, DATE

Există încă tracex4_user.c, programul principal. Practic, acest program ascultă evenimentele kmem_cache_alloc_node. Atunci când se întâmplă un astfel de eveniment, se execută codul eBPF corespunzător. Codul salvează atributul IP al obiectului în hartă, iar apoi acest obiect este emis ciclic în programul principal. Exemplu:

$ sudo .\/tracex4
obiect 0xffff8d6430f60a00 are 2sec vechime și a fost alocat la ip ffffffff9891ad90
obiect 0xffff8d6062ca5e00 are 23sec vechime și a fost alocat la ip ffffffff98090e8f
obiect 0xffff8d5f80161780 are 6sec vechime și a fost alocat la ip ffffffff98090e8f

Cum sunt legate programul din spațiul utilizatorului și programul eBPF? La inițializare, tracex4_user.c încarcă fișierul obiect tracex4_kern.o prin intermediul funcției încarcă_fisier_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;
}

La executarea încarcă_fisier_bpf sondele definite în fișierul eBPF sunt adăugate în /sys/kernel/debug/tracing/kprobe_events. Acum ascultăm aceste evenimente, iar programul nostru poate să facă ceva atunci când acestea se întâmplă.

$ 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

Toate celelalte programe din sample/bpf/ sunt structurate în mod similar. Există întotdeauna câte două fișiere:

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

Programul eBPF definește hărți și funcții legate de secțiune. Când nucleul emite un eveniment de un anumit tip (de exemplu, tracepoint), funcțiile legate sunt executate. Hărțile asigură schimbul de date între programul nucleului și programul din spațiul utilizatorului.

Concluzie

În acest articol, au fost discutate pe scurt BPF și eBPF. Știu că există foarte multe informații și resurse despre eBPF, așa că voi recomanda câteva materiale suplimentare pentru studiu.

Recomand să citiți:

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster