Trop de champs ! 3 façons d'éviter l'explosion du mapping dans Elasticsearch

blog-thumb-website-search.png

Un système est qualifié d'observable lorsqu'il est doté de trois éléments, à savoir des logs, des indicateurs et des traces. Tandis que les indicateurs et les traces ont des structures prévisibles, les logs (en particulier ceux des applications) sont, en règle générale, des données non structurées qui doivent être collectées et analysées pour être vraiment utiles. Par conséquent, la maîtrise de vos logs est indiscutablement l'étape la plus difficile à franchir pour atteindre l'observabilité. 

Dans cet article, nous présentons trois stratégies efficaces permettant aux équipes de développement de gérer les logs avec Elasticsearch. Pour obtenir des informations plus détaillées à ce sujet, nous vous conseillons de regarder la vidéo ci-dessous.

Video thumbnail

[Article associé : Améliorer l’observabilité et la gestion des données dans le cloud avec Elastic]

Elasticsearch au service de vos données

Parfois, nous ne maîtrisons pas les types de logs que nous recevons dans notre cluster. Par exemple, une entreprise fournissant des solutions d'analyse des logs dispose d'un budget spécifique pour le stockage des logs de sa clientèle. Par conséquent, la question du stockage doit rester stable. (Elastic gère des cas similaires dans Consulting.) 

Souvent, la clientèle indexe des champs au cas où elle en aurait besoin pour mener des recherches. Si c'est également votre cas, les techniques suivantes devraient s'avérer utiles en vous aidant à diminuer vos coûts et à orienter les performances de votre cluster vers ce qui compte vraiment.

Tout d'abord, définissons le problème. Imaginons un document JSON doté de trois champs, à savoir message, transaction.user et transaction.amount, illustré ci-dessous.

{
 "message": "2023-06-01T01:02:03.000Z|TT|Bob|3.14|hello",
 "transaction": {
   "user": "bob",
   "amount": 3.14
 }
}

Le mapping d'un index contenant ce type de documents pourrait ressembler à ce qui suit.

PUT dynamic-mapping-test
{
 "mappings": {
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Or, grâce à Elasticsearch, il est possible d'indexer de nouveaux champs sans devoir obligatoirement préciser un mapping au préalable. C'est une des raisons pour lesquelles Elasticsearch est si facile à utiliser : nous pouvons intégrer de nouvelles données en un tournemain. Ainsi, il est possible d'indexer des champs n'entrant pas dans le mapping original, comme dans l'exemple ci-dessous.

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field with arbitrary data"
 }
}

Un champ GET dynamic-mapping-test/_mapping affiche le nouveau mapping généré pour l'index. Il contient désormais transaction.field3 en tant que text et keyword, soit deux nouveaux champs.

{
 "dynamic-mapping-test" : {
   "mappings" : {
     "properties" : {
       "transaction" : {
         "properties" : {
           "user" : {
             "type" : "keyword"
           },
           "amount" : {
             "type" : "long"
           },
           "field3" : {
             "type" : "text",
             "fields" : {
               "keyword" : {
                 "type" : "keyword",
                 "ignore_above" : 256
               }
             }
           }
         }
       },
       "message" : {
         "type" : "text"
       }
     }
   }
 }
}

Et voilà comme le problème se crée : lorsque nous n'avons aucune maîtrise des données envoyées à Elasticsearch, nous pouvons facilement faire face à ce qui est qualifié d'explosion du mapping. Rien ne nous empêche de créer des sous-champs et des sous-sous-champs qui comprendront les mêmes types susmentionnés, à savoir text et keyword, comme l'illustre l'exemple ci-dessous.

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field",
   "field4": {
     "sub_user": "a sub field",
     "sub_amount": "another sub field",
     "sub_field3": "yet another subfield",
     "sub_field4": "yet another subfield",
     "sub_field5": "yet another subfield",
     "sub_field6": "yet another subfield",
     "sub_field7": "yet another subfield",
     "sub_field8": "yet another subfield",
     "sub_field9": "yet another subfield"
   }
 }
}

Dans ce cas, nous nous retrouvons à gâcher de la mémoire RAM et de l'espace disque pour stocker ces champs, car des structures de données seront créées pour permettre de mener des recherches et d'effectuer des agrégations. En outre, ces champs ne serviront peut-être à rien. Ils existent simplement au cas où ils auraient besoin d'être utilisés pour mener des recherches. 

Dans le cadre de Consulting, lorsque nous devons optimiser un index, une des premières étapes consiste à inspecter l'utilisation de chaque champ dans un index pour connaître ceux dans lesquels des recherches ont été vraiment menées et ceux qui gâchent des ressources.

Stratégie no 1 : la méthode strict

