En kort introduktion till BPF och eBPF

Hej, Habr! Vi vill informera dig om att vi förbereder en bok för release."Linux Observability med BPF".

En kort introduktion till BPF och eBPF
Eftersom den virtuella BPF-maskinen fortsätter att utvecklas och används aktivt i praktiken, har vi översatt en artikel för dig som beskriver dess huvudsakliga funktioner och nuvarande tillstånd.

Under de senaste åren har programmeringsverktyg och -tekniker blivit allt populärare för att kompensera för begränsningarna hos Linuxkärnan i de fall där högpresterande paketbearbetning krävs. En av de mest populära teknikerna av detta slag kallas bypass av kärnan (kernel bypass) och låter, kringgå kärnans nätverkslager, utföra all paketbearbetning från användarutrymmet. Att kringgå kärnan innebär också att styra nätverkskortet från användarutrymme. Med andra ord, när vi arbetar med ett nätverkskort förlitar vi oss på föraren användarutrymme.

Genom att överföra full kontroll över nätverkskortet till ett användarutrymmesprogram minskar vi kärnoverhead (kontextväxling, nätverkslagerbearbetning, avbrott, etc.), vilket är ganska viktigt när man kör med hastigheter på 10Gb/s eller högre. Kernel bypass plus en kombination av andra funktioner (satsvis bearbetning) och noggrann prestandajustering (NUMA redovisning, CPU-isolering, etc.) motsvarar grunderna för högpresterande nätverksbearbetning i användarutrymmet. Kanske är ett exemplariskt exempel på detta nya tillvägagångssätt för paketbehandling DPDK från Intel (Utvecklingssats för dataplan), även om det finns andra välkända verktyg och tekniker, inklusive Ciscos VPP (Vector Packet Processing), Netmap och, naturligtvis, snabb.

Att organisera nätverksinteraktioner i användarutrymmet har ett antal nackdelar:

  • OS-kärnan är ett abstraktionslager för hårdvaruresurser. Eftersom användarrymdprogram måste hantera sina resurser direkt måste de också hantera sin egen hårdvara. Detta innebär ofta att du måste programmera dina egna drivrutiner.
  • Eftersom vi helt och hållet ger upp kärnans utrymme, ger vi också upp all nätverksfunktionalitet som kärnan tillhandahåller. Användarutrymmesprogram måste återimplementera funktioner som redan kan tillhandahållas av kärnan eller operativsystemet.
  • Program fungerar i sandlådeläge, vilket allvarligt begränsar deras interaktion och hindrar dem från att integreras med andra delar av operativsystemet.

När nätverksarbete sker i användarutrymmet, uppnås prestandavinster genom att flytta paketbearbetning från kärnan till användarutrymmet. XDP gör precis tvärtom: det flyttar nätverksprogram från användarutrymmet (filter, resolvers, routing, etc.) till kärnutrymmet. XDP tillåter oss att utföra en nätverksfunktion så snart ett paket träffar ett nätverksgränssnitt och innan det börjar röra sig upp i kärnnätverkets delsystem. Som ett resultat ökar paketbehandlingshastigheten avsevärt. Men hur tillåter kärnan användaren att köra sina program i kärnutrymmet? Innan vi svarar på denna fråga, låt oss titta på vad BPF är.

BPF och eBPF

Trots det förvirrande namnet är BPF (Berkeley Packet Filtering) i själva verket en virtuell maskinmodell. Denna virtuella maskin designades ursprungligen för att hantera paketfiltrering, därav namnet.

Ett av de mest kända verktygen som använder BPF är tcpdump. När du fångar paket med hjälp av tcpdump användaren kan ange ett uttryck för att filtrera paket. Endast paket som matchar detta uttryck kommer att fångas. Till exempel uttrycket "tcp dst port 80” refererar till alla TCP-paket som kommer till port 80. Kompilatorn kan förkorta detta uttryck genom att konvertera det till BPF-bytekod.

