Krótki wstęp do BPF i eBPF

Cześć, Habr! Informujemy, że przygotowujemy książkę "Linux Observability with BPF".

Krótki wstęp do BPF i eBPF
Ponieważ maszyna wirtualna BPF wciąż ewoluuje i jest aktywnie stosowana w praktyce, przetłumaczyliśmy dla was artykuł opisujący jej główne możliwości oraz aktualny stan.

W ostatnich latach zyskały popularność narzędzia do programowania oraz techniki mające na celu zrekompensowanie ograniczeń jądra Linux, gdy wymagana jest wysokowydajna obróbka pakietów. Jedną z najpopularniejszych takich technik nazywa się obejściem jądra (kernel bypass) i pozwala na wykonywanie całej obróbki pakietów z przestrzeni użytkownika, omijając poziom sieciowy jądra. Obejście jądra również zakłada zarządzanie kartą sieciową z przestrzeni użytkownika. Innymi słowy, podczas pracy z kartą sieciową polegamy na sterowniku przestrzeni użytkownika.

Przekazując pełną kontrolę nad kartą sieciową programowi z przestrzeni użytkownika, redukujemy koszty związane z działaniem jądra (przełączanie kontekstu, obróbka poziomu sieciowego, przerwania itp.), co jest dość istotne przy pracy z prędkościami 10 Gb/s lub wyższymi. Obejście jądra w połączeniu z innymi możliwościami (obróbka pakietów) oraz staranna konfiguracja wydajności (uwzględnianie NUMA, izolacja CPU, itd.) są podstawami wysokowydajnej sieci w przestrzeni użytkownika. Przykładem nowego podejścia do obróbki pakietów jest DPDK od Intela (Data Plane Development Kit), chociaż istnieją również inne powszechnie znane narzędzia i techniki, w tym VPP od Cisco (Vector Packet Processing), Netmap oraz oczywiście Snabb.

Organizacja interakcji sieciowych w przestrzeni użytkownika wiąże się z pewnymi wadami:

  • Jądro systemu operacyjnego jest poziomem abstrakcji dla zasobów sprzętowych. Ponieważ programy w przestrzeni użytkownika muszą zarządzać swoimi zasobami bezpośrednio, muszą również kontrolować własny sprzęt. Często oznacza to konieczność programowania własnych sterowników.
  • Ponieważ całkowicie rezygnujemy z przestrzeni jądra, rezygnujemy również z całej funkcjonalności sieciowej dostarczanej przez jądro. Programy w przestrzeni użytkownika muszą ponownie zaimplementować te funkcje, które mogą już być dostarczane przez jądro lub system operacyjny.
  • Programy działają w trybie sandboxu, co poważnie ogranicza możliwości ich interakcji i utrudnia integrację z innymi częściami systemu operacyjnego.

W zasadzie, w organizacji interakcji sieciowych w przestrzeni użytkownika wzrost wydajności osiąga się poprzez przeniesienie przetwarzania pakietów z jądra do przestrzeni użytkownika. XDP robi dokładnie odwrotnie: przenosi programy sieciowe z przestrzeni użytkownika (filtry, transformacje, routowanie, itp.) do obszaru jądra. XDP pozwala nam wykonać funkcję sieciową, gdy tylko pakiet trafi na interfejs sieciowy i zanim zacznie poruszać się w górę w systemie sieciowym jądra. W rezultacie prędkość przetwarzania pakietów znacznie wzrasta. Jednak jak jądro pozwala użytkownikowi uruchamiać swoje programy w przestrzeni jądra? Zanim odpowiemy na to pytanie, przyjrzyjmy się, czym jest BPF.

BPF i eBPF

Pomimo nieco niejasnej nazwy BPF (Berkeley Packet Filter) – to w rzeczywistości model wirtualnej maszyny. Ta wirtualna maszyna została pierwotnie zaprojektowana do przetwarzania filtracji pakietów, stąd nazwa.

Jednym z najbardziej znanych narzędzi korzystających z BPF jest tcpdump. Przy przechwytywaniu pakietów za pomocą tcpdump użytkownik może określić wyrażenie do filtrowania pakietów. Przechwytywane będą tylko pakiety, które odpowiadają temu wyrażeniu. Na przykład, wyrażenie “tcp dst port 80” dotyczy wszystkich pakietów TCP przychodzących na port 80. Kompilator może uprościć to wyrażenie, przekształcając je w bajtowy kod 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

Oto, co zasadniczo robi powyższy program:

  • Instrukcja (000): ładuje pakiet z przesunięciem 12, jako 16-bitowe słowo do akumulatora. Przesunięcie 12 odpowiada ethertype pakietu.
  • Instrukcja (001): porównuje wartość w akumulatorze z 0x86dd, czyli z wartością ethertype dla IPv6. Jeśli wynik jest prawdziwy, licznik programu przechodzi do instrukcji (002), a jeśli nie – do (006).
  • Instrukcja (006): porównuje wartość z 0x800 (wartość ethertype dla IPv4). Jeśli odpowiedź to true, program przechodzi do (007), w przeciwnym razie do (015).

