Krótkie wprowadzenie do BPF i eBPF

Witaj, Habro! Pragniemy poinformować, że przygotowujemy książkę do wydania.”Obserwowalność Linuksa za pomocą BPF".

Krótkie wprowadzenie do BPF i eBPF
Ponieważ maszyna wirtualna BPF ciągle ewoluuje i jest aktywnie wykorzystywana w praktyce, przetłumaczyliśmy dla Państwa artykuł opisujący jej główne możliwości i aktualny stan.

W ostatnich latach coraz większą popularnością cieszą się narzędzia i techniki programistyczne, mające na celu kompensację ograniczeń jądra Linuksa w przypadkach, gdy wymagane jest wysokowydajne przetwarzanie pakietów. Jedną z najpopularniejszych technik tego rodzaju jest tzw obejście jądra (obejście jądra) i umożliwia, z pominięciem warstwy sieci jądra, wykonanie całego przetwarzania pakietów z przestrzeni użytkownika. Ominięcie jądra wiąże się również ze sterowaniem kartą sieciową przestrzeń użytkownika. Innymi słowy, pracując z kartą sieciową, polegamy na sterowniku przestrzeń użytkownika.

Przenosząc pełną kontrolę nad kartą sieciową do programu działającego w przestrzeni użytkownika, zmniejszamy obciążenie jądra (przełączanie kontekstu, przetwarzanie warstwy sieci, przerwania itp.), co jest dość ważne w przypadku pracy z szybkościami 10 Gb/s lub wyższym. Obejście jądra plus kombinacja innych funkcji (przetwarzanie wsadowe) i staranne dostrajanie wydajności (Księgowość NUMA, Izolacja procesoraitp.) odpowiadają podstawom wysokowydajnego przetwarzania sieciowego w przestrzeni użytkownika. Być może wzorowym przykładem tego nowego podejścia do przetwarzania pakietów jest DPDK od Intela (Zestaw deweloperski płaszczyzny danych), chociaż istnieją inne dobrze znane narzędzia i techniki, w tym Cisco VPP (przetwarzanie pakietów wektorowych), Netmap i oczywiście Snabb.

Organizowanie interakcji sieciowych w przestrzeni użytkownika ma wiele wad:

  • Jądro systemu operacyjnego stanowi warstwę abstrakcji zasobów sprzętowych. Ponieważ programy przestrzeni użytkownika muszą bezpośrednio zarządzać swoimi zasobami, muszą także zarządzać własnym sprzętem. Często oznacza to konieczność zaprogramowania własnych sterowników.
  • Ponieważ całkowicie rezygnujemy z przestrzeni jądra, rezygnujemy także z całej funkcjonalności sieciowej zapewnianej przez jądro. Programy przestrzeni użytkownika muszą ponownie implementować funkcje, które mogą już być zapewniane przez jądro lub system operacyjny.
  • Programy działają w trybie piaskownicy, co poważnie ogranicza ich interakcję i uniemożliwia integrację z innymi częściami systemu operacyjnego.

Zasadniczo, gdy tworzenie sieci odbywa się 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, programy rozpoznawania nazw, routing itp.) do przestrzeni jądra. XDP pozwala nam wykonywać funkcje sieciowe, gdy tylko pakiet trafi do interfejsu sieciowego i zanim zacznie przemieszczać się do podsystemu sieci jądra. W rezultacie prędkość przetwarzania pakietów znacznie wzrasta. Jednakże, w jaki sposób jądro pozwala użytkownikowi na uruchamianie programów w przestrzeni jądra? Zanim odpowiemy na to pytanie, przyjrzyjmy się, czym jest BPF.

BPF i eBPF

Pomimo mylącej nazwy, BPF (Berkeley Packet Filtering) jest w rzeczywistości modelem maszyny wirtualnej. Ta maszyna wirtualna została pierwotnie zaprojektowana do obsługi filtrowania pakietów, stąd nazwa.

