Une brève introduction à BPF et eBPF

Hé Habr ! Nous vous informons que nous préparons la sortie d'un livre"Observabilité Linux avec BPF".

Une brève introduction à BPF et eBPF
Comme la machine virtuelle BPF continue d'évoluer et est activement utilisée dans la pratique, nous avons traduit pour vous un article décrivant ses principales fonctionnalités et son état actuel.

Ces dernières années, les outils et techniques de programmation ont gagné en popularité pour compenser les limitations du noyau Linux dans les cas où un traitement de paquets hautes performances est requis. L'une des méthodes les plus populaires de ce type s'appelle contournement du noyau (contournement du noyau) et permet, en sautant la couche réseau du noyau, d'effectuer tout le traitement des paquets depuis l'espace utilisateur. Le contournement du noyau implique également la gestion de la carte réseau à partir de espace utilisateur. En d'autres termes, lorsque vous travaillez avec une carte réseau, nous nous appuyons sur le pilote espace utilisateur.

En transférant le contrôle total de la carte réseau à un programme en espace utilisateur, nous réduisons la surcharge causée par le noyau (commutateurs de contexte, traitement de la couche réseau, interruptions, etc.), ce qui est assez important lors de l'exécution à des vitesses de 10 Gb/s ou plus haut. Contourner le noyau plus une combinaison d'autres fonctionnalités (le traitement par lots) et un réglage minutieux des performances (Comptabilité NUMA, Isolation du processeur, etc.) correspondent aux bases de la mise en réseau haute performance de l'espace utilisateur. Peut-être qu'un exemple exemplaire de cette nouvelle approche du traitement des paquets est DPDKName d'Intel (Kit de développement de plan de données), bien qu'il existe d'autres outils et techniques bien connus, notamment VPP de Cisco (Vector Packet Processing), Netmap et, bien sûr, croquer.

L'organisation des interactions réseau dans l'espace utilisateur présente un certain nombre d'inconvénients :

  • Un noyau de système d'exploitation est une couche 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 programmer vos propres pilotes.
  • Puisque nous abandonnons complètement l'espace du noyau, nous abandonnons également toutes les fonctionnalités de mise en réseau fournies par le noyau. Les programmes de l'espace utilisateur doivent réimplémenter des fonctionnalités qui peuvent déjà être fournies par le noyau ou le système d'exploitation.
  • Les programmes fonctionnent en mode bac à sable, ce qui limite considérablement leur interaction et les empêche de s'intégrer à d'autres parties du système d'exploitation.

Essentiellement, lors de la mise en réseau dans l'espace utilisateur, les gains de performances sont obtenus 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, convertisseurs, routage, etc.) vers la zone du noyau. XDP nous permet d'exécuter la fonction réseau dès que le paquet atteint l'interface réseau et avant qu'il ne commence à remonter jusqu'au sous-système réseau du noyau. En conséquence, la vitesse de traitement des paquets est considérablement augmentée. Cependant, comment le noyau permet-il à l'utilisateur d'exécuter ses programmes dans l'espace du noyau ? Avant de répondre à cette question, regardons ce qu'est le BPF.

BPF et eBPF

Malgré son nom pas tout à fait clair, BPF (Packet Filtering, Berkeley) est en fait un modèle de machine virtuelle. Cette machine virtuelle a été conçue à l'origine pour gérer le filtrage de paquets, d'où son nom.

L'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 le filtrage de paquets. Seuls les paquets correspondant à cette expression seront capturés. Par exemple, l'expression "tcp dst port 80» fait référence à tous les paquets TCP arrivant sur le port 80. Le compilateur peut raccourcir cette expression en la convertissant 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

C'est essentiellement ce que fait le programme ci-dessus:

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

Et ainsi de suite, jusqu'à ce que le programme de filtrage de paquets renvoie un résultat. Il s'agit généralement d'un booléen. Le renvoi d'une valeur différente de zéro (instruction (014)) signifie que le paquet correspond, et le renvoi de zéro (instruction (015)) signifie que le paquet ne correspond pas.

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

Parce que BPF est une machine virtuelle, il définit l'environnement dans lequel les programmes s'exécutent. En plus du bytecode, il définit également un modèle de mémoire de paquet (les instructions de chargement sont implicitement appliquées à un paquet), des registres (A et X; registres d'accumulateur et d'index), un stockage de mémoire de travail et un compteur de programme implicite. Fait intéressant, le bytecode BPF a été calqué sur le Motorola 6502 ISA. Comme Steve McCann l'a rappelé dans son rapport en plénière au Sharkfest '11, il connaissait la version 6502 du lycée lors de la programmation sur l'Apple II, et cette connaissance a influencé son travail de conception du bytecode BPF.

