Introduction au BPF et à l'eBPF

Bonjour, Habr! Nous vous informons qu'un livre intitulé "Linux Observability with BPF".

Introduction au BPF et à l'eBPF
Étant donné que la machine virtuelle BPF continue d'évoluer et est activement utilisée en pratique, nous avons traduit pour vous un article décrivant ses principales fonctionnalités et son état actuel.

Ces dernières années, des outils de programmation et des techniques visant à compenser les limitations du noyau Linux, lorsqu'un traitement de paquets à haute performance est requis, ont gagné en popularité. L'une des techniques les plus populaires de ce type s'appelle bypass du noyau (kernel bypass) et permet, en contournant le niveau réseau du noyau, d'effectuer tout le traitement des paquets depuis l'espace utilisateur. Le bypass du noyau implique également la gestion de la carte réseau depuis l'espace utilisateur. En d'autres termes, lorsque nous travaillons avec la carte réseau, nous nous appuyons sur le pilote l'espace utilisateur.

En transférant le contrôle total de la carte réseau à un programme de l'espace utilisateur, nous réduisons les coûts associés au fonctionnement du noyau (changement de contexte, traitement du niveau réseau, interruptions, etc.), ce qui est assez important lorsque l'on travaille à des vitesses de 10 Gb/s ou plus. Le bypass du noyau, associé à une combinaison d'autres fonctionnalités (traitement par lots) et un réglage minutieux des performances (prise en compte de NUMA, isolement des CPU, etc.) correspondent aux fondamentaux du traitement réseau à haute performance dans l'espace utilisateur. Un exemple emblématique de cette nouvelle approche du traitement des paquets est DPDK de Intel (Data Plane Development Kit), bien qu'il existe d'autres outils et techniques largement connus, parmi lesquels VPP de Cisco (Vector Packet Processing), Netmap et, bien sûr, Snabb.

L'organisation des interactions réseau dans l'espace utilisateur présente plusieurs inconvénients :

  • Le noyau OS est un niveau d'abstraction pour les ressources matérielles. Étant donné que les programmes de l'espace utilisateur doivent gérer directement leurs ressources, ils doivent également gérer leur propre matériel. Cela signifie souvent la nécessité de programmer leurs propres pilotes.
  • Puisque nous renonçons complètement à l'espace noyau, nous renonçons également à toutes les fonctionnalités réseau fournies par le noyau. Les programmes de l'espace utilisateur doivent donc réimplémenter les fonctions qui sont éventuellement déjà fournies par le noyau ou le système d'exploitation.
  • Les programmes fonctionnent en mode bac à sable, ce qui limite considérablement leurs capacités d'interaction et les empêche de s'intégrer avec d'autres parties du système d'exploitation.

En fait, lors de l'organisation des interactions réseau dans l'espace utilisateur, l'augmentation des performances est obtenue en déplaçant le traitement des paquets du noyau vers l'espace utilisateur. XDP fait exactement le contraire : il déplace les programmes réseau de l'espace utilisateur (filtres, transformateurs, routage, etc.) vers l'espace noyau. XDP nous permet d'exécuter une fonction réseau dès qu'un paquet atteint l'interface réseau et avant qu'il ne commence à remonter dans la sous-système réseau du noyau. En conséquence, la vitesse de traitement des paquets augmente considérablement. Cependant, comment le noyau permet-il à l'utilisateur d'exécuter ses programmes dans l'espace noyau ? Avant de répondre à cette question, examinons ce qu'est BPF.

BPF et eBPF

Malgré son nom peu clair, BPF (Filtrage de paquets de Berkeley) est en fait un modèle de machine virtuelle. Cette machine virtuelle a été initialement conçue pour le filtrage de paquets, d'où son nom.

Un des outils les plus connus utilisant BPF est tcpdump. Lors de la capture de paquets avec tcpdump l'utilisateur peut spécifier une expression pour filtrer les paquets. Seuls les paquets correspondant à cette expression seront capturés. Par exemple, l'expression “tcp dst port 80” concerne tous les paquets TCP entrant sur le port 80. Le compilateur peut réduire cette expression en la transformant en bytecode 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

