Journées du patrimoine et Cour des Comptes

En ce samedi 19 septembre 2015, journée du patrimoine, je me suis dis que ça pourrait être sympa d’aller visiter la Cour des Comptes; et avec ce que j’en ramène, je fais un petit compte-rendu rapide.

La Cour des Comptes pour les Nuls

La Cour des Comptes, dans notre république, tient lieu de contrôleur comptable et financier de l’État; pour faire court, et selon la formule consacrée : « la Cour des comptes juge les comptes des comptables publics » (oui, c’est moche).
Ses missions sont d’évaluer, de contrôler et de certifier l’utilisation des deniers publics par le gouvernement, les administrations et les entreprises publiques. Cela inclue évidemment les effets constatés des réformes de financement, la pertinence d’investissements, les implémentations de politiques sociales transverses à de multiples entités comme la Sécurité Sociale, etc.

Le travail de la Cour, très technique et qui peut paraître rébarbatif, est peu connu et reconnu, les rapports (systématiquement publics) étant généralement commentés à la va-vite par les médias lors de leur sortie. Pourtant, ce rôle est essentiel, puisqu’il permet de nourrir en données factuels le parlement afin qu’il puisse en âme et conscience voter les lois de finances, notamment le budget de l’État (un de ses rôles principaux rappelons-le), donne aux décideurs des clefs et propositions afin d’améliorer l’efficacité des politiques publiques, informe les différents acteurs de la démocratie (y compris le citoyen lambda) des dysfonctionnements et de la plus ou moins mauvaise gestion (d’incompétence ou de malice) des dépenses publiques qui représentaient en 2014 la bagatelle d’environ 1220 milliards d’euros.

Visiter cette institution vitale était pour moi, passionné (parfois trop) par les questions d’interventionnisme, d’économie politique et de dette souveraine, une sorte de petit pèlerinage. Je m’attendais cependant à une visite assez rapide, concise et plutôt centrée sur les bâtiments (le joli Palais Cambon, et la Tour Chicago adjacente et fraîchement retapée, dans le 1er arrondissement), avec également quelques planches expliquant le travail de la Cour, l’histoire de l’institution… bref quelque chose de court et sans fioritures.

Parfois ce serait pas plus mal que ce soit rapide et sans fioriture….

En vrai ce qu’il s’est passé

Déjà en arrivant rue Cambon, petit coup de stress : il y a la queue. Je suis un peu étonné, surtout quand je pense aux nouvelles du jour qui parlait de plusieurs heures d’attente devant l’Élysée… Les Sages rameuteraient autant de personnes ?

Ce n’est heureusement pas le cas et on rentre très rapidement. Premier bon point : un café est généreusement offert à l’entrée (ce qui explique la queue).

Mais surtout, alors qu’on vient à peine d’emprunter l’escalier d’honneur pour rentrer dans le bâtiment à proprement parler, on lève la tête ; et là on voit, en haut des escaliers, DIDIER MIGAUD HIMSELF, bonhomme, qui accueille et sert la pogne à CHAQUE PERSONNE.

Je ne suis pas là depuis 5 minutes et déjà l’émotion m’étreint.

À partie de là, je comprends que ça ne va pas être plan plan, que la Cour a sorti l’artillerie et entend bien faire sa com et profiter de cette journée pour informer les curieux.
Et effectivement, les magistrats de la Cour ne se sont pas du tout échappés. Ils n’ont pas délégué le job a des secrétaires ou des agents de communications, ils n’ont pas enregistré des vidéos plates et convenues en amont pour les diffuser ensuite. Ils étaient là, en chaire et en os, complétement disponibles, avenants, pédagogues, ouverts. J’ai été véritablement impressionné par la capacité de ces femmes et hommes à être proches de chacun, alors qu’un des gros travers de notre République est justement cet éloignement (ressenti ou avéré) des élites politiques et administratives des citoyens.

Le parcours était légèrement balisé pour conserver un semblant d’ordre, mais les fonctionnaires de la Cour était simplement postés dans les pièces et attendaient le chaland, allaient même à sa rencontre à force de sourires affables et de « surtout n’hésitez pas si vous avez la moindre question ». Au gré de l’intérêt des questions du public, de petits groupes se formaient autour des fonctionnaires, la discussion s’engageait très simplement et librement.
Au-delà du premier président (qui était plus occupé à serrer des mains – des milliers – qu’à discuter), on pouvait ainsi passer du temps avec des présidents de chambres, des rapporteurs, des fonctionnaires de la Cour, et  discuter de leurs missions, mais aussi – chose beaucoup plus polémique – des conclusions de leurs rapports.

Les missions et valeurs

Sur les missions (et donc forcément les non-missions) de la Cour des Comptes, une question du public (plutôt averti) est revenue plusieurs fois : n’étaient-ils pas agacés, sinon excédés, de produire une quantité de recommandations qui ne sont pour un grand nombre d’entre elles suivies d’aucun effet ? Ne devrait-il pas, en plus de leur capacité d’audit, avoir également un pouvoir coercitif sur les contrôlés ?
Les magistrats reconnaissaient une certaine inertie et parfois une mauvaise volonté des contrôlés suites aux rapports, contre lesquels ils ne sont pas armés. Cependant, beaucoup de précisions sur cette sensation « d’inefficacité » ont été apportées :

  • Il y a tout de même un nombre importants de recommandation qui sont suivies d’effets. Cependant, la magie de l’administration aidant, les effets mettent souvent plusieurs années à se faire sentir.
  • La démocratie s’accommoderait mal d’une autorité ayant à la fois le pouvoir de contrôler et de punir. Lorsque la Cour des Comptes constate des anomalies, elle transmet les dossiers aux autorités compétentes – en particulier au pénal s’il y a suspicion d’infraction.
  • Lors de contrôles réguliers, la Cour commence systématiquement par analyser les suites données à ses recommandations. Le caractère répétitif, d’un contrôle à l’autre, d’une observation est mis en avant.
  • In fine, les magistrats sont très clairs : la sanction doit venir des urnes. La Cour informe, les citoyens en tiennent compte (ou non) au moment du choix. Le renvoi de balle peut paraître facile, il n’empêche que, si dysfonctionnement il y a, il se trouve plus dans l’engagement de chacun dans la démocratie ou dans sa forme, plutôt que dans la mission de la Cour des Comptes.

Les différents magistrats ont également souvent appuyés sur l’importance de l’échange avec les contrôlés. Si d’un point de vue extérieur, la Cour est souvent vue comme cette maîtresse qui assène des coups de règles métallique sur les doigts, ses fonctionnaires insistent sur l’utilité du contrôle et de la critique, en bref le principe d’amélioration continue.
Ils remarquent à ce niveau que le simple fait de commencer un audit dans une administration améliore naturellement son fonctionnement. Et évidemment, ils appliquent à eux-même ce principe en demandant régulièrement à se faire contrôler par des organismes équivalent.

Pour ce qui est des valeurs, tous les fonctionnaires rencontrés mettent en avant leur déontologie et les procédures de l’institution, présentées pour l’essentiel ici. Ces valeurs sont loin d’être des punch-lines bidons pour vendre du produit vaisselle ; toute personne suivant avec quelques intérêts la Cour connait bien son indépendance et sa liberté dans ses propositions qui, malgré des formulations qu’on peut parfois trouver trop retenues, sont régulièrement à contre-courant de la bien-pensance des temps modernes.
Mais justement, la force de ses propositions vient des deux autres valeurs qui sont la collégialité, qui nécessairement bride (au moins en partie) les velléités d’un rapporteur de porter un message partisan, et enfin la contradiction, qui oblige la Cour à parfois prendre en compte, et au moins toujours communiquer avec ses rapports les réponses des contrôlés.
Un magistrat insistait fortement sur la nécessité absolue pour chaque fonctionnaire de la Cour d’être absolument factuel et rigoureux dans ses observations, sous peine de les voir retoquées par ses pairs.
Alors certes, en tant que production humaine, il est impossible qu’un rapport soit absolument neutre, mais les procédures de la Cour font en sorte de s’y approcher un maximum.