$ 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

Detta är vad programmet ovan gör i princip:

  • Instruktion (000): Laddar paketet vid offset 12, som ett 16-bitars ord, i ackumulatorn. Offset 12 motsvarar paketets etertyp.
  • Instruktion (001): jämför värdet i ackumulatorn med 0x86dd, det vill säga med etertypvärdet för IPv6. Om resultatet är sant, går programräknaren till instruktion (002), och om inte, sedan till (006).
  • Instruktion (006): jämför värdet med 0x800 (etertypvärde för IPv4). Om svaret är sant går programmet till (007), om inte så till (015).

Och så vidare tills paketfiltreringsprogrammet returnerar ett resultat. Detta är vanligtvis en boolesk. Att returnera ett värde som inte är noll (instruktion (014)) betyder att paketet accepterades och att returnera ett nollvärde (instruktion (015)) betyder att paketet inte accepterades.

Den virtuella BPF-maskinen och dess bytekod föreslogs av Steve McCann och Van Jacobson i slutet av 1992 när deras tidning publicerades BSD-paketfilter: Ny arkitektur för paketfångning på användarnivå, denna teknik presenterades första gången vid Usenix-konferensen vintern 1993.

Eftersom BPF är en virtuell maskin, definierar den miljön i vilken program körs. Förutom bytekoden definierar den också batchminnesmodellen (laddningsinstruktioner appliceras implicit på batchen), register (A och X; ackumulator- och indexregister), skrapminneslagring och en implicit programräknare. Intressant nog var BPF-bytekoden modellerad efter Motorola 6502 ISA. Som Steve McCann mindes i sin plenumsrapport på Sharkfest '11 var han bekant med build 6502 från sin gymnasietid programmering på Apple II, och denna kunskap påverkade hans arbete med att designa BPF-bytekoden.

BPF-stöd är implementerat i Linux-kärnan i versioner v2.5 och högre, tillsatt främst av Jay Schulists ansträngningar. BPF-koden förblev oförändrad fram till 2011, då Eric Dumaset gjorde om BPF-tolken för att köras i JIT-läge (Källa: JIT för paketfilter). Efter detta kunde kärnan, istället för att tolka BPF-bytekod, direkt konvertera BPF-program till målarkitekturen: x86, ARM, MIPS, etc.

Senare, 2014, föreslog Alexey Starovoitov en ny JIT-mekanism för BPF. Faktum är att denna nya JIT blev en ny BPF-baserad arkitektur och kallades eBPF. Jag tror att båda virtuella datorerna har funnits tillsammans under en tid, men för närvarande är paketfiltrering implementerad baserat på eBPF. Faktum är att i många exempel på modern dokumentation förstås BPF som eBPF, och klassisk BPF är idag känd som cBPF.

eBPF utökar den klassiska BPF virtuella maskinen på flera sätt:

  • Baserat på moderna 64-bitars arkitekturer. eBPF använder 64-bitars register och ökar antalet tillgängliga register från 2 (ackumulator och X) till 10. eBPF tillhandahåller även ytterligare opkoder (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Frikopplad från nätverkslagersubsystemet. BPF var knuten till batchdatamodellen. Eftersom den användes för paketfiltrering fanns dess kod i delsystemet som tillhandahåller nätverkskommunikation. Den virtuella eBPF-maskinen är dock inte längre bunden till datamodellen och kan användas för alla ändamål. Så nu kan eBPF-programmet anslutas till spårpunkt eller kprobe. Detta öppnar vägen för eBPF-instrumentering, prestandaanalys och många andra användningsfall i samband med andra kärndelsystem. Nu finns eBPF-koden i sin egen sökväg: kernel/bpf.
  • Globala datalager som kallas Maps. Kartor är nyckel-värde-lagringar som möjliggör datautbyte mellan användarutrymme och kärnutrymme. eBPF tillhandahåller flera typer av kartor.
  • Sekundära funktioner. I synnerhet för att skriva om ett paket, beräkna en kontrollsumma eller klona ett paket. Dessa funktioner körs inuti kärnan och är inte program för användarutrymme. Du kan också ringa systemsamtal från eBPF-program.
  • Avsluta samtal. Programstorleken i eBPF är begränsad till 4096 byte. Svansanropsfunktionen gör att ett eBPF-program kan överföra kontrollen till ett nytt eBPF-program och därmed kringgå denna begränsning (upp till 32 program kan länkas på detta sätt).

eBPF: exempel

Det finns flera exempel för eBPF i Linux-kärnkällorna. De finns på samples/bpf/. För att sammanställa dessa exempel, skriv bara in:

$ sudo make samples/bpf/

Jag kommer inte att skriva ett nytt exempel för eBPF själv, utan kommer att använda ett av proverna som finns i samples/bpf/. Jag ska titta på några delar av koden och förklara hur det fungerar. Som exempel valde jag programmet tracex4.

I allmänhet består vart och ett av exemplen i samples/bpf/ av två filer. I detta fall:

  • tracex4_kern.c, innehåller källkoden som ska köras i kärnan som eBPF-bytekod.
  • tracex4_user.c, innehåller ett program från användarutrymmet.

I det här fallet måste vi kompilera tracex4_kern.c till eBPF bytecode. För närvarande i gcc det finns ingen backend för eBPF. Lyckligtvis, clang kan mata ut eBPF-bytekod. Makefile användningsområden clang för sammanställning tracex4_kern.c till objektfilen.

Jag nämnde ovan att en av de mest intressanta funktionerna i eBPF är kartor. tracex4_kern definierar en karta:

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 är en av många typer av kort som erbjuds av eBPF. I det här fallet är det bara en hash. Du kanske också har lagt märke till en annons SEC("maps"). SEC är ett makro som används för att skapa en ny sektion av en binär fil. Egentligen i exemplet tracex4_kern ytterligare två avsnitt definieras:

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

Dessa två funktioner låter dig ta bort en post från kartan (kprobe/kmem_cache_free) och lägg till en ny post på kartan (kretprobe/kmem_cache_alloc_node). Alla funktionsnamn skrivna med versaler motsvarar makron definierade i bpf_helpers.h.

Om jag dumpar sektionerna av objektfilen bör jag se att dessa nya sektioner redan är definierade:

$ 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

Har fortfarande tracex4_user.c, huvudprogram. I grund och botten lyssnar det här programmet på händelser kmem_cache_alloc_node. När en sådan händelse inträffar exekveras motsvarande eBPF-kod. Koden sparar objektets IP-attribut i en karta, och objektet loops sedan genom huvudprogrammet. Exempel:

$ 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

Hur är ett användarrymdprogram och ett eBPF-program relaterade? Vid initiering tracex4_user.c laddar en objektfil tracex4_kern.o använder funktionen 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;
}

Genom att göra load_bpf_file sonder som definieras i eBPF-filen läggs till /sys/kernel/debug/tracing/kprobe_events. Nu lyssnar vi efter dessa händelser och vårt program kan göra något när de händer.

$ 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

Alla andra program i sample/bpf/ är uppbyggda på liknande sätt. De innehåller alltid två filer:

  • XXX_kern.c: eBPF-program.
  • XXX_user.c: huvudprogram.

eBPF-programmet identifierar kartor och funktioner associerade med en sektion. När kärnan utfärdar en händelse av en viss typ (t.ex. tracepoint), exekveras de bundna funktionerna. Korten tillhandahåller kommunikation mellan kärnprogrammet och användarrymdprogrammet.

Slutsats

Den här artikeln diskuterade BPF och eBPF i allmänna termer. Jag vet att det finns mycket information och resurser om eBPF idag, så jag kommer att rekommendera några fler resurser för vidare studier

Jag rekommenderar att läsa:

Källa: will.com

Lägg en kommentar