March 3, 2019

Introduction to eBPF and XDP

English version here
Cela fait un moment que j’entends parler de eBPF et de XDP (eXpress Data Path) mais je n’avais pas vraiment eu l’occasion de jouer avec. J’ai donc décidé comme projet de week-end d’écrire un programme XDP le plus simple possible permettant de filtrer les paquets pour une addresse IP donnée. Je présenterais comment ce programme fonctionne, comment le compiler et l’exécuter.

eBPF et XDP

Je ne connais pas encore dans le détail ces technologies, donc n’hésitez pas à me remonter mes eventuelles erreurs.

eBPF est une technologie du kernel Linux permettant d’écrire des programmes qui seront compilés en bytecode BPF. Ce bytecode est ensuite vérifié (certaines erreurs comme une lecture sans vérifier si la valeur lue est null ne sont pas possibles empêchent la compilation) et exécuté dans une machine virtuelle présente dans le kernel.

eBPF peut être utilisé pour écrire des outils de monitoring (en attachant le programme pour réagir à des événements comme des appels systèmes par exemple). Ici, l’intêret de eBPF est son faible impact sur les performances du système que l’on instrumente.

Mais eBPF permet aussi d’intéragir avec le réseau avec XDP (comme par exemple pour écrire un load balancer ou un firewall). Ici aussi, l’intêret de XDP est qu’il s’exécute au plus prêt du hardware et permet donc d’atteindre de très bonnes performances.

Voici une collection de liens avec plus d’informations sur ces sujets:

Je ne sais pas pour vous, mais pour moi eBPF et XDP sont clairement le genre de technologies où même après lecture de 20 articles sur le sujet, je ne suis pas vraiment sûr de bien comprendre ce qu’il se passe vraiment. J’ai donc décidé de pratiquer un maximum pour mieux comprendre tout cela, et cet article sera (j’espère !) le premier d’une longue série.

Installation

Pour compiler un programme BPF, le plus simple semble de compiler directement le programme dans l’arborescence du kernel Linux.

On commence donc par cloner le kernel avec un git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git.

J’ai également dû installer sur ma machine (Debian) un certain nombre de paquets. Peut être que vous en aurez d’autres à installer (vous le saurez à la compilation): apt-get install bison clang flex libelf-dev llvm.

Allez maintenant à la racine du projet Linux, et exécutez make headers_install puis make menuconfig (pour cette phase, j’ai tout simplement laissé la configuration par défaut).

Vous devriez maintenant pouvoir compiler les programmes BPF inclus dans le kernel avec make samples/bpf/ (attention, le / est important à la fin) sans erreurs.

Mise en place du projet.

Comme dit précédemment, mon but est d’écrire un programme permettant de filtrer tous les paquets venant d’une addresse IP donnée sur l’interface localhost. Le nom de mon programme sera xdp_ip_filter.

     Makefile

Nous allons tout d’abord rajouter dans le fichier samples/bpf/Makefile les instructions pour compiler notre futur programme. Vous verrez dans ce fichier de multiples déclarations commençant par hostprogs-y, rajouter la ligne hostprogs-y += xdp_ip_filter.

De la même façon, rajoutez la ligne xdp_ip_filter-objs := bpf_load.o xdp_ip_filter_user.o à l’endroit où se trouve les déclarations commençant par xdp_, puis always += xdp_ip_filter_kern.o un peu plus loin.

Le Makefile est maintenant prêt.

     Les fichiers du projet

Nous allons travailler dans deux fichiers samples/bpf/xdp_ip_filter_kern.c et samples/bpf/xdp_ip_filter_user.c. Le fichier kern contiendra le code qui sera compilé en bytecode BPF, le fichier user sera notre point d’entrée pour démarrer ce dernier. Je me référerais souvent à ces fichiers par les noms abrégés user ou kern.

Le code de ces fichiers est disponible à ces deux emplacements:

Il faut savoir que je n’ai pas fait de C depuis très longtemps (et j’ai jamais pratiqué le C à haut niveau), donc mon code est assez moche (mais ce n’est pas très grave pour cet exemple ¯_(ツ)_/¯).
Je vous conseille également de lire cet article en ayant ouvert dans votre éditeur favoris les deux fichiers.

xdp_ip_filter_kern.c

Après la déclaration des headers (que vous pouvez retrouver dans les liens mis au dessus), nous avons une première macro:

#define bpf_printk(fmt, ...)                    \
({                              \
           char ____fmt[] = fmt;                \
           bpf_trace_printk(____fmt, sizeof(____fmt),   \
                ##__VA_ARGS__);         \
})

Cette macro sera utilisée comme logger. Son fonctionnement n’est pas important.

     Les maps

On a maintenant une partie plus intéressante:

struct bpf_map_def SEC("maps") ip_map = {
	.type        = BPF_MAP_TYPE_HASH,
	.key_size    = sizeof(__u32),
	.value_size  = sizeof(__u32),
	.max_entries = 1,
};

struct bpf_map_def SEC("maps") counter_map = {
	.type        = BPF_MAP_TYPE_PERCPU_ARRAY,
	.key_size    = sizeof(__u32),
	.value_size  = sizeof(__u64),
	.max_entries = 1,
};

On définit ici deux maps. Ces maps sont donc des associations clé/valeurs, et c’est ces maps qui sont utilisées pour intéragir avec l’extérieur (notre fichier user que je présenterais tout à l’heure). Le programme user pourra lire et écrire dans ces maps, même chose pour le programme kern. Vous pouvez donc voir les maps comme de la mémoire partagée entre les deux programmes, et c’est d’ailleurs à ma connaissance la seule façon de faire communiquer ces programmes entre eux.

La première map ip_map est une map de type BPF_MAP_TYPE_HASH (voyez ça comme une map classique), dont les clés et valeurs sont des u32 (en effet, une addresse IP v4 peut être représentée sous forme d’un simple integer). Cette map ne peut contenir qu’une entrée (cf max_entries).
Cette map servira au programme user à transmettre au programme kern l’adresse IP à filtrer (et ici, on ne filtre qu’une IP donc la map n’aura qu’une entrée).

La seconde map nommée counter_map est une map de type BPF_MAP_TYPE_PERCPU_ARRAY. Ce type indique que l’on aura en fait une map par core de notre CPU (si vous avez 8 cores, vous aurez 8 maps). Ces map serviront à compter par core combien de paquets ont été filtrés. Le type ARRAY indique également que la clé de notre map sera entre 0 et max_entries -1 (donc dans notre cas nous n’aurons qu’une entrée pour la clé 0). On aura donc pour chaque core une map dont la valeur pour la clé 0 contiendra le nombre de paquets filtrés par ce core.

maps xdm et abpf

     Le code

Récupération de l’IP à filtrer

Ici, nous avons une fonction prenant en paramètre une struct xdp_md. Cette struct contiendra le paquet réseau avec lequel nous allong intéragir.

SEC("xdp_ip_filter")
int _xdp_ip_filter(struct xdp_md *ctx) {
  // key of the maps
  u32 key = 0;
  // the ip to filter
  u32 *ip;

  bpf_printk("starting xdp ip filter\n");

  // get the ip to filter from the ip_filtered map
  ip = bpf_map_lookup_elem(&ip_map, &key);
  if (!ip){
    return XDP_PASS;
  }
  bpf_printk("the ip address to filter is %u\n", ip);

La première chose à faire est de récupérer dans la map ip_map l’ip addresse que nous voulons filtrer. Pour cela, nous utilisons bpf_map_lookup_elem sur ip_map avec comme clé 0 (rappelez vous, notre map n’a qu’un élément). Comme dit précédemment, l’IP retournée par bpf_map_lookup_elem est sous format u32 en little endian (par exemple 192.168.1.78 ⇒ 0xC0A8014E en hexadécimal ⇒ on lit à l’envers ⇒ 0x4E0180C0 ⇒ 1308721344 en base 10).
Vous pouvez voir également que j’utilise bpf_printk comme un logger.

lookup map ebpf

Récupération de l’IP source du paquet

Maintenant, nous voulons récupérer l’adresse IP source du paquet.

  void *data_end = (void *)(long)ctx->data_end;
  void *data     = (void *)(long)ctx->data;
  struct ethhdr *eth = data;

  // check packet size
  if (eth + 1 > data_end) {
    return XDP_PASS;
  }

  // check if the packet is an IP packet
  if(ntohs(eth->h_proto) != ETH_P_IP) {
    return XDP_PASS;
  }

  // get the source address of the packet
  struct iphdr *iph = data + sizeof(struct ethhdr);
  if (iph + 1 > data_end) {
    return XDP_PASS;
  }
  u32 ip_src = iph->saddr;
  bpf_printk("source ip address is %u\n", ip_src);

Nous commençons par récupérer la donnée contenue dans ctx grâce à ctx→data, et un pointer sur la fin du paquet via (void *)(long)ctx→data_end. Ensuite, on assigne data à une struct de type ethhdr (représentant une trame Ethernet).

On doit ensuite vérifier que eth + 1 ne dépasse pas data_end en mémoire. Ce check est obligatoire (sans cela, le programme refuse de compiler). Si la taille est supérieuse, on ne fait rien (on laisse passer le paquet en retournant XDP_PASS).

On vérifie ensuite que le paquet est un paquet IP via if(ntohs(eth→h_proto) != ETH_P_IP). Si le paquet n’est pas un paquet IP, il ne nous intéresse pas, donc là aussi on retourne XDP_PASS.

Nous créons maintenant une nouvelle struct de type iphdr à partir de la struct ethernet, nous refaisons également une vérification (obligatoire) sur data_end, puis nous récupérons enfin l’IP source du paquet via iph→saddr.

Filtrer le paquet

On a maintenant l’IP source, nous allons la comparer avec l’IP que nous avons récupérée dans la map en début de programme:

  // drop the packet if the ip source address is equal to ip
  if (ip_src == *ip) {
    u64 *filtered_count;
    u64 *counter;
    counter = bpf_map_lookup_elem(&counter_map, &key);
    if (counter) {
      *counter += 1;
    }
    return XDP_DROP;
  }
  return XDP_PASS;
}

Ici, on compare ip_src avec ip. Si le paquet source doit être filtré, on incrémente dans la map counter_map le compteur de paquet filtré (en utilisant encore la clé 0) via bpf_map_lookup_elem (qui retourne un pointeur dont on peut donc incrémenter la valeur), et on filtre le paquet en retournant XDP_DROP. Sinon, on retourne XDP_PASS.

C’est tout pour le programme kern !

xdp_ip_filter_user.c

     Le code

Ce fichier commence comme l’autre par l’inclusion de nombreux fichiers headers, puis de:

static int ifindex = 1; // localhost interface ifindex
static __u32 xdp_flags = 0;

// unlink the xdp program and exit
static void int_exit(int sig) {
  printf("stopping\n");
  bpf_set_link_xdp_fd(ifindex, -1, xdp_flags);
  exit(0);
}

On définit ici une variable ifindex qui est l’index de l’interface localhost (je parlerais plus en détail de cela plus loin), puis xdp_flags qui vaut zéro.

La fonction int_exit est une fonction servant à stopper le programme kern en cas de signal, via bpf_set_link_xdp_fd.

La fonction main, récupération de l’IP

Voici maintenant la fonction main qui sera exécutée pour démarrer notre programme BPF:

int main(int argc, char **argv) {
  const char *optstr = "i:";
  char *filename="xdp_ip_filter_kern.o";
  char *ip_param = "127.0.0.1";
  int opt;
  // maps key
  __u32 key = 0;

  while ((opt = getopt(argc, argv, optstr)) != -1) {
    switch(opt)
      {
      case 'i':
        ip_param=optarg;
      break;
    }
  }

  // convert the ip string to __u32
  struct sockaddr_in sa_param;
  inet_pton(AF_INET, ip_param, &(sa_param.sin_addr));
  __u32 ip = sa_param.sin_addr.s_addr;
  printf("the ip to filter is %s/%u\n", ip_param, ip);

Ici, on définit quelques variables comme les paramètres attendues à main, le nom du fichier .o (xdp_ip_filter_kern.o) qui devra être lancé, et une valeur par défaut pour l’IP à filtrer (127.0.0.1).

On récupère l’IP à filtrer (qui sera passé via l’option -i au programme), et on la convertit en un u32 (par exemple "192.168.1.78" ⇒ 0xC0A8014E ⇒ on lit à l’envers ⇒ 0x4E0180C0 ⇒ 1308721344 en base 10).

Changements de limits

On voit dans beaucoup de programmes eBPF que les limites du système sont augmentées, j’ai laissé ce comportement:

// change limits
  struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
  if (setrlimit(RLIMIT_MEMLOCK, &r)) {
    perror("setrlimit(RLIMIT_MEMLOCK, RLIM_INFINITY)");
    return 1;
  }

Chargement du programme eBPF

// load the bpf kern file
  if (load_bpf_file(filename)) {
    printf("error %s", bpf_log_buf);
    return 1;
  }

  if (!prog_fd[0]) {
    printf("load_bpf_file: %s\n", strerror(errno));
    return 1;
  }

  // add sig handlers
  signal(SIGINT, int_exit);
  signal(SIGTERM, int_exit);

Ici, on charge le fichier xdp_ip_filter_kern.o (qui contient notre fichier précédent compilé), et on ajoute le handler int_exit sur les signaux SIGINT et SIGTERM.

Ajout de l’IP à filtrer dans la map

Il faut maintenant ajouter l’IP que nous voulons filtrer dans la map nommée ip_map que nous avons utilisée dans le fichier xdp_ip_filter_kern.c:

  // set the first element of the first map to the ip passed as a parameter
  int result = bpf_map_update_elem(map_fd[0], &key, &ip, BPF_ANY);
  if (result != 0) {
    fprintf(stderr, "bpf_map_update_elem error %d %s \n", errno, strerror(errno));
    return 1;
  }

Ici, on met à jour la map avec la fonction bpf_map_update_elem. map_fd[0] retourne la première map définie dans le fichier kern, qui est bien notre map ip_map (l’ordre de déclaration des maps a donc de l’importance !). La map contiendra donc maintenant pour la clé 0 l’IP à filtrer (et donc le programme kern pourra la récupérer comme vu précédemment).

ebpf update map

Ajout du programme XDP sur une interface

Dans la fonction int_exit vue précédemment, nous appelions bpf_set_link_xdp_fd pour stopper le programme XDP, en utilisant notamment la variable ifindex. En fait, un programme XDP est lié à une interface (et dans int_exit, nous le détachions donc de l’interface).

Il faut donc dans notre main l’attacher à l’interface dont nous voulons filtrer les paquets:

// link the xdp program to the interface
  if (bpf_set_link_xdp_fd(ifindex, prog_fd[0], xdp_flags) < 0) {
    printf("link set xdp fd failed\n");
    return 1;
  }

Ici, on attachons à l’interface localhost notre programme XDP.

Collecte des statistiques

A partir de ce moment, notre programme XDP est démarré, et commence à filtrer des paquets. Nous voulons savoir combien de paquets ont été filtrés, en récupérant pour chaque core de notre CPU la valeur dans la map counter_map vue précédemment.

  int i, j;

  // get the number of cpus
  unsigned int nr_cpus = bpf_num_possible_cpus();
  __u64 values[nr_cpus];

  // "infinite" loop
  for (i=0; i< 1000; i++) {
    // get the values of the second map into values.
    assert(bpf_map_lookup_elem(map_fd[1], &key, values) == 0);
    printf("%d\n", i);
    for (j=0; j < nr_cpus; j++) {
      printf("cpu %d, value = %llu\n", j, values[j]);
    }
    printf("\n\n");
    sleep(2);
  }

Rappelez vous, la map counter_map est par core (type BPF_MAP_TYPE_PERCPU_ARRAY). Nous récupérons notre nombre de core via bpf_num_possible_cpus, puis nous créons deux boucles:

  • Une pour périodiquement récupérer les valeurs de la map, toutes les 2 secondes. bpf_map_lookup_elem est appelé sur la map numéro 2 (map_fd[1], donc counter_map), pour la clé 0 pour réaliser cela. Les valeurs sont stockées dans le tableau values (car il y a une valeur par core).

  • Une qui va intérer sur le tableau values pour afficher à l’écran les statistiques pour chaque core.

ebpf update map

Ici, on voit que bpf_map_lookup_elem récupère pour chaque map "counter_map" de chaque core la valeur à l’index 0 et la stocke dans un tableau nommé values, où l’index du tableau est le numéro du core.

Fin du programme

A la fin du programme, on détache le programme de l’interface localhost.

  printf("end\n");
  // unlink the xdp program
  bpf_set_link_xdp_fd(ifindex, -1, xdp_flags);
  return 0;

C’est maintenant terminé, place à la compilation et aux tests !

Tester le programme

Lancer make samples/bpf/, cela devrait compiler sans erreurs votre programme. Vous pouvez maintenant le tester. Par exemple, filtrons tous les paquets venant de l’IP 192.168.1.78:

cd samples/bpf/
sudo ./xdp_ip_filter -i "192.168.1.78"

L’output devrait être le suivant:

the ip to filter is 192.168.1.78/1308731584
0
cpu 0, value = 0
cpu 1, value = 0
cpu 2, value = 0
cpu 3, value = 0
cpu 4, value = 0
cpu 5, value = 0
cpu 6, value = 0
cpu 7, value = 0
cpu 8, value = 0
cpu 9, value = 0
cpu 10, value = 0
cpu 11, value = 0
cpu 12, value = 0
cpu 13, value = 0
cpu 14, value = 0
cpu 15, value = 0

Vous pouvez vérifier que votre programme kern est bien attaché à l’interface localhost en appelant ip link list. une ligne prog/xdp devrait être rajoutée sur l’interface:

ip link list
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 69 tag 1ddc7360e5987edf

Vous pouvez d’ailleurs à tout moment détacher les programmes XDP de votre interface via la commande ip link set dev lo xdp off.

Testons maintenant si notre programme marche. Pour cela, j’utilise scapy pour crafter des paquets réseaux. Installez le (via pip ou via le gestionnaire de paquet de votre distribution). Puis en root, ouvrez un interpréteur python avec python et envoyez un paquet ICMP ayant comme source 192.168.1.78 vers localhost:

from scapy.all import  *
conf.L3socket=L3RawSocket
sr1(IP(src="192.168.1.78", dst="127.0.0.1")/ICMP())

La réponse n’arrivera jamais, car le paquet a été filtré par notre programme ! D’ailleurs, l’output de votre programme devrait être:

cpu 0, value = 0
cpu 1, value = 0
cpu 2, value = 0
cpu 3, value = 0
cpu 4, value = 0
cpu 5, value = 0
cpu 6, value = 0
cpu 7, value = 0
cpu 8, value = 0
cpu 9, value = 1
cpu 10, value = 0
cpu 11, value = 0
cpu 12, value = 0
cpu 13, value = 0
cpu 14, value = 0
cpu 15, value = 0

Ici, mon core numéro 9 a filtré le paquet. Réessayez, et vous verrez les compteurs s’incrémenter !

Vous pouvez également consulter les logs du program kern (l’output de bpf_printk) en allant lire le fichier /sys/kernel/debug/tracing/trace, n’hésitez pas à rajouter plus de logs si besoin.

Conclusion

J’ai appris beaucoup de choses sur eBPF et XDP en écrivant ce programme. C’est définitivement une technologie puissante, très intéressante, mais pas forcément évidente à utiliser (surtout pour quelqu’un n’ayant pas d’expérience en développement kernel). Certains projets comme bcc ou bpftrace ont l’air plus accessibles, mais écrire un peu de C permet de rentrer rapidement dans le vif du sujet.

Ce ne sera sûrement pas mon seul article sur le sujet, mon prochain projet sera peut être d’écrire un petit outil pour collecter une métrique quelconque de mon système par exemple.

Tags: devops

Add a comment








If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

Top of page