Si nous voulons avoir la totale maîtrise de la structure des logs que nous stockons dans Elasticsearch et sur leur méthode de stockage, nous pouvons établir une définition claire du mapping. Ainsi, tout élément n'entrant pas dans ce cadre n'est tout simplement pas stocké. 

En utilisant le champ dynamic: strict au niveau le plus élevé ou dans un sous-champ, nous rejetons les documents qui ne correspondent pas à notre définition de mappings, ce qui oblige l'expéditeur à respecter le mapping prédéfini.

PUT dynamic-mapping-test
{
 "mappings": {
   "dynamic": "strict",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Ensuite, nous essayons d'indexer notre document avec un champ supplémentaire.

POST dynamic-mapping-test/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field"
   }
 }
}

Ainsi, nous obtenons la réponse suivante.

{
 "error" : {
   "root_cause" : [
     {
       "type" : "strict_dynamic_mapping_exception",
       "reason" : "mapping set to strict, dynamic introduction of [field3] within [transaction] is not allowed"
     }
   ],
   "type" : "strict_dynamic_mapping_exception",
   "reason" : "mapping set to strict, dynamic introduction of [field3] within [transaction] is not allowed"
 },
 "status" : 400
}

Si vous souhaitez avec certitude stocker uniquement les composants des mappings, cette stratégie oblige l'expéditeur à respecter le mapping prédéfini.

Stratégie no 2 : la méthode pas si strict

Nous pouvons faire preuve d'un petit peu plus de flexibilité et laisser passer certains documents, même s'ils ne correspondent pas exactement à nos attentes, en utilisant "dynamic": "false".

PUT dynamic-mapping-disabled
{
 "mappings": {
   "dynamic": "false",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Grâce à cette stratégie, nous acceptons l'ensemble des documents qui nous parviennent, mais indexons uniquement les champs précisés dans le mapping. Ainsi, il n'est pas possible de mener des recherches dans les champs supplémentaires. En d'autres termes, avec ces nouveaux champs, nous ne gâchons pas de la mémoire RAM, seulement de l'espace disque. Les champs peuvent toujours être visibles dans les hits d'une recherche qui comprend une agrégation top_hits. Or, nous ne pouvons pas y mener de recherche ni réaliser une agrégation étant donné qu'aucune structure de données n'est créée pour ce contenu.

La solution n'est pas nécessairement drastique. Il est possible d'avoir une racine pour être strict et un sous-champ pour accepter de nouveaux champs sans les indexer. Cette question est bien abordée dans notre documentation consacrée à la définition du paramètre dynamic dans des objets internes (Setting dynamic on inner objects).

PUT dynamic-mapping-disabled
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "message": {
        "type": "text"
      },
      "transaction": {
        "dynamic": "false",
        "properties": {
          "user": {
            "type": "keyword"
          },
          "amount": {
            "type": "long"
          }
        }
      }
    }
  }
}

Stratégie no 3 : champs d'exécution

Elasticsearch prend en charge le schéma de lecture et le schéma d'écriture. Chacun est doté de ses propres avertissements. Avec dynamic:runtime, les nouveaux champs seront ajoutés au mapping en tant que champs d'exécution. Nous indexons les champs précisés dans le mapping, mais aussi autorisons les recherches et les agrégations dans les champs supplémentaires uniquement au moment de la requête. En d'autres termes, nous ne gâchons pas de mémoire RAM d'emblée avec les nouveaux champs, mais les requêtes sont plus lentes, car les structures de données se forment au moment de l'exécution.

PUT dynamic-mapping-runtime
{
 "mappings": {
   "dynamic": "runtime",
   "properties": {
     "message": {
       "type": "text"
     },
     "transaction": {
       "properties": {
         "user": {
           "type": "keyword"
         },
         "amount": {
           "type": "long"
         }
       }
     }
   }
 }
}

Indexons à présent notre grand document.

POST dynamic-mapping-runtime/_doc
{
 "message": "hello",
 "transaction": {
   "user": "hey",
   "amount": 3.14,
   "field3": "hey there, new field",
   "field4": {
     "sub_user": "a sub field",
     "sub_amount": "another sub field",
     "sub_field3": "yet another subfield",
     "sub_field4": "yet another subfield",
     "sub_field5": "yet another subfield",
     "sub_field6": "yet another subfield",
     "sub_field7": "yet another subfield",
     "sub_field8": "yet another subfield",
     "sub_field9": "yet another subfield"
   }
 }
}

Un champ GET dynamic-mapping-runtime/_mapping montre que notre mapping a évolué lors de l'indexation de notre grand document.

