Network Codify

← Blog

Cardinalité : ce que coûte une dimension, selon l'architecture

Quand on construit une plateforme d’observabilité réseau, on cherche naturellement à ajouter du contexte aux données collectées. Prenons une métrique ou un événement syslog qui concerne une interface : on peut envisager de l’enrichir avec le VLAN porté, le subnet, la VRF, et jusqu’au nom de l’application concernée, selon ce que l’environnement permet d’associer à cette interface. La règle prudente est connue : enrichir à l’ingestion avec des dimensions stables et univoques, et renvoyer le reste à la lecture. Cet article explique pourquoi cette prudence n’est pas une précaution de principe, et pourquoi son prix n’est pas le même selon le système qui stocke les données.

Toutes les informations utiles n’ont pas vocation à devenir des dimensions d’identité. Leur place dans le modèle de données compte autant que leur contenu, et pour comprendre pourquoi, il faut partir d’une notion simple : la cardinalité.

Ce qu’est la cardinalité

La cardinalité d’un champ est le nombre de valeurs distinctes qu’il prend dans un jeu de données. Pour dimensionner une plateforme, ce qui compte est moins ce nombre à un instant donné que celui qu’il pourrait atteindre. Un champ environment qui vaut production, staging ou development a une cardinalité de trois, même si un million de logs portent la valeur production.

Information Exemples de valeurs Cardinalité à anticiper
Environnement production, staging, development Faible et bornée
Constructeur cisco, arista, juniper Faible
Équipement router01, router02, switch01 Liée à la taille du parc
Adresse IP source 10.1.2.34, 192.0.2.18 Potentiellement très élevée
Identifiant de session 8af923, b710ef Croît à chaque nouvelle session

La cardinalité dépend du périmètre et de la période. Une adresse IP qui identifie un équipement dans un parc stable n’a pas le même profil qu’une adresse de client sur un service exposé à Internet. Et une forte cardinalité ne veut pas dire « beaucoup de données » : elle veut dire beaucoup de valeurs différentes, ou beaucoup de combinaisons différentes. Pour comprendre pourquoi ces combinaisons comptent, il faut regarder quelles dimensions servent à identifier les données stockées.

Quand une dimension participe à l’identité

Une dimension est une paire nom et valeur : device="router01", site="paris", environment="production". Elle sert à sélectionner et à regrouper : les métriques d’un routeur, les logs d’un site. Dans les systèmes qui organisent les données en séries ou en streams, certaines dimensions jouent aussi un rôle structurel : elles participent à l’identité du groupe dans lequel les données sont rangées. Appelons-les dimensions d’identité. D’autres informations peuvent rester dans le contenu des événements, lorsque le modèle le permet. Les logs et les métriques n’offrent pas les mêmes possibilités : les exemples qui suivent montrent ce que chaque modèle permet de conserver et de rechercher.

L’image la plus simple est celle d’une armoire de dossiers. Ajouter une mesure ou un log revient à glisser une feuille dans un dossier existant. Introduire une nouvelle combinaison de dimensions d’identité revient à ouvrir un dossier de plus. Une armoire avec beaucoup de feuilles dans quelques dossiers ne se gère pas comme une armoire avec des millions de dossiers presque vides. Les dimensions d’identité choisissent le dossier ; les données le remplissent.

Deux conséquences découlent de cette image, avant même de parler d’un produit. D’abord, ce qui compte n’est pas la cardinalité de chaque dimension prise séparément, mais le nombre de combinaisons réellement observées, et la vitesse à laquelle de nouvelles combinaisons apparaissent. Mille équipements avec cent interfaces font cent mille dossiers. Ajouter une dimension à mille valeurs par combinaison en fait cent millions, en théorie. Seules les combinaisons effectivement reçues existent. Et une dimension dont la valeur découle d’une autre n’ajoute aucune combinaison : mille équipements qui ont chacun un seul site font toujours mille combinaisons quand on ajoute le site, pas mille fois le nombre de sites. Ce raisonnement suppose que cette association reste stable sur la période étudiée.

