MongoDB vs. Elasticsearch: The Quest of the Holy Performances

MongoDB vs. Elasticsearch: The Quest of the Holy Performances

Beaucoup de temps s’est écoulé depuis le dernier article… Près de 10 mois. Ce délai a pour raisons principalement un changement d’emploi, puisque j’ai abandonné le monde de la multinationale fait d’inertie, de politique et d’enfumage pour passer à celui de la PME, où c’est le bordel, où une équipe de 3 personnes doit gérer un projet de la conception au support client, en passant par le dév, le packaging, la livraison, où les gars sont tous techniquement bien affûtés et ne sont pas là pour se caser à vie dans la position à la fois la plus confortable et la plus rémunératrice possible.
Il y a des avantages, il y a des inconvénients. Le temps fait partie simultanément des 2 catégories, puisqu’on peut se plaindre d’en avoir moins, et en même temps reconnaître qu’on en a moins parce qu’on aime ce qu’on fait et qu’on s’investit plus qu’autrefois.

Dans tous les cas, j’ai pour mon boulot passé un peu de temps à comparer deux moteurs NoSQL et ai finit par écrire un petit article pour notre blog, article dont je vous fournis gracieusement la version française ci-dessous. Vous trouverez l’original ici.

L’article a été initialement écrit en anglais, vous excuserez donc certaines tournures qui pourraient être maladroite en français. En effet, même si je sais très bien ce que j’ai voulu dire, la traduction, quelque soit le sens, est un art exigeant, et d’autant plus dans le domaine informatique qui est, qu’on le veuille ou non, éminemment anglais dans son vocabulaire. On essaie très fort en France de trouver des traductions pour tout, mais ça donne bien souvent des résultats excessivement ridicules (on devrait dire fouineur pour hacker ? Réellement ?).
Par contre je n’ai pas traduit les titres. 87% du temps passé à écrire cet article sont dans les titres, avec leurs mauvais jeux de mots et leurs références bancales, alors on touche pas aux titres !

Enfin, avant l’article, je tiens aussi à remercier Nootal et Geekou pour m’avoir relu et corrigé.

Voilà.


Historiquement, les principales activités de QuarksLab sont le reverse engineering, l’analyse de malware et les pentests offensifs. Mais ces occupations ont donné naissances à plusieurs outils intéressants, et dont nous sommes certain qu’ils pourraient être très utiles aux entreprises désireuses de développer leurs compétences en interne plutôt qu’externaliser toutes leurs problématiques de sécurité.

Ivy fait partie de ces outils. On pourrait même parler de solution à part entière, dans la mesure où c’est maintenant un projet mature, bénéficiant d’un process de développement industriel, d’un déploiement automatique et de clients entre autres.
Ivy est un logiciel de reconnaissance réseau dont le but est de détecter des failles potentielles sur des réseaux de l’ordre de 1 à 100 millions d’adresses IP (ou plus, selon ce que vous pouvez vous offrir). Les sondes collecteurs peuvent accomplir un grand nombre d’opérations réseaux, depuis la récupération de headers HTTP basique jusqu’aux tentatives d’exploitations de services vulnérables. « Qui ne le fait pas ? » me direz vous – oui mais en plus de ça, Ivy permet l’intégration de plugins personnalisés, ce qui permet de s’adapter aux services les plus spécifiques, du moment qu’ils peuvent être atteint avec une adresse IP (actuellement IPv4, l’intégration d’IPv6 étant dans les cartons).

Mais assez parlé de la partie « collecteur ». Nous ne parlons pas non plus de notre composant central, le « dispatcheur », malgré qu’il contiennent plusieurs développements intéressants. Le point central d’un logiciel comme Ivy n’est pas tellement de pouvoir récupérer des données que d’être capable de fournir aux utilisateurs le moyen le plus rapide, précis et pertinent d’exploiter ces données. Et nous voilà arrivés au choix de la base de données.

Environment

Equipement

Parlons tout d’abord de notre équipement. Nous ne voulons que notre frontend Ivy ai besoin de tourner sur de gros serveurs. Ce serait beaucoup trop facile, et surtout très encombrant. Nous voulons que le frontend soit capable de tourner sur un ordinateur léger et commun, comme un Raspberry Pi A… enfin peut-être pas, on attend tout de même une base de données de plusieurs millions, sinon milliards, de documents. Par contre, un ordinateur équipé d’un processeur Intel Core 7 4700MQ, de 8GB RAM et d’un SSD honnête est un choix raisonnable (les coïncidences font que ce sont également les caractéristiques exactes de l’ordinateur portable sur lequel la plupart des tests présentés ci-après ont été fait). Nous avons également plusieurs utilisateurs qui utilisent des machines virtuelles pour faire tourner Ivy, avec 2 vCPU et 2GB RAM. Enfin, lorsque nous avons besoin d’un peu plus de puissance de feu, nous avons un serveur équipé d’un processeur Intel Xeon E5-1620, de 64GB RAM et du RAID-0 de SSD. Rien de plus : pas de serveur outrageusement puissant, pas de cluster avec des centaines de nœuds, par de service cloud extérieur comme AWS.

Tools

L’équipe Ivy a tendance à privilégier le langage Python, donc la plupart (sinon tous) des scripts présentés ci-après seront écrit dans ce langage.

On partira du principe que les variables globales db et es seront définies comme ceci :

  db = pymongo.MongoClient()[<some_database>]
  es = elasticsearch.Elasticsearch()

Where does Ivy come from

