~blog
Bug de connectivité IPv6 : voyage au coeur de radvd et des options de socket
Context
Shadow est un service de cloud computing spécialisé dans le jeu vidéo. Pour fournir des machines isolées aux utilisateurs, ils comptent fortement sur les machines virtuelles.
Chaque machine virtuelle dispose d’une connectivité avec à la fois IPv4 et IPv6. En IPv4, la configuration réseau est poussée via DHCP, fournissant à la fois l’addressage et les routes par défaut. En IPv6, nous devons utiliser le mécanisme du protocole NDP: le Router Advertisement.
Récemment, nous avons changé notre stack réseau sur les hyperviseurs. Alors que les interfaces virtuelles pour chaque machine virtuelle étaient créées au préalable et persistantes, nous sommes passés à des interfaces virtuelles transitoires les créant et les supprimant chaque fois qu’une machine virtuelle est démarrée et arrêtée.
Après quelques semaines, nous avons remarqué que les machines virtuelles pouvaient perdre aléatoirement leur connectivité IPv6. Bien que désactiver l’IPv6 permette de rétablir un accès à la machine virtuelle, nous avons approfondi nos recherches pour découvrir quelle pourrait être la cause de ce probleme.
Analyse de la connectivité IPv6 dans la VM
Il est observé que la VM ne possède aucune route IPv6 autre que locale dans sa table de routage.
> route print
[...]
IPv6 Route Table
Active Routes:
If Metric Network Destination Gateway
1 331 ::1/128 On-link
4 271 fd12:0:0:a01::a/128 On-link
4 271 fe80::/64 On-link
4 271 fe80::d756:522a:7e8b:339c/128 On-link
1 331 ff00::/8 On-link
4 271 ff00::/8 On-link
Persistent Routes: None
Exemple de configuration correcte:
> route print
[...]
IPv6 Route Table
Active Routes:
If Metric Network Destination Gateway
13 271 ::/0 fe80::fc42:c6ff:fe7e:6388
1 331 ::1/128 On-link
13 271 fd12:0:0:a08::a/128 On-linka
13 271 fe80::/64 On-link
13 271 fe80::6b86:ba8a:cc33:f514/128 On-link
1 331 ff00::/8 On-link
13 271 ff00::/8 On-link
Persistent Routes: None
La VM possède une connectivité IPv6 mais ne sait pas où envoyer ses paquets IPv6.
En IPv6, une fois que la VM a monté et configuré ses interfaces réseau, elle émet une requête de type “RS” (router sollicitation) afin de découvrir les routeurs disponibles.
Entre donc ici radvd
, un petit logiciel installé sur le host, qui va recevoir cette sollicitation et répondre par un “RA” (router advertisement). La VM comprendra que c’est par ce routeur qu’elle doit passer (et ajoute donc la route ::/0 via fe80::fc42:c6ff:fe7e). Ces mécanismes font partie d’un protocole qui s’appelle le NDP (Neighbor Discovery Protocol / protocole de découverte d’hôtes voisins).
Mais ce router advertisement n’est jamais émis lorsque radvd rencontre un certain bug… Une capture du trafic via tcpdump
a pu mettre en évidence l’absence de ce celui ci !
radvd, the culprit
Afin de communiquer ce router sollicitation / router advertisement, radvd va configurer sa socket pour ajouter au groupe multicast l’interface de la VM, permettant à celle-ci de recevoir ces annonces et de configurer automatiquement son interface réseau.
Pour essayer de comprendre ce qui se passe dans radvd1, on utilise strace
. strace
est un outil de debug permettant de suivre les appels systèmes (syscall) d’un logiciel. Avec l’option -p
, on peut suivre ceux d’un processus déjà en cours d’exécution.
$ strace -p <PID de radvd>
[...]
setsockopt(3, SOL_IPV6, IPV6_ADD_MEMBERSHIP, {inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap2")}, 20) = -1 ENOMEM (Cannot allocate memory)
[...]
setsockopt(3, SOL_IPV6, IPV6_ADD_MEMBERSHIP, {inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap4")}, 20) = -1 ENOMEM (Cannot allocate memory)
[...]
setsockopt(3, SOL_IPV6, IPV6_ADD_MEMBERSHIP, {inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap3")}, 20) = -1 ENOMEM (Cannot allocate memory)
[...]
setsockopt(3, SOL_IPV6, IPV6_ADD_MEMBERSHIP, {inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap1")}, 20) = -1 ENOMEM (Cannot allocate memory)
[...]
setsockopt
est un syscall permettant de pouvoir spécifier des options sur une socket. Par exemple on peut utiliser ce syscall pour forcer la reutilisation d’une adresse (avec SO_REUSE_ADDR
), changer la taille du buffer allouée pour la reception des données sur cette socket (avec SO_RCVBUF
). Dans notre cas, il est utilisé pour de rejoindre un groupe multicast (avec IPV6_ADD_MEMBERSHIP
).
Les arguments de setsockopt
dans ce contexte:
3
: le file descriptor de la socket que l’on veut modifier, ça sera toujours la mêmeSOL_IPV6
: protocole IPv6IPV6_ADD_MEMBERSHIP
: Option de la socket pour définir que l’on veut ajouter un membre à un groupe multicast ipv6{inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap1")}
: la structure qui contient les informations sur le groupe multicast a rejoindre-
net_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr)
: converti l’ipff02::2
de string vers binaire
-
ipv6mr_interface=if_nametoindex("tap1")
: index de l’interface réseau tap1
20
: la taille de la structure passée précédement
Tout simplement on essaye d’ajouter l’interface tap1
dans un groupe multicast ipv6 ff02::2
. Mais il renvoie le code d’erreur ENOMEM qu’il échoue, car il ne peut pas allouer de mémoire pour cette opération. Pourtant, côté serveur, il ne semble pas avoir de souci niveau mémoire disponible.
Il faudra donc analyser en profondeur pour déterminer où exactement ce ENOMEM est renvoyé.
En creusant un peu..
Ici on va utiliser l’outil ftrace
qui va pouvoir suivre l’exécution des différentes fonctions côté kernel qui composent le syscall setsockopt
. ftrace
ou function tracer
est un traceur interne du kernel linux qui permet d’analyser, debugger et examiner ce qui se passe dans le kernel via le système de fichier tracefs
.
$ cd /sys/kernel/tracing
# Demande a ftrace de tracer seulement le processus radvd
$ echo <PID de radvd> > set_ftrace_pid
# Demande a ftrace de tracer l'execution de toutes les fonctions exécutées
$ echo function_graph > current_tracer
Pour reproduire le bug, on créé une interface réseau de type tap et on lance une vm qui tente de boot en réseau, qui va permettre de générer un router advertisement de la part de radvd (et donc déclencher cet appel setsockopt()):
$ ip tuntap add mode tap tap1
$ ip link set tap1 up
$ ip addr add fd12:0:0:a08::$1/64 dev tap1
$ QEMU="qemu-system-x86_64 -boot n -net nic -net tap,ifname=tap$1,script=no,downscript=no -nographic"
Et on arrête le ftrace
:
$ echo nop > current_tracer
La trace a été un peu réduite pour la lisibilité
$ cat /sys/kernel/tracing/trace
60) ! 304.905 us | } /* syscall_trace_enter.constprop.0 */
60) | __x64_sys_setsockopt() {
60) | __sys_setsockopt() {
60) | sockfd_lookup_light() {
60) | __fdget() {
60) 0.376 us | __fget_light();
60) 0.995 us | }
60) 1.719 us | }
[..]
60) | sock_common_setsockopt() {
60) | rawv6_setsockopt() {
60) | ipv6_setsockopt() {
60) | do_ipv6_setsockopt() {
60) | rtnl_lock() {
60) | mutex_lock() {
60) 0.308 us | __cond_resched();
60) 0.866 us | }
60) 1.440 us | }
60) | sockopt_lock_sock() {
60) 0.285 us | __cond_resched();
60) | _raw_spin_lock_bh() {
60) 0.321 us | preempt_count_add();
60) 0.998 us | }
60) | _raw_spin_unlock_bh() {
60) | __local_bh_enable_ip() {
60) 0.278 us | preempt_count_sub();
60) 0.860 us | }
60) 1.400 us | }
60) 4.289 us | }
60) | ipv6_sock_mc_join() {
60) | __ipv6_sock_mc_join() {
60) | rtnl_is_locked() {
60) 0.286 us | mutex_is_locked();
60) 0.864 us | }
60) 0.375 us | sock_kmalloc();
60) 6.348 us | }
60) 7.243 us | }
60) | sockopt_release_sock() {
60) | release_sock() {
60) | _raw_spin_lock_bh() {
60) 0.318 us | preempt_count_add();
60) 1.033 us | }
60) | _raw_spin_unlock_bh() {
60) | __local_bh_enable_ip() {
60) 0.307 us | preempt_count_sub();
60) 0.878 us | }
60) 1.407 us | }
60) 3.493 us | }
60) 4.086 us | }
[..]
60) + 22.281 us | }
60) + 23.195 us | }
60) + 23.953 us | }
60) + 24.821 us | }
60) 0.302 us | kfree();
60) + 32.012 us | }
60) + 32.633 us | }
On peut donc voir que utiliser setsockopt
appelle:
- sock_common_setsockopt
- rawv6_setsockopt
- do_ipv6_setsockopt
- sockopt_lock_sock
- ipv6_sock_mc_join
- __ipv6_sock_mc_join
- sock_kmalloc
- sockopt_release_sock
Si ignore un peu tous les lock, la fonction intéressante ici est ipv6_sock_mc_join
(‘ipv6 socket multicast join’) puisque c’est elle qui ajoute l’interface au groupe multicast et fait aussi un appel à sock_kmalloc
.
(ipv6_sock_mc_join
est un petit wrapper sur __ipv6_sock_mc_join
, juste pour ajouter un argument en plus)
static int __ipv6_sock_mc_join(struct sock *sk, int ifindex,
const struct in6_addr *addr, unsigned int mode)
{
struct net_device *dev = NULL;
struct ipv6_mc_socklist *mc_lst;
struct ipv6_pinfo *np = inet6_sk(sk);
struct net *net = sock_net(sk);
int err;
ASSERT_RTNL();
if (!ipv6_addr_is_multicast(addr))
return -EINVAL;
for_each_pmc_socklock(np, sk, mc_lst) {
if ((ifindex == 0 || mc_lst->ifindex == ifindex) &&
ipv6_addr_equal(&mc_lst->addr, addr))
return -EADDRINUSE;
}
[...]
La structure struct ipv6_mc_socklist
est la représentation d’un membre du groupe multicast:
cf plus tôt dans le strace:
{inet_pton(AF_INET6, "ff02::2", &ipv6mr_multiaddr), ipv6mr_interface=if_nametoindex("tap1")}
addr
: l’adresse ipv6 du groupe multicastifindex
: index de l’interface qui va reçevoir les paquets multicastsfmode
: est défini à MCAST_EXCLUDE (via le wrapper ipv6_sock_mc_join, c’est l’argument “mode” de __ipv6_sock_mc_join)rcu
: Read-Copy-Update, une mécanique garantissant la lecture d’une donnée valide dans le contexte de données lues beaucoup plus souvent qu’écrites, de manière concurrente (voir https://www.kernel.org/doc/html/next/RCU/whatisRCU.html)
struct ipv6_mc_socklist {
struct in6_addr addr;
int ifindex;
unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */
struct ipv6_mc_socklist __rcu *next;
struct ip6_sf_socklist __rcu *sflist;
struct rcu_head rcu;
};
Sur un kernel 6.1, la taille de cette structure est de 56 bytes:
gdb /usr/lib/debug/lib/modules/6.1.0-18-amd64/vmlinux
GNU gdb (Debian 13.1-3) 13.1
(gdb) print sizeof(struct ipv6_mc_socklist)
$1 = 56
(gdb)
Après quelques vérifications, la fonction sock_kmalloc
est appelée, avec en argument:
sk
: structure de la socket sur laquelle on travaillesizeof(struct ipv6_mc_socklist)
: taille de la structure à ajouterGFP_KERNEL
: Get Free Page flag, contrôle la méthode d’allocation de mémoire dans le kernel (voir: https://www.kernel.org/doc/html/next/core-api/memory-allocation.html)
[suite de __ipv6_sock_mc_join]
mc_lst = sock_kmalloc(sk, sizeof(struct ipv6_mc_socklist), GFP_KERNEL);
if (!mc_lst)
return -ENOMEM; <- On arrive ici dans notre bug!
[...]
Dans notre cas, sock_kmalloc
semble retourner NULL
puisque nous récupérons un ENOMEM de la part de ipv6_sock_mc_join
et qu’aucune autre fonction dans la suite du code n’a été exécutée (cf le ftrace)
/*
* Allocate a memory block from the socket's option memory buffer.
*/
void *sock_kmalloc(struct sock *sk, int size, gfp_t priority)
{
int optmem_max = READ_ONCE(sock_net(sk)->core.sysctl_optmem_max);
if ((unsigned int)size <= optmem_max &&
atomic_read(&sk->sk_omem_alloc) + size < optmem_max) {
void *mem;
/* First do the add, to avoid the race if kmalloc
* might sleep.
*/
atomic_add(size, &sk->sk_omem_alloc);
mem = kmalloc(size, priority);
if (mem)
return mem;
atomic_sub(size, &sk->sk_omem_alloc);
}
return NULL;
}
sk_omem_alloc
(‘socket option/other memory alloc’) est une variable de type atomic_t (c’est juste un int32_t) qui stock la somme de toutes les allocations mémoires effectuées sur cette socket pour le buffer option (cf atomic_add(size, &sk->sk_omem_alloc);
optmem_max
est la valeur du sysctl net.core.optmem_max
. Dans notre cas, la valeur de celle-ci est de 20480. C’est la taille maximale de la mémoire qui peut être allouée pour le buffer option d’une socket.
$ sysctl net.core.optmem_max
net.core.optmem_max = 20480
En résumé, une socket a une taille maximale définie par la sysctl net.core.optmem_max
pour le buffer “option”, qui est rempli par setsockopt()
. La struct struct ipv6_mc_socklist
utilisée pour stocker les data à propos des interfaces membre du groupe multicast pèse 56 bytes et il faut donc 365 membres pour remplir entièrement le buffer “option” de cette socket, pour un total de 20440 bytes, le 366 génèrera le code ENOMEM car il faudrait 20496 bytes totaux pour accueillir ce nouveau membre.
En théorie ce buffer ne devrait pas être rempli, mais radvd ne supprime pas l’interface du groupe multicast lorsque l’interface est supprimée du serveur (les interfaces tap des VM sont supprimées et crées à la volée lors du lancement d’une VM dans notre situation). De ce fait, plus il y a de machines virtuelles qui vont se lancer et s’éteindre et donc de nouvelles interfaces tap ajoutées au groupe multicast, plus le buffer option de la socket va se remplir et ne jamais être vidé.
Seul un restart du service permet de le vider (on peut aussi augmenter la valeur de net.core.optmem_max
mais le problème risque de ressurgir si la valeur n’est pas fortement augmentée, c’est un fix très temporaire).
La résolution
Si on peut ajouter des interfaces dans un groupe multicast via IPV6_ADD_MEMBERSHIP
.. ne faudrait-il pas les retirer quand elles disparaissent ? Il existe une option pour setsockopt
qui est IPV6_DROP_MEMBERSHIP
qui permet de réaliser cette action.
On peut voir le cleanup dans la fonction cleanup_iface
dans le code de radvd:
int cleanup_iface(int sock, struct Interface *iface)
{
/* leave the allrouters multicast group */
cleanup_allrouters_membership(sock, iface);
return 0;
}
Qui appelle cleanup_allrouters_membership
int cleanup_allrouters_membership(int sock, struct Interface *iface)
{
struct ipv6_mreq mreq;
memset(&mreq, 0, sizeof(mreq));
mreq.ipv6mr_interface = iface->props.if_index;
/* ipv6-allrouters: ff02::2 */
mreq.ipv6mr_multiaddr.s6_addr32[0] = htonl(0xFF020000);
mreq.ipv6mr_multiaddr.s6_addr32[3] = htonl(0x2);
setsockopt(sock, SOL_IPV6, IPV6_DROP_MEMBERSHIP, &mreq, sizeof(mreq));
return 0;
}
Cependant, cleanup_iface
n’est jamais déclenché sauf lors de la fin du programme.
Le fix est donc, lorsqu’un évènement de suppression de l’interface est détecté, un cleanup_iface
est déclenché:
if (nh->nlmsg_type == RTM_DELLINK) {
dlog(LOG_INFO, 4, "netlink: %s removed, cleaning up", iface->props.name);
cleanup_iface(icmp_sock, iface);
}
Merci @xdbob pour le patch, Claire pour le debug réseau et @cedricmaunoury pour le coup de main lors de la recherche !
-
Nous n’avions aucun log de radvd qui écrit sur stdout/stderr par defaut puis se daemonize, fermant ses fd ↩︎