Enfin, que ce soit dans ses observations ou ses propositions, le travail de la Cour ne se limite pas à des bilans purement comptable. Une partie de sa mission est effectivement de vérifier la cohérence des données, mais elle évalue et juge également de l’efficacité des dépenses : elle ne critique pas le cher, mais la gabegie. De la même façon, ces propositions sont la plupart du temps inspirés d’exemples réels et comparables, et vont dans le sens d’une meilleure efficacité de la dépense. Elle ne demande jamais de couper aveuglément dans les budgets pour un résultat hasardeux.
Le rapport 2015 sur la  Sécurité Sociale, en comparant les systèmes français et allemand, en est un parfait exemple : certes le système allemand est beaucoup moins dépensier, mais le français a de meilleurs résultats sanitaires. Les propositions à ce niveau ne sont pas de jeter l’un pour le remplacer par l’autre, mais de s’inspirer de ce qui fonctionne mieux outre-Rhin et qui est compatible avec notre choix de société.
Ce travail d’évaluation, de savoir si une politique publique ou une administration atteint ses objectifs, sa raison d’être, ou au moins n’est pas nuisible, est particulièrement important dans notre pays où on se contente de Fire-and-Forget, où le contrôle a posteriori de l’action publique est très (très) loin d’être systématique et encore moins impartial,  avec le succès qu’on connait.

La façon de porter un message

Mais le plus intéressant de la visite s’est fait sur un autre niveau, sur la façon dont le message de la Cour des Comptes se transmet au citoyen.

Le rôle de la Cour ne se limite pas à communiquer uniquement dans les administrations et les sphères politiques. Elle cherche également à atteindre le citoyen, qui est censé donner aux dirigeants leurs légitimités, et doit donc s’informer pour accomplir efficacement ce devoir.

La Cour a cependant une production très technique, et si elle fait des efforts pour être accessible (via des synthèses pour chacun de ses rapports, à travers les médias sociaux comme touitère, …), on ne peut lucidement demander à chacun de lire les centaines de pages de rapports qu’elle produit chaque mois.

Habilement, une salles de l’étage est remplie de production journalistique commentant ces rapports, et je crois à ce moment avoir saisi le message que voulait nous faire passer la Cour.
La vue d’ensemble permet de voir très rapidement que les secteurs où intervient la Cour sont extrêmement variés (des LGV au nucléaire, de la Sécu à l’armée, du budget au concessions d’autoroute) et les titres (évidemment un peu racoleurs) du type « La Cour des Comptes dézingue la gestion de <tartampion> » ou « Les comptes de <truc_corporation> jugés irréalistes par la Cour » soulignent que cette dernière ne joue pas au faire-valoir consensuel.

La Cour des Comptes vue par la presse.

Mais on a tout de même un goût d’inachevé pour deux travers classiques de notre époque : d’une part, les articles se concentrent souvent sur un point anecdotique, d’autre part ils sont souvent très idéologiquement marqués.

L’anecdote prend le pas sur l’information

Je ne veux pas m’étendre 5 heures sur cette critique déjà ancienne de la course à l’audience et la mise en avant du sensationnalisme putassier au dépend d’une information solide et utile. Mais cette stratégie n’est plus réservée aux tabloïds depuis longtemps, et médias reconnus comme personnalités publiques en abusent de plus en plus et – consciemment ou pas – détournent dangereusement l’attention des points importants.

La couverture médiatique des rapports de la Cour n’y échappe pas. Le dernier rapport sur les comptes de la Sécurité Sociale pèse plus de 760 pages, sans les annexes. Il souligne une complexité dantesque, une déresponsabilisation à chaque niveau, un déficit structurel et récurrent, dont la diminution (souvent vantée par les responsables politiques) est très limitée, et qu’il serait plus juste efficace de limiter ses dépenses (de gestion par exemple) plutôt que systématiquement augmenter les taxes et impôts qui le nourrissent. Le rapport établit, autant se faire que peu, une comparaison avec le système allemand, loue la rigueur budgétaire (excédentaire) de ce dernier tout en soulignant le caractère plus égalitaire, solidaire et sanitairement efficace du système français.
La plupart des articles sur ce rapport ont mis en avant le fait qu’il y avait beaucoup plus d’infirmier libéraux et de kinésithérapeutes par habitants dans le sud de la France.

 L’idéologie aveugle

Mais au-delà de la faible couverture de leur travail, les magistrats semblaient surtout ennuyés par son traitement, jugé partial.
On peut comprendre qu’un homme politique s’acharne de façon machiavélique (au sens premier du terme), malgré des années à être systématiquement dans le faux, à vilipender les conclusions de la Cour afin de vendre son action pour de l’art[1].
Par contre, on attend des médias un certain goût pour la réalité, un travail de fond, factuel, qui permette ensuite de porter une ligne éditoriale plus ou moins orientées politiquement.

Je suis depuis plusieurs années les productions de la Cour des Comptes (sans lire toutefois les rapports de 700 pages…), ainsi que de nombreux journaux assez mainstreams, généralistes, que je trouve raisonnablement mesurés sur de nombreux sujets. Il n’est pas rare de voir dans ces derniers des discours ou annonces politiques retransmises dans des articles sans observation ou critique du journaliste.
La Cour n’a pas droit à cette mansuétude. Son travail est systématiquement présenté comme étant froid, sec, austère (mot épouvantail de notre époque s’il en est) et il est très fréquent de voir ses propositions remis en cause : « un tour de vis supplémentaire sur les dépenses ne risquait-il pas d’étouffer encore un peu plus les capacités de reprise ? ». C’est inepte d’une part parce le postulat « moins de dépenses publiques tue la reprise » mériterait démonstration, et d’autre part parce que, comme expliqué plus haut, la Cour anticipe les conséquences de ses propositions, et a donc déjà soit calculée avec ce risque, soit jugé qu’il était infondé.

Plusieurs fonctionnaires de la Cour regrettaient ce manque de pédagogie et de recul. Ne pas avoir de relais est un véritable frein à sa mission, qui est de fournir des analyses factuelles à la société qui se charge ensuite de décider d’une idéologie en âme et conscience.

Ce que j’en ramène

Avant tout énormément de respect pour ces fonctionnaires qui font un énorme travail de l’ombre et sont très loin de bénéficier d’un traitement médiatique à la hauteur de leur rôle essentiel.
Énormément de respect aussi pour l’humilité, la retenue et la pondération qu’ils donnent à leurs discours, notamment quand ils parlent de leurs rapports. On le sait, nous avons en France un lien très passionnel à tout ce qui touche la politique… et dans notre pays la politique touche à tout. J’ai noté plusieurs discussions où je sais que, moi personnellement mis à leur place, j’aurais vraisemblablement perdu mon objectivité en faveur d’un discours plus partisan. Eux au contraire étaient d’un sang froid et d’une tempérance extraordinaire, malgré parfois des remarques du public pas forcément faciles à supporter.

Bravo à eux !

13894825431191. Toute ressemblance avec 80% des politiciens de ces 30 dernières années ne serait évidemment que pur et fortuit hasard. Aaah le hasard…

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 :

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 :

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:

 … vers le nouveau :

… 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 :

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

… 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 :

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 :

… 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 :

… 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 :

 L’équivalent Elasticsearch est :

 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 :

en :

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

… 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 :

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 :

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 :

… deviendra :

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 :

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

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 :

… tandis qu’avec Elasticsearch on écrira :

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 :

L’agrégation avec Elasticsearch est :

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.

"by country" aggregation results

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

"by server version" aggregation results