Ensuite, le renouvellement compte autant que le nombre : une dimension qui prend une valeur nouvelle à chaque événement, un identifiant de session par exemple, ouvre sans cesse des dossiers qui ne contiendront qu’une feuille. Ce sont deux charges différentes. Beaucoup de données dans des dossiers stables coûtent du volume : du stockage, prévisible, qui se compresse bien. Une création continue de dossiers ajoute du travail par identité : des entrées d’index, de la mémoire et des fragments de données parfois peu remplis. Leur gestion et leur conservation ont un coût, même si peu de dossiers sont ouverts en même temps. La façon dont ces fragments sont regroupés, compactés et supprimés dépend du moteur.

Reste à savoir où, dans la chaîne de collecte, se prend la décision qui ouvre les dossiers.

Le problème n’est pas d’extraire un champ, c’est d’en faire une dimension d’identité

Un équipement envoie un message syslog dont le contenu ressemble à LOGIN_SUCCESS user=alice src_ip=10.1.2.34 session=8af923. Un pipeline de collecte, quel que soit l’outil, analyse ce texte, normalise les noms de champs et ajoute du contexte. L’événement devient un objet structuré :

{
  "device": "router01",
  "site": "paris",
  "severity": "info",
  "event_type": "LOGIN_SUCCESS",
  "username": "alice",
  "src_ip": "10.1.2.34",
  "session_id": "8af923"
}

Extraire username, src_ip ou session_id est utile, même si ces champs ont beaucoup de valeurs distinctes. Et ce parsing n’ouvre, à lui seul, aucun dossier : il enrichit l’événement. La décision qui compte vient après, au moment d’envoyer l’événement vers le stockage : quelles informations deviennent des dimensions d’identité, et lesquelles restent attachées à chaque événement. Ce choix se configure dans le collecteur, dans son composant de sortie ou dans la chaîne d’ingestion.

Le parsing produit un événement structuré. Seuls les champs devenus dimensions d'identité ouvrent des dossiers ; le reste voyage dans le contenu.

Garder session_id dans le contenu de chaque événement n’augmente pas le nombre de dossiers. En faire une dimension d’identité fait entrer ses valeurs dans l’identité des groupes, et chaque nouvelle session ouvre alors une combinaison. Cela ne veut pas dire que le pipeline est gratuit : le parsing consomme du processeur, les champs supplémentaires grossissent les événements, un traitement avec état ou une agrégation par session consomme de la mémoire. Mais ces coûts sont d’une autre nature que l’explosion du nombre de dossiers. Et c’est là que l’architecture du système de stockage entre en jeu : tous n’ont pas de dossiers, et ceux qui en ont ne les paient pas au même prix.

Ce que cela coûte, selon l’architecture

Les métriques en séries (Prometheus, InfluxDB 1 et 2)

Commençons par les systèmes auxquels l’image des dossiers s’applique directement. Prometheus et ses dérivés (Mimir, Thanos, VictoriaMetrics) stockent des séries temporelles : chaque série est identifiée par un nom de métrique et un ensemble de labels, et contient des échantillons datés.

interface_receive_bytes_total{device="router01", interface="Eth1"}
identité de la série : nom de métrique + labels
    10:00 → 123456
    10:01 → 128000
    10:02 → 135000
le compteur change à chaque collecte, la série reste la même
Tant que l'identité ne change pas, les collectes alimentent la même série.

Deux équipements avec deux interfaces chacun font quatre séries : le nombre de séries suit la taille du parc, rien d’inquiétant. Le problème apparaît quand une dimension non bornée entre dans l’identité, une adresse source sur un compteur de paquets par exemple.

packets_total{device="router01", interface="Eth1", src_ip="10.1.1.10"} 42
packets_total{device="router01", interface="Eth1", src_ip="10.1.1.11"} 17
chaque adresse observée sur cette interface ouvre une série
1 000 équipements × 100 interfaces × 2 directions = 200 000 séries
× 1 000 adresses par combinaison = 200 000 000 séries
Un calcul de scénario, pas une prévision : seules les combinaisons réellement reçues deviennent des séries.