{
 "dynamic-mapping-runtime" : {
   "mappings" : {
     "dynamic" : "runtime",
     "runtime" : {
       "transaction.field3" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_amount" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field3" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field4" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field5" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field6" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field7" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field8" : {
         "type" : "keyword"
       },
       "transaction.field4.sub_field9" : {
         "type" : "keyword"
       }
     },
     "properties" : {
       "transaction" : {
         "properties" : {
           "user" : {
             "type" : "keyword"
           },
           "amount" : {
             "type" : "long"
           }
         }
       },
       "message" : {
         "type" : "text"
       }
     }
   }
 }
}

Désormais, il est possible de mener des recherches dans les nouveaux champs à l'instar d'un champ de mot-clé normal. Il convient de noter que le type de données est défini par conjecture lors de l'indexation du premier document. Toutefois, il est possible de le maîtriser à l'aide de modèles dynamiques.

GET dynamic-mapping-runtime/_search
{
 "query": {
   "wildcard": {
     "transaction.field4.sub_field6": "yet*"
   }
 }
}

Voici le résultat.

{
…
 "hits" : {
   "total" : {
     "value" : 1,
     "relation" : "eq"
   },
   "hits" : [
     {
       "_source" : {
         "message" : "hello",
         "transaction" : {
           "user" : "hey",
           "amount" : 3.14,
           "field3" : "hey there, new field",
           "field4" : {
             "sub_user" : "a sub field",
             "sub_amount" : "another sub field",
             "sub_field3" : "yet another subfield",
             "sub_field4" : "yet another subfield",
             "sub_field5" : "yet another subfield",
             "sub_field6" : "yet another subfield",
             "sub_field7" : "yet another subfield",
             "sub_field8" : "yet another subfield",
             "sub_field9" : "yet another subfield"
           }
         }
       }
     }
   ]
 }
}

Bien. Il est facile de voir comment cette stratégie peut s'avérer utile lorsque nous ne savons pas quel type de documents nous allons ingérer. Par conséquent, l'utilisation de champs d'exécution semble être une approche prudente qui trouve un bon compromis entre les performances et la complexité du mapping.

Remarque concernant l'utilisation de Kibana et des champs d'exécution

Il faut se rappeler que, si nous ne précisons aucun champ lorsque nous menons des recherches dans Kibana via sa barre dédiée (par exemple, en saisissant simplement "hello" au lieu de "message: hello"), cette recherche concernera tous les champs, y compris l'ensemble des champs d'exécution que nous avons déclarés. Cela n'est probablement pas souhaitable pour vous. Dans ce cas, notre index doit utiliser le paramètre dynamique index.query.default_field. Il faut le configurer pour l'ensemble de nos champs cartographiés ou pour certains seulement. En outre, il faut permettre de mener des requêtes explicites dans les champs d'exécution (par ex., "transaction.field3: hey").

Voici à quoi notre mapping actualisé finirait par ressembler.

PUT dynamic-mapping-runtime
{
  "mappings": {
    "dynamic": "runtime",
    "properties": {
      "message": {
        "type": "text"
      },
      "transaction": {
        "properties": {
          "user": {
            "type": "keyword"
          },
          "amount": {
            "type": "long"
          }
        }
      }
    }
  },
  "settings": {
    "index": {
      "query": {
        "default_field": [
          "message",
          "transaction.user"
        ]
      }
    }
  }
}

Sélection de la meilleure stratégie

Chaque stratégie a ses avantages et ses inconvénients. Par conséquent, la meilleure stratégie dépend de votre cas d'utilisation spécifique. Voici un récapitulatif pour vous aider à choisir la stratégie qui répondra le mieux à vos besoins.

Stratégie

Avantages

Inconvénients

No 1 - strict

Les documents stockés sont garantis conformes au mapping.

Les documents sont rejetés s'ils contiennent des champs non déclarés dans le mapping.

No 2 - dynamic: false

Les documents stockés peuvent contenir plusieurs champs, mais seuls ceux qui ont été cartographiés utiliseront les ressources.

Les champs non cartographiés ne peuvent pas être utilisés pour mener des recherches ou réaliser des agrégations.

No 3 - champs d'exécution

Elle comprend tous les avantages de la stratégie no 2.

Les champs d'exécution peuvent être utilisés dans Kibana à l'instar de tout autre champ.

Les temps de réponse sont relativement plus longs lorsque des requêtes sont menées dans les champs d'exécution.

L'observabilité est une des réussites majeures de la Suite Elastic. Qu'il s'agisse de stocker de manière sécurisée des années de transactions financières tout en effectuant le suivi des systèmes impactés ou d'ingérer plusieurs téraoctets d'indicateurs réseau tous les jours, notre clientèle bénéficie d'une observabilité dix fois plus rapide à un coût réduit. 

Vous avez envie de vous lancer avec Elastic Observability ? Le cloud est la meilleure option qui s'offre à vous. Commencez un essai gratuit d'Elastic Cloud dès aujourd'hui !