Nous avons vu ce qu’était un programme XDP dans la première partie : cela peut être un moyen de créer un répartisseur de charge ou plus couramment appelé : load balancer.
Dans cette partie, je vous propose de créer un programme XDP qui va répartir la charge de paquets de type ICMP. On va faire cela avec le framework Rust Aya.
Suivez le guide !
Je suppose que vous êtes déjà dans un environnement pour développer avec Aya. Si ce n’est pas le cas, vous pouvez utiliser le lab Killercoda :
Qu’allons nous faire ?#
Load Balancer ICMP#
Nous allons créer un load balancer ICMP (Internet Control Message Protocol). Ainsi en pingant le load balancer on va, en réalité, pinger un autre serveur qu’on appelle backend.
Rappelons brièvement comment fonctionne le protocole ICMP. Le client envoie à un serveur un paquet ICMP de type echo request. Le serveur répond par un autre paquet ICMP de type echo reply.
Pour en savoir plus sur ICMP, vous pouvez consulter cette page.
Pourquoi pas TCP ou UDP ?#
C’est vrai qu’un load balancer ICMP peut sembler pas super utile. Cependant cela me semble le plus pédagogique pour commencer car il est le plus facile à créer. Par ailleurs, la plupart des concepts dont on va parler dans cet article sera encore utile pour ces load balancers.
À titre indicatif, voici l’ordre de difficulté selon moi :
- Load Balancer ICMP
- Load Balancer UDP
- Load Balancer TCP
Créons naïvement le load balancer#
Recréons l’environnement de développement#
Comme nous l’avons vu dans la première partie, je vous propose d’installer des namespaces :
git clone https://github.com/littlejo/eunomia.dev
cd eunomia.dev/docs/tutorials/42-xdp-loadbalancer/
./setup.shRappelons ce que cela a généré :
Nous allons créer un load balancer XDP au niveau de l’interface veth6 qui va répartir la charge sur h2 et h3. Nous allons modifier l’adresse de destination dans XDP de 10.0.0.10 par 10.0.0.2 ou 10.0.0.3 :
C’est ce qu’on appelle un DNAT (Destination Network Address Translation).
Pour commencer nous allons rediriger les paquets réseaux uniquement vers h2 (10.0.0.2), ça donnerait donc :
Créons le programme XDP Hello world#
Générons déjà le programme Aya :
cargo generate --name lb-xdp \
-d program_type=xdp \
-d default_iface=veth6 \
https://github.com/aya-rs/aya-template
cd lb-xdp/En prévision, nous allons utiliser des crates supplémentaires :
- network-types pour les structures Rust des en-têtes de niveau 1, 2 et 3 ;
- blog-xdp, la crate de ce blog, pour ne pas copier/coller des fonctions helpers précédemment écrites.
On va donc modifier le fichier lb-xdp-ebpf/Cargo.toml et rajouter dans la section dependencies :
network-types = "0.1.0"
blog-xdp = { git = "https://github.com/littlejo/blog-xdp" }Buildons le programme hello world :
cargo buildTestons le rapidement :
ip netns exec lb cargo runon fait ping -c1 10.0.0.10, ça fait bien received a packet du côté cargo run.
Récupérons uniquement les pings#
Maintenant, on va modifier le programme Aya du côté kernel : lb-xdp-ebpf/src/main.rs. On va récupérer uniquement les paquets ICMP.
Nous avons déjà écrit un code similaire dans un précédent épisode donc je vous donne le résultat des modifications :
use network_types::{
eth::{EthHdr, EtherType},
ip::{Ipv4Hdr, IpProto},
icmp::IcmpHdr,
};
use blog_xdp::helper::ptr_at;
#[inline(always)]
fn filter_icmp(ctx: &XdpContext) -> Option<(*const Ipv4Hdr, *const IcmpHdr)> {
let ethhdr: *const EthHdr = ptr_at(ctx, 0).ok()?;
match unsafe { (*ethhdr).ether_type() } {
Ok(EtherType::Ipv4) => {}
_ => return None,
}
let ipv4hdr: *const Ipv4Hdr = ptr_at(ctx, EthHdr::LEN).ok()? ;
let icmphdr: *const IcmpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN).ok()? ;
match unsafe { (*ipv4hdr).proto } {
IpProto::Icmp => {}
_ => return None,
}
Some((ipv4hdr, icmphdr))
}
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
let dst_addr = unsafe { (*ipv4hdr).dst_addr };
let src_addr = unsafe { (*ipv4hdr).src_addr };
info!(&ctx,
"src={:i}, dst={:i}", src_addr, dst_addr);
Ok(xdp_action::XDP_PASS)
}Le message de type : src=10.0.0.1, dst=10.0.0.10 est affiché seulement quand un paquet ICMP est envoyé. C’est à cet endroit où on va développer le load balancer.
Vérifions :
ip netns exec lb cargo runSur un autre terminal, pingons le futur load balancer :
ping -c 2 10.0.0.10Sur le terminal cargo run, il est alors affiché :
[INFO lb_xdp] src=10.0.0.1, dst=10.0.0.10
[INFO lb_xdp] src=10.0.0.1, dst=10.0.0.10N’hésitez pas à tester d’autres protocoles pour vérifier qu’il ne s’affiche rien.
Améliorons les logs#
Pour mieux comprendre comment va se passer cette répartition de charge, il est intéressant d’afficher un peu plus de détail sur le paquet reçu.
Regardons le contenu d’une en-tête ICMP :
- Type : le type de requête ICMP (
8correspond à echo request et0correspond à echo reply) - Code : le code permet de préciser le type (pas utile pour echo request et echo reply)
- Checksum : pour l’intégrité du paquet
Pour récupérer ces éléments, on peut utiliser la crate network-types.

