La montée en charge des passerelles VPN modernes vers des débits de plusieurs gigabits par seconde requiert une distribution hautement efficiente de millions de datagrammes UDP chiffrés sur des dizaines de cœurs de processeur. Dans l'architecture réseau traditionnelle, les fils d'exécution associés à un port d'écoute unique se disputent les paquets via des files d'attente centralisées, provoquant une contention sévère sur les verrous tournants (spinlocks) et saturant le processeur.
Bien que l'option de socket Linux SO_REUSEPORT permette à plusieurs sockets indépendants de s'attacher au même couple adresse IP et port, le mécanisme de répartition par défaut du noyau repose sur un hachage statique à 4 champs (IP source, port source, IP destination, port destination). Dans les flux chiffrés à fort débit, ce hachage rigide présente des limites architecturales majeures : il brise l'affinité de session lors de l'itinérance mobile, désaligne les accès mémoire par rapport aux nœuds NUMA (Non-Uniform Memory Access) et provoque une invalidation continuelle des mémoires caches L3 entre processeurs.
Pour lever ces freins à l'évolutivité, les ingénieurs réseau Linux associent SO_REUSEPORT aux programmes eBPF de sélection de socket (BPF_PROG_TYPE_SK_REUSEPORT). En exécutant un routage programmable directement lors de la résolution du socket dans le noyau, les passerelles obtiennent une affinité de session déterministe, un équilibrage sans verrou et une stricte localité NUMA.
Limites d'échelle du hachage standard SO_REUSEPORT
Dans le noyau Linux par défaut, les datagrammes UDP entrants correspondant à un groupe SO_REUSEPORT sont distribués aux sockets disponibles par l'algorithme reciprocal_scale(skb_get_hash(skb), num_sockets). Si cette approche convient au trafic web sans état, elle dégrade les performances VPN dans trois situations critiques :
Datagramme UDP entrant
│
▼
┌─────────────────────────────────┐
│ Hachage noyau a 4 champs │
│ (src_ip, src_port, dst_ip, dst) │
└──────────────┬──────────────────┘
│
├─────────────────┬─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Socket 0 │ │ Socket 1 │ │ Socket 2 │
│ Cœur 0 (NUMA0)│ │ Cœur 4 (NUMA0)│ │ Cœur 8 (NUMA1)│
└──────────────┘ └──────────────┘ └──────┬───────┘
│ <--- Goulot d'etranglement bus UPI
▼ (Invalidation ligne cache L3)
Cache L3 distant
- Itinérance des clients et réassignation NAT : les utilisateurs de VPN mobiles basculent couramment du réseau Wi-Fi vers le réseau cellulaire, ou subissent une réattribution de port par le routeur NAT de l'opérateur. Lorsque l'adresse IP ou le port source change, le hachage à 4 champs bascule immédiatement, redirigeant les paquets d'une session active vers un autre fil d'exécution. Ce dernier ne possède pas les clés de session dans sa mémoire cache locale, ce qui déclenche des transferts d'état coûteux ou une renégociation complète.
- Latence d'accès mémoire entre nœuds NUMA : sur les serveurs multiprocesseurs actuels, les cartes réseau PCIe sont rattachées directement à un socket processeur donné (nœud NUMA 0). Lorsque le hachage du noyau assigne un paquet à un fil d'exécution opérant sur le nœud NUMA 1, chaque transaction mémoire doit franchir le bus d'interconnexion Ultra Path Interconnect (UPI). Cela fait grimper le temps de traitement de 800 nanosecondes à plus de 3,2 microsecondes par paquet.
- Déséquilibre de charge et flux massifs : la répartition par hachage est purement probabiliste. Lorsqu'un petit nombre de sessions à très haut débit sont hachées sur le même cœur, celui-ci sature à 100 % et rejette des paquets, alors même que les cœurs voisins demeurent inexploités.
Architecture programmable : BPF_PROG_TYPE_SK_REUSEPORT
Le type de programme BPF_PROG_TYPE_SK_REUSEPORT, associé à la structure de données BPF_MAP_TYPE_REUSEPORT_SOCKARRAY, substitue à la fonction de hachage rigide du noyau des règles de distribution personnalisées.
Datagramme chiffre entrant
│
▼
┌────────────────────────────────────────────────────────┐
│ Classificateur eBPF : BPF_PROG_TYPE_SK_REUSEPORT │
│ - Inspecte la charge UDP (index de session WireGuard) │
│ - Lit smp_processor_id() pour alignement NUMA │
│ - Appelle la fonction bpf_sk_select_reuseport() │
└──────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────┐
│ BPF_MAP_TYPE_REUSEPORT_SOCKARRAY │
└────────────────┬─────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Cœur actif 0 │ (Local au nœud NUMA 0) │ Cœur actif 1 │ (Local au nœud NUMA 0)
│ Descripteur 0│ │ Descripteur 1│
└──────────────┘ └──────────────┘
Lorsqu'un paquet arrive dans la phase de recherche du socket UDP, le noyau déclenche le programme eBPF avant toute mise en file d'attente. Le code dispose du contexte struct sk_reuseport_md :
skb->dataetskb->data_end: pointeurs directs vers les données du paquet.sk: socket candidat présélectionné.ip_protocol: protocole de transport (UDP ou TCP).bind_inany: mode de liaison du socket.
En exécutant la fonction d'assistance bpf_sk_select_reuseport(md, &reuseport_map, &key, flags), le code eBPF sélectionne directement l'indice du socket cible dans la table mémoire.
Implémentation noyau : aiguillage par index de session WireGuard
Dans les passerelles WireGuard, chaque trame de données chiffrée renferme un identifiant receiver_index de 32 bits situé à l'octet 4 de la charge UDP. Cette valeur identifie de manière univoque la session du correspondant.
Le code eBPF suivant démontre l'extraction de cet identifiant pour orienter le paquet vers un cœur réservé sur le nœud NUMA local :
#include <linux/bpf.h>
#include <linux/filter.h>
#include <bpf/bpf_helpers.h>
#define WG_DATA_PACKET_TYPE 4
#define NUMA_NODE_CORES 16
struct {
__uint(type, BPF_MAP_TYPE_REUSEPORT_SOCKARRAY);
__uint(max_entries, 64);
__type(key, __u32);
__type(value, __u32);
} reuseport_sock_map SEC(".maps");
SEC("sk_reuseport")
int select_vpn_socket(struct sk_reuseport_md *ctx)
{
void *data = ctx->data;
void *data_end = ctx->data_end;
/* Verification des limites : taille minimale de charge UDP */
if (data + 8 > data_end)
return SK_PASS;
__u8 packet_type = *(__u8 *)data;
__u32 target_socket_index = 0;
if (packet_type == WG_DATA_PACKET_TYPE) {
/* Extraction de l'index de session situe au 4e octet */
__u32 receiver_index = *(__u32 *)(data + 4);
/* Conservation stricte de l'affinite de session */
target_socket_index = receiver_index % NUMA_NODE_CORES;
} else {
/* Trame d'initialisation : affectation au cœur CPU local */
__u32 current_cpu = bpf_get_smp_processor_id();
target_socket_index = current_cpu % NUMA_NODE_CORES;
}
/* Selection de socket sans aucun verrou */
int ret = bpf_sk_select_reuseport(ctx, &reuseport_sock_map, &target_socket_index, 0);
if (ret == 0)
return SK_PASS;
return SK_DROP;
}
char _license[] SEC("license") = "GPL";
Grâce à ce mécanisme, l'ensemble des paquets d'une session est traité de manière pérenne par le même cœur processeur, quelles que soient les mutations d'IP ou de port du client. Par ailleurs, les poignées de main initiales sont traitées sur le cœur recevant l'interruption matérielle de la carte réseau, conservant la chaleur des mémoires caches L1 et L2.
Configuration depuis l'espace utilisateur via SO_ATTACH_REUSEPORT_EBPF
Pour activer l'aiguillage, le service initialise les sockets et attache le bytecode eBPF au moyen des appels POSIX standards :
int setup_reuseport_cluster(int epoll_fd, int bpf_prog_fd, int sock_map_fd)
{
int num_cores = 16;
for (int i = 0; i < num_cores; i++) {
int sfd = socket(AF_INET, SOCK_DGRAM, 0);
int opt = 1;
setsockopt(sfd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(51820),
.sin_addr.s_addr = htonl(INADDR_ANY)
};
bind(sfd, (struct sockaddr *)&addr, sizeof(addr));
/* Enregistrement du descripteur de socket dans la table BPF */
__u32 key = i;
bpf_map_update_elem(sock_map_fd, &key, &sfd, BPF_ANY);
/* Attachement du programme eBPF au premier socket du groupe */
if (i == 0) {
setsockopt(sfd, SOL_SOCKET, SO_ATTACH_REUSEPORT_EBPF,
&bpf_prog_fd, sizeof(bpf_prog_fd));
}
}
return 0;
}
Une fois greffé sur le premier socket, le noyau applique uniformément la règle de décision eBPF à tous les membres du groupe SO_REUSEPORT.
Intégration en production dans l'architecture RiceGuard
Dans son réseau Anycast distribué, RiceGuard emploie BPF_PROG_TYPE_SK_REUSEPORT pour maximiser la densité de traitement :
- Suppression des transferts inter-NUMA : les boucles de calcul cryptographique de RiceGuard sont strictement confinées aux cœurs CPU reliés au bus PCIe de l'interface réseau 100G. Le déchiffrement et l'acheminement des flux s'effectuent au sein du cache L3 local, réduisant la latence globale de 60 %.
- Continuité des sessions en mobilité : lors du passage d'une antenne relais à une autre, le filtre eBPF associe le jeton de trame au fil d'exécution existant sans nécessiter de reprise de négociation cryptographique.
- Mises à niveau sans perte de paquets : lors des déploiements applicatifs à chaud, les nouveaux sockets s'insèrent dans la table
REUSEPORT_SOCKARRAYtandis que les anciens terminent le traitement de leurs flux actifs, prévenant toute rupture de service.
L'alliance de SO_REUSEPORT et de la programmabilité eBPF confère à WireGuard et RiceGuard une extensibilité multi-cœur de premier ordre et une parfaite isolation du trafic dans les infrastructures à très haute densité.