La prise en charge de BPF est implémentée dans le noyau Linux dans la version v2.5 et ultérieure, ajoutée principalement par Jay Schullist. Le code BPF est resté inchangé jusqu'en 2011, date à laquelle Eric Dumaset a repensé l'interpréteur BPF pour qu'il fonctionne en mode JIT (Source : JIT pour les filtres de paquets). Après cela, au lieu d'interpréter le bytecode BPF, le noyau pourrait convertir directement les programmes BPF vers l'architecture cible : x86, ARM, MIPS, etc.

Plus tard, en 2014, Alexei 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 s'appelait eBPF. Je pense que les deux machines virtuelles ont coexisté pendant un certain temps, mais le filtrage de paquets est actuellement implémenté au-dessus d'eBPF. En fait, dans de nombreux exemples de documentation moderne, BPF est appelé eBPF, et BPF classique est aujourd'hui connu sous le nom de cBPF.

eBPF étend la machine virtuelle BPF classique de plusieurs manières :

  • S'appuie sur des architectures 64 bits modernes. eBPF utilise des registres 64 bits et augmente le nombre de registres disponibles de 2 (accumulateur et X) à 10. eBPF fournit également des opcodes supplémentaires (BPF_MOV, BPF_JNE, BPF_CALL…).
  • Détaché du sous-système de la couche réseau. BPF était lié au modèle de données par lots. Puisqu'il était utilisé pour filtrer les paquets, son code se trouvait dans le sous-système qui fournissait les interactions réseau. Cependant, la machine virtuelle eBPF n'est plus liée à un modèle de données et peut être utilisée à n'importe quelle fin. Ainsi, maintenant le programme eBPF peut être connecté à tracepoint ou à kprobe. Cela ouvre la porte à l'instrumentation eBPF, à l'analyse des performances et à de nombreux autres cas d'utilisation dans le contexte d'autres sous-systèmes du noyau. Maintenant, le code eBPF se trouve dans son propre chemin : kernel/bpf.
  • Magasins de données mondiaux appelés Maps. Les cartes sont des magasins clé-valeur qui permettent l'échange de données entre l'espace utilisateur et l'espace noyau. eBPF propose plusieurs types de cartes.
  • Fonctions secondaires. En particulier, pour écraser un package, calculer une somme de contrôle ou cloner un package. Ces fonctions s'exécutent à l'intérieur du noyau et n'appartiennent pas aux programmes de l'espace utilisateur. De plus, des appels système peuvent être effectués à partir de programmes eBPF.
  • Terminez les appels. La taille du programme dans eBPF est limitée à 4096 octets. La fonction de fin d'appel permet à un programme eBPF de transférer le contrôle à un nouveau programme eBPF et ainsi de contourner cette limitation (jusqu'à 32 programmes peuvent être enchaînés de cette façon).

Exemple eBPF

Il existe plusieurs exemples d'eBPF dans les sources du noyau Linux. Ils sont disponibles sur samples/bpf/. Pour compiler ces exemples, tapez simplement :

$ sudo make samples/bpf/

Je n'écrirai pas moi-même un nouvel exemple pour eBPF, mais j'utiliserai l'un des exemples disponibles dans samples/bpf/. Je vais regarder certaines parties du code et expliquer comment cela fonctionne. A titre d'exemple, j'ai choisi le programme tracex4.

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

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

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

J'ai mentionné plus haut que l'une des fonctionnalités les plus intéressantes d'eBPF est 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 est l'un des nombreux types de cartes proposés par eBPF. Dans ce cas, c'est juste un hachage. Vous avez peut-être aussi remarqué l'annonce SEC("maps"). SEC est une macro utilisée pour créer une nouvelle section d'un fichier binaire. En fait, dans l'exemple tracex4_kern deux autres sections sont définies :

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

Ces deux fonctions permettent de supprimer une entrée de la carte (kprobe/kmem_cache_free) et 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 vide les sections du fichier objet, je devrais voir que ces nouvelles sections sont déjà définies :

$ 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

Il y a encore tracex4_user.c, programme principal. Fondamentalement, ce programme écoute les événements kmem_cache_alloc_node. Lorsqu'un tel événement se produit, le code eBPF correspondant est exécuté. Le code enregistre l'attribut IP de l'objet sur une carte, puis l'objet est mis en boucle dans le programme principal. Exemple:

$ 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

Quel est le lien entre le programme de l'espace utilisateur et le programme eBPF ? A l'initialisation tracex4_user.c charge le fichier objet tracex4_kern.o en utilisant la fonction 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;
}

En faisant load_bpf_file les sondes définies dans le fichier eBPF sont ajoutées à /sys/kernel/debug/tracing/kprobe_events. Maintenant, nous écoutons ces événements et notre programme peut faire quelque chose quand ils 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 de sample/bpf/ sont structurés de la même manière. Ils contiennent toujours deux fichiers :

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

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

Conclusion

Dans cet article, BPF et eBPF ont été abordés en termes généraux. Je sais qu'il existe aujourd'hui beaucoup d'informations et de ressources sur eBPF, je recommanderai donc quelques documents supplémentaires pour une étude plus approfondie.

Je recommande de lire:

Source: habr.com

Ajouter un commentaire