Document numberMongoDB (sec)Elasticsearch (sec)Ratio (MongoDB/Elasticsearch)
29 103 278106,5392,73638,943
24 504 269102,6041,86654,983
4 213 24813,7520,22461,344
336 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.

Flask m’a tuer

Vous le savez peut-être déjà, je suis un peu extrémiste.

Je refuse de reconnaître qu’un outil est bon simplement parce qu’il permet de faire le job. Et je me méfie comme de la peste des outils qui permettent de faire le job facilement.

Flask, le framework Python, n’a à ces égards pas de chance, car tout le monde le décrit comme étant diaboliquement facile d’usage, et violemment efficace. Assez à la mode depuis un moment (on en parle sur une bonne moitié des articles du flux RSS de Python Weekly), ce n’était qu’une affaire de temps avant que je le vois débarquer dans un des POCs que je dois durcir.
Évidemment c’est rapidement arrivé; et franchement, si on veut faire du développement un minimum sérieux, ce framework donne envie de rouler au Caterpillar sur des paniers de chatons mignons.

Le contexte tout d’abord

Prenons une application utilisant Flask comme base afin de servir du bon vieux HTML d’un côté, et une api REST de l’autre. Histoire de rajouter du délire pétillant dans la marmite, on va aussi accéder à la base via Flask (flask.ext.pymongo).
Le tout est dans un bon vieux virtualenv des familles (oui, cette information n’apporte et n’apportera rien, c’est juste pour imprégner dans votre esprit que virtualenv, malgré ses nombreux défauts, c’est bien).

Premier code

Soit un code trivial produit par un pauvre besogneux noob de Flask qui a fervemment suivit les tutos du ouaibe afin de répondre aux différents « besoins » précisés ci-avant, dont l’archi et le code viennent ci-après (baladez votre curseur sur le code pour voir le nom des fichiers) :

Analyse statique

 C’est foutrement moche.

Pas la moindre classe, du mélange immonde de langages aux finalités différentes (code Python et affichage HTML – voir views.py), des globales en veux-tu en voilà, des décorateurs qu’on ne maîtrise absolument pas, des imports louches (pour définir les routes après l’instanciation de Flask)… tout ce qu’il faut pour garantir un programme bien figé et non viable après 6 mois de développement.

Je trouve aussi assez douteux que l’app soit instanciée dans le __init__.py (et donc à chaud lorsqu’on importe le package). Pour moi ce devrait être une ligne présente dans le run.py (et in fine dans uwsgi, qui est assez malin pour instancier une classe )… Au lieu de ça on manipule (à nouveau) une belle globale app toute moche qui s’est instanciée dans notre dos sans qu’on ai rien demandé (et sera instanciée à chaque fois qu’on importera le package depuis l’extérieur… ce qui arrive à chaque fichier de test !). C’est pas net en terme d’architecture…

On n’a même pas encore commencé à fouiller et c’est déjà tellement sale que je n’oserai pas l’utiliser pour un skyblog. Essayons néanmoins d’en faire quelque chose.

Analyse moins statique

On commence par regarder la base, le fichier models.py. Et la base de la base : l’accès à la base de données.

On lance donc une console Python, histoire de voir les méthodes qu’on a sous la main et manipuler un peu l’outil…

o_o Sérieusement ? Je dois mettre un contexte en place pour accéder à l’aide ?

Et voilà, en 5 lignes de codes, le pire point noir de Flask : tout ce qu’on manipule est construit sous la forme de wrapper / proxy totalement dépendant de l’instance de Flask et est totalement incapable de vivre sans elle.
Flask n’est pas le gentil berger qui donne accès aux pâtures à ses brebis (comprendre : met en forme puis distribue simplement des arguments aux bibliothèques qu’il intègre), Flask est le parasite qui asservit un animal viable et refuse de le voir exister en dehors de son strict contrôle (comprendre … mais vous avez compris).

Il faut donc créer un contexte – et donc générer un instance de Flask – pour, entre autre :

  • accéder à la base de données, alors que dans le cas présent, pymongo fait très bien le taff tout seul, le wrapper de Flask se contentant de rajouter 2 piètres méthodes de manipulations de fichiers faciles à remplacer par du code qu’on maîtrise;
  • faire un rendu HTML (c’est à dire qu’on associe un dictionnaire de valeur à un template HTML à travers la fonction flask.render_template) via Jinja2. Alors qu’encore une fois, dans ce cas Jinja2 se débrouille très bien tout seul;
  • générer une réponse contenant du json (flask.jsonify). Cette méthode construit une instance de flask.Response, une bête surcouche par dessus werkzeug.Reponse
  •  accéder aux données d’une requête via la globale flask.request, qui est un des pires poisons de Flask du fait de la difficulté à l’extraire d’un code l’utilisant.

Quand on regarde notre code, on se rend compte que chacun de nos fichiers est emprisonné dans Flask… Tester l’application efficacement, sans overhead énorme juste pour instancier Flask et les contextes ? Impossible. Réutiliser des portions de code pour d’autres projets (sans aucun rapport avec Flask) ? Impossible. Avoir ce sentiment de liberté que tout développeur Python devrait connaître ? Impossible.

Cette dépendance à notre instance Flask est d’autant plus vicieuse qu’elle est le meilleure moyen de créer des boucles de dépendances au niveau des imports. Typiquement, ce n’est pas un hasard si, dans __init__.py, les imports de views et api sont à la fin.

Tests

Bon, ça a beau faire une couverture à 100%, c’est tout de même assez dégueulasse dans l’esprit.
Les gars de Flask sont sympas, on a des méthodes exprès (Flask.test_client, Flask.test_request_context) pour créer des contextes et faire nos tests… C’est un peu comme si un gouvernement taxait votre travail à 50% et ensuite vous fasse des remises dans certaines conditions [1] : c’est toujours ça de gagné, mais ça reste une vaste fumisterie à la base.
Pour tout dire, à voir à quel point ce qu’on teste est basique, ça fait un peu pitié d’avoir des tests si encombrés et peu clairs. Et puis quand ça sera moins basique, je vous jure que c’est franchement immonde à configurer.

Où on en est après ça ?

  • Un code bien à plat, assez moche, rempli de dépendances (douteuses) à Flask un peu partout qui figent le comportement et freinent fortement sa bonification (nettoyage de code et factorisation)
  • des imports hasardeux qui révèlent que l’init du package ne consiste pas seulement en l’instanciation de l’app Flask (ce qui déjà en soit est moche) mais aussi à l’interprétation des fichiers views.py et models.py pour faire le mapping entre URLs et fonctions;
  • des tests bien lourds qui donnent pas envie (et dont le maintien sera donc vraisemblablement abandonné sous peu, soyons honnêtes).
    Soulignons en passant, dans le test test_api.test002_insert, le petit mock sur la fonction api.new_object (ce n’est pas ça qu’on veut tester ici, alors on le remplace par un truc qui passe quoi qu’il arrive). Du fait que le module api que l’on manipule ici est le même que dans tous les autres tests, on est obligé de faire ces très inélégantes étapes de 1) sauvegarder l’ancienne fonction, 2) mettre le mock, puis 3) remettre l’ancienne fonction une fois le test terminé.
    D’une part, c’est laid; d’autre part, c’est clairement une pirouette qu’on a tendance à oublier de faire, et après on ne comprend pas pourquoi nos tests ont des comportement erratiques alors que la moitié des fonctions se retrouvent remplacées par des mocks…

Bref globalement on est très loin d’avoir fait un quelconque programme. Je considère qu’à ce niveau nous disposons au mieux d’un script qui fournit dans un ordre donné des informations à Flask. L’intelligence que l’on apporte doit être entièrement consommée par le framework et ne peut être utilisée simplement par personne d’autre. Sans Flask, ce code ne vaut donc rien, puisqu’on ne peut rien en tirer pour un autre usage.

