~blog

Bug de connectivité IPv6 : voyage au coeur de radvd et des options de socket

Posted at — avr. 2, 2024

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ême
  • SOL_IPV6: protocole IPv6
  • IPV6_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’ip ff02::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 multicast
  • ifindex: index de l’interface qui va reçevoir les paquets multicast
  • sfmode: 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:

[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);
	}

Un patch a été soumi à radvd.

Merci @xdbob pour le patch, Claire pour le debug réseau et @cedricmaunoury pour le coup de main lors de la recherche !


  1. Nous n’avions aucun log de radvd qui écrit sur stdout/stderr par defaut puis se daemonize, fermant ses fd ↩︎