Chaque série entraîne du travail : maintenir son identité, gérer ses échantillons, la retrouver dans les index. Une multiplication excessive se paie en mémoire, en stockage et en index, en coût d’ingestion, en requêtes et en règles d’alerte plus lentes dès qu’elles parcourent beaucoup de séries, et dans les cas extrêmes en saturation. Le churn, la création continue de séries courtes, est une charge à part, et une cardinalité maîtrisée ne rend pas le volume gratuit : la fréquence de collecte et la rétention comptent toujours. C’est pour cela que les bonnes pratiques Prometheus déconseillent les dimensions non bornées, identifiants d’utilisateurs en tête.

InfluxDB, dans ses versions 1 et 2, suit le même modèle sous d’autres noms : une série est une mesure plus un jeu de tags, les tags sont indexés, les fields ne le sont pas. La distinction entre tag et field est exactement celle entre identité et contenu, et l’explosion du nombre de séries quand une adresse IP passe en tag est un accident que beaucoup d’équipes réseau ont vécu.

Séparer le contexte du détail mesuré

Limiter les labels d’une métrique pose une question : quelles informations pourra-t-on retrouver ensuite ? Si l’on ne conserve qu’un compteur de paquets par interface, aucune jointure ne redonnera ensuite la répartition de ces paquets par adresse source. Cette information n’a pas été mesurée, elle est perdue.

Il faut donc distinguer deux choses que le mot « dimension » confond. Le contexte descriptif, le site d’un équipement, son rôle, sa VRF, se récupère par jointure avec une autre source, l’inventaire par exemple, et n’a pas besoin d’entrer dans l’identité des séries. La dimension de mesure, l’adresse source d’un trafic, la file d’attente d’un compteur, fait partie de l’observation : la retirer de l’identité, c’est renoncer au détail, et ce renoncement se décide en connaissant les analyses attendues, pas pour économiser des séries.

Voyons comment séparer ce contexte descriptif de la mesure dans Prometheus. Dans son modèle courant de séries, un échantillon ne porte pas de champs de contexte libres comparables au contenu d’un log : ce contexte est généralement représenté par des labels, qui participent à l’identité. Joindre du contexte à la lecture y prend deux formes. La première est la série d’information, ce que Prometheus appelle une info metric, par convention suffixée _info et de valeur constante 1 : une série de contexte à cardinalité maîtrisée, par exemple interface_info{device, interface, vlan, vrf} 1, générée depuis la source de vérité, et jointe à la mesure au moment de la requête :

rate(interface_receive_bytes_total[5m])
  * on (device, interface) group_left (vlan, vrf)
    interface_info

Le VLAN et la VRF n’entrent pas dans l’identité du compteur ; ils vivent dans une série à part. Un changement de VLAN ou de VRF crée une nouvelle série d’information : le renouvellement est concentré sur ces séries, sans changer l’identité des compteurs. La jointure suppose une seule série interface_info correspondante par couple (device, interface) à chaque instant d’évaluation. La source doit retirer l’ancienne combinaison lorsqu’elle expose la nouvelle ; plusieurs correspondances font échouer cette jointure. Cet exemple suppose donc un VLAN et une VRF univoques par interface.

La seconde forme est la source de vérité qui produit le sélecteur : on lui demande d’abord quelles interfaces portent l’application cherchée, et sa réponse devient le filtre sur device et interface de la requête PromQL. L’inventaire filtre, la TSDB mesure.

Dans les deux cas, retrouver le contexte passé suppose de l’avoir historisé. Un routeur qui passe de Paris à Lyon garde ses anciennes métriques ; les joindre à son site actuel attribue à Lyon du trafic produit à Paris. Il faut décider si l’analyse porte sur le contexte au moment de l’événement ou sur le contexte actuel, et dans le premier cas, la source de contexte doit conserver les périodes de validité et permettre une jointure temporelle. La remarque vaut pour les dimensions corrélées : « un équipement n’a qu’un site » est vrai à un instant donné, pas forcément sur toute la rétention, et ajouter le site crée alors de nouvelles identités au fil des déménagements.

Les logs en streams (Loki)

Loki applique le même principe aux logs. Un stream est identifié par son ensemble de labels, dans un même tenant, et contient des entrées horodatées. Le texte du message peut changer complètement d’une entrée à l’autre sans créer de nouveau stream ; ce sont les labels qui définissent son identité. Prenons trois événements avec les mêmes labels device et severity, mais trois adresses source différentes.