Pourtant, en tant que développeur, il est tout à fait concevable qu’on se dise un jour « tient, cette génération HTML est intéressante, je pourrais l’utiliser ailleurs » ou encore « ce travail au-dessus de mongodb pourrait me servir sur un projet similaire ». Ou alors tout d’un coup on veut ajouter un thread à notre appli pour faire de l’administration ou quoi que ce soit d’autre, et on veut – comme par hasard – exploiter du code qu’on a déjà fait et qui est là, sous notre main…

Et bien on ne peut pas – pas encore.

Second code

Pour pallier en parti à ces défauts, j’ai tendance à passer sur un code de ce genre-là :

 J’ai la faiblesse de penser que ce code est infiniment plus propre, plus facile à tester, plus facile à factoriser, plus facile à porter vers d’autres programmes que le précédent.

Certes il a des points négatifs. Il est plus long (75 lignes au lieu de 51, ce qui fait tout de même près de 50% de plus), et s’il peut paraître plus clair pour quelqu’un avec un bagage Python raisonnable, je comprends qu’il puisse paraître relativement plus obscure si on débarque tout juste dans le langage et qu’on n’a pas de notion de programmation orientée objets.

Je m’autorise de plus certains raccourcis. J’estime par exemple que jsonify est parfaitement dispensable (complexité inutile, et en plus je ne sais pas pourquoi mais je galère à le taper), tout comme le wrapper sur pymongo.

À côté de ça cependant, on découvre que chaque classe a sa propre indépendance, et est surtout totalement découplée de Flask (mise à part la classe Application évidemment, qui sert justement de point de référence contrôlé vers Flask).
La construction de l’application est synthétisée dans la classe Application (et non plus dispersé un peu partout dans le code via des décorateurs moches qui en plus entravent violemment l’accès aux fonctions du fait du contexte qui doit être créé), ce qui rend immédiatement lisible l’architecture logiciel et décharge les autres classes de surcouches sans rapport avec leur boulot (ce qui nous rapproche du single responsibility principle).
Il devient extrêmement facile de réutiliser une classe dans ce cadre, et les tests deviennent plus propres, puisqu’on peut enfin tester ce qu’on veut sans se manger une erreur de contexte dans la trogne.

 Effectivement, c’est de nouveau un peu plus long qu’à l’origine (+7 lignes[2]) car il faut prendre le temps d’instancier nos classes dans les setUp. Sur une batterie de tests plus trapue cependant, on va vraisemblablement gagner de la place.
Surtout cette fois-ci on peut réellement se concentrer sur ce qu’on doit tester, pas sur de l’auxiliaire.

Tiens, en passant, dans test_api.test002_insert, on n’a même plus besoin de sauvegarder l’ancienne fonction que l’on mock, puisque l’api sera réinstanciée au test suivant !

Petit bonus : on gagne du temps ! Les tests précédents prenaient en moyenne 120,8ms, les nouveaux ne prennent que 106,2ms. Imaginez lorsque vous aurez plusieurs centaines de tests !

Great success

Ce n’est pas fini

Ce n’est jamais fini.

On pourrait commencer par remonter l’instanciantion d’Application dans le run.py. De cette façon on obtiendrait réellement une bibliothèque d’un côté (comme je l’entends : un ensemble de classes qui forment un tout indépendant et qu’il faut instancier pour les utiliser) et un script de lancement de l’application de l’autre (le run.py qui instancie notre application et la lance).

Il faut évidemment dégager le code HTML vers des templates extérieurs. C’est réellement trivial avec Jinja2, mais comme je suis une feignasse, je vous laisse le boulot.

Vous avez bien sûr remarqué que pas mal de code pouvait être factorisé (les instanciations des classes Views / Api / Models assez similaires, les tearDown dans les tests). Plus qu’à faire jouer l’héritage !
Il faudrait aussi faire une vraie interface vers la base de données, c’est un peu moyen de balader mongo un peu partout avec des accès directement depuis Api ou Views.

En plus des routes classiques (@route), le code comprend probablement des décorateurs pour la gestion d’erreur (@errorhandler) et pour préfixer/postfixer les requêtes (@before_request, @after_request). Vous pouvez facilement vous en passer en exploitant les champs before_request_funcs (ou after) et error_handler_spec de votre instance de Flask (au hasard, à l’initialisation d’Application).
Attention cependant si vous utilisez la classe LoginManager de flask.ext.login, cette dernière va manipuler les champs before_request_funcs (et after); il faut donc veiller à ne pas écraser ces modifications.

On pourrait aussi extraire réellement le code métier de Views et Api. Dans l’idée, ces deux classes souvent font globalement la même chose (à part qu’elles présentent les informations dans des formats différents), ce qui fait qu’il n’est pas rare de voir Views exploiter des méthodes d’Api (dont les retours sont plus « bruts »). Il vaut mieux se constituer une vraie classe métier fournissant des informations indifféremment à Views et Api, qui elles se concentrent uniquement sur leur rôle d’interface (réception et traitement des requêtes, génération de réponses).

Si on utilise request (flask.request), il faudrait le neutraliser dans une classe à part. C’est vraisemblablement une étape peu évidente, car request est certainement la globale de Flask qui colle le plus au code tellement on a tendance à l’utiliser à outrance (il faut avouer qu’elle est bien pratique dès qu’on fait du POST).

Autres trucs un peu moisis dans Flask

J’avoue avoir été étonné de l’engouement autour de ce framework. Il ne fait pourtant pas grand chose de plus (à par nous emmerder) que beaucoup d’autres…

J’ai quand même pris le temps de regarder un peu tout de même pour être sûr de ne pas passer à côté de quelque chose… Petite sélection :

  • L’existence de la méthode Flask.make_reponse est assez dingue en soit. Elle illustre parfaitement le côté « boah, les views et api peuvent bien retourner n’importe quoi, en fait on s’en fout ». Jusqu’à maintenant tu pouvais renvoyer une string que Flask se chargeait de passer en werkzeug.Response, mais bon, si tu veux toi-même renvoyer une Response… La souplesse c’est bien, mais là c’est une invitation à faire du code malsain.
    Au moins avec ça on est assuré que passer de jsonify à bson.json_util.dumps ne change rien (et que jsonify est réellement inutile).
  • La palme de la globale la plus vicieuse revient sans conteste à request (from flask import request). Pour peu que vous travailliez avec une boîte noire en face, qui vous envoie du JSON pas forcément de la bonne façon (headers manquant j’imagine), l’accès aux données dans request devient un peu folklorique. Ça m’a au moins permis d’apprendre l’existence des MultiDict (mais là on parle plus d’une sournoiserie de werkzeug).
    Au final, on ne peut vraiment faire confiance qu’à request.get_data() – qui est deprecated et est vouée à disparaître…

 Conclusion of ze dead

Je vais être un peu tendre : Flask, on peut l’utiliser, ça peut servir. Le problème c’est qu’il faudrait l’utiliser de façon totalement antinaturelle et opposée à tout ce qu’on peut trouver dans ses tutos, si on voulait l’exploiter pour autre chose qu’un projet étudiant.

Vous êtes libre de faire comme vous le sentez. Mais gardez toujoursune pensée pour le pauvre type qui devra reprendre votre code derrière vous.

 

1. Toute ressemblance avec des cas réels et contemporains ne serait évidemment que pur et fortuit hasard.

2. En sachant que la classe Application n’est pas testée ici. En tant que surcouche à Flask, il faudrait vérifier au moins le type de ses attributs et l’existence des routes.

Python, du script au développement

J’ai très souvent tendance à mettre (un peu outrageusement) en avant le langage Python, que ce soit à travers des petites piques plus ou moins discrètes ou via des articles consacrés. Cet article en est un nouveau, mais cependant j’ai cette fois l’ambition de prendre un peu le contre-pied de l’admiration béate, notamment en invectivant certaines pratiques que j’estime à termes préjudiciables pour le langage.