Jednym z najbardziej znanych narzędzi wykorzystujących BPF jest tcpdump. Podczas przechwytywania pakietów za pomocą tcpdump użytkownik może określić wyrażenie do filtrowania pakietów. Przechwycone zostaną tylko pakiety pasujące do tego wyrażenia. Na przykład wyrażenie „tcp dst port 80” odnosi się do wszystkich pakietów TCP przychodzących na port 80. Kompilator może skrócić to wyrażenie, konwertując je na kod bajtowy 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 offsetem 12 jako 16-bitowe słowo do akumulatora. Przesunięcie 12 odpowiada typowi eterowemu pakietu.
  • Instrukcja (001): porównuje wartość w akumulatorze z 0x86dd, czyli z wartością typu eteru dla IPv6. Jeśli wynik jest prawdziwy, licznik programu przechodzi do instrukcji (002), a jeśli nie, to do (006).
  • Instrukcja (006): porównuje wartość z 0x800 (wartość typu eteru dla IPv4). Jeśli odpowiedź jest prawdziwa, to program przechodzi do (007), jeśli nie, to do (015).

I tak dalej, aż program filtrujący pakiety zwróci wynik. Zwykle jest to wartość logiczna. Zwrócenie wartości niezerowej (instrukcja (014)) oznacza, że ​​pakiet został przyjęty, zwrócenie wartości zerowej (instrukcja (015)) oznacza, że ​​pakiet nie został zaakceptowany.

Maszyna wirtualna BPF i jej kod bajtowy zostały zaproponowane przez Steve'a McCanna i Van Jacobsona pod koniec 1992 roku, kiedy opublikowano ich artykuł Filtr pakietów BSD: nowa architektura przechwytywania pakietów na poziomie użytkownikatechnologię tę po raz pierwszy zaprezentowano na konferencji Usenix zimą 1993 roku.

Ponieważ BPF jest maszyną wirtualną, definiuje środowisko, w którym działają programy. Oprócz kodu bajtowego definiuje także model pamięci wsadowej (instrukcje ładowania są domyślnie stosowane do partii), rejestry (A i X; rejestry akumulatorowe i indeksowe), pamięć zapasową i ukryty licznik programu. Co ciekawe, kod bajtowy BPF był wzorowany na Motoroli 6502 ISA. Jak wspomina Steve McCann w swoim raport plenarny na Sharkfest '11 znał kompilację 6502 z czasów, gdy był w szkole średniej, programował na Apple II i ta wiedza wpłynęła na jego pracę przy projektowaniu kodu bajtowego BPF.

Obsługa BPF jest zaimplementowana w jądrze Linuksa w wersjach v2.5 i wyższych, dodana głównie dzięki staraniom Jaya Schullista. Kod BPF pozostał niezmieniony aż do 2011 roku, kiedy Eric Dumaset przeprojektował interpreter BPF tak, aby działał w trybie JIT (Źródło: JIT dla filtrów pakietów). Następnie jądro, zamiast interpretować kod bajtowy BPF, mogłoby bezpośrednio konwertować programy BPF na docelową architekturę: x86, ARM, MIPS itp.

Później, w 2014 r., Aleksiej Starowoitow zaproponował nowy mechanizm JIT dla BPF. W rzeczywistości ten nowy JIT stał się nową architekturą opartą na BPF i został nazwany eBPF. Myślę, że obie maszyny wirtualne współistniały przez jakiś czas, ale obecnie filtrowanie pakietów jest realizowane w oparciu o eBPF. W rzeczywistości w wielu przykładach współczesnej dokumentacji BPF jest rozumiane jako eBPF, a klasyczny BPF jest dziś znany jako cBPF.