{device="router01", severity="warning"}
    ├── 10:00:01 LOGIN_FAILED src_ip=10.0.0.1
    ├── 10:00:04 LOGIN_FAILED src_ip=10.0.0.2
    └── 10:00:08 LOGIN_FAILED src_ip=10.0.0.3
src_ip dans le contenu : un stream, trois entrées

{device=“router01”, severity=“warning”, src_ip=“10.0.0.1”} {device=“router01”, severity=“warning”, src_ip=“10.0.0.2”} {device=“router01”, severity=“warning”, src_ip=“10.0.0.3”} src_ip en label : trois streams, une entrée chacun

Le nombre de logs n'a pas changé. Leur répartition, si.

Loki indexe les labels de ses streams et stocke les logs dans des blocs compressés, les chunks. Quand les logs se dispersent entre une multitude de streams très courts, l’index grossit et les chunks se multiplient, ce qui augmente les coûts et dégrade les performances. Il ne s’agit pas pour autant de tout mettre dans un seul stream, car le débit par stream compte aussi : on cherche des regroupements utiles et stables.

Rechercher les champs conservés hors des labels

Reprenons le session_id extrait du syslog. Conservé dans le contenu, il ne crée pas de stream supplémentaire, mais il doit encore pouvoir servir à une investigation. Dans Loki, si les lignes stockées sont au format JSON, une requête LogQL sélectionne des streams par leurs labels, analyse leurs lignes et filtre ce champ :

{device="router01", severity="info"}
  | json
  | session_id="8af923"

Les accolades sélectionnent les streams à partir des labels indexés. La suite extrait les champs et ne garde que les événements de la session cherchée. Cette extraction à la lecture ne modifie pas les streams stockés ; elle a un coût de traitement, que l’on contient en bornant le périmètre et la période. Pour conserver une information hors de l’identité du stream, Loki offre aussi les métadonnées structurées, qui attachent des informations aux entrées sans les inclure ni dans le texte du log ni dans les labels du stream.

Emplacement Exemple Ouvre un dossier ?
Dimension d’identité device="router01" Oui
Champ dans le contenu "session_id": "8af923" Non
Métadonnée structurée session_id attaché à l’entrée Non

Garder une information dans l’événement garantit qu’elle existe, pas qu’une recherche la trouvera assez vite pour servir pendant un incident, quand l’ingénieur attend la réponse à l’écran. L’exemple ci-dessus part d’un cas favorable : l’équipement et la sévérité sont connus, les streams à parcourir sont peu nombreux. En investigation, on ne connaît souvent qu’un identifiant de session, sans équipement, sans site, avec une période incertaine, et la même requête doit alors parcourir une part bien plus large des logs. La question de conception qui manque le plus souvent est celle-ci : avec quelles informations l’enquêteur commence-t-il, sur quelle période, et sous quel délai attend-il une réponse ? C’est elle qui décide si un champ peut rester dans le contenu, ou s’il lui faut un index. Même les métadonnées structurées ont leur revers : compter ou regrouper les logs par une métadonnée à forte cardinalité recrée, le temps de la requête, autant de groupes qu’il y a de valeurs, et Loki plafonne ce nombre. La cardinalité que l’on a tenue hors de l’identité réapparaît alors à la lecture. Conserver le champ répond donc à la question de sa disponibilité ; le mode de recherche détermine le travail nécessaire pour l’exploiter.

Les stores colonnaires (InfluxDB 3)

Le coût d’un index par combinaison n’est toutefois pas commun à tous les moteurs. Le passage d’InfluxDB 1 et 2 à InfluxDB 3 permet de comprendre la différence.

InfluxDB 3 et les entrepôts colonnaires utilisés derrière certaines stacks rangent les données autrement : une dimension est une colonne, chaque ligne porte ses valeurs, et une combinaison de dimensions n’a plus d’entrée d’index qui lui soit propre. L’identité de série n’a pas disparu pour autant, InfluxDB 3 conserve une clé primaire faite de l’horodatage et des tags, mais elle ne matérialise plus un dossier par combinaison. La limite de cardinalité des séries, qui était une falaise dans InfluxDB 1 et 2, disparaît donc en grande partie dans InfluxDB 3.