Le Python est un langage facile à prendre en  main. Tout le monde le dit, c’en devient même presque la justification première de son utilisation. Il suffit d’écrire dans un fichier un shebang et quelques lignes qui semblent naturelles, un chmod +x et c’est partie ! On a notre « Hello World! ».
Mais le Python n’est pas un langage simple. Au contraire, il est extraordinairement complexe et subtil.

Le problème est que, dans les milieux où on n’a pas le temps et/ou la volonté d’étudier les langages, l’ensemble des personnes intervenant sur des projets Python sont persuadées que c’est facile. Les managers prévoient des plannings courts et provisionnent des ressources souvent débutantes et les développeurs font ce qu’ils ont toujours fait : du Python facile, à plat, bancal. Souvent les projets n’ont pas d’architecte logiciel, et ce sont les développeurs qui créent l’architecture au fil du développement.

Python est beaucoup utilisé dans des cas peu contraignants, notamment des wrappers sur des bibliothèques C, C++, Matlab ou Fortran, et des interfaces (API, GUI et beaucoup WebUI). Dans ces cas, le code amateur fait illusion, c’est pas super réactif, ça plante de temps en temps mais il suffit de redémarrer le service et c’est de nouveau debout, en général sans gros impact sur les calculs sous-jacents.
Malheureusement, au bout d’un moment les managers sont convaincus de posséder plusieurs ressources Python expérimentées voire expertes, et alors on commence à demander du développement industriel en Python pour des applications de cœur de métier…

Objectivement, Python a de beaux arguments pour être utilisé dans le développement industriel. Le langage et sa communauté ont depuis longtemps prouvé qu’ils étaient mûrs, fiables et efficaces. Le langage évolue de façon sérieuse et rigoureuse, à travers les PEP par exemple. Le travail sur les interpréteurs est réellement excellent et offre des performances remarquables au vue des contraintes fortes du langage. De plus, de part sa modularité au niveau de l’interpréteur justement, le langage peut facilement se porter dans des environnements d’exécutions multiples, d’abord au niveau du système d’exploitation évidemment, mais aussi pour ce qui est d’exécuter le programme, avec des passerelles vers du bytecode Java, du C/C++, du JavaScript, du .NET, etc.
De plus, Python est, s’il est bien écrit, extrêmement clair (c’est pour moi, l’outil informatique qui illustre le mieux la philosophie KISS). Il bénéficie d’une palette de bibliothèques de très bonnes qualités, et pas seulement pour le calcul scientifique. L’ensemble des outils gravitant autour de Python (frameworks, versionning, terminal) est lui aussi très riche. Un bémol cependant : Python n’a pas encore trouvé un puissant éditeur dédié (autre que Emacs, bien sûr).
Enfin, Python est massivement utilisé dans le cloud computing et la cybersécurité, qui sont comme on le sait (un peu trop sûrement) les probables futurs grands domaines de croissance de l’informatique.

Mais voilà, comme on peut l’entendre dans tant de mauvais films, lorsqu’un jeune bellâtre persécuté se découvre des pouvoirs après [s’être fait mordre par une [araignée | un vampire | un loup-garou ] | avoir subit le rayonnement d’une quelconque substance forcément radioactive – et verte de préférence | avoir fait un pacte avec le diable ] et vient chercher conseil auprès d’une quelconque figure plus ou moins paternelle, cette dernière (ça a beau être paternelle, ça reste une figure) commence souvent sa réplique par : « Un grand pouvoir implique de grandes responsabilités » [1].
Le problème de Python est qu’il donne réellement un grand pouvoir, mais ce pouvoir est souvent utilisé de façon imprécise, sans maîtrise véritable de l’outil.

Je demandais récemment au chef du pôle de dev de ma boîte pourquoi il voulait me faire intervenir sur des projets Python ni complexes ni intéressants, lui opposant naïvement que « tout le monde sait coder en Python ». Il m’a répondu : « tout le monde sait scripter en Python ». Je me suis souvenu du code que je devais relire et réécrire chaque jour : des fichiers de plusieurs milliers de lignes, des fonctions de 400 lignes, une syntaxe aléatoire, des « optimisations » douteuses, pas de classes (dans tous les sens du terme), pas de factorisation, pas de tests, pas de réflexion sur le code et les algorithmes.

Je prends aussi ces critiques pour moi-même : j’ai personnellement commencé sérieusement le Python sur un PoC assez important avec beaucoup de libertés. On savait ce qu’on faisait, on avait nos objectifs, et, malgré quelques pierres d’achoppement, on contrôlait techniquement notre sujet. Le projet a rapidement donné des résultats très sympas, les fonctionnalités se multipliaient et on créait ce qui nous semblait être (et qui l’était peut-être, mais je ne peux pas juger objectivement) de l’innovation.
Au bout d’un an environ, nous avions atteint un « plus haut » dans le projet : les objectifs étaient atteints, les difficultés techniques levées, les performances honnêtes sinon bonnes, l’ergonomie correcte.
L’étape suivante logique aurait été une industrialisation. Pour différentes raisons, il n’en fut rien, on nous demanda simplement de continuer à enrichir notre PoC.
À partir de là, ce fut la descente aux enfers. Nous avons commencé à avoir beaucoup de mal à ajouter des fonctionnalités : chaque modification faisait sauter le code à l’autre bout du programme, le code et sa structure était incompréhensible, on tombait dans des états incohérents qu’il fallait laborieusement déboguer à la main. Fort de toute l’expérience et du recul apportés par des mois de développement, nous avons réfléchit à la logique de notre programme, à nos algorithmes, nous avons profondément modifié l’architecture, plusieurs fois, pour la rationaliser et factoriser du code, mais nous n’avons pu faire le nettoyage jusqu’au bout, car il fallait toujours intégrer des fonctionnalités. Nous avons essayé de faire des tests automatiques, mais le code était beaucoup trop dense, c’était tout simplement impossible.
Après un peu plus de deux ans, le projet fut arrêté. Une délivrance : le programme n’était plus viable, instable, le moindre développement aboutissait à des régressions fonctionnelles et demandait alors des semaines de débogage. Un bilan terrible : l’absence de cadre sérieux avait seule tué le projet.

Cette expérience m’a rendu particulièrement sensible aux problématiques de développement efficace. J’ai eu la chance après ça de travailler sous un gourou avec une belle connaissance des process de développement industriel en environnement d’intégration continue, et pas du tout du genre à faire des concessions dessus.
Ses préceptes sont assez simples :

  • il doit être possible, à tout moment, de produire une version stable (sûrement incomplète, mais stable) du programme;
  • priorité absolue à la stabilité : si un seul indicateur dans l’environnement d’intégration continue échoue, on arrête tout développement pour analyser et corriger le problème;
  • un bug n’est pas considéré corrigé s’il n’y a pas de tests permettant à l’avenir de révéler ce bug;
  • tout ajout de code doit passer par ces étapes :
    1. écriture du/des tests reflétant le comportement attendu,
    2. développement du code,
    3. validation par les tests (réécriture du code/du/des tests si besoin),
    4. factorisation.
  • La couverture de tests doit être maximale, et un commit poussé signifie intrinsèquement que tous les tests passent pour ce commit.
  • Le code est composé de deux parties principales : la première au contact avec l’extérieur (utilisateur, bdd, fichiers, autres programmes, etc.) qui doit être blindée et qui nourrit en données de confiance la seconde, interne au programme, et qui au contraire doit planter à la moindre dérive (ce qui permet de souligner les défauts d’implémentation et/ou les cas non traités par la première catégorie).
  • il doit être impossible, en lisant le code, de pouvoir identifier son auteur.