Ainsi pour récupérer le type de paquet ICMP, on peut le faire de cette manière :
let type_ = unsafe { (*icmphdr).type_ };Créons une fonction dédiée au logs :
#[inline(always)]
fn log_icmp(ctx: &XdpContext, ipv4hdr: *const Ipv4Hdr, icmphdr: *const IcmpHdr) {
let type_ = unsafe { (*icmphdr).type_ };
let dst_addr = unsafe { (*ipv4hdr).dst_addr };
let src_addr = unsafe { (*ipv4hdr).src_addr };
info!(ctx,
"src={:i} dst={:i} ({})", src_addr, dst_addr, type_);
}Le code principal devient :
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
log_icmp(&ctx, ipv4hdr, icmphdr); //MODIFY
Ok(xdp_action::XDP_PASS)
}Relançons ip netns exec lb cargo run puis le ping :
[INFO lb_xdp] src=10.0.0.1 dst=10.0.0.10 (8)On peut maintenant s’assurer que le paquet ICMP est bien de type echo request.
veth7. Ça serait inversé : l’IP source serait 10.0.0.10 et l’IP destination 10.0.0.1. Le paquet ICMP serait alors de type echo reply.Modifions l’IP de destination#
C’est à partir de maintenant que ça devient intéressant. Rappelons que le but est d’obtenir cela dans un premier temps :
On doit donc remplacer l’adresse de destination par 10.0.0.2 :
let backend_ip = [10, 0, 0, 2];
unsafe {
(*ipv4hdr).dst_addr = backend_ip;
}Cela n’est pas possible de faire ça car la variable ipv4hdr n’est pas mutable. Il faut donc remplacer const par mut :
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)? ;La fonction ptr_at retourne un Result<*const T, u16>. On pourrait recréer une fonction quasi identique mais avec comme retour Result<*mut T, u16>. Mais il y a plus propre en reprennant la fonction originelle et en castant le type :
#[inline(always)]
fn ptr_at_mut<T>(ctx: &XdpContext, offset: usize) -> Result<*mut T, u16> {
let ptr = ptr_at::<T>(ctx, offset)?;
Ok(ptr as *mut T)
}On se retrouve alors avec le code principal suivant :
#[inline(always)]
fn filter_icmp(ctx: &XdpContext) -> Option<(*mut Ipv4Hdr, *const IcmpHdr)> { //MODIFY
let ethhdr: *const EthHdr = ptr_at(ctx, 0).ok()?;
match unsafe { (*ethhdr).ether_type() } {
Ok(EtherType::Ipv4) => {}
_ => return None,
}
let ipv4hdr: *mut Ipv4Hdr = ptr_at_mut(ctx, EthHdr::LEN).ok()? ; //MODIFY
let icmphdr: *const IcmpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN).ok()? ;
match unsafe { (*ipv4hdr).proto } {
IpProto::Icmp => {}
_ => return None,
}
Some((ipv4hdr, icmphdr))
}
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
let backend_ip = [10, 0, 0, 2]; //ADD
unsafe { //ADD
(*ipv4hdr).dst_addr = backend_ip; //ADD
} //ADD
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Testons la compilation et l’installation du programme eBPF :
ip netns exec lb cargo runPas de soucis.
Testons le ping :
ping -c 2 10.0.0.10Au niveau de cargo run, on voit bien les paquets avec la nouvelle destination :
[INFO lb_xdp] src=10.0.0.1 dst=10.0.0.2 (8)
[INFO lb_xdp] src=10.0.0.1 dst=10.0.0.2 (8)MAIS cela ne ping plus :
PING 10.0.0.10 (10.0.0.10) 56(84) bytes of data.
--- 10.0.0.10 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1006msOn semble à la fois proche et loin du but. Regardons où peut venir le problème.
Résolvons le problème#
Debugging#
Souvent quand on a un problème réseau, on se sert d’un outil bien pratique : tcpdump
ip netns exec lb tcpdump -l -i veth6 icmp -vvRelançons des pings…
Et regardons ce que donne tcpdump :
tcpdump: listening on veth6, link-type EN10MB (Ethernet), snapshot length 262144 bytes
23:22:36.133053 IP (tos 0x0, ttl 64, id 526, offset 0, flags [DF], proto ICMP (1), length 84, bad cksum 2491 (->2499)!)
10.0.0.1 > 10.0.0.2: ICMP echo request, id 165, seq 1, length 64
23:22:37.193765 IP (tos 0x0, ttl 64, id 1495, offset 0, flags [DF], proto ICMP (1), length 84, bad cksum 20c8 (->20d0)!)
10.0.0.1 > 10.0.0.2: ICMP echo request, id 165, seq 2, length 64Vous ne voyez rien ? Regardez bien la fin des lignes, vous allez voir un truc comme : bad cksum 2491 (->2499)!.
Ce qu’on pourrait traduire par :
mauvaise somme de contrôle : 2491 au lieu de 2499.
Peut-être que d’habitude le checksum (ou la checksum ?) est aussi mauvais ? Vérifions. Arrêtons le programme XDP et regardons ce que donne tcpdump :
23:27:18.896834 IP (tos 0x0, ttl 64, id 64521, offset 0, flags [DF], proto ICMP (1), length 84)
10.0.0.1 > 10.0.0.10: ICMP echo request, id 166, seq 1, length 64
23:27:18.896911 IP (tos 0x0, ttl 64, id 28797, offset 0, flags [none], proto ICMP (1), length 84)
10.0.0.10 > 10.0.0.1: ICMP echo reply, id 166, seq 1, length 64On voit une trace classique de echo request ⟺ reply avec ping. Et surtout on ne voit plus de problème de checksum.
C’est finalement assez logique : un checksum sert à vérifier l’intégrité d’un paquet. Si on modifie un élément d’un paquet, le checksum ne sera plus valide et le système d’exploitation rejettera le paquet.
Quel•le•s checksum ?#
Il suffit donc de modifier le checksum pour que le paquet soit de nouveau intègre ? Oui mais si je regarde les différentes en-têtes, il n’y en a pas qu’un :
- Niveau 1 (
EthHdr) : il n’y a pas de checksum - Niveau 2 (
Ipv4Hdr) : il y a un checksum - Niveau 2 bis (
IcmpHdr) : il y a également un checksum
Donc lequel (ou peut-être lesquels) fait défaut ?
Pour cela il faut savoir comment est calculé le checksum pour les en-têtes de l’IPv4 et de l’ICMP.
Checksum IPv4#
Il y a une page wikipedia dédiée (en anglais faut pas rêver) qui est consacré au checksum IPv4 :
The Internet checksum, also called the IPv4 header checksum is a checksum used in version 4 of the Internet Protocol (IPv4) to detect corruption in the header of IPv4 packets.
Traduction :
Le checksum Internet aussi appelé checksum de l’en-tête IPv4 est un checksum utilisé dans IPv4 pour détecter la corruption de l’en-tête des paquets IPv4.
Comme on a modifié un paramètre (l’IP de destination) de l’en-tête des paquets IPv4 (Ipv4Hdr) le checksum est alors invalide.
Checksum ICMP#
Reste donc à regarder si le checksum de l’ICMP utilise l’en-tête de IPv4 pour son calcul. Là c’est moins populaire.
J’ai lu la RFC 792 (juste la partie pour le checksum, je vous rassure) :
The checksum is the 16-bit ones’s complement of the one’s complement sum of the ICMP message starting with the ICMP Type. For computing the checksum , the checksum field should be zero. This checksum may be replaced in the future.
Traduction :
Le checksum est le complément à un sur 16 bits de la somme en complément à un de tout le message ICMP, en commençant par le champ Type. Pour calculer le checksum, le champ checksum lui-même doit être mis à zéro. Ce mécanisme de checksum pourra être remplacé à l’avenir.
Pour résumer, le checksum est calculé en fonction du message ICMP c’est à dire l’en-tête ICMP (IcmpHdr) et le payload :
Ainsi la modification de l’en-tête de IPv4 ne change en rien le checksum de ICMP.
Recalculons le checksum#
Le calcul d’un checksum pourrait faire l’objet d’un article complet. Il y a différentes méthodes pour le calculer. J’ai écrit une fonction dans la crate blog-xdp pour le recalcul du checksum de l ’en-tête IP. La fonction iph_csum() s’utilise de façon simple. On modifie des éléments de l’en-tête IP puis on calcule le checksum avec cette fonction ce qui donne pour le changement d’une IP :
unsafe {
(*ipv4hdr).dst_addr = backend_ip;
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}Nous avons utilisé la fonction set_checksum() de la crate network-types pour mettre à jour le nouveau checksum de l’en-tête IP :
Le code principal devient :
use blog_xdp::helper::iph_csum; //ADD
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
let backend_ip = [10, 0, 0, 2];
unsafe {
(*ipv4hdr).dst_addr = backend_ip;
let chksum = iph_csum(ipv4hdr); //ADD
(*ipv4hdr).set_checksum(chksum); //ADD
}
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Testons le code :
ip netns exec lb cargo runSur un autre terminal :
ping -c2 10.0.0.10On voit alors :
PING 10.0.0.10 (10.0.0.10) 56(84) bytes of data.
64 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.582 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.591 ms (DIFFERENT ADDRESS!)On voit que le client détecte que l’adresse IP qui répond n’est pas celle qu’il ping.
Dans un “vrai” load balancer, cela ne serait pas suffisant : on devrait également changer l’adresse MAC de destination. On peut évidemment récupérer manuellement cet élément pour faire des tests mais la difficulté est tout autre pour le faire automatiquement.
Pour cela, il faudrait créer un petit programme utilisateur qui lance des requêtes ARP pour remplir une map eBPF avec une correspondance IP => MAC.
Répartir sur plusieurs IPs#
Pour loadbalancer sur plusieurs IPs, on peut utiliser la fonction random d’eBPF :