Concrètement, on ne désigne plus une série, on la filtre. Chaque mesure est une table, les tags et les fields sont des colonnes, et la série d’une interface se lit en SQL comme n’importe quelle ligne :

SELECT time, rx_bytes
FROM interface
WHERE device = 'router01'
  AND interface = 'Eth1'
  AND time >= now() - INTERVAL '1 hour'
ORDER BY time;

Les tags servent toujours à filtrer. Les données persistées sont stockées dans des fichiers Parquet, un format colonnaire. Le moteur peut exploiter le tri et les statistiques pour écarter des données inutiles à la requête. L’efficacité de cette sélection dépend de l’organisation des données et des filtres utilisés : un filtre sur src_ip ne garantit pas, à lui seul, que peu de données seront lues.

Prenons un tag src_ip contenant un million d’adresses distinctes. Dans InfluxDB 1 et 2, ces adresses peuvent multiplier les séries et alourdir leur index. InfluxDB 3 supprime cette contrainte liée à l’ancien moteur de stockage.

La cardinalité reste toutefois importante pour comprendre le coût des requêtes. Rechercher les mesures d’une seule adresse ne demande pas le même travail que calculer un total pour chacune des adresses : dans le second cas, le moteur peut devoir calculer et restituer un million de résultats. Même une recherche ciblée peut lire beaucoup de données si leur organisation ne permet pas d’écarter efficacement les fichiers inutiles.

InfluxDB 3 permet donc de conserver des dimensions très cardinales, mais la rapidité des analyses dépend toujours des données à parcourir et des résultats à produire.

Les index inversés (Elasticsearch, OpenSearch et Splunk)

Pour les logs, une autre approche consiste à préparer la recherche en indexant les valeurs présentes dans les événements. C’est le principe à examiner avec Elasticsearch et OpenSearch. Un index inversé fonctionne comme l’index à la fin d’un livre : à chaque valeur, il associe la liste des documents qui la contiennent. Au lieu de partir d’un document pour lire ses champs, le moteur part d’une valeur pour trouver ses documents, d’où le nom. Par défaut, chaque champ d’un document entre dans cet index ; le mapping, c’est-à-dire le schéma qui décrit les champs, permet d’en exclure certains ou de changer la façon de les indexer. Chercher 10.1.2.34 revient alors à ouvrir l’entrée 10.1.2.34 et à lire la liste des documents, sans parcourir les logs : retrouver les documents qui contiennent une adresse précise est ce pour quoi ces moteurs sont faits. Pour des logs, il n’y a pas d’identité de groupe : ce sont les champs indexés et les agrégations qui portent le coût.

Ce coût se paie à trois endroits. D’abord dans les agrégations : regrouper des documents par un champ contenant un million de valeurs distinctes peut demander beaucoup de mémoire et de calcul. Le coût dépend de l’agrégation, du volume de données parcouru et du nombre de groupes à produire. Ensuite dans la taille de l’index : chaque champ indexé ajoute ses entrées, et un log riche de trente champs pèse bien plus que le texte qu’il contient. Enfin dans la structure elle-même : si un pipeline crée un nouveau champ par valeur, par exemple un champ nommé d’après l’identifiant de session, le schéma grossit à chaque événement, et c’est ce cas qui met un cluster à genoux, bien avant un champ unique à un million de valeurs. Le choix analogue à « identité ou contenu » se fait donc champ par champ : indexer ce champ ou non, et permettre ou non les agrégations dessus.

Splunk utilise lui aussi un index du texte des événements, mais le choix du moment où extraire les champs le distingue d’Elasticsearch et d’OpenSearch. En plus des champs indexés par défaut, il permet d’extraire des champs à la recherche. On peut ainsi faire évoluer une extraction sur les événements conservés, au prix du traitement nécessaire pour les analyser. L’extraction à l’indexation reste possible, avec un coût supplémentaire d’indexation et de stockage. Ce compromis mérite donc sa propre ligne dans la comparaison.

Comparer les avantages et les limites

Les exemples précédents montrent les compromis entre stockage, ingestion et recherche. Le tableau les rassemble pour comparer les approches. Les métriques et les logs sont séparés pour rendre ces compromis explicites. Un même moteur peut combiner plusieurs mécanismes ; ce tableau ne constitue pas un classement de performances.