À part le dernier sur lequel j’ai des réticences (je préfère une approche plus « artisanale », avec un vrai apport de l’expertise – et donc aussi, quand ça casse, de la responsabilité – de chacun directement visible dans le code), ces points sont dans l’esprit assez naturels et sains. Mais ils reflètent un idéal qu’on ne respecte pas, d’abord nous développeurs par paresse et/ou ignorance, et ensuite les managers en priorisant l’ajout de fonctionnalités qui brillent par rapport à la factorisation et le nettoyage de code.

Nous partons donc de cet idéal fiers, guillerets et naïfs, pour arriver dans la bonne pratique du développement qui sent la vieille sueur rance… Et quoi de mieux pour illustrer les dérives possibles que de me baser sur des citations (véridiques – du moins dans le fond) que j’ai pu entendre dans mes projets ?

« Je ferais les tests au prochain commit ! »

On connait tous notre tendance à la procrastination. Remettre des tests à plus tard et le meilleur moyen de ne jamais les faire. Et quand il faut s’y mettre, il y en a tellement à faire, et les modifications de code qui viennent avec sont tellement importantes que ça en devient franchement déplaisant.
Faire des tests au fil de l’eau est extrêmement sain, pour des tonnes de raisons :

  • les tests, s’ils sont durs à écrire, permettent de voir immédiatement où le code est complexe et on en profite pour créer des nouvelles méthodes plus simples. Plus globalement, ils permettent de prendre un peu de recul sur notre code tout jeune, ce qui aide à l’améliorer;
  • en faisant des tests (qui passent), on capitalise une confiance inouïe sur ce qu’on fait. Non seulement les tests permettent de voir tout de suite où on a développé n’importe comment, mais ils permettent aussi à l’inverse de valider un bon travail. Quand on arrive à une base honorable de tests que l’on sait pertinents (pour les avoir écrit) avec une bonne couverture et qu’ils passent tous, là on a des garanties tangibles sur la solidité du code, et on push l’esprit apaisé;
  • toujours sur la confiance, les tests font gagner un temps énorme : plus besoin de déployer et lancer l’application, de cliquer à 2-3 endroits pour voir si notre modification est effective. Les tests passent, ça suffit.

Je ne vous dis pas tout ça parce que je suis embrigadé par le très puissant Lobby Pour Un Monde Mieux Testé. Je le dis parce qu’avant ça me faisait chier, et puis j’en ai fait, et maintenant, quand je code, si les tests passent, je n’essaie même pas de lancer le programme, je le considère comme stable les yeux fermés. Et ça ne m’est encore jamais retourné dans la gueule.

« On va pas faire des classes, ya trois fois rien à coder ! »

001xq5d7Non.

Tu fais des classes.

Ça va pas ralentir le code.
Ça va pas le rendre incompréhensible.

Tu fais des classes.

Sérieusement, quand on me dit ça, je ne sais jamais trop quoi répondre. En fait, je comprends quelque chose comme : « je suis sur un projet de dev et je vais le saboter en faisant du script ». Comme en général chaque collègue lance une cinquantaine de trolls par jour, je pense à une blague, et pourtant … non.

Je crois que tous les sites / threads / forums du monde dans lesquels on trouve le mot « Python » et le mot « Class » disent qu’il faut les utiliser. Je ne vais pas perdre votre temps à reprendre ces arguments, à part un : celui de la maîtrise de la complexité (algorithmique) du code.
Idéalement, chaque classe matérialise un concept simple, si besoin en s’appuyant sur d’autres classes. Le résultat, c’est qu’il doit être trivial pour chacun de ces éléments de déterminer la complexité de leurs méthodes, et donc du programme entier. Il y a trop de composants qu’on ne maîtrise pas dans un programme informatique pour qu’on puisse se payer le luxe de perdre la main sur son propre code.
En Python, les classes répondent infiniment mieux à cette problématique de maîtrise de la complexité que des bibliothèques de fonctions à plat.

Pour ça et pour tout le reste, tu fais des classes.

« Ça, le framework le fait en 2 lignes »

Avant j’aimais bien les frameworks. En Python, la plupart (comme Django ou Flask) arrivent avec un peu tout à portée de main. Gestion des utilisateurs, gestion de l’API, de la bdd, génération de pages d’admin, etc., tout est simple, tout est beau.

Et puis j’ai voulu faire du code un peu plus sérieux. Depuis, j’ai beaucoup plus tendance à avoir des coups de sang contre les frameworks que de leur être reconnaissant.

Un framework, c’est une boîte noire [2], on ne sait pas comment ça marche, c’est un environnement auquel on ne peut pas faire confiance. C’est le monde extérieur, et pour s’en protéger, il y a deux choses : blinder les échanges avec lui et surtout minimiser au maximum l’adhérence de notre code avec. Sans ça, on va droit vers une forte dépendance au framework qui est extrêmement néfaste, ne serait-ce que dans le cas (pas si improbable) que le projet porteur du framework soit abandonné.

Pour plus de détail là-dessus, je vous invite à patienter quelques mois, le temps que j’écrive un petit brûlot sur Flask qui m’a posé de vrais problèmes de fond et qui sous ses dessous affriolants cache des trucs franchement gênants…

« C’est vraiment pas grand chose, on le rajoute pour la release. »

C’est en général la phrase qui indique qu’un projet est condamné. En soit, ça semble mineur, car effectivement, souvent ce n’est pas grand chose. Mais ça souligne une dérive  dangereuse : du moment qu’on accepte un unique ajout par rapport à ce qui est prévu, tout ce qui a été planifié ne vaut plus rien.
Du point de vue d’un manager, un petit ajout de 5 min équivaut surtout à la fonctionnalité qui fait la différence par rapport au concurrent. Et le manager, dans sa tête, il n’a pas une fonctionnalité qui fait la différence, il en a trois milles. Et il va arriver à toutes les introduire, parce qu’il prend la pression du client, ou du commercial, ou du boss, et parce que c’est son boulot de challenger (un peu) ses gars.

Résultat : comme on ne récupère pas du temps sur les fonctionnalités déjà prévues, on taille dans le reste : tests, nettoyage de code, refactoring. Du travail essentiel, je le souligne encore, mais à court terme, c’est vrai, ça ne clignote pas, ça ne fait pas un « beep » rassurant, ça ne permet pas d’avoir une image à côté de son login.

Avec ce genre de fonctionnement, le projet va inexorablement à sa fin, avec des pentes différentes selon la virtuosité des développeurs, mais l’idée globale est qu’à la première release, tout est fonctionnellement au niveau, avec même des petites trucs non prévues en plus (ce qui va encore plus renforcer à l’avenir la propension à ajouter des tâches non planifiées) ; à la deuxième, ça peut tenir, il y a toujours quelques fonctionnalités en plus, mais les gars près du terrain sentent que le code commence à avoir de l’inertie. À la troisième, c’est vraiment la galère de travailler, on commence à passer un temps non négligeable sur du code qui vieillit mal; et après ça on n’en parle plus…

65498798Ici, le job de l’équipe, et en premier lieu du responsable technique s’il y en a un, c’est de calmer le gars qui ajoute des trucs pas prévus.
Si c’est un développeur un peu fou, ultra motivé, ça doit pouvoir se cadrer assez facilement, à l’équipe de le catalyser vers les choses vraiment bénéfiques au projet.
Si c’est un manager, ou un supérieur (ou les deux), c’est plus délicat… Mais il faut encaisser le choc, et typiquement un bon respo technique a parmi ses taches les plus importantes de protéger son équipe de ce genre de pression malsaine, en jouant de son aura / de son autorité pour refuser des ajouts impromptus. Ce n’est parfois pas très bien vu de s’opposer systématiquement à ce genre de demandes, et c’est d’autant plus rageant qu’on sait qu’on le fait pour le bien du projet, mais il faut s’y coller. Et tant pis pour l’augmente, on travaille pour la gloire…