Voici ce que fait fondamentalement le programme ci-dessus :

  • Instruction (000) : charge le paquet avec un décalage de 12, sous la forme d'un mot de 16 bits dans l'accumulateur. Le décalage de 12 correspond à l'ethertype du paquet.
  • Instruction (001) : compare la valeur dans l'accumulateur à 0x86dd, c'est-à-dire à la valeur d'ethertype pour IPv6. Si le résultat est vrai, le compteur du programme passe à l'instruction (002), sinon à (006).
  • Instruction (006) : compare la valeur à 0x800 (valeur de l'ethertype pour IPv4). Si la réponse est true, le programme passe à (007), sinon à (015).

Et cela jusqu'à ce que le programme de filtrage des paquets renvoie un résultat. En général, il s'agit d'un booléen. Un retour de valeur non nulle (instruction (014)) signifie que le paquet correspond, et un retour nul (instruction (015)) signifie que le paquet ne correspond pas.

La machine virtuelle BPF et son bytecode ont été proposées par Steve McCanne et Van Jacobson à la fin de 1992, lorsque leur article a été publié. Filtre de paquets BSD : une nouvelle architecture pour la capture de paquets au niveau utilisateur., cette technologie a été présentée pour la première fois lors de la conférence Usenix à l'hiver 1993.

Étant donné que BPF est une machine virtuelle, elle définit l'environnement dans lequel les programmes s'exécutent. En plus du bytecode, elle définit également le modèle de mémoire des paquets (les instructions de chargement s'appliquent implicitement au paquet), les registres (A et X ; registres accumulateur et index), le stockage de mémoire temporaire, et un compteur de programmes implicite. Il est intéressant de noter que le bytecode BPF a été modelé d'après l'ISA Motorola 6502. Comme Steve McCanne s'en souvenait dans son discours d'ouverture à Sharkfest ‘11, il était familier avec le montage 6502 depuis ses années de lycée, lorsqu'il programait sur Apple II, et ces connaissances ont influencé son travail sur la conception du bytecode BPF.

Le support de BPF a été implémenté dans le noyau Linux à partir de la version v2.5 et plus, principalement grâce aux efforts de Jay Sullivan. Le code BPF est resté sans changements significatifs jusqu'en 2011, lorsque Eric Dumazet a révisé l'interpréteur BPF pour fonctionner en mode JIT (Source : JIT pour les filtres de paquets). Après cela, le noyau a pu transformer directement les programmes BPF pour l'architecture cible : x86, ARM, MIPS, etc.

Plus tard, en 2014, Alexey Starovoitov a proposé un nouveau mécanisme JIT pour BPF. En fait, ce nouveau JIT est devenu une nouvelle architecture basée sur BPF et a été nommé eBPF. Je pense que pendant un certain temps, les deux machines virtuelles ont coexister, mais actuellement, le filtrage des paquets est réalisé sur la base de eBPF. En fait, dans de nombreux exemples de la documentation moderne, BPF désigne eBPF, et le BPF classique est aujourd'hui connu sous le nom de cBPF.

eBPF étend la machine virtuelle BPF classique à plusieurs égards :

  • S'appuie sur des architectures modernes à 64 bits. eBPF utilise des registres 64 bits et augmente le nombre de registres disponibles de 2 (accumulateur et X) à 10. eBPF fournit également des codes d'opération supplémentaires (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Détaché du sous-système de niveau réseau. BPF était lié au modèle de données par paquets. Étant donné qu'il était utilisé pour filtrer des paquets, son code se trouvait dans le sous-système assurant les interactions réseau. Cependant, la machine virtuelle eBPF n'est plus liée au modèle de données et peut être utilisée à des fins diverses. Ainsi, un programme eBPF peut désormais être connecté à un tracepoint ou à un kprobe. Cela ouvre la voie à l'instrumentation eBPF, à l'analyse de performance et à de nombreuses autres options d'utilisation dans le contexte d'autres sous-systèmes du noyau. Le code eBPF est maintenant situé sur son propre chemin : kernel/bpf.
  • Des magasins de données globaux appelés Cartes. Les Cartes sont des stores de type "clé-valeur" qui assurent l'échange de données entre l'espace utilisateur et l'espace noyau. eBPF fournit des cartes de plusieurs types.
  • Fonctions auxiliaires. En particulier, pour réécrire un paquet, calculer un contrôle de somme ou cloner un paquet. Ces fonctions s'exécutent à l'intérieur du noyau et ne concernent pas les programmes de l'espace utilisateur. De plus, des appels système peuvent être effectués depuis les programmes eBPF.
  • Appels de fin. La taille d'un programme en eBPF est limitée à 4096 octets. La possibilité d'appel de fin permet à un programme eBPF de transférer l'exécution à un nouveau programme eBPF, contourner ainsi cette limite (jusqu'à 32 programmes peuvent être liés).

eBPF : exemple

Dans les sources du noyau Linux, il existe plusieurs exemples pour eBPF. Ils sont disponibles à l'adresse samples/bpf/. Pour compiler ces exemples, entrez simplement :

$ sudo make samples/bpf/

Je ne vais pas créer moi-même un nouvel exemple pour eBPF, mais je vais utiliser l'un des échantillons disponibles dans samples/bpf/. Je vais examiner certaines sections du code et expliquer comment cela fonctionne. Pour cet exemple, j'ai choisi le programme tracex4.

En général, chaque exemple dans samples/bpf/ se compose de deux fichiers. Dans ce cas :

  • tracex4_kern.c, contient le code source qui doit s'exécuter dans le noyau en tant que bytecode eBPF.
  • tracex4_user.c, contient le programme de l'espace utilisateur.

Dans ce cas, nous devons compiler tracex4_kern.c dans le bytecode eBPF. Actuellement, il n'y a pas gcc de partie serveur pour eBPF. Heureusement, clang peut produire du bytecode eBPF. Makefile utilise clang pour la compilation tracex4_kern.c en fichier objet.

J'ai mentionné précédemment qu'une des caractéristiques les plus intéressantes de l'eBPF sont les cartes. tracex4_kern définit une carte :

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 – l'un des nombreux types de cartes offerts par l'eBPF. Dans ce cas, il s'agit simplement d'un hachage. Vous avez également pu remarquer la déclaration SEC("maps"). SEC est un macro utilisé pour créer une nouvelle section dans un fichier binaire. En fait, dans l'exemple, tracex4_kern définit encore deux sections :

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;

    // obtenons l'adresse IP de l'appelant 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;
}   

Ces deux fonctions permettent de supprimer une entrée de la carte (kprobe/kmem_cache_free) et d'ajouter une nouvelle entrée à la carte (kretprobe/kmem_cache_alloc_node). Tous les noms de fonctions, écrits en majuscules, correspondent à des macros définies dans bpf_helpers.h.

Si je devais faire un dump des sections du fichier objet, je devrais voir que ces nouvelles sections sont déjà définies :

$ objdump -h tracex4_kern.o

tracex4_kern.o : format de fichier elf64-little

Sections :
Idx Nom Taille VMA LMA Décalage de fichier Algn
0 .text 00000000 0000000000000000 0000000000000000 00000040 2**2
CONTENU, ALOUER, CHARGER, EN_LECTURE_SEULE, CODE
1 kprobe/kmem_cache_free 00000048 0000000000000000 0000000000000000 00000040 2**3
CONTENU, ALOUER, CHARGER, RELOCALISER, EN_LECTURE_SEULE, CODE
2 kretprobe/kmem_cache_alloc_node 000000c0 0000000000000000 0000000000000000 00000088 2**3
CONTENU, ALOUER, CHARGER, RELOCALISER, EN_LECTURE_SEULE, CODE
3 maps 0000001c 0000000000000000 0000000000000000 00000148 2**2
CONTENU, ALOUER, CHARGER, DONNÉES
4 licence 00000004 0000000000000000 0000000000000000 00000164 2**0
CONTENU, ALOUER, CHARGER, DONNÉES
5 version 00000004 0000000000000000 0000000000000000 00000168 2**2
CONTENU, ALOUER, CHARGER, DONNÉES
6 .eh_frame 00000050 0000000000000000 0000000000000000 00000170 2**3
CONTENU, ALOUER, CHARGER, RELOCALISER, EN_LECTURE_SEULE, DONNÉES

Il y a aussi tracex4_user.c, le programme principal. En principe, ce programme écoute les événements kmem_cache_alloc_node. Lorsqu'un tel événement se produit, le code eBPF correspondant s'exécute. Le code enregistre l'attribut IP de l'objet dans la carte, et ensuite cet objet est affiché en boucle dans le programme principal. Exemple :

$ sudo ./tracex4
l'objet 0xffff8d6430f60a00 a 2 secondes, a été alloué à l'ip ffffffff9891ad90
l'objet 0xffff8d6062ca5e00 a 23 secondes, a été alloué à l'ip ffffffff98090e8f
l'objet 0xffff8d5f80161780 a 6 secondes, a été alloué à l'ip ffffffff98090e8f

Comment le programme d'espace utilisateur est-il lié au programme eBPF ? Lors de l'initialisation, tracex4_user.c charge le fichier objet tracex4_kern.o en utilisant la fonction charger_fichier_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;
}

En cours d'exécution charger_fichier_bpf les sondes, définies dans le fichier eBPF, sont ajoutées à /sys/kernel/debug/tracing/kprobe_events. Nous écoutons maintenant ces événements, et notre programme peut agir lorsque ceux-ci se produisent.

$ 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

Tous les autres programmes dans sample/bpf/ sont structurés de manière similaire. Ils contiennent toujours deux fichiers :

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

Le programme eBPF définit des cartes et des fonctions liées à une section. Lorsque le noyau émet un événement d'un certain type (par exemple, tracepoint), les fonctions associées sont exécutées. Les cartes permettent l'échange de données entre le programme noyau et le programme en espace utilisateur.

Conclusion

Cet article a brièvement examiné BPF et eBPF. Je sais qu'il existe aujourd'hui une multitude d'informations et de ressources sur eBPF, donc je vais recommander quelques matériaux supplémentaires pour un apprentissage plus approfondi.

Je recommande de lire :

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster