Augmentation des performances de la RIB d’Akvorado grâce au sharding

Vincent Bernat

Pour associer des informations de routage, telles que les chemins d’AS ou les communautés BGP, aux flux, Akvorado peut importer des routes via le BGP Monitoring Protocol (BMP). Comme la table de routage Internet contient plus d’un million de routes, Akvorado doit gérer des dizaines de millions de routes1. C’est un défi de longue date2, mais je pense que ce problème est désormais résolu grâce au sharding de la RIB, une méthode qui découpe la base de routage en plusieurs parties afin de permettre les mises à jour concurrentes.

Implémentation précédente#

Akvorado associe deux éléments pour construire sa RIB :

  1. un arbre de préfixes et
  2. une liste de routes attachée à chaque préfixe.
Implémentation de la RIB BMP d'Akvorado avant le sharding, avec la disposition
mémoire de chaque structure et un unique verrou.
Implémentation de la RIB BMP d'Akvorado sans sharding. Un unique verrou en lecture/écriture.

Dans le diagramme ci-dessus, la RIB stocke cinq préfixes IPv4 et deux préfixes IPv6. L’un d’eux, 2001:db8:1::/48, contient trois routes :

  • depuis le pair 3, via 2001:db8::3:1, AS 65402, chemin d’AS 65402, communauté 65402:31 ;
  • depuis le pair 4, via 2001:db8::4:1, mêmes ASN, chemin d’AS et communauté ;
  • depuis le pair 5, via 2001:db8::5:1, AS 65402, chemin d’AS 65401 65402, communauté 65402:31.

La structure rib est définie en Go de la manière suivante :

type rib struct {
    tree          *bart.Table[prefixIndex]
    routes        map[routeKey]route
    nlris         *intern.Pool[nlri]
    nextHops      *intern.Pool[nextHop]
    rtas          *intern.Pool[routeAttributes]
    nextPrefixID  prefixIndex
    freePrefixIDs []prefixIndex
}

L’arbre de préfixes s’appuie sur le paquet bart, une adaptation de l’algorithme ART de Donald Knuth. Les benchmarks montrent qu’il surpasse les autres implémentations pour les recherches, les insertions et la consommation mémoire3. De plus, l’auteur est d’une grande aide.

Stocker les routes dans une table associative#

La liste des routes de chaque préfixe n’est pas stockée directement dans l’arbre de préfixes : cela mettrait trop de pression sur le ramasse-miettes en allouant un tableau par préfixe.

À la place, la RIB attribue un identifiant de 32 bits unique à chaque préfixe, soit en prenant le dernier identifiant disponible dans le tableau freePrefixIDs, soit en utilisant la valeur nextPrefixID avant de l’incrémenter. Les routes sont ensuite stockées dans la table associative routes, qui s’appuie en Go sur une structure particulièrement optimisée. Pour récupérer les routes attachées à un préfixe, nous les cherchons un par un dans la table routes via une clef de 64 bits qui combine l’index de 32 bits du préfixe avec l’index de 32 bits représentant la position de la route dans la liste. Akvorado parcourt les routes de la première à la dernière pour trouver la meilleure4. Il sait qu’il n’y a plus de route lorsque la clé ne renvoie aucun résultat.

type prefixIndex uint32
type routeIndex uint32
type routeKey uint64

Canonisation des routes#

Une route contient un identifiant de pair BGP, un NLRI partiel5, le prochain saut et les attributs.

type route struct {
    peer       uint32
    nlri       intern.Reference[nlri]
    nextHop    intern.Reference[nextHop]
    attributes intern.Reference[routeAttributes]
    prefixLen  uint8
}

type nlri struct {
    family bgp.Family
    path   uint32
    rd     RD
}
type nextHop netip.Addr
type routeAttributes struct {
    asn              uint32
    asPath           []uint32
    communities      []uint32
    largeCommunities []bgp.LargeCommunity
}

Pour économiser la mémoire et les allocations, les NLRI, les prochains sauts et les attributs de route sont canonisés : un entier de 32 bits remplace la valeur réelle. Ce mécanisme est antérieur au paquet unique introduit dans Go 1.23. Nous le conservons car ses compromis sont différents :

  • il utilise un comptage de références explicite plutôt que des pointeurs faibles ;
  • il fonctionne avec des valeurs non comparables implémentant les méthodes Hash() et Equal()6 ;
  • il utilise des instances explicites pour la canonisation, ce qui se révèlera utile pour le sharding ;
  • il offre de meilleures performances. Voir par exemple ce benchmark ;
  • il consomme deux fois moins de mémoire grâce à des références 32 bits plutôt que des pointeurs ;
  • mais il n’est pas utilisable de manière concurrente.

Qu’est-ce qui pose problème ?#

Note

Pour l’AS 12322, nous n’utilisons pas encore BMP7. Mais Gerhard Bogner a eu la patience, la disponibilité et les compétences techniques pour m’aider à déboguer ce problème.