Titre pour dire que c’est la fin d’au dessus et le début d’en dessous (et oui c’est délibéré)

Je ne veux pas finir par une vibrante scène où j’appellerai le monde à se concentrer sur la paix et la beauté intérieure.

Mais j’aime le Python, je pense qu’il a sa place dans le développement industriel, et je pense qu’il mérite de devenir un langage de tout premier plan.
Les problématiques soulignées plus haut peuvent être transposées à n’importe quel langage auquel on soumettrait une logique industrielle. Cependant dans le cas du Python, l’immense souplesse du langage fait qu’on se rend compte souvent très tard, trop tard, de l’obligation d’être rigoureux.

Je voudrais donc encourager toute personne à qui on demande un jour de faire du développement un peu sérieux en Python, et qui n’en a jamais fait, de prendre du recul sur tout ce qu’elle a pu faire en Python jusqu’à présent. D’être réellement, et de bonne foi, critique sur son propre travail, et de toujours améliorer son code.

Arrêtez de scripter, vous vous faites du mal. Faites du vrai code, devenez des badass.

1. Phrase non contractuelle, peut changer selon le bousin projeté.

2. Je refuse de tomber dans l’argument facile de l’Open Source « visible par tous » etc. etc. J’adore l’Open Source, mais tout le monde sait pertinemment que 99% de leur utilisation se fait sans évaluation ni audit (voir cet article sur l’utilisation ‘facile’ des logiciels open source en partant du cas HeartBleed). Rarement (jamais ?) une entreprise provisionne ne serait-ce qu’une semaine à une équipe pour évaluer et apprendre à maîtriser sérieusement un framework avant de commencer le développement. Alors oui, le réalisme oblige de considérer tout framework (et toute dépendance) comme une boîte noire.

Trucs amusants (et parfois dangereux) du langage Python

python-logoLe Python est un langage merveilleux, toute personne objective en conviendra (les autres n’étant que des suppôts de Satan, avec deux « a », comme dans « Java »). Même en l’abordant avec quelques vagues connaissances en informatique, sa simplicité d’usage, son naturel et la richesse de ses bibliothèques et frameworks permettent à n’importe quel rookie d’en faire un usage plus que satisfaisant, que ce soit pour du scripting ou de la POO plus dense (ou un mélange des deux pour les coquins).
Le Python est d’autant plus merveilleux qu’on apprend toujours de lui, même après des années d’utilisation quotidienne. Les découvertes amènent un code plus clair, plus concis, plus efficace, mais toujours élégant, que ce soit dans la syntaxe du code ou dans son architecture.
Il est heureux d’aborder ces subtilités de cette façon, étalées dans le temps : leur assimilation devient beaucoup plus efficace, elles sont mieux comprises, mieux maîtrisées et permettent de résoudre, sinon de mitiger, des problèmes qui nous sont devenus concrets avec l’expérience. Bien souvent d’ailleurs, on les découvre en cherchant une solution à un problème; et au détour d’un thread de StackOverflow, lors d’un voyage dans un interpréteur, durant une discussion avec un gourou, TAC, la limpide vérité apparaît, nappée de son évidence immaculée… L’apothéose d’une pensée…

Après cette introduction un brin racoleuse, je me propose d’aller complétement à contre-courant en livrant d’un coup une petite sélection de mécanismes et autres possibilités du langage qui me sont chers personnellement, soit parce que je ne les connaissais pas et qu’elles m’ont grandement facilité la vie, soit parce que je pensais les utiliser (à tort) à bon escient, jusqu’à ce qu’un bug véreux me force à me mettre plus sérieusement dedans.

Mesdames, mesdemoiselles, messieurs, les trip(e)s Python du Maréchal :

List comprehension

S’il y a bien un unique avantage qu’ont les ingénieurs faisant de la modélisation scientifique, et qui sont trop jeunes pour coder en Fortran et trop vieux pour connaître le Python – ceux qui codent en MATLAB® donc -, c’est qu’ils savent combien la boucle for est un véritable poison. Cette dernière est en effet bien souvent coupable de l’inconcevable lenteur des programmes développés avec ce langage. Pour contrer cela, ils utilisent massivement la vectorisation, ce qui est très puissants dans ces milieux où tout ou presque se réduit à des matrices et des vecteurs.
Et bien le Python propose un mécanisme similaire, chez nous appelé « list comprehension ». Calmons-nous tout de suite, les gains en performances ne sont à aucun moment comparables aux facteurs 10.000 souvent atteint en MATLAB®. L’idée est d’ailleurs sensiblement différente : la vectorisation vise à s’affranchir de la boucle for en concentrant le calcul dans une expression, tandis qu’une list comprehension sert avant tout à réduire le champ des possibles de la boucle, notamment en posant les conditions dès le départ.
Imaginons ce bout de code :

C’est passablement inélégant et inutilement long. On peut simplifier de cette façon:

Là vous me direz que la boucle « existe » toujours et que le gain est vraisemblablement misérable.
C’est exact à cette échelle, mais un programme complet peut présenter des centaines voir des milliers d’occurrences de ce genre de code. En reprenant exactement les exemples ci-dessus, et les faisant tourner 10 millions de fois chacun, j’obtiens en moyenne 8,5 s avec le premier, 6,4 s avec le second, qui est donc 25% plus rapide ! (et encore, la magie des stats m’aurait permis de truander un 33%…)
Ajoutons à cela le fait qu’ici la charge utile de la boucle est quasiment inexistante : un test et un append. Dans le cas où le code devient plus trapu, ou qu’on parcourt plusieurs listes avec des tests récurrents, ou qu’on souhaite accéder à des éléments précis des objets de la liste, … la list comprehension devient un puissant outil de factorisation, de clarification et d’accélération du code.

Prenons la liste d suivante :

… où Whatever() instancie une classe possédant une méthode whatever_func qu’on veut appeler avec en argument la valeur du champ "name" associé.
La manipulation suivante :

…permet de la faire, mais est excessivement sale.
Contrairement à :

… qui, vous en conviendrez, a tout de même plus de gueule [1].

Alors oui, j’entends l’argument que j’ai bien de la chance que x tombe bien sur l’instance de Whatever(), ce qui m’évite un douloureux AttributeError pas du tout géré dans le cas contraire… Et oui. Mais je vais pas tout faire à votre place non plus !

Pour conclure sur les list comprehension, sans aller jusqu’à promouvoir les codes qui ramènent tous leurs tests dans des listes, générant ainsi des lignes d’une longueur gargantuesque, j’encourage très vivement leur utilisation dès qu’elles permettent de condenser du code de façon lisible.

Decorators (et surtout property)

Les décorateurs, c’est la vie. Vous les utilisez forcément, même sans le savoir. Les routines fournies par des frameworks comme Django ou Flask (pour lier une méthode à une URL, vérifier si l’utilisateur est loggué, …), les classmethod/staticmethod, tout ça c’est des décorateurs.

Je n’ai pas envie de faire le topo sur comment faire des décorateurs, considérez cet article bien complet pour cela.

Ce qui m’intéresse ici est surtout le décorateur property. On le cantonne souvent au rôle d’un simple getter, mais il s’agit de bien plus que ça.
L’usage basique de property est le suivant:

Ce qu’on oublie souvent à ce niveau, c’est que la méthode field n’est, justement, plus une méthode field à ce point. Ou, plus précisément, Test.field ne renvoie plus sur la méthode field:

En fait, le décorateur property a créé dans Test une variable de classe field pointant sur une instance du type property. Ici, nous n’avons renseigné que le champs getter de property, mais il reste les champs setter et deleter:

Bien comprendre ici que les field.setter et field.deleter permettent de définir les champs setter et deleter de field, instance du type property.
Sans utiliser la notation @, l’équivalent de tout cela est :