Ivy est né avec MongoDB. La problématique principale de ses premières versions était de pouvoir stocker facilement des données non structurées. Pour chaque adresse IP  scannée par Ivy, certains ports pouvaient être ouverts, d’autres non, parfois aucun. Des informations pouvaient être récupérés, ou pas, un plugin pouvait être compatible, récolter des données incomplètes, brutes ou structurées. Au final, la structure des données analysées pouvait drastiquement changer d’une adresse IP à une autre.

Il était donc dès le début, nécessaire d’utiliser une base acceptant des données non structurées. Et à cette époque, MongoDB semblait être le meilleur choix : schemaless, utilisant des documents au format BSON, avec un client Python solide, de bons retours de la communauté, une documentation sérieuse, une CLI intuitive, des agrégations puissantes et prometteuses, ainsi que d’autres petits gadgets (comme par exemple l’ObjectId).

La façon dont nous stockions des données dans MongoDB était très simple : chaque adresse IP d’un scan est un document, appartenant à une unique collection. Les champs les plus important de ce document sont : quels ports sont ouvert, et quels plugins ont retournés un réponse. Les sous-documents contenus dans ces deux champs peuvent être très complexes.

Par exemple, voici le document d’une adresse IP très basique :

{ "_id" : ObjectId("54c921e3902ed0377abffcab"),
  "country" : "US",
  "hostIpnum" : "1234567890",
  "tlds" : ["zetld.com"],
  "ports" : [{
    "service" : "http",
    "server" : "Microsoft IIS",
    "version" : "",
    "port" : {
      "value" : 443,
      "protocol" : "tcp"
   }}],
  "pluginResults" : {
    "sc:nse:http-headers" : [{
      "relatedPorts" : [{
        "value" : 443,
        "protocol" : "tcp"
      }],
      "data" : {
      "outputRaw" : "\n  Content-Length: 3151  \n  \n  (Request type: GET)\n"
    }}],
    "sc:nse:sslv2" : [{
      "relatedPorts" : [{
        "value" : 443,
        "protocol" : "tcp"
      }],
      "data" : {
      "outputRaw" : "\n  SSLv2 supported\n    SSL2_RC4_128_WITH_MD5\n    SSL2_DES_192_EDE3_CBC_WITH_MD5"
    }}]}
}

MongoDB n’a évidemment aucun problème pour gérer ce genre de document.

Nous avons alors commencé à développer deux features intéressantes : les filtres et les agrégations

Filtre veut dire exactement ce que ça veut dire. Pouvoir filtrer les adresses IP, quelque soit le facteur discriminant, est essentiel pour pouvoir facilement fouiner dans des résultats.
Un filtre très simple pourrait être : n’affiche que les adresses IP avec au moins 4 ports ouverts.

Les agrégations servent à éclairer un point de vue particulier sur un scan. Ivy n’est pas vraiment adapté à l’analyse IP par IP. Il est conçu pour exploiter les données extraites d’un réseau entier. Connaître la distribution du nombre de ports ouverts, le classement des principaux services détectés sur le réseaux, ou tout simplement les pays auxquels appartiennent les adresses IP sont des informations cruciales auxquelles les utilisateurs doivent avoir un accès immédiat et adapté à ce qu’ils cherchent. Bien sûr les agrégations doivent tenir comptes des éventuels filtres appliqués.
Par exemple, affiche la distribution des noms de services avec leurs version sur toutes les adresses IP est une agrégation.

Inferno – The twilight of MongoDB

Apocalypse

Toutes ces fonctionnalités fonctionnaient sans soucis sur nos bases d’exemples. Notre environnement de tests était encore naissant, et il nous manquait une plate-forme d’intégration complète avec un sample à taille réelle pour stresser nos développements, ce qui fait que nous n’avons pas vu arriver l’orage.

Nous avions été accepté pour présenter Ivy à la conférence Hack In The Box 2014 en Malaisie. Pour l’occasion, nous voulions utiliser un scan que nous avions déjà fait sur ce pays. La dimension du pays n’était pas excessive – 7 millions d’adresse IP, nous avions déjà fait beaucoup plus important -, mais le scan était assez poussé, il impliquait beaucoup de ports et de plugins, et les résultats étaient très denses. Pour la démonstration, nous avons migré ce scan dans une VM et l’avons testé.

Les performances étaient tellement mauvaises que le serveur HTTP levait des timeout errors, laissant l’interface vide et froide comme les landes écossaises en hiver.

Il s’avérait que chaque agrégation prenait entre 30 et 40 secondes. Dans la mesure où la page principale d’un scan contenait 4 agrégations, chaque passage sur cette page bloquait le système entier, empêchant tout autre accès à la base de données.

Comment avons-nous réussit à rendre ces performances acceptables ? Et bien comme toujours lorsque les deadlines sont très proches : avec des hacks sales. Nous avons mis en place un mécanisme de cache très stupide, séparé les résultats dans des collections dédiées à un unique scan, blindé ces dernières d’indexes, et dopé la VM à 6GB.

La VM Ivy a bien tenu le coup durant la conférence, mais il semblait évident que nous avions de réels problèmes de performance dans notre logiciel.

Ivy a été conçu de façon à ne pas être asservi à MongoDB. Le niveau d’abstraction est d’ailleurs suffisant pour permettre de changer de base de données assez facilement. Bien sûr, il faudrait épurer un peu de dette technique, mais la plus grande partie du code – notamment les filtres – sont construits par-dessus une IR (Intermediate Representation) qui nous permet de changer de base de données simplement en implémentant un classe de traduction IR_to_new_db.

Néanmoins, dans la mesure où nous étions des newbies avec MongoDB, nous avons pensé que nous ne sachions tout simplement pas l’utiliser, et qu’il fallait juste prendre le temps de tester notre logiciel pour améliorer les performances, sans avoir besoin de changer de base de données.

