Ysance - Your Data in Action

    10 Conseils pour coder vos projets data comme un badass

    [fa icon="calendar"] 01/04/19 14:13 / par Cédric Despres

     I am a (data) bad ass

     

     

    Depuis le début des années 2000, j’ai contribué à de nombreux projets Web, mis en place des architectures SOA, principalement avec des technologies Java. J’ai toujours eu à cœur que les développements soient réalisés avec de bonnes pratiques afin de réduire les risques d’anomalies et en faciliter la maintenance. Les pratiques dans ces projets se sont standardisées et sont même enseignées dans les formations d’ingénieur. Je travaille depuis 5 ans sur des plateformes traitant massivement de la donnée (Big Data). Mon équipe intègre continuellement de nouveaux ingénieurs, souvent débutants, dans le monde de la data. Le domaine étant nouveau, les bonnes pratiques se font encore rares, même s’il en existe dans le domaine des bases de données éprouvées depuis longtemps, celle-ci ne s’y applique pas totalement. Dans cet article, je propose ma réflexion autour de 10 conseils qui me semblent essentiels de suivre lorsqu’on débute dans ce domaine.

     

    1) Penser au cycle de vie d’une donnée dès la phase de conception.

     

    Une erreur souvent commise est de remettre à plus tard la réflexion autour de la question de la fin de vie d’une donnée. À quel moment une donnée doit-elle être supprimée ?

    Le traitement associé à la suppression de donnée n’est pas un sujet sur lequel on met l’effort au début d’un projet, le développeur se focalise sur les enjeux business plutôt que de penser à quand et surtout comment la donnée sera effacée et/ou archivée.

    Pourtant, depuis la mise en place de la réglementation européenne de protection des données personnelles (RGPD), ce sujet n’est pas à prendre à la légère. Le responsable d’un traitement associé à des données personnelles doit s’assurer du respect de cette réglementation notamment en termes de durée de conservation et de droit à l’oubli. La société s’expose à des sanctions lourdes (jusqu’à 4% du chiffre d’affaires) en cas de non-respect avéré.

    D’autre part, la durée de conservation d’une donnée peut impacter la manière dont les données doivent être modélisées. Une mauvaise modélisation peut conduire à la mise en place de traitements très lourds, voire impossibles à réaliser en l’état, si le cas de la suppression n’a pas été prévu. Il convient donc que les règles de gestion de suppression soient définies et les index et/ou les dates permettant de rechercher les données à supprimer soient correctement implémentés. Enfin, il est bon à savoir que certaines bases de données (Google BigQuery, Cassandra...) proposent des mécanismes de suppression automatique des données basée sur des durées de vie (lifecycle) faciles à mettre en oeuvre lors de l’implémentation initiale d’une table, mais beaucoup plus difficilement une fois en production.

     

    2) La modélisation relationnelle des données sous une forme normale ne s’applique pas aux bases de données distribuées.

     

    Les développeurs ont appris durant leur formation à modéliser selon des règles bien précises appelées “modélisation relationnelle”. Plus particulièrement encore les développeurs de bases de données décisionnelles sont formés à l’usage de modèles dits “en étoile” ou “en flocon” avec comme référence le livre The Datawarehouse Toolkit de Raph Kimbell.

    Rappelez-vous les règles de normalisation en forme normale :

    1ère forme normale (fn) : les attributs d’une table doivent être atomiques (une seule information dans un attribut)

    2ème fn : une table doit posséder des attributs de clés primaires.  De plus, un attribut non clé ne dépend pas d'une partie de la clé mais de toute la clé

    3ème fn : un attribut non clé ne dépend pas d’un attribut non clé

    Je vous épargne la 4ème et la 5ème fn mais ce qu’il faut retenir est que ces règles ont pour objectif d’éviter à tout prix la duplication d’une information dans une base de données. Ces règles ont pour conséquence concrète de décomposer un modèle de données en de nombreuses tables, l’accès aux données communes à différentes tables s’effectuant grâce au mécanisme de “jointure”, largement éprouvé… sur les bases de données relationnelles monolithiques.

    Bienvenue dans le (nouveau) monde de la (big) data avec ses systèmes distribués : Hadoop, Presto, Apache Cassandra, Elasticsearch, Amazon Redshift, BigQuery, etc. Vous avez peut être entendu parler du théorème de CAP comme Consistency, Availability, Performance. Ce théorème dit que, dans un système de base de données distribué, on ne peut avoir ces trois critères réunis et qu’il ne peut en avoir que deux réunis. Notre sujet ici est celui de l’impact de la modélisation sur la performance. Une modélisation relationnelle sur un système distribué peut avoir des impacts catastrophiques sur les temps d’exécution des requêtes pour une simple raison : les jointures et les systèmes distribués ne font pas bon ménage.

    Mettons-nous en situation sur Hadoop avec Hive/Spark d’une jointure sur des données réparties sur différentes machines. Les données sont sous la forme de blocs, chaque bloc appartenant à un fichier logique (au sens HDFS), un ou plusieurs fichiers forment une “table” telle qu’elle est déclarée sous la forme de métadonnées dans le metastore de Hive. Les blocs sont répartis sur les différents disques d’une machine et sur plusieurs machines.

    Prenons maintenant deux tables et réalisons une jointure sur des champs : que fait le job Spark déclenché par Hive ?

    Avant de répondre à cette question, il convient d’expliquer comment une base de données relationnelle réalise l’opération de jointure de manière classique :

    • La première étape est de construire une “hashtable” faisant la correspondance entre le (ou les) champ(s) de jointure et l’ID interne de la ligne dans la table. Le système choisit la plus petite des deux tables. Cette hashtable est construite en mémoire dans la limite d’une taille définie et de l’espace disponible.
    • La deuxième étape consiste à filtrer les données de la deuxième table en associant l’ID de ligne correspondant dans la hashtable.
    • Le moteur de la base de donnée réitère l’opération depuis la première étape jusqu’à ce que toutes les données de la 1ère table aient été parcourues. Et voilà, le tour est joué, les lignes des 2 tables ayant la même valeur pour le (ou les) champ(s) choisis ont été associés (c’est ce qu’on appelle une jointure).

    Dans un système distribué, la première étant globalement la même en répartissant le travail sur plusieurs processus (Executor en Spark) répartis sur plusieurs machines. Chaque Executor construit un morceau de la hashtable correspondant  aux blocs de données présents sur la machine. On obtient donc une hashtable répartie. La deuxième étape est beaucoup complexe, les données de hashtable et de la table à joindre n’étant pas sur les mêmes machines, il est nécessaire de rendre disponible la hashtable pour tous les Executors qui participent au job. Ce mécanisme s’appelle “Shuffle”. Il implique des opérations intensives en CPU, réseau et IO disques pour que les Executors qui vont parcourir la deuxième table obtiennent toutes les données de la hashtable en appelant successivement les autres Executors via une API distante (utilisant le framework Netty) avec des étapes de sérialisation / dé-sérialisation des données consommatrice en CPU et en mémoire. Je reste dans un cas extrêmement simplifié car Spark utilise tout une panoplie de mécanismes avancés pour optimiser l’opération (notamment Tungsten).

    Vous l’aurez compris, les jointures sont les ennemis de la performance dans un système distribué. Quel est la solution ? Dé-normalisez, oubliez un instant ce que vous avez appris et vous allez regrouper les données dans une même table. Cela implique de la duplication de données mais avec un socle big data, cela a été rendu possible à un coût raisonnable.

    Evidemment cela n’est pas toujours possible de dé-normaliser sans augmenter de manière trop conséquente le volume d’une base de données et les jointures restent souvent nécessaires. Il n’y a hélas pas de règle absolue pour décider de dénormaliser. Cependant, quand une entité a une faible cardinalité, il est plutôt souhaitable de ne pas en faire une table.

    Un exemple concret pour illustrer cette idée :

     

    Table Client (plusieurs dizaines de millions de lignes)

    ID Client

    Nom Client

    Magasin préféré

    007

    BOND

    MILLENIUM

    008

    DUPONT

    KWARK

    009

    MARTIN

    LES 5 TEMPS

    010

    HOLMES

    KWARK

     

    Table Magasin (Plusieurs centaines de lignes)

    Nom

    Adresse du magasin

    MILLENIUM

    NANTES

    KWARK

    LILLE

    LES 5 TEMPS

    PARIS

     

    En modèle normal, il faut faire une jointure pour obtenir l’adresse du magasin préféré d’un client.

     

    En dé-normalisant, on obtiendrait une table client avec de la donnée dupliquée (violation de la 2ème fn) mais avec un accès direct à une information importante pour le traitement que vous voulez réaliser.

     

    ID Client

    Nom Client

    Magasin préféré

    Adresse du magasin

    007

    BOND

    MILLENIUM

    NANTES

    008

    DUPONT

    KWARK

    LILLE

    009

    MARTIN

    LES 5 TEMPS

    PARIS

    010

    HOLMES

    KWARK

    LILLE





    3) “Idempotence” : un job lancé plusieurs fois de suite doit avoir le même résultat



    Si vous n’avez jamais entendu prononcer le mot  “idempotence”, j’espère qu’il vous restera en mémoire. Ce concept est primordial dans le traitement de la donnée.

    Les personnes assurant au quotidien le bon fonctionnement des traitements d’une plateforme data sont confrontés à des incidents pour de multiples raisons : la qualité de la donnée, un cas fonctionnel non traité, les limites techniques et les défaillances de l’infrastructure ou tout simplement un bug...

    Une fois le problème résolu, la seule action à faire doit être de relancer le traitement sans se poser de questions : “Puis-je relancer le traitement sans risque ? Va-t-il s’exécuter avec les mêmes données ? Dans quel état sont les données en destination du traitement ? Y a-t-il un risque d’insérer des données en double, ou pire, de supprimer des données ?”

    Un job doit donc produire le même résultat quand il est lancé plusieurs fois de suite avec les mêmes données en entrée et avec les mêmes paramètres. Un job est comme une fonction mathématique :

    • Il ne modifie pas les données qu’il a en entrée,
    • Ses paramètres sont fixes et explicites, son contexte d’exécution ne varie pas avec des paramètres cachés,
    • Il doit être capable de restaurer l’état initial des données produites s’il échoue (le fameux rollback).

    Plus facile à dire qu’à faire, surtout avec des bases de données big data où les transactions avec rollback ne sont pas implémentées et où les données sont immuables.

    En résumé, rendre vos jobs “idempotents” est crucial pour une production efficace et sans douleur.

     

    4) Un job est responsable de son environnement de travail

     

    Ce conseil est corollaire au conseil précédent. Certains jobs vont créer des espaces de travail pour stocker les résultats intermédiaires d’un traitement. Cela peut être des fichiers ou des tables.

    Une bonne gestion de ces données temporaire s’impose pour éviter des écueils classiques :

    • le full disk, les fichiers temporaires des précédents jobs s'accumulent avec le temps jusqu’à ce qu’il ne reste plus de place disponible.
    • le “file already exists” quand un traitement est relancé après un incident.
    • la perte d’intégrité de données par l’utilisation d’une table temporaire avec le résidu d’un traitement  précédent.

    Pour éviter ce type de problème fréquent, pensez donc à prévoir a minima en fin de traitement et de manière idéale en début, à vous assurer que l’espace de travail de votre job est propre.

     

    5) Restreindre vos jobs à une petite taille et leur donner un rôle bien précis

     

    Nous pouvons être tentés de faire un job qui fait tout. Celui-ci va enchaîner tout un ensemble d’opérations jusqu’à obtenir le résultat souhaité : intégrer de la donnée externe, valider le contenu des données, effectuer des agrégats, des calculs et enfin rendre disponible son résultat pour un autre usage.

    Cette pratique est absolument à proscrire : des dizaines d’années de bonnes pratiques (je me réfère encore à un livre de Kimball) ne me contrediront pas.

    Le découpage des jobs en “petits” traitements a trois objectifs :

    1. Faciliter la maintenance du job : découper vos traitements en plusieurs jobs vous aidera à garantir un niveau de qualité et de compréhension du rôle du job sans avoir recours à une documentation. Moins un job réalise d’opérations, plus il est facile à comprendre et donc à faire évoluer sans régression (Keep It Simple and Stupid.

    2. Etre efficace en cas d’incidents : il vous sera plus facile de diagnostiquer l’origine d’un incident avec un pipeline de petits jobs plutôt qu’avec un gros job monolithique. De plus, vous allez pouvoir reprendre l’exécution d’un pipeline de traitements en cas d’échec sans réexecuter l'enchaînement des opérations depuis le début.

    3. Pouvoir paralléliser les actions qui peuvent l’être : en confiant à la technologie adaptée vos jobs (Hadoop, Celery...), vous pourrez exécuter en même temps les opérations sans dépendance entre elles afin de gagner sur le temps d’exécution du pipeline complet.

     

    Il existe un modèle de découpage des jobs en rôle éprouvé qu’il faut continuer à suivre :

    • des jobs d’intégration de données dans une zone de “staging”,
    • des jobs de contrôle de l’intégrité et de la qualité des données,
    • des jobs d’enrichissement, d’analyse et d'agrégation,
    • des jobs d’exposition des données pour leur usage suivant.

     Gardez  en tête que cette structuration vous aidera à concevoir des pipelines de data où vous pourrez éventuellement réutiliser des jobs pour des objectifs différents. Sur notre plateforme nous utilisons par exemple un job d’export de fichiers vers des destinations externes (Amazon S3, SFTP, GCS…) dans plusieurs pipelines.



    6) Séparer la logique d’orchestration de la logique de traitement

     

    L’orchestration des traitements joue plusieurs rôles très importants dans la gestion de traitement de données :

    • programmer à une date et/ou une fréquence l’exécution d’un pipeline de jobs,
    • s’assurer de l’enchaînement correct des jobs en fonction de leurs dépendances,
    • s’assurer que le job a les ressources nécessaires pour pouvoir s’exécuter dans de bonnes conditions.

    Une erreur souvent commise est de ne pas avoir recours à un outil d’orchestration et de réaliser un job monolithique réalisant de nombreuses opérations sans donner aucun contrôle sur leur priorité,  ni offrir la possibilité de paralléliser plusieurs tâches.

     

    Il existe de nombreux outils fournissant ces services de manière complète ou partielle. Dans l’écosystème Hadoop, c’est le couple Apache Oozie / Apache Hadoop YARN, mais il y a aussi Azkaban, Luigi pour ne citer que des solutions Open Source. Bien souvent, c’est une simple Crontab associée à des outils “maison”. Dans notre cas, nous utilisons le framework Airflow.



    7) Utiliser des solutions scalables pour le traitement et le stockage de données.

     

    En 2019, le déploiement d’une architecture monolithique peut être considérée comme une dette technique. Certains me contrediront en affirmant qu’il n’est pas toujours possible de mettre en place des architectures big data en début de projet. On doit souvent commencer avec peu de moyens, le temps de faire un proof of concept avant d’obtenir le budget nécessaire pour passer à l’échelle. C’est vrai.

    Une architecture scalable n’est pas forcément une architecture dite “Big Data”. Par scalabilité, j’entends d’être en capacité de pouvoir ajouter davantage de ressources de calcul quand celles-ci sont en train d’atteindre leurs limites. Pas besoin d’un cluster Hadoop pour déclencher des traitements répartis sur plusieurs machines. Par exemple, une architecture de type Airflow / Celery organise l’exécution des traitements sur une ou plusieurs machines de manière robuste.

    Les bases de données classiques offrent pour la plupart un mode clusterisé, mais avant d’arriver à ce type de solution, ces bases peuvent déjà adresser plusieurs centaines de giga-octets de données. Il est également possible de mettre en place une architecture en sharding (répartition des données sur plusieurs serveurs en fonction d’une clé).

    Enfin de nombreuses solutions cloud offrent des solutions nativement scalables pour traiter et exposer de la donnée : Amazon Redshift, Google BigQuery, Azure Cosmos DB, MongoDB Atlas, etc. Elles ont pour avantage de vous affranchir en majeure partie de tâches d’exploitation et d’administration. Attention, elles n’ont pas cependant pas toutes le même rôle à jouer dans une architecture data.



    8) Tracer les évènements importants du job.

     

    Contrairement à une application web, un job est muet quand il s’exécute si on ne s’occupe pas de le faire parler avec des traces.

    Il est vital pour une bonne gestion de vos jobs en production de mettre en place des traces aux moments clés de leurs exécutions afin d’être en mesure de répondre aux questions suivantes :

    • Mon job a-t-il débuté ?
    • Que fait mon job en ce moment et qu’a-t-il déjà réalisé ?
    • Combien de temps mon job a-t-il mis pour réaliser une étape ?
    • Pourquoi mon job a-t-il échoué ?
    • Combien y avait-il de lignes dans les données en entrée et les données en sortie ?
    • Mon job est-t-il terminé ?

    A contrario, il ne faut surtout pas tracer des informations confidentielles comme des logins/mots de passe/credentials/clés/... , ce qui constituerait un faille importante de sécurité.

    Même si pour la plupart c’est une évidence : toutes les traces doivent débuter par un horodatage (timestamp).

    Voici un petit exemple pour illustrer ce conseil :

    19/02/28 14:54:00 INFO ForgetMeJob$: Begin of Job

    19/02/28 14:54:01 INFO ConfigurationReader$: Reading configuration from myfile.json
    19/02/28 14:54:02 INFO DataInReader$: Loading data from customer table
    19/02/28 14:54:16 INFO DataInReader$: Nb of customer : 15890389

    19/02/28 14:57:43 INFO Filter$: Filtering customers
    19/02/28 14:58:50 INFO Filter$: Nb of customer : 6567657
    19/02/28 15:02:21 INFO Remove$: Removing customers
    19/02/28 15:02:21 INFO Remove$: Nb of customer : 6567657
    19/02/28 15:02:27 INFO ForgetMeJob$: End of job

     

    9) Ne pas mystifier la techno, comprendre en profondeur le fonctionnement d’un traitement vous aidera à rendre vos traitements plus efficaces.

     

    Nous avons la chance en 2019 (ce qui n’a pas été toujours le cas pour les plus anciens) d’avoir accès au code des outils et des frameworks. Certes, cela demande un effort important pour se plonger dans un code que nous n’avons pas écrit et qui est souvent complexe. C’est aussi le moyen de savoir clairement ce que fait (et ne fait pas) le framework. Lire régulièrement du code vous apprendra également les bonnes pratiques de style et de structuration appliquées par des développeurs expérimentés.

    Un développeur curieux résoudra la plupart des problèmes de performance ou de bug (souvent en dernier recours après des heures voire des jours de recherche) en se plongeant dans le code.

    Avec de l’entraînement, cela deviendra de plus en plus facile et naturel. Ne vous sous-estimez pas, vous en êtes capable. Après tout, ce n’est que du code !

     

    10) Everything as code : le code ne ment pas.

     

    Ce dernier conseil est une conviction personnelle. J’ai vu trop de développeurs se perdre  avec :

    • des outils graphiques qui offrent la promesse du moindre effort mais qui au final rendent le déploiement de jobs et de pipelines de données peu maîtrisé.
    • des fichiers de configurations volumineux et/ou multiples où il est impossible d’avoir une vision d’ensemble du comportement prévu des traitements, tellement les informations sont dispersées. C’est ce qu’on peut appeler communément de la dentelle ou un plat de spaghettis, question de point de vue.
    • l’impossibilité de réaliser des tests unitaires, l’outil fermant cette possibilité par sa nature “boîte noire” et autogénérant un code source inexploitable.

    Ce conseil n’est pas spécifique au développement de traitements de données et s’applique aussi au développement de n’importe quel type d’application. Cette idée facilite l’adhésion à l’approche devops qui a pour objectif d’être le plus efficace possible pour passer les étapes nécessaires du développement à la production (CI : Continuous Integration / CD : Continuous Deployment). Un job est une application comme une autre qui doit être validée par des tests unitaires, packagée et déployée sur un environnement de qualification, puis en production. Quoi de mieux que du code pour automatiser cette chaîne ?



    Pour terminer...

     

    Nous avons la chance de participer au retour de la data au centre de la préoccupation de l'ingénierie informatique avec des défis sans cesse grandissants de volumétries et de performances. La complexité du domaine de la data nécessite forcément la spécialisation d’ingénieur de développement dans ce domaine. Il reposera sur eux de grandes responsabilités en organisant et en produisant de la valeur à partir de l’or noir du numérique qu’est la data.  J’espère que la lecture de ces conseils issus de mon expérience vous aura donné de bonnes directions ou, a minima, interrogé sur vos propres idées.

     

     

    Thèmes : Big Data, Data, Data Science, Data Services, Data Scientist

    S'abonner au blog