L’utilisation du @property permet de définir implicitement une variable de le classe Test de même nom que la méthode décorée, et d’utiliser ce nom pour gérer les champs getter et deleter. Cette « souplesse » apporte en revanche l’obligation d’appeler les méthodes décorées par les setter et deleter du même nom que la la méthode décorée par property, et évidemment de déclarer cette dernière en premier.

C’est très clair, et c’est génial.

Usage du « with »

with, c’est le mot-clef utilisé massivement pour tripatouiller dans des fichiers. Prisonnier de ce rôle ingrat, ce pauvre with mériterait plus de considération car son utilisation peut être beaucoup plus large.
Partons de sa définition. On a l’équivalence (ref : PEP 343) entre ces deux morceaux de code :

Quelques enseignements de ce code:

  • Il est facile de créer des objets manipulables avec with, il suffit simplement que EXPR renvoie l’instance d’une classe possédant des méthodes __enter__ et __exit__.
  • Le code est blindé, on va pas s’amuser à rentrer dans le with et faire des pâtés de sable sans être sûr de l’existence des méthodes __enter__ et __exit__.
    Inversement, on ne peut sortir du with sans appeler __exit__, même si BLOCK contient un break, un return, ou un raise (merci la double imbrication de try).
  • L’utilisation de with renvoie l’idée de la création d’un contexte temporaire, et peut donc s’appliquer à une grande quantité d’opérations (fichiers, connexions, contrôle de threads, etc.). Assez naturellement, Python propose un module contextlib pour faciliter l’écriture de code pouvant être exploité par with.

Le multithreading en Python

On touche ici une notion intéressante du Python. Ses détracteurs insistent souvent sur une tare présumée du langage : il serait incapable de gérer efficacement le multithreading.

Précisons d’abord les choses, en nous restreignant à l’interpréteur CPython, qui est l’interpréteur Python le plus utilisé (à tel point qu’on parle généralement de lui lorsqu’on évoque l’interpréteur de Python). Concentrons-nous de plus sur la version 2.7 du langage, la version 3 apportant des modifications significatives à ce niveau que je ne maîtrise pas. Dans ces conditions, toute réflexion sur les aspects de multithreading va inexorablement tomber sur le Global Interpreter Lock (GIL de son petit nom).
Je vous invite à lire ce très intéressant article pour saisir le problème et prendre un peu d’avance sur ce qui va être dit plus bas.

La restriction qu’apporte le GIL est que CPython ne peut exécuter qu’une instruction à la fois, et de fait, il ne peut gérer qu’un seul thread à la fois. Lorsqu’il doit distribuer [2] plusieurs threads, c’est à dire plusieurs flux d’exécutions indépendants, sur plusieurs processeurs physiques, l’interpréteur devient le goulot d’étranglement du programme, et les gains en performances par rapport à un programme linéaire sont généralement faibles, voir parfois négatif, puisqu’en plus la distribution sur plusieurs processeurs physiques demandes une intervention beaucoup plus conséquente du noyau, ne serait-ce que pour la cohérence des caches.

La capture d’écran ci-dessous illustre bien ce propos : le processeur à 100% fait tourner un programme très simple faisant à l’infini un incrément sur une variable. Ce programme exploite totalement le processeur, et uniquement dans le user space.

2 programmes Python, un mono-thread, le second avec 10 threads.

2 programmes Python, un single thread, le second possédant 10 threads.

Les autres processeurs sont partiellement occupés par un second programme, qui lance 10 threads contenant chacun exactement la même charge que le premier programme (incrément infini d’une variable). On voit que l’interpréteur CPython plafonne à une utilisation cumulée aux alentours de 180% de processeur, et les barres rouges soulignent la part importante des opérations du kernel space.

Le gain véritable est donc limité, et encore les threads ne sont ici pas inter-dépendants : ils ne travaillent pas sur les mêmes variables, ils ne communiquent pas entre eux… Si c’était le cas, on pourrait attendre une nouvelle dégradation des performances.

La solution ici est donc de travailler sur différents process, donc utiliser os.fork ou encore la bibliothèque multiprocessing (très agréable à utiliser). Chaque process étant dévolue à un interpréteur dédié, l’exploitation des processeurs est bien meilleure comme on peut le voir ci-dessous (toujours la même charge que précédemment, mais dans 5 process différents).

5 process

5 process

Cela signifie-t-il qu’il faille simplement bannir tout usage du multithreading dans Python ?

Évidemment que non !

Les exemples ci-dessus montrent qu’il ne faut pas utiliser les threads Python pour du calcul distribué. Dans ce cas, effectivement, CPython n’est pas pertinent et inefficace. Mais il s’agit d’un cas de figure très spécifique. Dans la vraie vie, que font la plupart des programmes ? Ils guettent une action utilisateur, ils lisent un fichier, ils requêtent une base de données… bref ils attendent ! Et dans ce cas l’interpréteur a largement le temps de gérer sa petite affaire sans ralentir qui que ce soit.

Donc si votre programme à besoin dans un coin d’un morceau de code indépendant du reste, comme par exemple surveiller de temps en l’espace restant sur un disque, ou lancer une commande shell et attendre qu’elle se termine sans bloquer le fil d’exécution principale du programme, les threads ont toute leur place ! Encore plus de part la richesse de la bibliothèque threading, qui permet une gestion très souple (et – grosse foire aux modules – les communications deviennent un jeu d’enfant si on ajoute la bibliothèque Queue).

Pour conclure sur ce point: oui, il y a bien une anguille sous CPython pour ce qui concerne les thread… Mais il est très réducteur de condamner directement leur usage ! Avec un peu de compréhension, on peut leur trouver une utilité très légitime.

…et encore d’autres

La liste est infinie tant le Python est riche. Je me permets cependant d’ajouter quelques item qui aurait peut-être mérité plus de précision (mais j’ai la flemme) :

  • L’opérateur is, trivial par définition mais curieux et finalement assez dangereux si on l’utilise pour des string ou des entiers/réels. Par exemples :

ou

Il vaut mieux en rester à sa définition et ne l’utiliser que pour vérifier si 2 instances sont effectivement les mêmes.

  • Les metaclass, puissant mécanisme de configuration de classes à leurs instanciations.
    Beaucoup d’experts déconseillent vivement leurs utilisations; pour reprendre une citation de Tim Peters tirée du thread de stack overflow proposé plus haut : « Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don’t. The people who actually need them know with certainty that they need them, and don’t need an explanation about why. »
    Non seulement elles rendent le code illisible, mais elles sont souvent révélatrices d’une mauvaise compréhension d’un problème (et d’une résolution inutilement complexe et dangereuse).
  • Les conditions inline qui permet de densifier du code et sont pratiques pour des petites vérifications (pour les plus grosses avec du traitement, ça devient vite illisible) :

  • Les fonctions lambda, ces vils bouts de codes chers aux paresseux. Ça c’est mal. N’en faites pas. Jamais.
  • Les fonctions built-in filter et reduce. Notez que filter permet dans certains cas de simplifier l’écriture des lists comprehension, par exemple :

devient

  • Les arguments par défauts dans la déclaration d’une fonction. L’argument par défaut étant créé en même temps que la fonction, attention aux effets de bord…

Conclusion:

Faites du Python ! C’est beau, pur, ça dégage l’esprit, et contrairement au Tai Chi, on n’est pas obligé de supporter cet odeur d’encens et ces sons de cloches horribles.
Y paraitrait même que certains milieux s’arracheraient les développeurs Python… À bon entendeur !

 

1. Ici vous remarquerez que je ne la ramène pas pour les perfs, vu que sur cet exemple je n’ai pas vu de gain convaincant (plutôt une légère perte…)

2. L’interpréteur ne « distribue » évidemment pas les instructions tout seul sur les processeurs, plusieurs acteurs interviennent dans l’affaire, à commencer par le noyau, voire même des routines hardware, par exemples si les processeurs intègrent du multithreading.