Le verrou global en lecture/écriture est le goulot d’étranglement de cette implémentation. Mais pourquoi ? Plusieurs composants utilisent la RIB, chacun avec ses propres contraintes :

  • Les consommateurs Kafka consultent la RIB pour enrichir les flux avec des informations de routage. Leur nombre est bornée par la somme des partitions Kafka8. Akvorado ajuste également ce nombre afin de garantir un export groupé efficace des flux vers ClickHouse. Sur notre installation, la quantité de consommateurs oscille entre 8 et 16. Comme nous voulons observer les données récentes le plus tôt possible, nous ne pouvons pas nous permettre que les consommateurs Kafka prennent trop de retard.

  • Les routeurs surveillés envoient leurs routes via le protocole BMP. À la connexion, ils peuvent envoyer des millions de routes9. Après la synchronisation initiale, les mises à jour arrivent en continu et peuvent connaître des pics ponctuels. Le routeur détecte une station BMP bloquée lorsque sa fenêtre TCP est pleine. Dans ce cas, il réinitialise la session. Bien qu’Akvorado dispose d’un important tampon en entrée, il doit appliquer les routes reçues suffisamment vite pour ne pas être considéré comme bloqué.

  • Quand un pair BGP distant tombe, Akvorado purge les routes associées en parcourant la RIB. Quand un routeur surveillé tombe, Akvorado patiente un peu, mais finit par purger toutes les routes associées.

En résumé : sur une installation chargée, la contention sur le verrou est forte autant pour les lecteurs que pour les écrivains, et aucun des deux ne peut prendre trop de retard.

Sharding de la RIB#

Première étape#

Pour supprimer le verrou global, la RIB est découpée en plusieurs « shards », chacun gérant un sous-ensemble des préfixes :

Implémentation de la RIB BMP d'Akvorado après le sharding, avec la disposition
mémoire de chaque structure et un verrou par partition.
Implémentation de la RIB BMP d'Akvorado avec sharding.

L’arbre de préfixes reste global et protégé par un unique verrou. Chaque partition dispose de son propre verrou en lecture/écriture, de sa table de routes et de ses blocs de canonisation pour stocker les NLRI, les prochains sauts et les attributs de route, ce qui n’aurait pas été possible avec le paquet unique de Go. Les index de préfixes sont eux aussi partitionnés : les 8 bits de poids fort correspondent à l’index de la partition et les 24 bits de poids faible à l’index local du préfixe.

Gerhard a confirmé que suite à ce changement à l’aveugle, le composant BMP a commencé à ronronner. 🎉

J’ai ensuite écrit un benchmark portant sur un demi-million de routes synthétiques mais plausibles10, réparties entre 0 et 8 écrivains qui modifient les routes aussi vite que possible, tandis que 1 à 16 lecteurs consultent en boucle un ensemble de 10 000 routes. J’ignore si ce benchmark est réaliste, mais il confirme les améliorations à la fois sur les latences en lecture et en écriture :

Deux cartes de chaleur. L'une pour le ratio de latence en lecture, l'autre
pour celui de la latence en écriture. Toutes deux comparent l'accélération
entre le code avant et après le sharding via des cases colorées. La plupart des
cases sont vertes.
Amélioration des performances de latence en lecture et en écriture après le sharding.

Il montre également qu’un grand nombre d’écrivains dégrade la latence en lecture.

Deuxième étape#

L’unique verrou en lecture/écriture protégeant l’arbre de préfixes constitue la cible suivante. Le paquet bart propose des méthodes alternatives pour les mutations. Elles renvoient un arbre mis à jour via une copie partielle. Les lecteurs n’ont plus besoin du verrou global, qui ne sert plus qu’à synchroniser les écrivains. L’arbre de préfixes est encapsulé dans un pointeur atomique.

Implémentation de la RIB BMP d'Akvorado pour le sharding avec lectures sans
verrou, avec la disposition mémoire de chaque structure.
Implémentation de la RIB BMP d'Akvorado avec sharding et lectures sans verrou.

Sans verrou, un lecteur peut désormais récupérer un index de préfixe périmé en parcourant sa copie de l’arbre, lorsqu’un écrivain concurrent supprime la dernière route attachée à cet index et le recycle pour un autre préfixe. Pour éviter ce problème, nous combinons l’index de préfixe avec un numéro de génération que nous stockons dans l’arbre :

type generation uint32
type prefixRef struct {
    idx prefixIndex
    gen generation
}
type rib struct {
    mu     sync.Mutex
    tree   atomic.Pointer[bart.Table[prefixRef]]
    shards []*ribShard
}

Chaque partition stocke le numéro de génération de chaque index de préfixe local. Ce numéro est incrémenté quand l’index de préfixe associé est libéré. Lors de la recherche des routes attachées à un index de préfixe, le lecteur vérifie que le numéro de génération correspond. Sinon, il considère que l’index a été recyclé et que la liste de routes est vide11. Le schéma précédent illustre ce cas pour l’index de préfixe 5, stocké avec un numéro de génération de 3, alors que la valeur actuelle dans le tableau []generations est 4. Le numéro de génération peut boucler, mais ce n’est pas un problème car les recherches sont rapides.

L’exécution du benchmark sur cette nouvelle implémentation montre les améliorations sur la latence en lecture ainsi qu’en écriture dès que le coût de la copie partielle de l’arbre de préfixes est amorti.

Six cartes de chaleur. Trois pour le ratio de latence en lecture, trois
autres pour celui de la latence en écriture. Elles comparent par paires les
valeurs sans sharding, avec sharding, et avec lectures sans verrou. Pour la
latence en lecture, la plupart des cases sont vertes, ce qui traduit
l'amélioration apportée par la seconde étape. Pour la latence en écriture,
l'accélération est négative pour un faible nombre de
lecteurs.
Amélioration des performances de latence en lecture et en écriture après les lectures sans verrou. La colonne centrale montre les améliorations cumulées des deux étapes.

Parmi les nombreuses tentatives d’optimisation du composant BMP, le sharding de la RIB est l’une des plus satisfaisantes. Akvorado 2.2 implémente la première étape. La PR #2433, rédigée pendant l’écriture de ce billet, implémente la seconde et sortira avec Akvorado 2.4. 🪓