Fixing the cripple

Dedicated collection

Ranger les adresses IP dans des collections spécifiques à chaque scan était un piètre hack que nous avons cependant intégré dans Ivy. En fait, son intérêt n’est pas vraiment d’améliorer la rapidité d’accès aux résultats des gros scans, mais plutôt d’isoler les autres (petits et moyens scans) pour que leurs performances ne soient pas grevées par les plus importants.

Pre-calculated figures

Les agrégations proposées dans Ivy était dynamique, de façon à être toujours adaptées aux différents filtres à un instant T. Pour l’état par défaut, lorsqu’aucun filtre n’est activé, nous avons pré calculé nos agrégations afin que leurs résultats soient immédiatement affichés.

Le problème que nous rencontrons maintenant est que dès qu’un utilisateur applique un filtre, le calcul des agrégations redevient dynamique et le temps de réponse redevient très long alors que l’ensemble est plus petit (car filtré), ce qui tranche négativement par rapport aux agrégations par défaut.

Indexes

Nous nous sommes rendu compte que plusieurs de nos indexes n’étaient pas du tout performant. Les indexes sur des feuilles à la racine des documents sont puissantes. Mais nos informations les plus intéressantes sont stockées beaucoup plus profondément.

Prenons l’exemple du filtre par numéro de port. Une requête très commune ressemble à {« ports.port.value »: 53}. Rien de bien compliqué. Ça ne parait pourtant pas si simple appliqué à un scan de 22 million d’adresse IP scan sur un portable

  • 168 secondes sans index,
  • 121 secondes avec un single index,
  • 116 secondes avec un sparse index (un index qui ignore les documents ne possédant pas le champs indexé).

Ce n’est pas tant le temps de réponse inacceptable qui fait peur ici. C’est le minuscule ratio de  ~1.39 (~1.46 avec un sparse index) entre un requête sur une collection sans aucun index, et sur une collection avec un index. À titre de comparaison, une requête sur une feuille à la racine du document comme « hostIpnum » (la requête est {« hostIpnum »: 1234567890}), donne:

  • 323.808 secondes sans index,
  • 0.148 secondes avec un single index,
  • 0.103 secondes avec un sparse index (en sachant qu’il est impossible qu’un document n’ai pas de champs « hostIpnum »...).

… ce qui donne un ratio de ~2186.68 (~3151.69 avec un index sparse) ce qui est bien plus efficace.

Nous avons tout d’abord pensé que c’était le fait que certains champs abritaient des listes, comme notre champs « ports », qui ruinaient les performances des indexes MongoDB. Dans la mesure où nos deux champs abritant les données les plus importantes, ports et plugins, étaient des listes, nous avons tentés d’utiliser un format exempt de liste, ce qui aurait dû améliorer les performances. Nous avons changer notre représentation des ports, depuis l’ancien:

{"ports": [ {"port": {"value": <value1>
             "protocol": <protocol1>},
             "service": <service1>,
             "version": <version1> },
            {"port": {"value": <value2>
             "protocol": <protocol2>},
             "service": <service2>,
             "version": <version2> },
          ...],
...}

 … vers le nouveau :

