Aller au contenu
  1. Ebpf-Another-Types/

Améliorons le petit load balancer avec XDP

·2640 mots·13 mins·
Joseph Ligier
Auteur
Joseph Ligier
CNCF ambassador | Kubestronaut 🐝
Sommaire
Apprenons XDP avec Aya - Cet article fait partie d'une série.
Partie 5: Cet article

Précédemment…
#

Dans la précédente partie nous avons vu comment répartir la charge d’un ping sur h2 et h3. Cependant la commande ping détecte que l’IP vient de h2 ou h3 et non de celle du load balancer :

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!)

Nous allons déjà voir en quoi c’est génant et comment nous allons résoudre le problème.

Cet article est la suite directe de l’article Créons un petit load balancer avec XDP. L’article étant trop long, j’ai décidé de le couper en 2 !

Mais c’est quoi le problème ?
#

Un peu de recul
#

Dans le réseau qu’on a créé depuis le début :

namepaces

Tout est à plat, tout le monde peut communiquer avec tout le monde.

En général, les clients, un load balancer et les backends sont dans des réseaux différents. Ainsi la solution de la partie précédente fonctionne uniquement si les backends peuvent router les paquets vers le client ce qui n’est pas forcément le cas pour des questions de sécurité.

Par ailleurs, cela “fonctionne” pour ICMP. Mais pour un autre protocole comme TCP, ça ne fonctionnerait pas : pendant la phase de connexion (3-way handshake), le client détecterait que le SYN envoyé au load balancer n’a pas la même IP que le paquet SYN-ACK du serveur et la connexion n’irait pas plus loin.

La solution actuelle n’est donc pas idéale. Comment fonctionne un “vrai” load balancer ? Il faudrait que le chemin de retour soit symétrique au chemin pris à l’aller pour éviter ce problème.

Actuellement, on a comme chemin aller (echo request) : client → loadbalancerbackend

namepaces

Et comme chemin retour (echo reply) : backend → client

namepaces

Il faudrait donc que le chemin retour soit plutôt : backendload balancer → client

namepaces

Ainsi le client croiera qu’il ping le load balancer mais pas un des deux backends.

On fait quoi alors ?
#

Une solution possible serait d’installer un programme XDP sur veth3 pour rediriger vers lb. Mais il faudrait modifier chaque interface de chaque client et on n’a pas forcément accès au client (imaginez que vous voulez créer un load balancer d’un cloud).

La solution est plutôt de faire croire aux backends que l’IP source n’est pas celle du client (10.0.0.1) mais celle de lb. C’est ce qu’on appelle un SNAT (Source Network Address Translation).

La première idée serait de continuer le programme XDP de la partie précédente en rajoutant le SNAT.

namepaces

Mais ça ne fonctionne pas : si on réécrit l’IP source en même temps que la destination, le paquet qui vient d’arriver sur veth6 semblerait provenir… de veth6 lui-même.

Pour contourner ce problème, il faut créer un autre programme XDP rattaché à l’interface suivante : veth7. On peut alors modifier l’IP source sans créer d’incohérence.

namepaces

Mais ce n’est pas fini ! Il faut gérer le retour. En effet le paquet s’arrêterait à lb sinon. Il faut donc au retour modifier le paquet qui est arrivé pour pouvoir joindre le client.

namepaces

Ainsi ce qu’on va faire aujourd’hui :

  • un programme XDP SNAT sur veth7
  • gérer le retour du paquet

Commençons d’abord par créer le programme qui va changer l’IP source.


Créons le load balancer côté SNAT
#

Créons le programme xdp Hello World
#

Générons déjà le programme Aya :

cargo generate --name lb-xdp-snat \
               -d program_type=xdp \
               -d default_iface=veth7 \
               https://github.com/aya-rs/aya-template
cd lb-xdp-snat

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-snat-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” et testons le rapidement :

cargo run

on 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 récupérer uniquement les paquets ICMP.

headers

On reprend tout ce qu’on a fait précédemment pour cela. On modifie le fichier lb-xdp-snat-ebpf/src/main.rs :

use blog_xdp::helper::{iph_csum, log_icmp, filter_icmp};

fn try_lb_xdp_snat(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);
    Ok(xdp_action::XDP_PASS)
}

Le message src=10.0.0.10 dst=10.0.0.1 (0) est affiché seulement quand un paquet ICMP est envoyé. C’est à cet endroit où on va développer la deuxième partie du load balancer.

Vérifions que le programme eBPF est bien lancé :

cargo run

Sur un autre terminal, pingons le futur load balancer :

ping -c 2 10.0.0.10

Sur le terminal cargo run, il est affiché :

[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)

Maintenant qu’on n’a filtré pour avoir uniquement des paquets ICMP, regardons comment on modifie l’adresse.

Modifions l’IP source
#

C’est à partir de maintenant que ça devient intéressant.

Il suffit “juste” de modifier une adresse IP par celle du load balancer pour dire aux backends (h2 et h3) de renvoyer au load balancer.

Regardons d’abord ce qu’il se passe quand les deux programmes eBPF tournent en même temps.

Installons le programme XDP qu’on vient de créer :

cargo run 

Dans un autre terminal, installons le programme XDP DNAT qu’on a créé dans la précédente partie :

ip netns exec lb cargo run 
ping -c 2 10.0.0.10

Voilà ce qu’on a fait :

namespaces

Voyons les logs du programme SNAT :

[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (5)
[INFO  blog_xdp::helper] src=10.0.0.1 dst=10.0.0.2 (8)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (5)
[INFO  blog_xdp::helper] src=10.0.0.1 dst=10.0.0.2 (8)
  • Le numéro 5 correspond à un redirect request : on ne s’en occupe pas
  • Le numéro 8 correspond à un echo request

On va s’occuper uniquement des echo requests et des echo reply, on va donc rajouter le filtre suivant :

let type_ = unsafe { (*icmphdr).type_ };
match type_ {
    8 => {},
    0 => {},
    _ => return Ok(xdp_action::XDP_PASS),
}
Le redirect request est un mécanisme pour informer un hôte qu’il existe une meilleure route pour atteindre la destination.

On va maintenant créer le SNAT : on va changer l’IP source pour faire croire au backend que le paquet d’origine vient du load balancer et non du client :

unsafe {
        (*ipv4hdr).src_addr = [10, 0, 0, 10];
        let chksum = iph_csum(ipv4hdr);
        (*ipv4hdr).set_checksum(chksum);
}

Voilà ce qu’on a fait au niveau du paquet :

namepaces

Le backend renvoie le paquet au load balancer.

Le code principal est alors :

fn try_lb_xdp_snat(ctx: XdpContext) -> Result<u32, u32> {
    let (ipv4hdr, icmphdr) = match filter_icmp(&ctx) {
        Some(x) => x,
        None => return Ok(xdp_action::XDP_PASS),
    };
    let type_ = unsafe { (*icmphdr).type_ };

    match type_ {
        8 => {},
        0 => {},
        _ => return Ok(xdp_action::XDP_PASS),
    }

    unsafe {
        (*ipv4hdr).src_addr = [10, 0, 0, 10];
        let chksum = iph_csum(ipv4hdr);
        (*ipv4hdr).set_checksum(chksum);
    }

    log_icmp(&ctx, ipv4hdr, icmphdr);
    Ok(xdp_action::XDP_PASS)
}

Relançons les deux programmes XDP et regardons ce qu’il se passe. Cela ne ping plus mais on a le log suivant sur le programme SNAT :

[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (8)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.3 (0)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (8)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.2 (0)

Le load balancer reçoit bien le paquet retour !

Par ailleurs, pour le chemin retour, on a déjà fait croire à l’initiateur du ping que l’IP source sera le load balancer et non le backend.

namepaces

Il ne reste plus qu’à modifier le programme pour qu’il renvoie sur l’IP du client d’origine.


Gérons le chemin retour
#

On n’est plus très loin du but ! Il ne nous reste plus que cette partie fonctionne maintenant :

namepaces

On a deux solutions :

  • on modifie le programme XDP sur veth6 pour que l’IP de destination soit celle du client
  • on modifie le programme XDP sur veth7 pour que l’IP de destination soit celle du client

Les 2 sont valables. Vu qu’on a déjà modifié l’adresse de destination sur veth6, la logique est de le faire également sur veth6.

Donc pour la fin de l’article nous allons modifier uniquement le programme de la partie précédente lb_xdp(). Le code du programme SNAT est donc terminé.

Sans suivi de connexion
#

On doit donc dire si on a affaire un ping de type “echo reply”, on doit modifier l’adresse de destination. Par contre, le programme eBPF ne connait pas pour l’instant cette adresse. Il va falloir l’aider. Comme on fait le test sur l’hôte, on va d’abord simplifier en hard-codant l’adresse de l’hôte (10.0.0.1) :

let type_ = unsafe { (*icmphdr).type_ };

if type_ == 0 {
    unsafe {
        (*ipv4hdr).dst_addr = [10, 0, 0, 1];
        let chksum = iph_csum(ipv4hdr);
        (*ipv4hdr).set_checksum(chksum);
    }
}

Testons : ça ping bien !

Au niveau de veth6 :

[INFO  lb_xdp] src=10.0.0.1 dst=10.0.0.3 (8)
[INFO  lb_xdp] src=10.0.0.3 dst=10.0.0.1 (0)

Au niveau de veth7 :

[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.3 (8)
[INFO  blog_xdp::helper] src=10.0.0.10 dst=10.0.0.1 (0)

On va améliorer la fonction lb_dnat() :

#[inline(always)]
fn lb_dnat(ipv4hdr: *mut Ipv4Hdr, icmphdr: *const IcmpHdr) {
    let dst_addr_mod = match unsafe {(*icmphdr).type_} {
        0 => [10, 0, 0, 1],
        _ => {
            let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
            [10, 0, 0, backend_index as u8]
        }
    };

    unsafe {
        (*ipv4hdr).dst_addr = dst_addr_mod;
        let chksum = iph_csum(ipv4hdr);
        (*ipv4hdr).set_checksum(chksum);
    }
}

Et la fonction principale 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, icmphdr);
    log_icmp(&ctx, ipv4hdr, icmphdr);

    Ok(xdp_action::XDP_PASS)
}

Avec suivi de la connexion
#

Les en-têtes cachées
#

Bon ça fonctionne si on ping depuis l’IP 10.0.0.1 mais si on ping depuis un autre namespace, ça ne marche pas car on l’a hard-codé. Comment ne pas hard-coder cette partie du code. Il faut que le programme eBPF retienne quelle IP source est passée pour pouvoir la renvoyer.

L’idée est de créer une map eBPF qui permet d’enregistrer l’IP source qui est entrée dans veth6. Pour pouvoir le renvoyer au moment du echo reply à la bonne IP.

Par contre il y a un problème : si plusieurs clients ping en même temps, il faut réussir à identifier à qui il faut renvoyer le ping. Pour cela, il faut trouver une donnée commune à un ping.

Rappelons ce que contient l’en-tête ICMP :

icmphdr

  • Type : à l’arrivée c’est toujours 8 (echo request) et à la sortie toujours 0 (echo reply). Cela ne va pas nous aider.
  • Code : c’est un sous-type qui précise le type, il sera toujours égale à 0 pour le type 8 et 0. Cela ne va pas nous aider.
  • Checksum : le checksum va changer entre le paquet en entrée et le paquet en sortie. Cela ne va pas nous aider.

Donc il n’y a rien dans l’en-tête qui peut nous identifier quelques choses de commun.

Mais si tu lis la RFC 792, il y a d’autres en-têtes qui varient selon le type.

rfc792

Ainsi on peut voir qu’il y a en commun :

  • Identifier : un identifiant créé par le client
  • Sequence Number : le numéro de séquence qui s’incrémente à chaque ping (vous savez le icmp_seq=?).

Une approche serait donc de créer une map eBPF : {[id, seq] → ip source}

Cette approche n’est pas idéale pour de la production : si deux clients différents utilisent le même identifiant, cela ne fonctionnerait pas. Pour avoir une solution fiable, au niveau d’eBPF il faudrait générer un id vraiment unique et créer une map : {[id_ebpf, seq] => [id_client, ip source]}. Il faudrait également modifier le paquet avec ce nouvel id et modifier le checksum ICMP et à la fin revenir avec l’id du client.

Comment récupérer ces en-têtes en Rust ?
#

Si tu regardes la documentation de network-types :

icmp hdr

On voit qu’il y a data qui est inclus dans l’en-tête icmp. Contiendrait-il Identifier et Sequence Number ? Oui :

The data field contains type-specific data such as echo identifiers/sequence numbers, redirect gateway addresses, or pointers to errors in received packets.

Il y a même des méthodes pour les récupérer facilement :

icmphdr documentation

Ainsi on a les méthodes echo_id() et echo_sequence(). Pour les récupérer, on peut écrire cela :

let id_: u16 = unsafe { (*icmphdr).echo_id().map_err(|_| 0u32)? };
let seq_: u16 = unsafe { (*icmphdr).echo_sequence().map_err(|_| 0u32)? };

Pour créer la map eBPF :

use aya_ebpf::maps::LruHashMap;
use aya_ebpf::macros::map;

#[map]
pub static SEQ_ID: LruHashMap<[u16; 2], [u8; 4]> = LruHashMap::with_max_entries(16, 0);

Au moment de l’arrivée du paquet (echo request) on va insérer l’IP dans la map :

let src_addr = unsafe { (*ipv4hdr).src_addr };
let _ = SEQ_ID.insert(&[seq_, id_], &src_addr, 0);

Quand le paquet va revenir (echo reply) on va récupérer l’IP dans la map pour savoir à qui l’envoyer :

let src_ip = unsafe {*SEQ_ID.get(&[seq_, id_]).ok_or(0u32)?}

En adaptant pour la fonction lb_dnat, on se retrouve avec :

use aya_ebpf::maps::LruHashMap;
use aya_ebpf::macros::map;
#[map]
pub static SEQ_ID: LruHashMap<[u16; 2], [u8; 4]> = LruHashMap::with_max_entries(16, 0);

#[inline(always)]
fn lb_dnat(ipv4hdr: *mut Ipv4Hdr, icmphdr: *const IcmpHdr) -> Result<u32,u32> {
    let id_: u16 = unsafe { (*icmphdr).echo_id().map_err(|_| 0u32)? }; //ADD
    let seq_: u16 = unsafe { (*icmphdr).echo_sequence().map_err(|_| 0u32)? }; //ADD

    let dst_addr_mod = match unsafe {(*icmphdr).type_} {
        0 => unsafe {*SEQ_ID.get(&[seq_, id_]).ok_or(0u32)?}, //INSTEAD OF 10.0.0.1
        _ => {
            let src_addr = unsafe { (*ipv4hdr).src_addr };
            let _ = SEQ_ID.insert(&[seq_, id_], &src_addr, 0); //ADD
            let backend_index = unsafe { bpf_get_prandom_u32() % 2 + 2 };
            [10, 0, 0, backend_index as u8]
        }
    };

    unsafe {
        (*ipv4hdr).dst_addr = dst_addr_mod;
        let chksum = iph_csum(ipv4hdr);
        (*ipv4hdr).set_checksum(chksum);
    }
    Ok(0)
}

Et le code principal est légèrement modifié:

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, icmphdr)?; //Just a ?
    log_icmp(&ctx, ipv4hdr, icmphdr);

    Ok(xdp_action::XDP_PASS)
}