Ainsi cette fonction donne un nombre pseudo-aléatoire de 32 bits non signés. Pour notre cas, il faut faire varier entre deux IPs : 10.0.0.2 et 10.0.0.3 qui sont, en réalité, des tableaux de 4 octets. Il suffit donc de faire varier la dernière valeur du tableau entre 2 et 3.
Comment faire cela en Rust ?
Il faut déjà importer la fonction random :
use aya_ebpf::helpers::bpf_get_prandom_u32;Et rajouter cela pour faire varier les deux IPs :
let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
let backend_ip = [10, 0, 0, backend_index as u8];On se retrouve avec :
PING 10.0.0.10 (10.0.0.10) 56(84) bytes of data.
64 bytes from 10.0.0.3: icmp_seq=1 ttl=64 time=0.088 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=2 ttl=64 time=0.098 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.3: icmp_seq=3 ttl=64 time=0.082 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=4 ttl=64 time=0.088 ms (DIFFERENT ADDRESS!)
64 bytes from 10.0.0.2: icmp_seq=5 ttl=64 time=0.081 ms (DIFFERENT ADDRESS!)Pour rendre le code lisible, on va créer une fonction dédiée pour le DNAT :
use aya_ebpf::helpers::bpf_get_prandom_u32;
#[inline(always)]
fn lb_dnat(ipv4hdr: *mut Ipv4Hdr) {
let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
let backend_ip = [10, 0, 0, backend_index as u8];
unsafe {
(*ipv4hdr).dst_addr = backend_ip;
let chksum = iph_csum(ipv4hdr);
(*ipv4hdr).set_checksum(chksum);
}
}Le code principal devient alors :
fn try_lb_xdp(ctx: XdpContext) -> Result<u32, u32> {
let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
lb_dnat(ipv4hdr);
log_icmp(&ctx, ipv4hdr, icmphdr);
Ok(xdp_action::XDP_PASS)
}Cet épisode est maintenant terminé ! Nous avons vu les bases de la création d’un load balancer XDP : le changement d’IP et le checksum.
Mais c’est quand même bizarre ce (DIFFERENT ADDRESS!)… J’ai jamais vu ça auparavant !
Dans le prochain épisode, nous allons améliorer ce load balancer XDP pour que le ping ne se rend compte de rien !