{"ports": {<protocol1>: {<port1>: {"service": <service1>,
                                   "version": <version1>},
           <protocol2>: {<port2>: {"service": <service2>,
                                   "version": <version2>},
          ...},
...}

… de cette façon, la requête n’était plus {« ports.port.value »: 80}, mais plutôt quelque chose comme {« ports.tcp.80 »: {« $exists » 1}}.

Déjà, avant toute chose, cette solution n’est pas viable, car certaines adresses IP possèdent un grand nombre de ports ouverts (plus de 20 n’est vraiment pas rare). Comme l’index devrait alors être appliqué soit sur « ports », soit sur « ports.<protocol> », le sous-document BSON de ce champ ne pourrait pas excéder la index key limit de 1024 bytes, sous peine de faire échouer les insertions de document (ou la construction de l’index).
Mais partons du principe que nous évitons ce problème en appliquant un index directement sur le champs « ports.tcp.80 » (ce qui signifie qu’à terme il nous faudrait créer un index pour chacun des 65 535 ports possibles, et pour chacun des protocoles TCP et UDP). Les résultats (avec la méthode explain de MongoDB) d’une requête sur une collection de 566 371 adresses IP donne :

  • 67 secondes sans index,
  • 108 secondes avec un single index,
  • 58 secondes avec un sparse index.

Pas besoin de commenter ces chiffres.

Une façon peu élégante de mitiger ce problème fut d’implémenter le calcul d’informations basiques pour les ports et les plugins (« nbOpenedPorts » <int> et « hasPluginResults » <bool>), stockés en tant que feuille à la racine des documents des adresses IP, avec des singles indexes, ce qui permet à Ivy d’injecter plus de conditions dans ses requêtes (par exemple, {« nbOpenedPorts »: {« $gt »: 0}, « ports.port.value »: 53}). Comme le montre les résultats de la méthode explain, MongoDB utilisent systématiquement ces nouveaux indexes pour ses requêtes, qui ont de meilleures performances que les anciens. Les résultats sur une collection de 22 millions de documents (sur notre plus puissant ordinateur) sont :

  • 23.6267 secondes avec {« ports.port.value »: 53},
  • 1.7939  secondes avec {« nbOpenedPorts » : {$gt: 0}, « ports.port.value »: 53}.

… ce qui est la différence entre le ragequit d’un utilisateur et une expérience « vraiment-pas-fou-mais-néamoins-acceptable ».

Cette stratégie a cependant ses limites dans la mesures où MongoDB ne sait pas exploiter plusieurs single indexes en même temps. MongoDB lance chaque requête indépendamment sur tous les indexes de la collections (et lance aussi une requête sans aucun index), puis attend la réponse la plus rapide, et enfin tue les requêtes restantes. Ce qui fait que la requête la plus précise possible ne pourra jamais être plus rapide que la requête la plus vague, mais possédant un élément qui fera jouer l’index le plus performant.

Nous ne pouvons pas utiliser les compound indexes, car le mécanisme de filtre et d’agrégations d’Ivy est trop complexe pour que nous puissions anticiper toutes les possibilités et il serait absurde de créer des compound indexes pour coller à chaque requête. Et même si nous le voulions, MongoDB impose un maximum de 64 indexes par collection, ce qui est réellement une limite handicapante lorsqu’on veut triturer nos données de n’importe quelle façon. Contrairement à d’autre options, cette limite ne semble pas pouvoir être modifier à travers la configuration.

Purgatorio – Still feeling a little tense in here

Time is everything

Avec ces modifications, ainsi que quelques autres pirouettes, nous avions un système très honnête, capable de gérer des scans de 5 à 10 millions d’adresses IP sur un portable, et jusqu’à 25-30 millions d’adresses IP sur notre serveur. Ces échelles restaient néanmoins nettement en dessous de nos ambitions initiales.

La question des agrégations était notre plus grande déception. Nous voulions qu’Ivy soit capable d’offrir le plus de liberté possible à ses utilisateurs pour qu’ils mettent en place leurs propres métriques, et nous étions impatient de développer un moteur d’agrégation souple et complet. Nos optimisations étaient convenable pour notre moteur actuel – liberté quasi-totale pour les filtres, et des agrégations simples et prédéfinies. Mais il était évident que nous ne pouvions pas donner plus de contrôle aux utilisateurs sur les agrégations à cause des performances.

Nous aurions pu rajouter d’autres agrégations prédéfinies, plus intéressantes, comme par exemple la distribution de la taille des clefs SSL publiques. Mais ça n’aurait été qu’un écran de fumée, avec en plus la garantie qu’un jour ou l’autre les métriques que nous fournissions ne seraient pas adaptées aux besoins d’un utilisateur.

Space and dark matters

Nous étions de plus gênés par plusieurs « largesses » de MongoDB, notamment pour ce qui est de l’administration ou de la manipulation de nos frontends.

MongoDB, très clairement, ne se pose aucune question pour ce qui est de l’espace. Absolument aucune. Désactiver l’option data file preallocation ou le forcer à ne pas générer de gros fichiers ne font que retarder l’inéluctable engloutissement de notre espace disque et de notre RAM.

Pour commencer, MongoDB garde sciemment de grands espaces inutilisés entre les documents lorsqu’il les insère, de façon à pouvoir stocker de futurs documents de façon plus ou moins ordonnée. Les indexes prennent également une place très significative. Par exemple, sur une collection nouvellement créée (donc, espérons-le, avec le minimum de pertes de fragmentation possible) possédant 9 indexes (en comptant l’index par défaut « _id »), nous avons :

  • taille du fichier BSON utilisé pour la restauration : ~2.77GB
  • db.<collection>.count(): 6,357,427 documents
  • db.<collection>.dataSize(): ~4.08GB
  • db.<collection>.storageSize(): ~4.47GB
  • db.<collection>.totalIndexSize(): ~1.20GB
  • db.<collection>.totalSize(): ~5.67GB

L’espace disque n’est pas (pour le moment) le problème ici, mais dans tous les cas les indexes devraient être en RAM pour être efficaces. En sachant qu’une collection de cette taille est considérée comme moyenne, et qu’elle a de nombreux frères et sœurs, on commence à transpirer un peu pour nos performances.

MongoDB a de plus une tendance certaine à la fragmentation, que ce soit sur le disque ou en RAM. L’utilisation des méthodes update et remove a un impact dangereux sur les collections, ainsi que l’insertion de documents avec des tailles très variables (ce qui est le cas dans Ivy, puisque certaines adresses IP peuvent être muettes tandis que d’autres sont très prolixes). On tombe ainsi sur des situations où une collection réduite à quelques milliers de documents, après en avoir comptés des millions, ne libérera quasiment pas d’espace disque – en fait, elle n’en libérera pas du tout.

Les choix techniques expliquant cette consommation d’espace sont compréhensibles, mais ça n’enlève rien au fait que c’est réellement gênant dans plusieurs de nos situations courantes :

  • la manipulation de VM en .ova (compression, uploading, downloading, etc.),
  • les dumps et restaurations de base de données (et même si les outils mongodump et mongorestore sont très pratiques),
  • la réduction de la taille de la base (c’est d’ailleurs assez ironique : la commande db.repairDatabase qui permet de nettoyer et réduire une base de données, à besoin d’autant d’espace libre que la taille de la base de données à réduire. En gros, au moment où le besoin d’espace se fait le plus sentir, il nous faudrait quelques centaines de giga supplémentaire pour s’en tirer…).

Well…

Nous étions arrivés à un point où nous pouvions encore voir quelques points sur lesquels nous pensions pouvoir encore gagner en performances. Mais les modifications à apporter étaient profondes et coûteuses, pour un gain incertain. Nous avons donc plutôt commencé à manipuler d’autres technologies, parmi lesquels Elasticsearch.

Paradisio? – The dawn of Elasticsearch

Il est impossible de comparer trivialement MongoDB et Elasticsearch. C’est tout simplement impossible. Les deux systèmes ne cherchent pas à répondre aux même problématiques et n’exploite pas la donnée de la même façon. Pour se cacher derrière les mots : MongoDB est une base de données, tandis qu’Elasticsearch est un moteur de recherche. Cela prend plus de sens lorsqu’on manipule les deux outils : MongoDB est tout dans la souplesse des données, et Elasticsearch a une approche plus prudente et ordonnée.

Tout d’abord, un petit topo sur Elasticsearch. Elasticsearch est – grossièrement – un wrapper par dessus la bibliothèque de recherche de texte Lucene. Pour faire simple, Lucene gère les opérations « bas niveau » comme l’indexation et le stockage des données, pendant qu’Elasticsearch apporte plusieurs couches d’abstraction pour accepter du JSON, offrir une API REST sur HTTP et faciliter la constitution de clusters.

Dans la mesure où on ne s’intéressera ni à des moteurs de recherche exploitant Lucene comme Solr, ni à l’exploitation de Lucene seul, et comme Elasticsearch est difficilement viable sans Lucene, nous ne ferons par la suite aucune différence formelle entre Elasticsearch et Lucene, et emploierons le terme Elasticsearch pour désigner le tout.

Poking the bear

Pour commencer, Elasticsearch devait au moins être capable de pouvoir reproduire le même comportement que nous avions avec MongoDB.

Filters

N.B : nous parlons de filtres au sens des filtres Ivy. Le système de requête d’Elasticsearch utilise des notions de filter et query que nous n’aborderons pas ici, dans la mesure où elles ne rentrent pas dans le périmètre présent.

Les filtres furent faciles à répliquer, juste le temps d’assimiler le système de requête d’Elasticsearch et de trouver les équivalences. Nous pouvions néanmoins déjà constater quelques améliorations au niveau de la logique des requêtes Elasticsearch par rapport à celles de MongoDB.

Par exemple, nous avions des problèmes avec l’opérateur $not de MongoDB, pour obtenir la négation d’un filtre. Prenons le filtre :

{"nbOpenedPorts": {$gt: 4},
 "ports.port.value": 80}

$not n’est pas un opérateur racine, ce qui fait que nous ne pouvons pas faire :

{$not: {
  "nbOpenedPorts": {$gt: 4},
  "ports.port.value": 80
}

… il nous faut propager le $not jusqu’à chaque feuille.

De plus, $not doit s’appliquer soit à une expression régulière, soit à une expression contenant un autre opérateur, ce qui signifie que la négation de {« ports.port.value »: 80} ne peut pas se faire avec $not, mais devra utiliser un autre opérateur, $ne.

Il nous faut donc propager l’opérateur $not à travers notre IR en inversant chaque opérateur les un après les autres, appliquer les lois de De Morgan, et remplacer $not par $ne lorsque c’est nécessaire.
Finalement, la négation de la requête sera :

{ $or: [
  {"nbOpenedPorts": {$not: {$gt: 4}}},
  {"ports.port.value": {$ne: 80}}
]}

Dans Elasticsearch au contraire, les opérateurs sont utilisés d’une façon beaucoup plus homogène et logique, ce qui évite ce genre de bidouillage.

L’équivalent de la requête de base est :

{"filter": {"and": [
  {"term":
    {"ports.port.value": 80}},
  {"range":
    {"nbOpenedPorts": {"gt": 4}}}
]}}

… ce qui est effectivement plus gras, mais à l’avantage d’être sans ambiguïté.

Pour nier cette requête, on peut très facilement insérer l’opérateur not à la base de la requête comme ceci :

{"filter": {"not": {"and": [
  {"term":
    {"ports.port.value": 80}},
  {"range":
    {"nbOpenedPorts": {"gt": 4}}}
]}}}

… sans aucun détour.

En passant, nous avons jeté un œil aux temps pris par ces requêtes. Sur un ordinateur portable, appliqué à un collection de 6 357 427 documents, pour compter le nombre de documents valides selon les requêtes nous obtenons :

  • 14.649 secondes avec MongoDB,
  • 0.715 seconds avec Elasticsearch,

… soit un ratio de ~20.5 au mieux entre MongoDB et Elasticsearch en faveur du dernier.

Aggregations

Comme nous l’avons déjà dit, le système de requête d’Elasticsearch est très homogène, et les agrégations confortent ce point.

MongoDB utilise un pipeline spécifique aux agrégations, de la forme [{« $match »: <query>}, {« $group »: <agg>}, {« $sort »: <sort>}, …].

Les agrégations Elasticsearch sont totalement intégrées dans les requêtes, et même si elles sont au final bien plus importantes qu’un pipeline MongoDB, et sont réellement plus claires. Prenons l’exemple très simple de l’agrégation « par pays ». Nous voulons connaître le nombre d’adresses IP pour chaque pays trouvé dans le scan.

Le pipeline MongoDB répondant à cette question est :

[{"$group": {"_id": "$country", 
             "total": {"$sum": 1}}},  # Sum of IP addresses by "country"
 {"$sort": {"_id": 1}}]               # Sort on the country names

 L’équivalent Elasticsearch est :

{"aggs": {<agg_name>:
  {"terms": {"field": "country",         # Aggregation on field "country"
             "size": 0                   # Return every buckets
             "order": {"_term": "asc"}}  # Sort on the country names
}}

 Encore une fois, les performances d’Elasticsearch par rapport à celles de MongoDB sont bluffantes. Sur le même ordinateur portable, avec la même collection, nous mesurons :

  • 42.116 secondes avec MongoDB (premier essai),
  • 3.799  secondes avec MongoDB (deuxième essai, le premier ayant « réchauffé » la RAM),
  • 0.902  secondes avec Elasticsearch (premier essai).

… soit un ratio de ~46.7 pour le première essai (~4.2 avec le second) entre MongoDB et Elasticsearch en faveur de ce dernier.

We ain’t goin’ out like that

Un aspect d’Elasticsearch qui diffère profondément avec MongoDB est que ce n’est pas schema-less. Il est possible d’indexer un document sans aucune information supplémentaire que la donnée seule, mais le moteur va automatiquement mapper les champs. Une fois que ces derniers sont définies, il n’est plus possible (contrairement à MongoDB), d’indexer un nouveau document du même type avec format différent.

Ce concept de mapping est peut-être ce qui explique les bien meilleurs résultats d’Elasticsearch par rapport à MongoDB pour ce qui concerne les filtres et les agrégations, et peut aussi bien donner une structure utile aux données que détruire une application en retournant de faux résultats.

String analysis

Par exemple, Elasticsearch analyse par défaut les champs de type string, ce qui fait qu’un champ « Foo&Bar-3″ sera schématiquement séparé en « foo », « bar », « 3 ». Cette division facilite le tri du texte dans un index inversé (utilisé par Lucene), et la normalisation des mots (dans ce cas passage en minuscules) améliore les performances de recherche. Elasticsearch offre plusieurs types d’analyses, et il est même possible d’en définir soi-même.

Dans notre cas cependant, nous voulons la valeur exacte. L’analyse de la string nous fait remonter de fausses données dans nos filtres et agrégations ; par exemple, une agrégations sur ports.port.server, un service comme ccproxy-ftp n’aurait pas été remonté, mais aurait été compté comme ccproxy et ftp séparément, ce qui est un résultat faux.

Il est heureusement possible de désactiver l’analyse des strings à travers le mapping du document, en redéfinissant le mapping par défaut :

{<field_name>: {"type": "string"}}

en :

{<field_name>: {"type": "string":
                "index": "not_analyzed"}}

Ou, si nous voulons  à la fois le champ accessible analysé et non analysé :

{<field_name>: {"type": "string",
                "fields": {<subfield_name>: {"type": "string",
                                             "index": "not_analyzed"}
}}

… et de cette façon la string analysée sera accessible dans le champs classique <field_name>, et la string non analysé dans le champ : <field_name>.<subfield_name>.

Document flattening

L’analyse de string peut être un peu déstabilisante lorsqu’on commence juste à manipuler Elasticsearch (notamment les premières agrégations avec des résultats visiblement faux), mais ce n’est finalement pas bien méchant.

Le mapping par défaut a d’autre effets plus retords, comme la mise à plat des listes. [NDM : oui cette traduction de array flattening est affreuse. Mais j’ai pas mieux sous la main.]

Notre modèle de données stock les ports ouverts d’une adresse IP dans une liste, comme :

{"ports": [{"port": {"value": 80,
                     "protocol": "tcp"},
            "server": "thttpd",
            "service": "http",
            "version": "2.25b 29dec2003"},
           {"port": {"value": 5000,
                     "protocol": "tcp"},
            "server": "",
            "service": "upnp",
            "version": ""},
            ...
]}

Mais par défaut, Elasticsearch n’interprète pas cette liste ports comme une liste de documents indépendant, mais va plutôt les mettre à plat en quelque chose qui ressemblerait à ceci :

{"ports.port.value": ["80", "5000"],
 "ports.server": ["thttpd"],
 "ports.service": ["http", "upnp"],
 "ports.version": ["2.25b 29dec2003"] # let's suppose we deactivated the string analyzing here
}

Ce rangement est encore une fois fait pour tirer le meilleur parti de l’index inversé de Lucene. Cela permet de favoriser des recherche texte intéressante, dans la mesure où diviser une phrase en mots facilite le tri les documents par proximité par rapport à la requête, et de calculer des recherches éventuellement plus pertinentes (comme ce qui fait Google lorsqu’il propose des mots ou des phrases différentes lorsqu’on tape une recherche). La requête Elasticsearch more like this en est un illustration immédiate.

Dans notre cas cependant, ce comportement casse notre système. Elasticsearch aurait retourné le document précédent comme étant valide selon la recherche {« server »: « thttpd », « service »: « upnp »}. Et une agrégation sur les versions de services aurait retourné un service upnup avec la version 2.25b 29dec2003, ce qui n’existe pas.

La résolution passe à nouveau par le mapping. Le type nested permet de signaler explicitement que des documents imbriqués doivent être gardés indépendant. Déclarer un champ nested est simple – il suffit d’ajouter une clef-valeur {« type »: « nested »} dans le mapping – mais inclure ce champ dans une requête va demander plus de modifications. Une requêtes qui précédemment s’exprimait :

{"filter": {"term": {"ports.port.value": 80}}}

… deviendra :

{"filter": {"nested": {
      "path": "ports",   # "ports" is the nested document here
      "filter": {"term": {"ports.port.value": 80}}
}}}

Et plus la requête sera importante, et moins elle sera clair. Mais bon, c’est pour ça qu’on a inventé l’ordinateur (en l’occurrence, nos requêtes sont automatiquement générées à travers notre IR).

Take a look under the hood

Inserting documents

Ces différentes configurations du mapping révèle un mal nécessaire d’Elasticsearch : le mapping ne peut être redéfinie a posteriori. Il doit être refait ailleurs et les données doivent ensuite être migrées depuis l’ancien vers le nouveau.

Cette contrainte fut le prétexte pour comparer les différences entre les mécanismes d’insertion de MongoDB et Elasticsearch. Chacun des deux propose une méthode bulk pour insérer des paquets de documents.

Une insertion bulk à travers le client pymongo pourra être :

def mongo_bulk(size):
  start = datetime.datetime.now()
  count = 0
  bulk = db.<collection>.initialize_unordered_bulk_op()   # Initializing the bulk. We don't need any order
                                                          # for pure insertion
  bulk.find({}).remove()                                  # Cleaning the output <collection>
  for doc in document_list_or_generator_or_whatever():
    count += 1
    bulk.insert(doc)
    if not count % size:                                  # A good value for 'size' could be 10k docs
      bulk.execute()
      bulk = db.<collection>.initialize_unordered_bulk_op()
      count = 0
  bulk.execute()
  return datetime.datetime.now() - start

… tandis qu’une implémentation avec le client Python elasticsearch sera :

def es_bulk(size):
  start = datetime.datetime.now()
  insert = list()
  for doc in document_list_or_generator_or_whatever():
    insert.append({"index": {"_index": <index>,       # The metadata explaining how to treat the following
                             "_type": <doc_type>}})   # document. We simply index it here
    insert.append(doc)                                # The document to be indexed
    if not len(insert) % size*2:                      # Due to metadata (see below), the list length is twice
                                                      # the number of documents to insert
      es.bulk(insert)
      insert = list()
  es.bulk(insert)
  return datetime.datetime.now() - start

Les 2 APIs bulk sont très similaires, avec les mêmes méthodes (insert, update, remove, etc.) et la même logique (possibilité de modifier un document avec un mécanisme de scripting comme par exemple incrémenter un int, etc.).
Sur un ordinateur portable, une insertion de 6 357 427 documents (alimenté, pour les deux fonctions, par le même document_list_or_generator_or_whatever), donne :

  • MongoDB (paquets de 10k documents) : 522 secondes, soit ~12 159 documents/seconde,
  • Elasticsearch (paquets de 10k documents, sans mapping) : 600 secondes, soit ~10 597 documents/seconde,
  • Elasticsearch (paquets de 10k documents, avec un mapping custom) : 626 secondes, soit ~10 161 documents/seconde.

Ces résultats restent du même ordre de grandeur. MongoDB est ~1,15 plus rapide qu’Elasticsearch avec un mapping par défaut, et ~1,20 plus rapide qu’avec un mapping maison.

Scrolling data sets

Les méthode limit et skip de MongoDB sont pratique pour nos données. Pour reproduire ce comportement, Elasticsearch propose une approche sensiblement différente avec sa méthode scroll. Le code pymongo sera :

def mongo_scroll(chunk):
    results = list()
    for a in range(0, int(db.<collection>.count()/chunk)+1):
        start = datetime.now()
        list(db.<collection>.find().skip(a*chunk).limit(chunk)) # 'list' to force the cursor to get data
        results.append(datetime.now()-start)
    return results

… tandis qu’avec Elasticsearch on écrira :

def es_scroll(chunk):
    results = list()
    scroll = es.search(<index>, <doc_type>, size=chunk, scroll="1m")
    for a in range(0, int(es.count(<index>, <doc_type>)["count"]/chunk)+1):
        start = datetime.now()
        scroll = es.scroll(scroll["_scroll_id"], scroll="1m")
        results.append(datetime.now()-start)
    return results

Les graphes suivants illustres les résultats de ces fonctions en parcourant plus de 23 millions de documents par intervalles de 100k documents.

Temps de parcours - total

  1. Il est clair que MongoDB est plus performant sur les premiers intervalles, mais il a tendance à ralentir, tandis qu’Elasticsearch est très constant.
  2. Ce décrochage impressionnant et soudain du temps de réponse de MongoDB à environ 9 millions de documents semble être dû aux lectures qui passent de la RAM au disque. À ce moment, nous relevons également que les IOs passent de ~600/s à ~8,000/s, la lecture disque passe de ~20M/s à ~150M/s et les page faults décollent de quelques pics à ~4,000/s jusqu’à un niveau moyenne à ~8,000/s.
    Les mesures sont notre serveur possédant 64GB RAM confirme cette analyse : dans ce cas le système n’est jamais en manque de RAM, et le temps de réponse continue sa croissance tranquille et continue sans impulsion brutale à la Dirac.
  3. Elasticsearch conserve son temps de réponse à environ 4 seconde. Le temps de réponse de MongoDB sur le serveur avec 64GB RAM continue sa progression, tandis que le MongoDB sur le portable a de gros problèmes de performance (avec des temps de réponses supérieurs à la minute sur la fin).

Une vue plus précise des premiers intervalles (avant le décrochage) :

Temps de parcours - détailEn fait, MongoDB n’a pas vraiment de réelle méthode de scrolling comme le scroll d’Elasticsearch, qui est fait pour anticiper, à chaque appel, les documents qui seront demandés à l’appel suivant. Au contraire, le mécanisme find + skip + limit de MongoDB traverse la collection depuis le premier document à chaque appel. Cela donne des problèmes de performances aigus si on l’utilise par exemple avec sort, qui consomme beaucoup de temps, dans la mesure où la collection sera triée à chaque appel.

Dans tous les cas, un jeu de données de 23 millions de documents est certainement un morceau sérieux, mais sans pour autant être extraordinaire. Cela illustre bien la façon comment MongoDB est satisfaisant avec des jeux modestes, mais a de grosses difficultés à s’adapter à la dimension d’un environnement Ivy classique.

N.B. : Notons que MongoDB délègue la gestion de la mémoire au système d’exploitation ; il n’est donc pas directement responsable d’une mauvaise gestion mémoire comme vu précédemment. Cela n’empêche pas que la courbe est rédhibitoire.

Off topic: comparing disk space

Dans la mesure où nous n’aurons pas d’autres occasions de comparer la taille prise par un même jeu de données par MongoDB et Elasticsearch, pour ce jeu de 23 millions de documents, MongoDB (sans aucun index sinon _id) occupait 26GB d’espace disque, tandis qu’Elasticsearch prenait 14GB.
Nous ne disons pas que « Elasticsearch prend moins de place que MongoDB » est une règle établie. Simplement que c’est toujours le cas chez nous.

Aggregations – the new deal

Les performances de l’insertion et du scrolling dans Elasticsearch n’étant pas fabuleuse, MongoDB gardait quelques arguments en sa faveur. Mais si on revient à la source du problème, on se souvient que ce qui nous a fait originellement douter sur MongoDB était les agrégations.
Dans Ivy les mécanismes étudiés précédemment sont surtout utilisés durant les phases d’administration ou de migration, sans contraintes de temps réelle. Ce qui n’est pas le cas des agrégations, manipulées par l’utilisateur qui attend une interface réactive.

De ce fait, la sentence du choix entre les deux moteur sera donnée par les performances des agrégations.

Enough talking

Afin de comparer les performances des agrégations, nous avons sélectionné 5 différents jeux de données, depuis un petit jeu de ~29k documents jusqu’à un jeu moyen de ~29M de documents.

Deux agrégations différentes étaient testées : l’agrégation très simple « par pays » que nous avons déjà rencontrée plus haut, et l’agrégation plus complexe « par version de serveur ».

Cette dernière agrégation calcule la distribution de couples (<server_name>, <server_version>) sur l’ensemble des adresses IP. Elle est très utile pour afficher, par exemple, les adresses IP possédant des services dépassés ou vulnérables. Cette agrégation est en générale celle qui pose le plus de problème à MongoDB dès que nos jeux de données atteignent les 10-15 millions d’adresses IP.

Le pipeline MongoDB pour l’agrégation « par version de serveur » est :

[{"$unwind": "$ports"},
 {"$match": {"ports.server": {"$exists": True, "$ne": ""}}},
 {"$group": {"_id": {"server": "$ports.server", "version": "$ports.version"},
             "total": {"$sum": 1}}},
 {"$sort": {"total": -1}}]

L’agrégation avec Elasticsearch est :

{"aggs": {"a1":
    {"nested": {"path": "ports"},
     "aggs": {"a2":
         {"terms": {"field": "ports.server", "size": 0},       # First aggregation on 'ports.server'
          "aggs": {"a3":
              {"terms": {"field": "ports.version", "size": 0}} # ... and for each, aggregation on 'ports.version'
         }}}}},
 "filter": {"nested":
     {"path": "ports",
      "filter": {"bool": {"must": {"exists": {"field": "ports.server"}}}}
}}}

Si le mapping d’Elasticsearch n’était pas confiuré, cette agrégation donnerait vraisemblablement un résultat totalement faux. : la version « 2.2.8 » d’Apache serait agrégée avec un serveur  « Allegro RomPager », la version « 4.51 UPnP/1.0 » serait diviser en  éléments incohérents, etc.

Mais partons du principe que le mapping a été correctement configuré et passons directement aux résultats.

Résultats des aggrégations "by country"

Nombre de documentsMongoDB (sec)Elasticsearch (sec)Ratio (MongoDB/Elasticsearch)
29 103 27819,4350,75825,647
24 504 26916,7950,77421,693
4 213 2483,9720,13329,891
336 8320,2990,0565,325
28 6720,0240,0054,863

Résultats des aggrégations "by servers"

Nombre de documentsMongoDB (sec)Elasticsearch (sec)Ratio (MongoDB/Elasticsearch)
29 103 278103,5392,73638,943
24 504 269102,6041,86654,983
4 213 28413,7520,22461,344
3336 8321,2860,05025,750
28 6720,1030,00813,620

Conclusion

Ces résultats illustrent que MongoDB n’est vraiment pas le meilleur choix pour nos besoins. Comme nous l’avons répété plusieurs fois durant cet article : les performances des agrégations sont la clef d’un logiciel comme Ivy, et ces résultats tuent tout simplement le débat en faveur d’Elasticsearch.

There is no such thing as a free lunch

Gardons tout de même la tête froide avec Elasticsearch. Ce n’est pas la solution ultime pour sauver des chatons, et elle possède des défauts :

  • Les contraintes du mapping nous oblige à être très vigilant sur les résultats renvoyés par Elasticsearch lorsque nous intégrons de nouvelles fonctionnalités dans la mesure où il peut renvoyer des résultats incorrect, comme vu précédemment. Même si il y a de bonne chance que ce mapping soit justement ce qui rend les requêtes Elasticsearch si efficace.
  • Elasticsearch ne répond pas non plus à d’anciennes problématiques, comme le stockage et la manipulation d’entier de 128 bits  (coucou IPv6 !).
  • Sans être un problème de performance, l’administration d’un nœud Elasticsearch paraît un peu plus fastidieux qu’une base MongoDB ; nous n’avons par exemple pas trouvé d’équivalent aux outils mongodump / mongorestore.

Cependant, et malgré que MongoDB ait de meilleurs résultats pour les insertions bulk et reste plus flexible, le potentiel d’Elasticsearch parait bien plus adapté à nos besoin ainsi que prometteur pour l’avenir, et la migration d’Ivy sur ce moteur est actuellement en cours.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.