Un dernier test
#

Vous pouvez tester avec des pings en parallèle, par exemple :

hping3 --icmp-ipid 1 10.0.0.10 --count 5 & \
       ip netns exec h3 hping3 --icmp-ipid 3 10.0.0.10 --count 5 & \
       ip netns exec h2 hping3 --icmp-ipid 2 10.0.0.10 --count 5
Le load balancer différencie les pings selon l’id et le seq. Si deux clients choisissent le même id, cela peut provoquer des collisions. Avec hping3, vous pouvez modifier l’id du ping avec l’option --icmp-ipid et de garantir que chaque client utilise un identifiant unique.

On a fait quoi au fait ?
#

Pour récapituler ce qu’on a mis en place lors de ces deux parties :

Load Balancer DNAT - SNAT

  • Lors de la réception d’un ICMP Echo Request (ping du client) :
    • on a réécrit l’adresse de destination avec une IP d’un des backendsDNAT
    • on a stocké l’IP du client dans une map eBPF en prévision du chemin retour → CONNTRACK
    • on a réécrit l’adresse source avec l’IP du load balancer pour que le chemin retour passe par le load balancerSNAT
  • Lors de la réception d’un ICMP Echo Reply (réponse du backend) :
    • on a récupéré l’IP du client dans la map eBPF → CONNTRACK
    • on a réécrit l’adresse de destination avec l’IP du client → DNAT
    • on a réécrit l’adresse source avec l’IP du load balancer pour que le client ne détecte pas que l’IP a changé → SNAT

Cet épisode est maintenant terminé !

Nous avons vu les bases de la création d’un load balancer XDP : le DNAT, SNAT, le CONNTRACK et le checksum.

Si vous voulez aller plus loin dans la création d’un load balancer, vous pouvez :

  • implémenter un load balancer UDP et TCP
  • implémenter un load balancer sur une machine virtuelle pour se rapprocher d’un environnement de production

J’espère que vous avez apprécié cette série d’article !

Vous pouvez retrouver l’ensemble des petits programmes créés pour ces articles :

Apprenons XDP avec Aya - Cet article fait partie d'une série.
Partie 5: Cet article

Articles connexes