I tak dalej, aż program filtracji pakietów zwróci wynik. Zwykle jest to wartość logiczna. Zwrócenie wartości różnej od zera (instrukcja (014)) oznacza, że pakiet pasuje, a zwrócenie zera (instrukcja (015)) oznacza, że pakiet nie pasuje.

Wirtualna maszyna BPF i jej kod bajtowy zostały zaproponowane przez Steve'a McCanna i Wana Jacobsona pod koniec 1992 roku, kiedy opublikowali swoją pracę. Filtr pakietów BSD: Nowa architektura do przechwytywania pakietów na poziomie użytkownika., po raz pierwszy ta technologia została zaprezentowana na konferencji Usenix w zimie 1993 roku.

Ponieważ BPF to wirtualna maszyna, definiuje środowisko, w którym uruchamiane są programy. Oprócz kodu bajtowego określa również model pamięci pakietu (instrukcje ładowania są stosowane do pakietu), rejestry (A i X; rejestry akumulatora i indeksu), pamięć roboczą oraz niejawny licznik programów. Co ciekawe, kod bajtowy BPF został zaprojektowany na wzór architektury ISA Motoroli 6502. Jak wspominał Steve McCann w swoim referacie plenarnym na Sharkfest '11, znał asembler 6502 jeszcze z czasów szkolnych, kiedy programował na Apple II, a ta wiedza miała wpływ na jego pracę przy projektowaniu kodu bajtowego BPF.

Wsparcie dla BPF zostało wprowadzone w jądrze Linux w wersji v2.5 i nowszych, dodane głównie przez Jaya Shullista. Kod BPF pozostawał bez poważnych zmian aż do 2011 roku, kiedy to Eric Dumazet przeprojektował interpreter BPF do działania w trybie JIT (Źródło: JIT dla filtrów pakietowych). Po tym, ono mogło bezpośrednio przekształcać programy BPF do celowej architektury: x86, ARM, MIPS, itd.

Później, w 2014 roku, Aleksiej Starowojtow zaproponował nowy mechanizm JIT dla BPF. W zasadzie ten nowy JIT stał się nową architekturą opartą na BPF i otrzymał nazwę eBPF. Myślę, że przez pewien czas obie wirtualne maszyny współistniały, ale obecnie filtracja pakietów realizowana jest na podstawie eBPF. W rzeczywistości, w wielu współczesnych dokumentach pod BPF rozumie się eBPF, a klasyczny BPF jest dziś znany jako cBPF.

eBPF w kilku aspektach rozszerza klasyczną wirtualną maszynę BPF:

  • Opiera się na nowoczesnych 64-bitowych architekturach. eBPF korzysta z 64-bitowych rejestrów i zwiększa liczbę dostępnych rejestrów z 2 (akumulator i X) do 10. W eBPF udostępniane są również dodatkowe kody operacyjne (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Odłączona od podsystemu warstwy sieciowej. BPF była związana z modelu danych pakietów. Ponieważ była używana do filtrowania pakietów, jej kod znajdował się w podsystemie, który zapewniał interakcje sieciowe. Jednak maszyna wirtualna eBPF nie jest już związana z modelem danych i może być używana do różnych celów. Teraz program eBPF można podłączyć do tracepoint lub kprobe. Otwiera to drogę dla instrumentacji eBPF, analizy wydajności i wielu innych możliwości wykorzystania w kontekście innych podsystemów jądra. Kod eBPF jest teraz zlokalizowany we własnej ścieżce: kernel/bpf.
  • Globalne magazyny danych zwane Mapami. Mapy to magazyny typu 'klucz-wartość', które zapewniają wymianę danych między przestrzenią użytkownika a przestrzenią jądra. W eBPF dostępne są mapy różnych typów.
  • Funkcje pomocnicze. W szczególności do przebudowy pakietów, obliczania sum kontrolnych lub klonowania pakietów. Funkcje te są wykonywane w jądrze i nie należą do programów przestrzeni użytkownika. Ponadto z programów eBPF można wykonywać wywołania systemowe.
  • Wywołania końcowe. Rozmiar programu w eBPF jest ograniczony do 4096 bajtów. Możliwość wywołania końcowego pozwala programowi eBPF przekazać kontrolę nowemu programowi eBPF, omijając w ten sposób to ograniczenie (można związać do 32 programów).

eBPF: przykład

W źródłach jądra Linux znajduje się kilka przykładów dla eBPF. Są one dostępne pod adresem samples/bpf/. Aby skompilować te przykłady, wystarczy wpisać:

$ sudo make samples/bpf/

Nie będę pisał nowego przykładu dla eBPF, a skorzystam z jednego z dostępnych w samples/bpf/. Przeanalizuję kilka fragmentów kodu i wyjaśnię, jak działa. Wybrałem przykład programu tracex4.

Ogólnie każdy z przykładów w samples/bpf/ składa się z dwóch plików. W tym przypadku:

  • tracex4_kern.c, zawiera kod źródłowy, który ma być wykonywany w jądrze jako bajt-kod eBPF.
  • tracex4_user.c, zawiera program z przestrzeni użytkownika.

W takim przypadku musimy skompilować tracex4_kern.c do kodu bajtowego eBPF. W tej chwili brakuje gcc serwera dla eBPF. Na szczęście, clang może generować kod bajtowy eBPF. Makefile używa clang do kompilacji tracex4_kern.c do pliku obiektowego.

Wspomniałem wcześniej, że jedną z najbardziej interesujących funkcji eBPF są mapy. tracex4_kern definiuje jedną mapę:

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 – jeden z wielu typów map oferowanych przez eBPF. W tym przypadku jest to po prostu hasz. Można również zauważyć ogłoszenie SEC("maps"). SEC to makro używane do tworzenia nowej sekcji pliku binarnego. W rzeczywistości w przykładzie tracex4_kern definiuje jeszcze dwie sekcje:

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;

    // uzyskujemy adres IP wywołującego 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;
}   