Approche Avantages concrets Inconvénients et limites
Métriques en séries : Prometheus, InfluxDB 1 et 2 Stockage compact pour des mesures répétées. Avec des dimensions stables, de nombreux échantillons partagent la même identité et se compressent au sein de la série. Chaque nouvelle combinaison ajoute un coût d’index et de mémoire. Des labels très variables créent beaucoup de séries courtes et peuvent saturer les ressources, même avec peu d’échantillons par série.
Logs en streams : Loki Moins de données à indexer qu’avec une indexation étendue des champs. Avec des labels maîtrisés et des streams bien remplis, le petit index et les logs compressés réduisent le coût de stockage. Les recherches hors des labels peuvent être lentes. Retrouver une session sans connaître l’équipement ou le site peut demander de parcourir beaucoup de logs. Mettre chaque session en label déplace le problème : l’index grossit et les petits chunks se multiplient.
Stockage colonnaire : InfluxDB 3 Conserver le détail sans l’ancien obstacle de cardinalité. On peut garder les mesures par adresse ou par flux sans multiplier les entrées d’un index de séries comme dans InfluxDB 1 et 2. Une forte cardinalité reste coûteuse à analyser. Un total par adresse peut produire un million de groupes. Une recherche ciblée peut aussi rester lente si l’organisation des données oblige à lire beaucoup de fichiers.
Champs indexés : Elasticsearch, OpenSearch Retrouver plus vite une valeur dans un champ indexé qu’en parcourant tous les logs du périmètre. Une adresse, un utilisateur ou une session peut servir de point de départ, même sans connaître l’équipement. Ce gain de recherche se paie à l’écriture et au stockage. Construire les index ajoute du travail à l’ingestion et les conserver prend de la place. Ils ne rendent pas gratuites les agrégations sur des millions de valeurs.
Texte indexé et champs extraits à la recherche : Splunk Explorer des champs sans les avoir tous définis à l’ingestion. On peut ajouter ou modifier une extraction et l’appliquer aux événements déjà conservés, si leur contenu contient l’information. L’extraction ajoute du travail aux recherches qui l’utilisent. Sur un périmètre large, analyser les événements peut allonger le délai de réponse. Indexer davantage de champs ajoute du travail à l’ingestion et augmente la taille de l’index, sans garantir des recherches plus rapides.

Avant de choisir un label, vérifiez le nombre de combinaisons qu’il peut créer et leur renouvellement, son utilité pour les recherches, et le détail que vous perdriez en l’écartant.

La règle à retenir

Pour les logs, extraire un champ ne coûte rien en soi. C’est l’endroit où on le conserve, dans le contenu, dans un index de champ ou dans l’identité d’un groupe, qui détermine son effet sur la cardinalité et le coût des recherches.

Pour les métriques, ajouter un label modifie l’identité des séries et peut multiplier leurs combinaisons.

Ce qu’on laisse hors de l’identité doit rester retrouvable. Un champ laissé dans le contenu des logs doit pouvoir être recherché dans un délai acceptable pour l’enquête. Un contexte récupéré par jointure, le site d’un équipement par exemple, doit être celui qui était vrai au moment de l’événement, pas celui d’aujourd’hui.

Le choix se fait donc à deux niveaux : conserver les informations nécessaires à l’analyse, puis décider comment les stocker et les retrouver. Pour les logs, un champ utile peut rester dans le contenu sans devenir un label. Pour les métriques, une dimension de mesure qui n’entre pas dans l’identité des séries à la collecte est perdue : aucune jointure ne la recréera ensuite. Seul le contexte descriptif peut venir d’une autre source, avec l’historique nécessaire.


Lectures recommandées : le modèle de données de Prometheus et ses bonnes pratiques de nommage, la documentation Loki sur la cardinalité et les métadonnées structurées, les recommandations InfluxDB 3 sur la conception du schéma, la documentation Prometheus sur le stockage et les jointures de vecteurs, et la documentation Elasticsearch sur le mapping.

Voir aussi le modèle d’indexation de Loki, les structures d’agrégation d’Elasticsearch et l’extraction des champs dans Splunk.