eBPF rozszerza klasyczną maszynę wirtualną BPF na kilka sposobów:

  • Oparty na nowoczesnych architekturach 64-bitowych. eBPF wykorzystuje rejestry 64-bitowe i zwiększa liczbę dostępnych rejestrów z 2 (akumulator i X) do 10. eBPF udostępnia również dodatkowe kody operacji (BPF_MOV, BPF_JNE, BPF_CALL...).
  • Odłączony od podsystemu warstwy sieciowej. BPF był powiązany z modelem danych wsadowych. Ponieważ służył do filtrowania pakietów, jego kod znajdował się w podsystemie zapewniającym komunikację sieciową. Jednak maszyna wirtualna eBPF nie jest już powiązana z modelem danych i może być używana do dowolnego celu. Zatem teraz program eBPF można podłączyć do Tracepointa lub kprobe. Otwiera to drogę do oprzyrządowania eBPF, analizy wydajności i wielu innych przypadków użycia w kontekście innych podsystemów jądra. Teraz kod eBPF znajduje się we własnej ścieżce: kernel/bpf.
  • Globalne magazyny danych zwane Mapami. Mapy to magazyny klucz-wartość, które umożliwiają wymianę danych między przestrzenią użytkownika a przestrzenią jądra. eBPF udostępnia kilka typów map.
  • Funkcje drugorzędne. W szczególności, aby przepisać pakiet, obliczyć sumę kontrolną lub sklonować pakiet. Funkcje te działają wewnątrz jądra i nie są programami przestrzeni użytkownika. Można także wykonywać wywołania systemowe z programów eBPF.
  • Zakończ połączenia. Rozmiar programu w eBPF jest ograniczony do 4096 bajtów. Funkcja wywołania końcowego pozwala programowi eBPF przenieść kontrolę do nowego programu eBPF i w ten sposób ominąć to ograniczenie (w ten sposób można połączyć maksymalnie 32 programy).

eBPF: przykład

Istnieje kilka przykładów eBPF w źródłach jądra Linuksa. Są one dostępne na stronie próbki/bpf/. Aby skompilować te przykłady, po prostu wpisz:

$ sudo make samples/bpf/

Nie będę sam pisał nowego przykładu dla eBPF, ale wykorzystam jeden z próbek dostępnych w sample/bpf/. Przyjrzę się niektórym częściom kodu i wyjaśnię, jak to działa. Jako przykład wybrałem program tracex4.

Ogólnie rzecz biorąc, każdy z przykładów w przykładach/bpf/ składa się z dwóch plików. W tym przypadku:

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

W tym przypadku musimy skompilować tracex4_kern.c do kodu bajtowego eBPF. Obecnie w gcc nie ma backendu dla eBPF. Na szczęście, clang może wyprowadzać kod bajtowy eBPF. Makefile używa clang do kompilacji tracex4_kern.c do pliku obiektowego.

Wspomniałem powyżej, że jedną z najciekawszych 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 to jeden z wielu rodzajów kart oferowanych przez eBPF. W tym przypadku jest to po prostu skrót. Być może zauważyłeś także reklamę SEC("maps"). SEC to makro służące do tworzenia nowej sekcji pliku binarnego. Właściwie na przykładzie tracex4_kern zdefiniowano 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;

    // получаем 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;
}   

Te dwie funkcje pozwalają na usunięcie wpisu z mapy (kprobe/kmem_cache_free) i dodaj nowy wpis do mapy (kretprobe/kmem_cache_alloc_node). Wszystkie nazwy funkcji pisane wielkimi literami odpowiadają makram zdefiniowanym w bpf_helpers.h.

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

$ 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

Wciąż mam tracex4_user.c, główny program. Zasadniczo ten program nasłuchuje zdarzeń kmem_cache_alloc_node. W przypadku wystąpienia takiego zdarzenia wykonywany jest odpowiedni kod eBPF. Kod zapisuje atrybut IP obiektu na mapie, a następnie obiekt jest zapętlany w programie głównym. Przykład:

$ 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

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

Wykonując load_bpf_file dodawane są sondy zdefiniowane w pliku eBPF /sys/kernel/debug/tracing/kprobe_events. Teraz nasłuchujemy tych wydarzeń, a nasz program może coś zrobić, gdy one nastąpią.

$ 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 sample/bpf/ mają podobną strukturę. Zawsze zawierają dwa pliki:

  • XXX_kern.c: program eBPF.
  • XXX_user.c: główny program.

Program eBPF identyfikuje mapy i funkcje powiązane z sekcją. Kiedy jądro generuje zdarzenie określonego typu (np. tracepoint), wykonywane są powiązane funkcje. Karty zapewniają komunikację pomiędzy programem jądra a programem przestrzeni użytkownika.

wniosek

W tym artykule ogólnie omówiono BPF i eBPF. Wiem, że obecnie dostępnych jest wiele informacji i zasobów na temat eBPF, dlatego polecę jeszcze kilka zasobów do dalszych badań

Polecam lekturę:

Źródło: www.habr.com

Dodaj komentarz