Te dwie funkcje umożliwiają usunięcie wpisu z mapy (kprobe/kmem_cache_free) i dodanie nowego wpisu do mapy (kretprobe/kmem_cache_alloc_node). Wszystkie nazwy funkcji, zapisane wielkimi literami, odpowiadają makrom zdefiniowanym w bpf_helpers.h.

Jeśli wyświetlę zrzut sekcji pliku obiektowego, powinienem zobaczyć, że te nowe sekcje są już zdefiniowane:

$ objdump -h tracex4_kern.o

tracex4_kern.o: format pliku elf64-little

Sekcje:
Idx Nazwa Rozmiar VMA LMA Offset pliku Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, TYLKO DO ODCZYTU, KOD
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, RELOKACJA, TYLKO DO ODCZYTU, KOD
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, RELOKACJA, TYLKO DO ODCZYTU, KOD
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, DANE
4 license 00000004 0000000000000000 0000000000000000 00000164 2**0
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, DANE
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, DANE
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
ZAWARTOŚĆ, ALOKACJA, ŁADOWANIE, RELOKACJA, TYLKO DO ODCZYTU, DANE

Jest jeszcze tracex4_user.c, główny program. W zasadzie ten program nasłuchuje zdarzeń kmem_cache_alloc_node. Gdy takie zdarzenie następuje, wykonywany jest odpowiedni kod eBPF. Kod zapisuje atrybut IP obiektu w mapie, a następnie ten obiekt jest cyklicznie wyświetlany w programie głównym. Przykład:

$ sudo .\/tracex4
obiekt 0xffff8d6430f60a00 ma 2 sekundy, został przydzielony pod adresem ip ffffffff9891ad90
obiekt 0xffff8d6062ca5e00 ma 23 sekundy, został przydzielony pod adresem ip ffffffff98090e8f
obiekt 0xffff8d5f80161780 ma 6 sekund, został przydzielony pod adresem ip ffffffff98090e8f

Jak powiązany jest program przestrzeni użytkownika z programem eBPF? Przy inicjalizacji tracex4_user.c ładuje plik obiektowy tracex4_kern.o za pomocą funkcji załaduj_plik_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;
}

Podczas wykonywania załaduj_plik_bpf sondy, zdefiniowane w pliku eBPF, są dodawane do /sys/kernel/debug/tracing/kprobe_events. Teraz słuchamy tych zdarzeń, a nasz program może coś zrobić, gdy się one wydarzą.

$ 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

Wszystkie inne programy w katalogu sample/bpf/ są zorganizowane w podobny sposób. Zawsze zawierają dwa pliki:

  • XXX_kern.c: program eBPF.
  • XXX_user.c: program użytkownika.

Program eBPF definiuje mapy i funkcje związane z sekcją. Gdy jądro generuje zdarzenie określonego typu (na przykład, tracepoint), powiązane funkcje są wykonywane. Mapa zapewnia wymianę danych pomiędzy programem jądra a programem przestrzeni użytkownika.

Podsumowanie

W tym artykule w skrócie omówiono BPF i eBPF. Wiem, że dzisiaj jest wiele informacji i zasobów na temat eBPF, dlatego polecę kilka dodatkowych materiałów do dalszego studiowania.

Polecam przeczytać:

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster