BPF dla najmłodszych, część pierwsza: rozszerzony BPF
Na początku istniała technologia i nazywała się BPF. Przyjrzeliśmy się jej Poprzedni, artykuł ze Starego Testamentu z tej serii. W 2013 roku, dzięki staraniom Aleksieja Starovoitova i Daniela Borkmana, opracowano jego ulepszoną wersję, zoptymalizowaną dla nowoczesnych maszyn 64-bitowych, która została włączona do jądra Linuksa. Ta nowa technologia została przez krótki czas nazwana Internal BPF, następnie Extended BPF, a teraz, po kilku latach, wszyscy nazywają ją po prostu BPF.
Z grubsza rzecz ujmując, BPF umożliwia uruchamianie dowolnego kodu dostarczonego przez użytkownika w przestrzeni jądra Linuksa, a nowa architektura okazała się na tyle udana, że będziemy potrzebowali jeszcze kilkunastu artykułów, aby opisać wszystkie jej zastosowania. (Jedyną rzeczą, której programiści nie zrobili dobrze, jak widać w kodzie wydajności poniżej, było stworzenie przyzwoitego logo.)
W artykule opisano strukturę wirtualnej maszyny BPF, interfejsy jądra do pracy z BPF, narzędzia deweloperskie, a także krótki, bardzo zwięzły przegląd istniejących możliwości, tj. wszystko, czego będziemy potrzebować w przyszłości do głębszego zbadania praktycznych zastosowań BPF.
Zarządzanie obiektami za pomocą wywołania systemowego bpf. Mając już pewne pojęcie o systemie, w końcu przyjrzymy się, jak tworzyć i manipulować obiektami z przestrzeni użytkownika za pomocą specjalnego wywołania systemowego − bpf(2).
Пишем программы BPF с помощью libbpf. Oczywiście możesz pisać programy za pomocą wywołania systemowego. Ale to jest trudne. Aby uzyskać bardziej realistyczny scenariusz, programiści nuklearni opracowali bibliotekę libbpf. Stworzymy podstawowy szkielet aplikacji BPF, który wykorzystamy w kolejnych przykładach.
Pomocnicy jądra. Tutaj dowiemy się w jaki sposób programy BPF mogą uzyskać dostęp do funkcji pomocniczych jądra - narzędzia, które wraz z mapami zasadniczo rozszerza możliwości nowego BPF w porównaniu do klasycznego.
Dostęp do map z programów BPF. W tym momencie będziemy już wiedzieć wystarczająco dużo, aby dokładnie zrozumieć, w jaki sposób możemy tworzyć programy korzystające z map. Rzućmy nawet okiem na wielki i potężny weryfikator.
Narzędzia programistyczne. Sekcja pomocy dotycząca montażu wymaganych narzędzi i jądra do eksperymentów.
Wnioski. Na końcu artykułu ci, którzy doczytali aż do tego momentu, znajdą motywujące słowa i krótki opis tego, co będzie się działo w kolejnych artykułach. Dla tych, którzy nie mają ochoty lub możliwości czekania na ciąg dalszy, podamy także szereg linków do samodzielnej nauki.
Wprowadzenie do architektury BPF
Zanim zaczniemy rozważać architekturę BPF, odniesiemy się po raz ostatni (och). klasyczny BPF, który powstał w odpowiedzi na pojawienie się maszyn RISC i rozwiązał problem wydajnego filtrowania pakietów. Architektura okazała się na tyle udana, że narodziła się w szalonych latach dziewięćdziesiątych w Berkeley UNIX, została przeniesiona na większość istniejących systemów operacyjnych, przetrwała do szalonych lat dwudziestych i wciąż znajduje nowe zastosowania.
Nowy BPF powstał jako odpowiedź na wszechobecność maszyn 64-bitowych, usług chmurowych oraz zwiększone zapotrzebowanie na narzędzia do tworzenia SDN (Sczęsto-dzdefiniowany ne-praca). Opracowany przez inżynierów sieci jądra jako ulepszony zamiennik klasycznego BPF, nowy BPF dosłownie sześć miesięcy później znalazł zastosowanie w trudnym zadaniu śledzenia systemów Linux, a teraz, sześć lat po jego pojawieniu się, będziemy potrzebować całego następnego artykułu, aby wymień różne typy programów.
Śmieszne obrazki
W swej istocie BPF jest maszyną wirtualną typu sandbox, która umożliwia uruchamianie „dowolnego” kodu w przestrzeni jądra bez narażania bezpieczeństwa. Programy BPF są tworzone w przestrzeni użytkownika, ładowane do jądra i podłączane do jakiegoś źródła zdarzeń. Zdarzeniem może być na przykład dostarczenie pakietu do interfejsu sieciowego, uruchomienie jakiejś funkcji jądra itp. W przypadku pakietu program BPF będzie miał dostęp do danych i metadanych pakietu (do odczytu i ewentualnie zapisu, w zależności od typu programu); w przypadku uruchomienia funkcji jądra argumenty funkcję, w tym wskaźniki do pamięci jądra itp.
Przyjrzyjmy się bliżej temu procesowi. Na początek porozmawiajmy o pierwszej różnicy w stosunku do klasycznego BPF, dla którego programy zostały napisane w asemblerze. W nowej wersji architektura została rozbudowana tak, aby można było pisać programy w językach wysokiego poziomu, przede wszystkim oczywiście w C. W tym celu opracowano backend dla llvm, który umożliwia generowanie kodu bajtowego dla architektury BPF.
Architektura BPF została zaprojektowana częściowo tak, aby działała wydajnie na nowoczesnych maszynach. Aby to zadziałało w praktyce, kod bajtowy BPF po załadowaniu do jądra jest tłumaczony na kod natywny przy użyciu komponentu zwanego kompilatorem JIT (JUst In Tja ja). Następnie, jeśli pamiętasz, w klasycznym BPF program był ładowany do jądra i atomowo dołączany do źródła zdarzenia - w kontekście pojedynczego wywołania systemowego. W nowej architekturze dzieje się to dwuetapowo – najpierw kod jest ładowany do jądra za pomocą wywołania systemowego bpf(2)a później, za pomocą innych mechanizmów, które różnią się w zależności od typu programu, program łączy się ze źródłem zdarzenia.
Tutaj czytelnik może zadać sobie pytanie: czy było to możliwe? W jaki sposób gwarantuje się bezpieczeństwo wykonania takiego kodu? Bezpieczeństwo wykonania gwarantuje nam etap ładowania programów BPF zwany weryfikatorem (po angielsku ten etap nazywa się weryfikatorem i w dalszym ciągu będę używał angielskiego słowa):
Weryfikator to analizator statyczny, który dba o to, aby program nie zakłócał normalnego działania jądra. Nie oznacza to zresztą, że program nie może ingerować w pracę systemu - programy BPF w zależności od typu potrafią czytać i przepisywać sekcje pamięci jądra, zwracać wartości funkcji, przycinać, dołączać, przepisywać a nawet przesyłać dalej pakiety sieciowe. Verifier gwarantuje, że uruchomienie programu BPF nie spowoduje awarii jądra i że program, który zgodnie z regułami ma prawo do zapisu np. danych pakietu wychodzącego, nie będzie w stanie nadpisać pamięci jądra poza pakietem. Weryfikatorowi przyjrzymy się bardziej szczegółowo w odpowiedniej sekcji, po zapoznaniu się ze wszystkimi pozostałymi komponentami BPF.
Czego więc nauczyliśmy się do tej pory? Użytkownik pisze program w C, ładuje go do jądra za pomocą wywołania systemowego bpf(2), gdzie jest sprawdzany przez weryfikator i tłumaczony na natywny kod bajtowy. Następnie ten sam lub inny użytkownik podłącza program do źródła zdarzenia i zaczyna on działać. Oddzielenie rozruchu i połączenia jest konieczne z kilku powodów. Po pierwsze, uruchomienie weryfikatora jest stosunkowo drogie, a pobierając kilka razy ten sam program, marnujemy czas komputera. Po drugie, sposób podłączenia programu zależy od jego typu, a jeden „uniwersalny” interfejs opracowany rok temu może nie nadawać się do nowych typów programów. (Chociaż teraz, gdy architektura jest już bardziej dojrzała, pojawił się pomysł ujednolicenia tego interfejsu na poziomie libbpf.)
Uważny czytelnik może zauważyć, że nie skończyliśmy jeszcze zdjęć. Rzeczywiście, wszystko powyższe nie wyjaśnia, dlaczego BPF zasadniczo zmienia obraz w porównaniu z klasycznym BPF. Dwie innowacje, które znacznie poszerzają zakres zastosowań, to możliwość wykorzystania pamięci współdzielonej i funkcji pomocniczych jądra. W BPF pamięć współdzielona realizowana jest za pomocą tzw. map – współdzielonych struktur danych z określonym API. Prawdopodobnie otrzymali tę nazwę, ponieważ pierwszym typem mapy, który się pojawił, była tablica mieszająca. Następnie pojawiły się tablice, lokalne (na procesor) tablice mieszające i tablice lokalne, drzewa wyszukiwania, mapy zawierające wskaźniki do programów BPF i wiele więcej. Interesujące dla nas jest teraz to, że programy BPF mają teraz możliwość utrzymywania stanu pomiędzy wywołaniami i udostępniania go innym programom oraz przestrzeni użytkownika.
Dostęp do Map jest możliwy z procesów użytkownika za pomocą wywołania systemowego bpf(2)oraz z programów BPF działających w jądrze przy użyciu funkcji pomocniczych. Co więcej, pomocnicy istnieją nie tylko do pracy z mapami, ale także do uzyskiwania dostępu do innych możliwości jądra. Na przykład programy BPF mogą używać funkcji pomocniczych do przekazywania pakietów do innych interfejsów, generowania zdarzeń wydajności, uzyskiwania dostępu do struktur jądra i tak dalej.
Podsumowując, BPF zapewnia możliwość załadowania dowolnego, tj. przetestowanego przez weryfikatora, kodu użytkownika do przestrzeni jądra. Kod ten może zapisywać stan pomiędzy wywołaniami i wymieniać dane z przestrzenią użytkownika, a także ma dostęp do podsystemów jądra dozwolonych przez tego typu programy.
To już przypomina możliwości, jakie dają moduły jądra, w porównaniu z którymi BPF ma pewne zalety (oczywiście można porównywać tylko podobne aplikacje, np. śledzenie systemu - z BPF nie da się napisać dowolnego sterownika). Można zwrócić uwagę na niższy próg wejścia (niektóre narzędzia korzystające z BPF nie wymagają od użytkownika umiejętności programowania w jądrze, czy w ogóle umiejętności programowania), bezpieczeństwo wykonywania (ręka w górę w komentarzach dla tych, którzy nie zepsuli systemu przy pisaniu lub testowanie modułów), atomowość - przy ponownym ładowaniu modułów występują przestoje, a podsystem BPF dba o to, aby żadne zdarzenia nie zostały pominięte (szczerze mówiąc, nie dotyczy to wszystkich typów programów BPF).
Obecność takich możliwości czyni BPF uniwersalnym narzędziem do rozbudowy jądra, co potwierdza praktyka: do BPF dodawanych jest coraz więcej nowych typów programów, coraz więcej dużych firm korzysta z BPF na serwerach bojowych 24×7, coraz więcej startupy budują swój biznes w oparciu o rozwiązania bazujące na BPF. BPF znajduje zastosowanie wszędzie: w ochronie przed atakami DDoS, tworzeniu SDN (na przykład wdrażając sieci dla kubernetes), jako główne narzędzie do śledzenia systemu i zbierania statystyk, w systemach wykrywania włamań i systemach sandbox itp.
Zakończmy tutaj część przeglądową artykułu i przyjrzyjmy się bardziej szczegółowo maszynie wirtualnej i ekosystemowi BPF.
Dygresja: narzędzia
Aby móc uruchomić przykłady z poniższych sekcji, możesz potrzebować przynajmniej kilku narzędzi llvm/clang przy wsparciu bpf i bpftool. W sekcji Narzędzia programistyczne Możesz przeczytać instrukcje dotyczące montażu narzędzi, a także swojego jądra. Ta sekcja została umieszczona poniżej, aby nie zakłócać harmonii naszej prezentacji.
Rejestry maszyn wirtualnych BPF i system instrukcji
Architekturę i system poleceń BPF opracowano z uwzględnieniem faktu, że programy będą pisane w języku C, a po załadowaniu do jądra tłumaczone na kod natywny. Dlatego liczbę rejestrów i zestaw poleceń dobrano z myślą o przecięciu, w sensie matematycznym, możliwości współczesnych maszyn. Dodatkowo na programy nałożono różne ograniczenia, np. do niedawna nie można było pisać pętli i podprogramów, a ilość instrukcji ograniczano do 4096 (obecnie programy uprzywilejowane mogą załadować nawet milion instrukcji).
BPF posiada jedenaście dostępnych dla użytkownika rejestrów 64-bitowych r0-r10 i licznik programów. Rejestr r10 zawiera wskaźnik ramki i jest tylko do odczytu. Programy mają w czasie wykonywania dostęp do 512-bajtowego stosu i nieograniczonej ilości pamięci współdzielonej w postaci map.
Programy BPF mogą uruchamiać określony zestaw programów pomocniczych jądra, a ostatnio także zwykłe funkcje. Każda wywoływana funkcja może przyjąć do pięciu argumentów przekazywanych w rejestrach r1-r5, a wartość zwracana jest przekazywana do r0. Gwarantuje się, że po powrocie z funkcji zawartość rejestrów r6-r9 Nie zmieni się.
W celu wydajnego tłumaczenia programów rejestry r0-r11 dla wszystkich obsługiwanych architektur są jednoznacznie mapowane na rzeczywiste rejestry, biorąc pod uwagę cechy ABI bieżącej architektury. Na przykład dla x86_64 rejestruje r1-r5, używane do przekazywania parametrów funkcji, są wyświetlane na rdi, rsi, rdx, rcx, r8, które służą do przekazywania parametrów do funkcji on x86_64. Na przykład kod po lewej stronie przekłada się na kod po prawej stronie w następujący sposób:
Zarejestrować r0 używany również do zwracania wyniku wykonania programu oraz w rejestrze r1 do programu przekazywany jest wskaźnik do kontekstu - w zależności od typu programu może to być np. struktura struct xdp_md (dla XDP) lub struktura struct __sk_buff (dla różnych programów sieciowych) lub struktury struct pt_regs (dla różnych typów programów śledzących) itp.
Mieliśmy więc zestaw rejestrów, pomocników jądra, stos, wskaźnik kontekstu i pamięć współdzieloną w postaci map. Nie żeby to wszystko było absolutnie konieczne w podróży, ale...
Kontynuujmy opis i porozmawiajmy o systemie poleceń do pracy z tymi obiektami. Wszystko (Prawie wszystko) Instrukcje BPF mają stały rozmiar 64-bitowy. Jeśli spojrzysz na jedną instrukcję na 64-bitowej maszynie Big Endian, zobaczysz
Tutaj Code - to jest kodowanie instrukcji, Dst/Src są kodowaniem odpowiednio odbiornika i źródła, Off - 16-bitowe wcięcie ze znakiem, oraz Imm jest 32-bitową liczbą całkowitą ze znakiem używaną w niektórych instrukcjach (podobnie jak stała K w cBPF). Kodowanie Code ma jeden z dwóch typów:
Klasy instrukcji 0, 1, 2, 3 definiują polecenia do pracy z pamięcią. Oni są nazywane, BPF_LD, BPF_LDX, BPF_ST, BPF_STXodpowiednio. Klasy 4, 7 (BPF_ALU, BPF_ALU64) stanowią zbiór instrukcji ALU. Klasy 5, 6 (BPF_JMP, BPF_JMP32) zawierają instrukcje skoku.
Dalszy plan studiowania systemu instrukcji BPF jest następujący: zamiast skrupulatnie wymieniać wszystkie instrukcje i ich parametry, przyjrzymy się kilku przykładom w tej sekcji i stanie się z nich jasne, jak instrukcje faktycznie działają i jak ręcznie zdemontuj dowolny plik binarny dla BPF. Dla utrwalenia materiału w dalszej części artykułu, z indywidualnymi instrukcjami spotkamy się także w działach dotyczących Verifiera, kompilatora JIT, tłumaczenia klasycznego BPF, a także przy studiowaniu map, wywoływaniu funkcji itp.
Spójrzmy na przykład, w którym kompilujemy program readelf-example.c i spójrz na wynikowy plik binarny. Ujawnimy oryginalną treść readelf-example.c poniżej, po przywróceniu jego logiki z kodów binarnych:
Kody poleceń są równe b7, 15, b7 и 95. Przypomnijmy, że trzy najmniej znaczące bity to klasa instrukcji. W naszym przypadku czwarty bit wszystkich instrukcji jest pusty, więc klasy instrukcji to odpowiednio 7, 5, 7, 5. Klasa 7 to BPF_ALU64, a 5 to BPF_JMP. Dla obu klas format instrukcji jest taki sam (patrz wyżej) i możemy przepisać nasz program w ten sposób (jednocześnie przepiszemy pozostałe kolumny w postaci ludzkiej):
Op S Class Dst Src Off Imm
b 0 ALU64 0 0 0 1
1 0 JMP 0 1 1 0
b 0 ALU64 0 0 0 2
9 0 JMP 0 0 0 0
operacja b klasa ALU64 - jest BPF_MOV. Przypisuje wartość do rejestru docelowego. Jeśli bit jest ustawiony s (źródło), to wartość pobierana jest z rejestru źródłowego, a jeśli tak jak w naszym przypadku nie jest ustawiona, to wartość pobierana jest z pola Imm. Zatem w pierwszej i trzeciej instrukcji wykonujemy operację r0 = Imm. Ponadto działanie JMP klasy 1 jest BPF_JEQ (skok, jeśli jest równy). W naszym przypadku od bitu S wynosi zero, porównuje wartość rejestru źródłowego z polem Imm. Jeśli wartości się pokrywają, następuje przejście do PC + OffGdzie PCjak zwykle zawiera adres kolejnej instrukcji. Wreszcie, operacja JMP klasy 9 jest BPF_EXIT. Ta instrukcja kończy działanie programu i powraca do jądra r0. Dodajmy nową kolumnę do naszej tabeli:
Op S Class Dst Src Off Imm Disassm
MOV 0 ALU64 0 0 0 1 r0 = 1
JEQ 0 JMP 0 1 1 0 if (r1 == 0) goto pc+1
MOV 0 ALU64 0 0 0 2 r0 = 2
EXIT 0 JMP 0 0 0 0 exit
Możemy to zapisać w wygodniejszej formie:
r0 = 1
if (r1 == 0) goto END
r0 = 2
END:
exit
Jeśli pamiętamy co jest w rejestrze r1 program otrzymuje wskaźnik do kontekstu z jądra i do rejestru r0 wartość jest zwracana do jądra, wtedy widzimy, że jeśli wskaźnik do kontekstu wynosi zero, to zwracamy 1, a w przeciwnym razie - 2. Sprawdźmy, czy mamy rację, patrząc na źródło:
Tak, to program bez znaczenia, ale przekłada się to na zaledwie cztery proste instrukcje.
Przykład wyjątku: instrukcja 16-bajtowa
Wspomnieliśmy wcześniej, że niektóre instrukcje zajmują więcej niż 64 bity. Dotyczy to na przykład instrukcji lddw (Kod = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — załaduj do rejestru podwójne słowo z pól Imm, Chodzi o to, że Imm ma rozmiar 32, a podwójne słowo ma 64 bity, więc ładowanie 64-bitowej wartości bezpośredniej do rejestru w jednej instrukcji 64-bitowej nie będzie działać. Aby to zrobić, używane są dwie sąsiadujące ze sobą instrukcje do przechowywania drugiej części 64-bitowej wartości w polu Imm. Przykład:
Spotkamy się ponownie z instrukcjami lddw, kiedy mówimy o relokacjach i pracy z mapami.
Przykład: demontaż BPF przy użyciu standardowych narzędzi
Nauczyliśmy się więc czytać kody binarne BPF i jesteśmy gotowi, aby w razie potrzeby przeanalizować każdą instrukcję. Warto jednak powiedzieć, że w praktyce wygodniej i szybciej jest deasemblować programy przy użyciu standardowych narzędzi, na przykład:
(Po raz pierwszy dowiedziałem się o niektórych szczegółach opisanych w tym podrozdziale z post Aleksiej Starowoitow w Blog BPF.)
Obiekty BPF – programy i mapy – tworzone są z przestrzeni użytkownika za pomocą poleceń BPF_PROG_LOAD и BPF_MAP_CREATE wywołanie systemowe bpf(2), omówimy dokładnie, jak to się dzieje w następnej sekcji. Tworzy to struktury danych jądra i dla każdej z nich refcount (liczba odwołań) jest ustawiona na jeden, a deskryptor pliku wskazujący obiekt jest zwracany użytkownikowi. Po zamknięciu uchwytu refcount obiekt zmniejsza się o jeden, a gdy osiągnie zero, obiekt ulega zniszczeniu.
Jeśli program korzysta z map, to refcount mapy te są zwiększane o jeden po załadowaniu programu, tj. ich deskryptory plików można zamknąć z poziomu procesu użytkownika i nadal refcount nie stanie się zerem:
Po pomyślnym załadowaniu programu zazwyczaj dołączamy go do jakiegoś generatora zdarzeń. Możemy na przykład umieścić go na interfejsie sieciowym, aby przetwarzał przychodzące pakiety lub łączyć się z niektórymi tracepoint w rdzeniu. W tym momencie licznik referencji również wzrośnie o jeden i będziemy mogli zamknąć deskryptor pliku w programie ładującym.
Co się stanie, jeśli teraz zamkniemy bootloader? Zależy to od rodzaju generatora zdarzeń (haka). Wszystkie zaczepy sieciowe będą istnieć po zakończeniu modułu ładującego, są to tak zwane zaczepy globalne. I na przykład programy śledzące zostaną wydane po zakończeniu procesu, który je utworzył (i dlatego nazywane są lokalnymi, od „lokalnego do procesu”). Technicznie rzecz biorąc, lokalne zaczepy zawsze mają odpowiedni deskryptor pliku w przestrzeni użytkownika i dlatego zamykają się po zamknięciu procesu, ale globalne nie. Na poniższym rysunku za pomocą czerwonych krzyżyków próbuję pokazać jak zakończenie programu ładującego wpływa na czas życia obiektów w przypadku hooków lokalnych i globalnych.
Dlaczego istnieje rozróżnienie między hakami lokalnymi i globalnymi? Uruchamianie niektórych typów programów sieciowych ma sens bez przestrzeni użytkownika, wyobraźmy sobie na przykład ochronę DDoS - program ładujący zapisuje reguły i łączy program BPF z interfejsem sieciowym, po czym program ładujący może się zabić. Z drugiej strony wyobraź sobie program śledzący debugowanie, który napisałeś na kolanach w dziesięć minut - po jego zakończeniu chciałbyś, aby w systemie nie pozostały żadne śmieci, a lokalne hooki o to zadbają.
Z drugiej strony wyobraź sobie, że chcesz połączyć się z punktem śledzenia w jądrze i zbierać statystyki przez wiele lat. W takiej sytuacji warto od czasu do czasu uzupełnić część dotyczącą użytkownika i powrócić do statystyk. System plików bpf zapewnia taką możliwość. Jest to system pseudoplików przechowywany wyłącznie w pamięci, który umożliwia tworzenie plików odwołujących się do obiektów BPF, zwiększając w ten sposób refcount obiekty. Następnie moduł ładujący może wyjść, a utworzone przez niego obiekty pozostaną żywe.
Tworzenie plików w bpffs, które odwołują się do obiektów BPF, nazywane jest „przypinaniem” (jak w następującym zdaniu: „proces może przypiąć program lub mapę BPF”). Tworzenie obiektów plikowych dla obiektów BPF ma sens nie tylko ze względu na przedłużenie życia obiektów lokalnych, ale także ze względu na użyteczność obiektów globalnych - wracając do przykładu z globalnym programem ochrony DDoS, chcemy móc przyjechać i popatrzeć na statystyki od czasu do czasu.
System plików BPF jest zwykle montowany w /sys/fs/bpf, ale można go również zamontować lokalnie, na przykład w ten sposób:
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint
Nazwy systemów plików tworzone są za pomocą polecenia BPF_OBJ_PIN Wywołanie systemowe BPF. Aby to zilustrować, weźmy program, skompilujmy go, prześlijmy i przypnijmy do niego bpffs. Nasz program nie robi nic pożytecznego, prezentujemy jedynie kod, abyś mógł odtworzyć przykład:
Teraz pobierzmy nasz program za pomocą narzędzia bpftool i spójrz na towarzyszące wywołania systemowe bpf(2) (niektóre nieistotne linie usunięte z wyjścia strace):
Tutaj załadowaliśmy program za pomocą BPF_PROG_LOAD, otrzymał deskryptor pliku z jądra 3 i za pomocą polecenia BPF_OBJ_PIN przypiął ten deskryptor pliku jako plik "bpf-mountpoint/test". Następnie program bootloader bpftool zakończył pracę, ale nasz program pozostał w jądrze, choć nie podłączaliśmy go do żadnego interfejsu sieciowego:
$ sudo bpftool prog | tail -3
783: xdp name test tag 5c8ba0cf164cb46c gpl
loaded_at 2020-05-05T13:27:08+0000 uid 0
xlated 24B jited 41B memlock 4096B
Możemy normalnie usunąć obiekt pliku unlink(2) a następnie odpowiedni program zostanie usunięty:
$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory
Usuwanie obiektów
Skoro już mowa o usuwaniu obiektów, warto wyjaśnić, że po odłączeniu programu od hooka (generatora zdarzeń) żadne nowe zdarzenie nie spowoduje jego uruchomienia, natomiast wszystkie bieżące instancje programu zostaną zakończone w normalnej kolejności .
Niektóre typy programów BPF pozwalają na wymianę programu w locie, tj. zapewnić atomowość sekwencji replace = detach old program, attach new program. W takim przypadku wszystkie aktywne instancje starej wersji programu zakończą swoją pracę, a z nowego programu zostaną utworzone nowe procedury obsługi zdarzeń, a „atomowość” oznacza tutaj, że żadne zdarzenie nie zostanie pominięte.
Dołączanie programów do źródeł zdarzeń
W tym artykule nie będziemy osobno opisywać programów łączących ze źródłami zdarzeń, ponieważ warto przestudiować to w kontekście konkretnego rodzaju programu. Cm. przykład poniżej, w którym pokazujemy, jak podłączone są programy takie jak XDP.
Manipulowanie obiektami za pomocą wywołania systemowego bpf
programy BPF
Wszystkie obiekty BPF są tworzone i zarządzane z przestrzeni użytkownika za pomocą wywołania systemowego bpf, posiadający następujący prototyp:
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
Oto zespół cmd jest jedną z wartości typu enum bpf_cmd, attr — wskaźnik do parametrów konkretnego programu i size — wielkość obiektu według wskaźnika, tj. zwykle to sizeof(*attr). W jądrze 5.8 wywołanie systemowe bpf obsługuje 34 różne polecenia i określenieunion bpf_attr zajmuje 200 linii. Ale nie powinniśmy się tego bać, ponieważ z poleceniami i parametrami będziemy zapoznawać się w kilku artykułach.
Zacznijmy od zespołu BPF_PROG_LOAD, który tworzy programy BPF - pobiera zestaw instrukcji BPF i ładuje go do jądra. W momencie załadowania uruchamiany jest weryfikator, następnie kompilator JIT i po pomyślnym wykonaniu zwracany jest użytkownikowi deskryptor pliku programu. W poprzedniej sekcji widzieliśmy, co się z nim dalej dzieje o cyklu życia obiektów BPF.
Napiszemy teraz niestandardowy program, który załaduje prosty program BPF, ale najpierw musimy zdecydować, jaki rodzaj programu chcemy załadować - będziemy musieli wybrać Typ i w ramach tego typu napisać program, który przejdzie test weryfikatora. Aby jednak nie komplikować procesu, oto gotowe rozwiązanie: weźmiemy taki program BPF_PROG_TYPE_XDP, która zwróci wartość XDP_PASS (pomiń wszystkie pakiety). W asemblerze BPF wygląda to bardzo prosto:
r0 = 2
exit
Po tym jak zdecydowaliśmy się że prześlemy, możemy powiedzieć jak to zrobimy:
Ciekawe zdarzenia w programie zaczynają się od definicji tablicy insns - nasz program BPF w kodzie maszynowym. W tym przypadku każda instrukcja programu BPF jest spakowana w strukturę bpf_insn. Pierwszy element insns jest zgodny z instrukcją r0 = 2, druga - exit.
Wycofać się. Jądro definiuje wygodniejsze makra do pisania kodów maszynowych i korzystania z pliku nagłówkowego jądra tools/include/linux/filter.h moglibyśmy napisać
Ale ponieważ pisanie programów BPF w kodzie natywnym jest konieczne tylko do pisania testów w jądrze i artykułów o BPF, brak tych makr tak naprawdę nie komplikuje życia programisty.
Po zdefiniowaniu programu BPF przystępujemy do ładowania go do jądra. Nasz minimalistyczny zestaw parametrów attr zawiera typ programu, zestaw i liczbę instrukcji, wymaganą licencję i nazwę "woo", za pomocą którego po pobraniu odnajdujemy nasz program w systemie. Program zgodnie z obietnicą ładowany jest do systemu za pomocą wywołania systemowego bpf.
Pod koniec programu znajdujemy się w nieskończonej pętli, która symuluje ładunek. Bez tego program zostanie zabity przez jądro, gdy deskryptor pliku, który zwrócił nam wywołanie systemowe, zostanie zamknięty bpf, i nie zobaczymy tego w systemie.
Cóż, jesteśmy gotowi do testów. Złóżmy i uruchommy program w ramach straceaby sprawdzić, czy wszystko działa tak, jak powinno:
Wszystko w porządku, bpf(2) zwrócił nam uchwyt 3 i weszliśmy w nieskończoną pętlę pause(). Spróbujmy znaleźć nasz program w systemie. Aby to zrobić, przejdziemy do innego terminala i skorzystamy z narzędzia bpftool:
Widzimy, że w systemie jest załadowany program woo którego globalny identyfikator to 390 i jest obecnie w toku simple-prog istnieje otwarty deskryptor pliku wskazujący na program (i if simple-prog w takim razie dokończę robotę woo zniknie). Zgodnie z oczekiwaniami, program woo zajmuje 16 bajtów – dwie instrukcje – kodów binarnych w architekturze BPF, ale w swojej natywnej postaci (x86_64) jest to już 40 bajtów. Spójrzmy na nasz program w jego oryginalnej formie:
bez niespodzianek. Przyjrzyjmy się teraz kodowi wygenerowanemu przez kompilator JIT:
# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
0: nopl 0x0(%rax,%rax,1)
5: push %rbp
6: mov %rsp,%rbp
9: sub $0x0,%rsp
10: push %rbx
11: push %r13
13: push %r14
15: push %r15
17: pushq $0x0
19: mov $0x2,%eax
1e: pop %rbx
1f: pop %r15
21: pop %r14
23: pop %r13
25: pop %rbx
26: leaveq
27: retq
niezbyt skuteczny dla exit(2), ale szczerze mówiąc, nasz program jest zbyt prosty, a dla nietrywialnych programów potrzebny jest oczywiście prolog i epilog dodany przez kompilator JIT.
Mapy
Programy BPF mogą wykorzystywać obszary pamięci strukturalnej, które są dostępne zarówno dla innych programów BPF, jak i dla programów w przestrzeni użytkownika. Obiekty te nazywane są mapami i w tej sekcji pokażemy, jak manipulować nimi za pomocą wywołania systemowego bpf.
Powiedzmy od razu, że możliwości map nie ograniczają się jedynie do dostępu do pamięci współdzielonej. Istnieją mapy specjalnego przeznaczenia zawierające np. wskaźniki do programów BPF lub wskaźniki do interfejsów sieciowych, mapy do pracy ze zdarzeniami perf itp. Nie będziemy o nich tutaj mówić, aby nie wprowadzać czytelnika w błąd. Poza tym ignorujemy kwestie synchronizacji, ponieważ w naszych przykładach nie jest to istotne. Pełną listę dostępnych typów map można znaleźć w <linux/bpf.h>, a w tej sekcji weźmiemy za przykład pierwszy w historii typ, tablicę mieszającą BPF_MAP_TYPE_HASH.
Jeśli utworzysz tabelę mieszającą, powiedzmy, w C++, powiesz unordered_map<int,long> woo, co po rosyjsku oznacza „Potrzebuję stołu woo nieograniczony rozmiar, którego klucze są typu int, a wartości to typ long" Aby utworzyć tablicę mieszającą BPF, musimy zrobić prawie to samo, z tą różnicą, że musimy określić maksymalny rozmiar tabeli, a zamiast określać typy kluczy i wartości, musimy określić ich rozmiary w bajtach . Aby utworzyć mapy użyj polecenia BPF_MAP_CREATE wywołanie systemowe bpf. Przyjrzyjmy się mniej więcej minimalnemu programowi tworzącemu mapę. Po poprzednim programie ładującym programy BPF, ten powinien wydawać Ci się prosty:
Tutaj definiujemy zestaw parametrów attr, w którym mówimy: „Potrzebuję tabeli mieszającej z kluczami i wartościami rozmiaru sizeof(int), w którym mogę umieścić maksymalnie cztery elementy.” Tworząc mapy BPF można określić inne parametry, przykładowo analogicznie jak w przykładzie z programem podaliśmy nazwę obiektu jako "woo".
Oto wywołanie systemowe bpf(2) zwrócił nam numer mapy deskryptorów 3 po czym program zgodnie z oczekiwaniami oczekuje na dalsze instrukcje w wywołaniu systemowym pause(2).
Teraz wyślijmy nasz program w tło lub otwórzmy inny terminal i spójrzmy na nasz obiekt za pomocą narzędzia bpftool (naszą mapę od innych możemy odróżnić po nazwie):
$ sudo bpftool map
...
114: hash name woo flags 0x0
key 4B value 4B max_entries 4 memlock 4096B
...
Liczba 114 to globalny identyfikator naszego obiektu. Dowolny program w systemie może użyć tego identyfikatora do otwarcia istniejącej mapy za pomocą polecenia BPF_MAP_GET_FD_BY_ID wywołanie systemowe bpf.
Teraz możemy pobawić się naszą tablicą mieszającą. Przyjrzyjmy się jego zawartości:
$ sudo bpftool map dump id 114
Found 0 elements
Pusty. Nadajmy temu wartość hash[1] = 1:
$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0
Spójrzmy jeszcze raz na tabelę:
$ sudo bpftool map dump id 114
key: 01 00 00 00 value: 01 00 00 00
Found 1 element
Brawo! Udało nam się dodać jeden element. Zauważ, że aby to zrobić, musimy pracować na poziomie bajtu, ponieważ bptftool nie wie jakiego typu są wartości w tablicy mieszającej. (Tę wiedzę można jej przekazać za pomocą BTF, ale o tym teraz.)
Jak dokładnie bpftool czyta i dodaje elementy? Zajrzyjmy pod maskę:
Najpierw otworzyliśmy mapę według jej globalnego identyfikatora za pomocą polecenia BPF_MAP_GET_FD_BY_ID и bpf(2) zwrócił nam deskryptor 3. Dalsze korzystanie z polecenia BPF_MAP_GET_NEXT_KEY mijając, znaleźliśmy pierwszy klucz w stole NULL jako wskaźnik do „poprzedniego” klucza. Jeśli mamy klucz, możemy to zrobić BPF_MAP_LOOKUP_ELEMktóra zwraca wartość do wskaźnika value. Następnym krokiem jest znalezienie następnego elementu poprzez przekazanie wskaźnika do bieżącego klucza, ale nasza tabela zawiera tylko jeden element i polecenie BPF_MAP_GET_NEXT_KEY zwroty ENOENT.
OK, zmieńmy wartość o klucz 1, powiedzmy, że nasza logika biznesowa wymaga rejestracji hash[1] = 2:
Zgodnie z oczekiwaniami, jest to bardzo proste: polecenie BPF_MAP_GET_FD_BY_ID otwiera naszą mapę według identyfikatora i polecenia BPF_MAP_UPDATE_ELEM nadpisuje element.
Zatem po utworzeniu tablicy skrótów w jednym programie możemy czytać i zapisywać jej zawartość w innym. Zauważ, że jeśli mogliśmy to zrobić z wiersza poleceń, może to zrobić każdy inny program w systemie. Oprócz poleceń opisanych powyżej, do pracy z mapami z przestrzeni użytkownika, następujące:
BPF_MAP_LOOKUP_ELEM: znajdź wartość według klucza
BPF_MAP_UPDATE_ELEM: aktualizacja/utwórz wartość
BPF_MAP_DELETE_ELEM: usuń klucz
BPF_MAP_GET_NEXT_KEY: znajdź następny (lub pierwszy) klucz
BPF_MAP_GET_NEXT_ID: umożliwia przeglądanie wszystkich istniejących map, tak to działa bpftool map
BPF_MAP_GET_FD_BY_ID: otwórz istniejącą mapę według jej globalnego identyfikatora
BPF_MAP_LOOKUP_AND_DELETE_ELEM: atomowo zaktualizuj wartość obiektu i zwróć starą
BPF_MAP_FREEZE: uczyń mapę niezmienną z przestrzeni użytkownika (tej operacji nie można cofnąć)
BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: operacje masowe. Na przykład, BPF_MAP_LOOKUP_AND_DELETE_BATCH - to jedyny niezawodny sposób na odczytanie i zresetowanie wszystkich wartości z mapy
Nie wszystkie z tych poleceń działają dla wszystkich typów map, ale ogólnie praca z innymi typami map z przestrzeni użytkownika wygląda dokładnie tak samo, jak praca z tablicami skrótów.
Dla porządku zakończmy nasze eksperymenty z tablicą mieszającą. Pamiętasz, że stworzyliśmy tabelę, która może zawierać aż cztery klucze? Dodajmy jeszcze kilka elementów:
$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long
Tak jak się spodziewaliśmy, nie udało nam się. Przyjrzyjmy się bliżej błędowi:
$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++
Wszystko jest w porządku: zgodnie z oczekiwaniami, zespół BPF_MAP_UPDATE_ELEM próbuje utworzyć nowy, piąty klucz, ale ulega awarii E2BIG.
Możemy więc tworzyć i ładować programy BPF, a także tworzyć i zarządzać mapami z przestrzeni użytkownika. Teraz logiczne jest, aby przyjrzeć się, jak możemy wykorzystać mapy z samych programów BPF. Moglibyśmy o tym mówić w języku trudnych do odczytania programów w kodach makr maszynowych, ale tak naprawdę nadszedł czas, aby pokazać, jak faktycznie pisze się i utrzymuje programy BPF - przy użyciu libbpf.
(Dla czytelników niezadowolonych z braku niskopoziomowego przykładu: szczegółowo przeanalizujemy programy wykorzystujące mapy i funkcje pomocnicze utworzone przy użyciu libbpf i powiedzieć, co dzieje się na poziomie instrukcji. Dla czytelników niezadowolonych bardzo, Dodaliśmy przykład w odpowiednim miejscu artykułu.)
Pisanie programów w BPF przy użyciu libbpf
Pisanie programów BPF przy użyciu kodów maszynowych może być interesujące tylko za pierwszym razem, a potem pojawia się uczucie sytości. W tym momencie musisz zwrócić swoją uwagę llvm, który posiada backend do generowania kodu dla architektury BPF, a także bibliotekę libbpf, który pozwala na pisanie strony użytkownika aplikacji BPF i ładowanie kodu programów BPF wygenerowanych przy użyciu llvm/clang.
W rzeczywistości, jak zobaczymy w tym i kolejnych artykułach, libbpf wykonuje bez niego sporo pracy (lub podobnych narzędzi - iproute2, libbcc, libbpf-goitp.) nie da się żyć. Jedna z zabójczych cech projektu libbpf to BPF CO-RE (Compile Once, Run Everywhere) - projekt pozwalający na pisanie programów BPF, które są przenośne z jednego jądra na drugie, z możliwością uruchamiania na różnych API (na przykład przy zmianie struktury jądra z wersji do wersji). Aby móc pracować z CO-RE, Twoje jądro musi być skompilowane z obsługą BTF (opisujemy jak to zrobić w dziale Narzędzia programistyczne. Możesz sprawdzić, czy twoje jądro jest zbudowane z BTF, czy nie, w bardzo prosty sposób - poprzez obecność następującego pliku:
Ten plik przechowuje informacje o wszystkich typach danych używanych w jądrze i jest używany we wszystkich naszych przykładach użycia libbpf. Porozmawiamy szczegółowo o CO-RE w następnym artykule, ale w tym - po prostu zbuduj sobie jądro CONFIG_DEBUG_INFO_BTF.
biblioteka libbpf mieszka bezpośrednio w katalogu tools/lib/bpf jądro i jego rozwój odbywa się poprzez listę mailingową [email protected]. Jednak na potrzeby aplikacji żyjących poza jądrem utrzymywane jest osobne repozytorium https://github.com/libbpf/libbpf w którym biblioteka jądra jest dublowana w celu umożliwienia dostępu do odczytu mniej więcej tak, jak jest.
W tej sekcji przyjrzymy się, jak stworzyć projekt wykorzystujący libbpf, napiszmy kilka (mniej lub bardziej bezsensownych) programów testowych i szczegółowo przeanalizujmy, jak to wszystko działa. Pozwoli nam to łatwiej wyjaśnić w kolejnych sekcjach dokładnie, w jaki sposób programy BPF współdziałają z mapami, pomocnikami jądra, BTF itp.
Zwykle projekty używają libbpf dodaj repozytorium GitHub jako podmoduł git, zrobimy to samo:
Nasz następny plan w tej sekcji jest następujący: napiszemy program BPF podobny do BPF_PROG_TYPE_XDP, tak samo jak w poprzednim przykładzie, ale w C kompilujemy go za pomocą clangi napisz program pomocniczy, który załaduje go do jądra. W kolejnych rozdziałach będziemy poszerzać możliwości zarówno programu BPF, jak i programu asystenta.
Przykład: tworzenie pełnoprawnej aplikacji przy użyciu libbpf
Na początek używamy pliku /sys/kernel/btf/vmlinux, o którym mowa powyżej, i utwórz jego odpowiednik w postaci pliku nagłówkowego:
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
W tym pliku będą przechowywane wszystkie struktury danych dostępne w naszym jądrze, przykładowo tak jest zdefiniowany w jądrze nagłówek IPv4:
Choć nasz program okazał się bardzo prosty, wciąż musimy zwrócić uwagę na wiele szczegółów. Po pierwsze, pierwszym plikiem nagłówkowym, który dołączamy, jest vmlinux.h, który właśnie wygenerowaliśmy za pomocą bpftool btf dump - teraz nie musimy instalować pakietu kernel-headers, aby dowiedzieć się, jak wyglądają struktury jądra. Z biblioteki przychodzi do nas następujący plik nagłówkowy libbpf. Teraz potrzebujemy go jedynie do zdefiniowania makra SEC, który wysyła znak do odpowiedniej sekcji pliku obiektowego ELF. Nasz program znajduje się w dziale xdp/simple, gdzie przed ukośnikiem definiujemy typ programu BPF – taka jest konwencja stosowana w libbpf, w oparciu o nazwę sekcji, przy uruchomieniu zastąpi prawidłowy typ bpf(2). Sam program BPF taki jest C - bardzo prosty i składa się z jednej linii return XDP_PASS. Wreszcie osobna sekcja "license" zawiera nazwę licencji.
Możemy skompilować nasz program przy użyciu llvm/clang w wersji >= 10.0.0 lub jeszcze lepszej, wyższej (patrz sekcja Narzędzia programistyczne):
Wśród ciekawych funkcji: wskazujemy docelową architekturę -target bpf i ścieżka do nagłówków libbpf, który niedawno zainstalowaliśmy. Nie zapomnij także o -O2, bez tej opcji w przyszłości mogą Cię spotkać niespodzianki. Przyjrzyjmy się naszemu kodowi, czy udało nam się napisać taki program, jaki chcieliśmy?
Tak, zadziałało! Mamy teraz plik binarny z programem i chcemy stworzyć aplikację, która załaduje go do jądra. W tym celu biblioteka libbpf oferuje nam dwie możliwości - skorzystaj z API niższego poziomu lub API wyższego poziomu. Pójdziemy drugą drogą, ponieważ chcemy nauczyć się pisać, ładować i łączyć programy BPF przy minimalnym wysiłku w celu ich późniejszego studiowania.
Najpierw musimy wygenerować „szkielet” naszego programu z jego pliku binarnego, używając tego samego narzędzia bpftool — szwajcarski nóż świata BPF (co można brać dosłownie, gdyż Daniel Borkman, jeden z twórców i opiekunów BPF, jest Szwajcarem):
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
W pliku xdp-simple.skel.h zawiera kod binarny naszego programu oraz funkcje zarządzania - ładowania, dołączania, usuwania naszego obiektu. W naszym prostym przypadku wygląda to na przesadę, ale działa również w przypadku, gdy plik obiektowy zawiera wiele programów i map BPF i aby załadować ten gigantyczny ELF, wystarczy wygenerować szkielet i wywołać jedną lub dwie funkcje z niestandardowej aplikacji, którą piszą. Przejdźmy teraz dalej.
Ściśle mówiąc, nasz program ładujący jest trywialny:
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"
int main(int argc, char **argv)
{
struct xdp_simple_bpf *obj;
obj = xdp_simple_bpf__open_and_load();
if (!obj)
err(1, "failed to open and/or load BPF objectn");
pause();
xdp_simple_bpf__destroy(obj);
}
Tutaj struct xdp_simple_bpf zdefiniowane w pliku xdp-simple.skel.h i opisuje nasz plik obiektowy:
Tutaj możemy zobaczyć ślady API niskiego poziomu: struktura struct bpf_program *simple и struct bpf_link *simple. Pierwsza struktura szczegółowo opisuje nasz program, napisany w tej sekcji xdp/simple, a drugi opisuje sposób, w jaki program łączy się ze źródłem zdarzenia.
Funkcja xdp_simple_bpf__open_and_load, otwiera obiekt ELF, analizuje go, tworzy wszystkie struktury i podstruktury (oprócz programu, ELF zawiera także inne sekcje - dane, dane tylko do odczytu, informacje o debugowaniu, licencję itp.), a następnie ładuje go do jądra za pomocą systemu dzwonić bpf, co możemy sprawdzić kompilując i uruchamiając program:
Przyjrzyjmy się teraz naszemu programowi używającemu bpftool. Znajdźmy jej identyfikator:
# bpftool p | grep -A4 simple
463: xdp name simple tag 3b185187f1855c4c gpl
loaded_at 2020-08-01T01:59:49+0000 uid 0
xlated 16B jited 40B memlock 4096B
btf_id 185
pids xdp-simple(16498)
i dump (używamy skróconej formy polecenia bpftool prog dump xlated):
# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
0: (b7) r0 = 2
1: (95) exit
Coś nowego! Program wydrukował fragmenty naszego pliku źródłowego C. Zrobiła to biblioteka libbpf, który znalazł sekcję debugowania w pliku binarnym, skompilował ją do obiektu BTF, załadował do jądra za pomocą BPF_BTF_LOAD, a następnie określił wynikowy deskryptor pliku podczas ładowania programu za pomocą polecenia BPG_PROG_LOAD.
Pomocnicy jądra
Programy BPF mogą uruchamiać funkcje „zewnętrzne” – pomocników jądra. Te funkcje pomocnicze umożliwiają programom BPF dostęp do struktur jądra, zarządzanie mapami, a także komunikację z „prawdziwym światem” - tworzenie zdarzeń wydajności, kontrolowanie sprzętu (na przykład przekierowywanie pakietów) itp.
Przykład: bpf_get_smp_processor_id
W ramach paradygmatu „uczenia się przez przykład” rozważmy jedną z funkcji pomocniczych, bpf_get_smp_processor_id(), pewien w pliku kernel/bpf/helpers.c. Zwraca numer procesora, na którym działa program BPF, który go wywołał. Ale nie jesteśmy tak zainteresowani jego semantyką, jak tym, że jego realizacja przebiega w jednym kierunku:
Definicje funkcji pomocniczych BPF są podobne do definicji wywołań systemowych w systemie Linux. Tutaj na przykład zdefiniowano funkcję, która nie ma argumentów. (Funkcja, która przyjmuje, powiedzmy, trzy argumenty, jest definiowana za pomocą makra BPF_CALL_3. Maksymalna liczba argumentów wynosi pięć.) Jest to jednak tylko pierwsza część definicji. Druga część polega na zdefiniowaniu struktury typów struct bpf_func_proto, który zawiera opis funkcji pomocniczej zrozumiałej dla weryfikatora:
Aby programy BPF danego typu mogły skorzystać z tej funkcji muszą ją zarejestrować np. dla typu BPF_PROG_TYPE_XDP funkcja jest zdefiniowana w jądrze xdp_func_proto, który na podstawie identyfikatora funkcji pomocniczej określa, czy XDP obsługuje tę funkcję, czy nie. Naszą funkcją jest obsługuje:
Nowe typy programów BPF są „definiowane” w pliku include/linux/bpf_types.h za pomocą makra BPF_PROG_TYPE. Zdefiniowane w cudzysłowie, ponieważ jest to definicja logiczna, a w terminologii języka C definicja całego zestawu konstrukcji betonowych występuje w innych miejscach. W szczególności w pliku kernel/bpf/verifier.c wszystkie definicje z pliku bpf_types.h służą do tworzenia szeregu struktur bpf_verifier_ops[]:
Oznacza to, że dla każdego typu programu BPF zdefiniowany jest wskaźnik do struktury danych tego typu struct bpf_verifier_ops, który jest inicjowany wartością _name ## _verifier_ops, tj., xdp_verifier_ops dla xdp. Struktura xdp_verifier_opsokreślone przez w pliku net/core/filter.c w następujący sposób:
Tutaj widzimy naszą znaną funkcję xdp_func_proto, który uruchomi weryfikator za każdym razem, gdy napotka wyzwanie Niektóre funkcje wewnątrz programu BPF, patrz verifier.c.
Przyjrzyjmy się, jak hipotetyczny program BPF wykorzystuje tę funkcję bpf_get_smp_processor_id. W tym celu przepisujemy program z poprzedniej sekcji w następujący sposób:
to jest, bpf_get_smp_processor_id jest wskaźnikiem funkcji, którego wartość wynosi 8, gdzie 8 jest wartością BPF_FUNC_get_smp_processor_id typ enum bpf_fun_id, który jest dla nas zdefiniowany w pliku vmlinux.h (plik bpf_helper_defs.h w jądrze jest generowane przez skrypt, więc „magiczne” liczby są w porządku). Funkcja ta nie przyjmuje żadnych argumentów i zwraca wartość typu __u32. Kiedy uruchomimy to w naszym programie, clang generuje instrukcję BPF_CALL „właściwy rodzaj” Skompilujmy program i spójrzmy na sekcję xdp/simple:
W pierwszej linii widzimy instrukcje call, parametr IMM co jest równe 8, i SRC_REG - zero. Zgodnie z umową ABI stosowaną przez weryfikatora jest to wywołanie funkcji pomocniczej numer osiem. Po uruchomieniu logika jest prosta. Zwróć wartość z rejestru r0 skopiowane do r1 a w liniach 2,3 jest konwertowany na typ u32 — górne 32 bity zostaną wyczyszczone. W liniach 4,5,6,7 zwracamy 2 (XDP_PASS) lub 1 (XDP_DROP) w zależności od tego, czy funkcja pomocnicza z linii 0 zwróciła wartość zerową czy niezerową.
Przetestujmy się: załaduj program i spójrz na wynik bpftool prog dump xlated:
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914
$ sudo bpftool p | grep simple
523: xdp name simple tag 44c38a10c657e1b0 gpl
pids xdp-simple(10915)
$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
0: (85) call bpf_get_smp_processor_id#114128
1: (bf) r1 = r0
2: (67) r1 <<= 32
3: (77) r1 >>= 32
4: (b7) r0 = 2
; }
5: (15) if r1 == 0x0 goto pc+1
6: (b7) r0 = 1
7: (95) exit
OK, weryfikator znalazł poprawnego pomocnika jądra.
Przykład: przekazanie argumentów i ostateczne uruchomienie programu!
Wszystkie funkcje pomocnicze na poziomie uruchamiania mają prototyp
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
Parametry funkcji pomocniczych przekazywane są w rejestrach r1-r5, a wartość jest zwracana w rejestrze r0. Nie ma funkcji, które przyjmują więcej niż pięć argumentów i nie oczekuje się, że w przyszłości zostanie dodana obsługa ich.
Przyjrzyjmy się nowemu pomocnikowi jądra i sposobowi przekazywania parametrów przez BPF. Przepiszmy xdp-simple.bpf.c w następujący sposób (pozostałe wiersze nie uległy zmianie):
SEC("xdp/simple")
int simple(void *ctx)
{
bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
return XDP_PASS;
}
Nasz program wypisuje numer procesora, na którym jest uruchomiony. Skompilujmy to i spójrzmy na kod:
W liniach 0-7 zapisujemy ciąg znaków running on CPU%un, a następnie w linii 8 uruchamiamy znajomy bpf_get_smp_processor_id. W liniach 9-12 przygotowujemy argumenty pomocnicze bpf_printk - rejestruje r1, r2, r3. Dlaczego jest ich trzech, a nie dwóch? Ponieważ bpf_printk - to jest opakowanie makr wokół prawdziwego pomocnika bpf_trace_printk, który musi przekazać rozmiar ciągu formatującego.
Dodajmy teraz kilka linii do xdp-simple.caby nasz program połączył się z interfejsem lo i naprawdę się zaczęło!
Tutaj używamy funkcji bpf_set_link_xdp_fd, który łączy programy BPF typu XDP z interfejsami sieciowymi. Zakodowaliśmy na stałe numer interfejsu lo, czyli zawsze 1. Uruchamiamy funkcję dwukrotnie, aby najpierw odłączyć stary program, jeśli był dołączony. Zauważ, że teraz nie potrzebujemy wyzwań pause lub nieskończona pętla: nasz program ładujący zakończy działanie, ale program BPF nie zostanie zabity, ponieważ jest podłączony do źródła zdarzenia. Po pomyślnym pobraniu i połączeniu program zostanie uruchomiony dla każdego przychodzącego pakietu sieciowego lo.
Pobierzmy program i spójrzmy na interfejs lo:
$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp name simple tag 4fca62e77ccb43d6 gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 669
Program, który pobraliśmy ma ID 669 i ten sam ID widzimy na interfejsie lo. Wyślemy kilka paczek do 127.0.0.1 (prośba + odpowiedź):
$ ping -c1 localhost
a teraz spójrzmy na zawartość wirtualnego pliku debugowania /sys/kernel/debug/tracing/trace_pipe, w którym bpf_printk pisze swoje wiadomości:
# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0
Zauważono dwie paczki lo i przetwarzane na CPU0 - nasz pierwszy pełnoprawny, pozbawiony sensu program BPF zadziałał!
Warto to zauważyć bpf_printk Nie bez powodu zapisuje plik debugowania: nie jest to najskuteczniejszy pomocnik do wykorzystania w produkcji, ale naszym celem było pokazanie czegoś prostego.
Dostęp do map z programów BPF
Przykład: użycie mapy z programu BPF
W poprzednich sekcjach dowiedzieliśmy się, jak tworzyć i używać map z przestrzeni użytkownika, a teraz spójrzmy na część jądra. Zacznijmy, jak zwykle, od przykładu. Przepiszmy nasz program xdp-simple.bpf.c w następujący sposób:
Na początku programu dodaliśmy definicję mapy woo: Jest to 8-elementowa tablica przechowująca wartości takie jak u64 (w C zdefiniowalibyśmy taką tablicę jak u64 woo[8]). W programie "xdp/simple" pobieramy bieżący numer procesora do zmiennej key a następnie użyj funkcji pomocniczej bpf_map_lookup_element otrzymujemy wskaźnik do odpowiedniego wpisu w tablicy, który zwiększamy o jeden. W tłumaczeniu na rosyjski: obliczamy statystyki, które procesory przetworzyły przychodzące pakiety. Spróbujmy uruchomić program:
Sprawdźmy, czy jest podłączona lo i wyślij kilka pakietów:
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 108
$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
Prawie wszystkie procesy były przetwarzane na CPU7. Nie jest to dla nas ważne, najważniejsze, że program działa i rozumiemy, jak uzyskać dostęp do map z programów BPF - za pomocą хелперов bpf_mp_*.
Indeks mistyczny
Możemy więc uzyskać dostęp do mapy z programu BPF za pomocą wywołań typu
$ llvm-readelf -r xdp-simple.bpf.o | head -4
Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
Offset Info Type Symbol's Value Symbol's Name
0000000000000020 0000002700000001 R_BPF_64_64 0000000000000000 woo
Jeśli jednak spojrzymy na już załadowany program, zobaczymy wskaźnik do właściwej mapy (linia 4):
Możemy zatem stwierdzić, że w momencie uruchomienia naszego programu ładującego link do &woo został zastąpiony czymś z biblioteką libbpf. Najpierw przyjrzymy się wynikom strace:
Widzimy to libbpf stworzył mapę woo a następnie pobrałem nasz program simple. Przyjrzyjmy się bliżej sposobowi ładowania programu:
dzwonić xdp_simple_bpf__open_and_load z pliku xdp-simple.skel.h
co powoduje xdp_simple_bpf__load z pliku xdp-simple.skel.h
co powoduje bpf_object__load_skeleton z pliku libbpf/src/libbpf.c
co powoduje bpf_object__load_xattr z libbpf/src/libbpf.c
Ostatnią funkcją będzie między innymi wywołanie bpf_object__create_maps, który tworzy lub otwiera istniejące mapy, zamieniając je w deskryptory plików. (Tutaj widzimy BPF_MAP_CREATE na wyjściu strace.) Następnie wywoływana jest funkcja bpf_object__relocate i to ona nas interesuje, bo pamiętamy to, co widzieliśmy woo w tabeli relokacji. Badając to, w końcu znajdziemy się w tej funkcji bpf_program__relocate, który i zajmuje się przenoszeniem map:
case RELO_LD64:
insn[0].src_reg = BPF_PSEUDO_MAP_FD;
insn[0].imm = obj->maps[relo->map_idx].fd;
break;
i zamień w nim rejestr źródłowy na BPF_PSEUDO_MAP_FD, a pierwszy IMM do deskryptora pliku naszej mapy i jeśli jest równy np. 0xdeadbeef, w rezultacie otrzymamy instrukcję
18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll
W ten sposób informacje mapowe są przesyłane do konkretnego załadowanego programu BPF. W takim przypadku mapę można utworzyć za pomocą BPF_MAP_CREATEi otwierane według identyfikatora za pomocą BPF_MAP_GET_FD_BY_ID.
Razem, podczas używania libbpf algorytm jest następujący:
podczas kompilacji w tabeli relokacji tworzone są rekordy dla linków do map
libbpf otwiera księgę obiektów ELF, wyszukuje wszystkie używane mapy i tworzy dla nich deskryptory plików
deskryptory plików są ładowane do jądra jako część instrukcji LD64
Jak możesz sobie wyobrazić, jeszcze wiele przed nami i będziemy musieli zajrzeć do rdzenia. Na szczęście mamy wskazówkę – spisaliśmy znaczenie BPF_PSEUDO_MAP_FD do księgi źródłowej i możemy ją zakopać, co doprowadzi nas do miejsca najświętszego wszystkich świętych - kernel/bpf/verifier.c, gdzie funkcja o charakterystycznej nazwie zastępuje deskryptor pliku adresem struktury typu struct bpf_map:
(pełny kod można znaleźć по ссылке). Możemy więc rozszerzyć nasz algorytm:
podczas ładowania programu weryfikator sprawdza poprawność wykorzystania mapy i zapisuje adres odpowiedniej struktury struct bpf_map
Podczas pobierania pliku binarnego ELF za pomocą libbpf Dzieje się dużo więcej, ale o tym porozmawiamy w innych artykułach.
Ładowanie programów i map bez libbpf
Zgodnie z obietnicą, oto przykład dla czytelników, którzy chcą wiedzieć, jak samodzielnie utworzyć i załadować program korzystający z map libbpf. Może to być przydatne, gdy pracujesz w środowisku, dla którego nie możesz tworzyć zależności, zapisywać każdego bitu lub pisać programu takiego jak ply, który na bieżąco generuje kod binarny BPF.
Aby ułatwić śledzenie logiki, przepiszemy nasz przykład w tych celach xdp-simple. Kompletny i nieco rozszerzony kod programu omawianego w tym przykładzie można znaleźć w tym miejscu istota.
Logika naszej aplikacji jest następująca:
utwórz mapę typów BPF_MAP_TYPE_ARRAY za pomocą polecenia BPF_MAP_CREATE,
utwórz program korzystający z tej mapy,
podłącz program do interfejsu lo,
co przekłada się na człowieka jako
int main(void)
{
int map_fd, prog_fd;
map_fd = map_create();
if (map_fd < 0)
err(1, "bpf: BPF_MAP_CREATE");
prog_fd = prog_load(map_fd);
if (prog_fd < 0)
err(1, "bpf: BPF_PROG_LOAD");
xdp_attach(1, prog_fd);
}
Tutaj map_create tworzy mapę w taki sam sposób, jak zrobiliśmy to w pierwszym przykładzie dotyczącym wywołania systemowego bpf - „jądro, proszę o wykonanie nowej mapy w postaci tablicy 8 elementów typu __u64 i zwróć mi deskryptor pliku":
Trudna część prog_load to definicja naszego programu BPF jako tablicy struktur struct bpf_insn insns[]. Ale ponieważ używamy programu, który mamy w C, możemy trochę oszukać:
W sumie musimy napisać 14 instrukcji w postaci struktur typu struct bpf_insn (rada: weź zrzut z góry, przeczytaj ponownie sekcję z instrukcjami, otwórz linux/bpf.h и linux/bpf_common.h i spróbuj ustalić struct bpf_insn insns[] na własną rękę):
Ćwiczenie dla tych, którzy sami tego nie napisali - znajdź map_fd.
W naszym programie pozostała jeszcze jedna nieujawniona część - xdp_attach. Niestety programów takich jak XDP nie można połączyć za pomocą wywołania systemowego bpf. Ludzie, którzy stworzyli BPF i XDP, pochodzili z internetowej społeczności Linuksa, co oznacza, że używali tego, który im najbardziej znany (ale nie normalne People) interfejs do interakcji z jądrem: gniazda sieciowe, Zobacz też RFC3549. Najprostszy sposób wdrożenia xdp_attach kopiuje kod z libbpf, czyli z pliku netlink.c, co właśnie zrobiliśmy, trochę go skracając:
Witamy w świecie gniazd Netlink
Otwórz typ gniazda netlink NETLINK_ROUTE:
int netlink_open(__u32 *nl_pid)
{
struct sockaddr_nl sa;
socklen_t addrlen;
int one = 1, ret;
int sock;
memset(&sa, 0, sizeof(sa));
sa.nl_family = AF_NETLINK;
sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
if (sock < 0)
err(1, "socket");
if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
warnx("netlink error reporting not supported");
if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
err(1, "bind");
addrlen = sizeof(sa);
if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
err(1, "getsockname");
*nl_pid = sa.nl_pid;
return sock;
}
Czytamy z tego gniazda:
static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
bool multipart = true;
struct nlmsgerr *errm;
struct nlmsghdr *nh;
char buf[4096];
int len, ret;
while (multipart) {
multipart = false;
len = recv(sock, buf, sizeof(buf), 0);
if (len < 0)
err(1, "recv");
if (len == 0)
break;
for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
nh = NLMSG_NEXT(nh, len)) {
if (nh->nlmsg_pid != nl_pid)
errx(1, "wrong pid");
if (nh->nlmsg_seq != seq)
errx(1, "INVSEQ");
if (nh->nlmsg_flags & NLM_F_MULTI)
multipart = true;
switch (nh->nlmsg_type) {
case NLMSG_ERROR:
errm = (struct nlmsgerr *)NLMSG_DATA(nh);
if (!errm->error)
continue;
ret = errm->error;
// libbpf_nla_dump_errormsg(nh); too many code to copy...
goto done;
case NLMSG_DONE:
return 0;
default:
break;
}
}
}
ret = 0;
done:
return ret;
}
Na koniec nasza funkcja, która otwiera gniazdo i wysyła do niego specjalną wiadomość zawierającą deskryptor pliku:
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
prog/xdp id 160
Hurra, wszystko działa. Nawiasem mówiąc, nasza mapa jest ponownie wyświetlana w postaci bajtów. Dzieje się tak dlatego, że w odróżnieniu od libbpf nie załadowaliśmy informacji o typie (BTF). Ale o tym porozmawiamy więcej następnym razem.
Narzędzia programistyczne
W tej sekcji przyjrzymy się minimalnemu zestawowi narzędzi programistycznych BPF.
Ogólnie rzecz biorąc, do tworzenia programów BPF nie potrzeba niczego specjalnego - BPF działa na dowolnym jądrze przyzwoitej dystrybucji, a programy są budowane przy użyciu clang, które można dostarczyć z opakowania. Jednak ze względu na to, że BPF jest w fazie rozwoju, jądro i narzędzia ciągle się zmieniają, jeśli nie chcesz pisać programów BPF przestarzałymi metodami z 2019 roku, będziesz musiał skompilować
llvm/clang
pahole
jego rdzeń
bpftool
(Dla porównania, ta sekcja i wszystkie przykłady w artykule zostały uruchomione w Debianie 10.)
llvm/brzęk
BPF jest przyjazny dla LLVM i chociaż ostatnio programy dla BPF można kompilować przy użyciu gcc, cały bieżący rozwój jest wykonywany dla LLVM. Dlatego w pierwszej kolejności zbudujemy obecną wersję clang z gita:
$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86"
-DLLVM_ENABLE_PROJECTS="clang"
-DBUILD_SHARED_LIBS=OFF
-DCMAKE_BUILD_TYPE=Release
-DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$
Teraz możemy sprawdzić czy wszystko zostało poprawnie połączone:
Nie będziemy instalować programów, które właśnie zbudowaliśmy, a zamiast tego po prostu je dodamy PATHna przykład:
export PATH="`pwd`/bin:$PATH"
(Można to dodać do .bashrc lub do osobnego pliku. Osobiście dodaję takie rzeczy do ~/bin/activate-llvm.sh i kiedy trzeba, to robię . activate-llvm.sh.)
Pahole i BTF
Użyteczność pahole używany podczas budowania jądra do tworzenia informacji debugowania w formacie BTF. Nie będziemy w tym artykule szczegółowo omawiać szczegółów technologii BTF, poza tym, że jest ona wygodna i chcemy z niej korzystać. Jeśli więc zamierzasz zbudować jądro, najpierw zbuduj pahole (bez pahole za pomocą tej opcji nie będziesz mógł zbudować jądra CONFIG_DEBUG_INFO_BTF:
$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole
Jądra do eksperymentowania z BPF
Badając możliwości BPF, chcę złożyć własny rdzeń. To w sumie nie jest konieczne, gdyż będziesz mógł kompilować i ładować programy BPF na jądro dystrybucyjne, jednak posiadanie własnego jądra pozwala na korzystanie z najnowszych funkcjonalności BPF, które pojawią się w Twojej dystrybucji najwyżej za kilka miesięcy lub, jak w przypadku niektórych narzędzi do debugowania, w dającej się przewidzieć przyszłości w ogóle nie zostaną spakowane. Ponadto jego własny rdzeń sprawia, że eksperymentowanie z kodem wydaje się ważne.
Do zbudowania jądra potrzebne jest po pierwsze samo jądro, a po drugie plik konfiguracyjny jądra. Aby poeksperymentować z BPF, możemy użyć zwykłego wanilia jądro lub jedno z jąder deweloperskich. Historycznie rzecz biorąc, rozwój BPF odbywa się w społeczności sieciowej Linuksa i dlatego wszystkie zmiany prędzej czy później przechodzą przez Davida Millera, opiekuna sieci Linuksa. W zależności od ich charakteru – edycji lub nowych funkcji – zmiany sieciowe dzielą się na jeden z dwóch rdzeni – net lub net-next. Zmiany dla BPF są rozdzielane w ten sam sposób pomiędzy bpf и bpf-next, które są następnie łączone odpowiednio w klasach net i net-next. Więcej szczegółów znajdziesz w bpf_devel_QA и Netdev — często zadawane pytania. Wybierz więc jądro w oparciu o swój gust i potrzeby stabilności systemu, na którym testujesz (*-next jądra są najbardziej niestabilne z wymienionych).
Omówienie sposobu zarządzania plikami konfiguracyjnymi jądra wykracza poza zakres tego artykułu - zakłada się, że albo już wiesz, jak to zrobić, albo gotowy do nauki na własną rękę. Jednakże poniższe instrukcje powinny w zupełności wystarczyć, aby otrzymać działający system obsługujący BPF.
Pobierz jedno z powyższych jąder:
$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next
Zbuduj minimalną działającą konfigurację jądra:
$ cp /boot/config-`uname -r` .config
$ make localmodconfig
Włącz opcje BPF w pliku .config według własnego wyboru (najprawdopodobniej CONFIG_BPF będzie już włączony, ponieważ system go używa). Oto lista opcji jądra wykorzystanych w tym artykule:
CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y
Wtedy możemy już łatwo złożyć i zainstalować moduły oraz jądro (swoją drogą można złożyć jądro za pomocą nowo zmontowanego clangpoprzez dodanie CC=clang):
$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install
i uruchom ponownie komputer z nowym jądrem (używam do tego kexec z pakietu kexec-tools):
v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e
bpftool
Najczęściej używanym narzędziem w tym artykule będzie narzędzie bpftool, dostarczany jako część jądra Linuksa. Jest napisany i utrzymywany przez programistów BPF dla programistów BPF i może być używany do zarządzania wszystkimi typami obiektów BPF - ładowania programów, tworzenia i edytowania map, odkrywania życia ekosystemu BPF itp. Można znaleźć dokumentację w postaci kodów źródłowych stron podręcznika w rdzeniu lub już skompilowany, sieć.
W momencie pisania tego tekstu bpftool jest gotowy tylko dla RHEL, Fedory i Ubuntu (zobacz na przykład ten wątek, która opowiada niedokończoną historię opakowań bpftool w Debianie). Ale jeśli już zbudowałeś jądro, zbuduj bpftool bułka z masłem:
$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s
Auto-detecting system features:
... libbfd: [ on ]
... disassembler-four-args: [ on ]
... zlib: [ on ]
... libcap: [ on ]
... clang-bpf-co-re: [ on ]
Auto-detecting system features:
... libelf: [ on ]
... zlib: [ on ]
... bpf: [ on ]
$
(Tutaj ${linux} - to jest katalog twojego jądra.) Po wykonaniu tych poleceń bpftool zostaną zebrane w katalogu ${linux}/tools/bpf/bpftool i można go dodać do ścieżki (przede wszystkim do pliku user root) lub po prostu skopiuj do /usr/local/sbin.
zbierać bpftool najlepiej używać tego drugiego clang, zmontuj jak opisano powyżej i sprawdź czy jest poprawnie zmontowany - za pomocą np. polecenia
$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...
który pokaże, które funkcje BPF są włączone w twoim jądrze.
Nawiasem mówiąc, poprzednie polecenie można uruchomić jako
# bpftool f p k
Odbywa się to analogicznie do narzędzi z pakietu iproute2, gdzie możemy na przykład powiedzieć ip a s eth0 zamiast ip addr show dev eth0.
wniosek
BPF pozwala na podbicie pchły, aby skutecznie zmierzyć i na bieżąco zmieniać funkcjonalność rdzenia. System okazał się bardzo udany, nawiązując do najlepszych tradycji UNIX-a: prosty mechanizm pozwalający na (prze)programowanie jądra pozwolił na eksperymenty ogromnej liczbie osób i organizacji. I choć eksperymenty, a także rozwój samej infrastruktury BPF są jeszcze dalekie od zakończenia, system posiada już stabilne ABI, które pozwala budować niezawodną, a co najważniejsze efektywną logikę biznesową.
Chciałbym zauważyć, że moim zdaniem technologia ta stała się tak popularna, ponieważ z jednej strony może grać (architekturę maszyny można zrozumieć mniej więcej w jeden wieczór), a z drugiej strony rozwiązywać problemy, których nie dało się (pięknie) rozwiązać przed jej pojawieniem się. Te dwa elementy razem zmuszają ludzi do eksperymentowania i marzeń, co prowadzi do powstawania coraz bardziej innowacyjnych rozwiązań.
Artykuł ten, choć nie jest szczególnie krótki, stanowi jedynie wprowadzenie do świata BPF i nie opisuje „zaawansowanych” funkcji i ważnych części architektury. Plan na przyszłość jest mniej więcej taki: następny artykuł będzie przeglądem typów programów BPF (w jądrze 5.8 obsługiwanych jest 30 typów programów), następnie w końcu przyjrzymy się, jak pisać prawdziwe aplikacje BPF przy użyciu programów śledzących jądro jako przykład, czas na bardziej szczegółowy kurs na temat architektury BPF, a następnie przykłady sieci BPF i aplikacji zabezpieczających.
Przewodnik referencyjny BPF i XDP — dokumentacja dotycząca BPF z rzęski, a dokładniej od Daniela Borkmana, jednego z twórców i opiekunów BPF. To jeden z pierwszych poważnych opisów, który różni się od pozostałych tym, że Daniel dokładnie wie o czym pisze i nie ma w nim błędów. W szczególności w tym dokumencie opisano sposób pracy z programami BPF typu XDP i TC przy użyciu dobrze znanego narzędzia ip z pakietu iproute2.
Dokumentacja/sieć/filter.txt — oryginalny plik z dokumentacją dla wersji klasycznej i później rozszerzonej BPF. Dobra lektura, jeśli chcesz zagłębić się w język asemblera i techniczne szczegóły architektoniczne.
Blog o BPF na Facebooku. Jest aktualizowany rzadko, ale trafnie, jak piszą tam Aleksiej Starovoitov (autor eBPF) i Andrii Nakryiko - (opiekun) libbpf).
Sekrety bpftoola. Zabawny wątek na Twitterze Quentina Monneta z przykładami i sekretami korzystania z bpftool.