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:
-
Sur Github, avec la coloration syntaxique ici.
-
Sur ce site aux url suivantes: xdp_ip_filter_kern.c et xdp_ip_filter_user.c.
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.
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.
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).
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]
, donccounter_map
), pour la clé0
pour réaliser cela. Les valeurs sont stockées dans le tableauvalues
(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.
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.
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).