Le métier d'architecte en SI

GNU/Linux Magazine n° 207 | septembre 2017 | Philippe Prados
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
Le métier d'architecte en systèmes d'information est souvent mal connu. Je vous propose de vous mettre à sa place pour imaginer une architecture innovante pour un nouveau SI.

Pour illustrer le travail d'un architecte, nous allons imaginer une situation spécifique et nous positionner dans sa tête. Quelles questions se pose-t-il ? Comment y répond-il ? Comment gère-t-il l’impact de ses choix ? Quand remet-il en cause ses décisions ?

1. Les grandes étapes pour concevoir une architecture

Un architecte de Système d'Information doit faire des choix structurants pour implémenter le système à venir. Ce n'est pas facile, car les erreurs peuvent être difficiles à rattraper. Il a une très grande responsabilité sur la réussite ou l’échec d'un projet.

Une architecture de SI est souvent décorrélée du besoin métier direct, même si une connaissance grosse-maille du besoin est absolument nécessaire. On ne conçoit pas un site marchand comme une application devant traiter un flux boursier.

La première difficulté est de déterminer les objectifs précis de l'architecture, en termes de patterns métiers, consistance, performance, élasticité, résilience, évolutivité, etc.

Il faut ensuite sélectionner des technologies, compatibles avec les objectifs, puis s'assurer de la cohérence de l'ensemble.

Parfois, un choix ponctuel peut invalider un objectif primordial d'une architecture. Par exemple, dans une architecture qui doit être élastique, utiliser une base de données avec verrou centralisé - type SQL - est souvent une mauvaise idée. L'architecture peut être très élégante, mais elle ne présente pas les caractéristiques désirées en termes de scalabilité (capacité à augmenter linéairement la puissance de calcul en ajoutant des serveurs). En effet, il n’est pas possible d’ajouter indéfiniment des instances de base SQL. Si la base de données est trop sollicitée, l'intégralité du SI et de l'architecture ne fonctionnent plus. La seule solution pérenne est de revoir l’ensemble de l’application et de l’architecture.

Enfin, il faut extraire les grands principes de l'architecture que toute évolution future devra conserver. Par exemple, « un message est immuable », même si on désire annuler son effet.

Chaque évolution dans la réflexion de l’architecte doit faire adapter la combinaison des technologies, les grands principes et autres « best-practices » dans un cercle vertueux.

C'est au terme de nombreux cycles que l'architecture finale émerge. Il sera alors temps d'en faire une première implémentation succincte afin de s'assurer de la pertinence des choix et de leurs combinaisons.

Au fur et à mesure de l'analyse, des faits émergents, ainsi que des interrogations.

2. Le scénario

Imaginons une entreprise qui possède un SI dont l'architecture est essentiellement basée sur des batchs et une base de données Oracle surpuissante. L'essentiel du code est en PL/SQL, exécuté dans la base de données. Régulièrement, des batchs sont exécutés pour extraire des données dans des fichiers à l’intention de prestataires, pour calculer des synthèses, la facturation, etc.

Cette entreprise doit gérer des événements venant du terrain concernant la livraison de biens, partout dans le monde. Ces événements arrivent périodiquement, pas toujours dans l’ordre. Les traitements batchs ingèrent ces événements pour déduire l’état des commandes et déclencher les facturations ou alerter les clients, dans un workflow bien précis.

Le DSI est inquiet, car la base de données n'est plus en capacité de répondre aux nouveaux besoins. Malgré un coût de licence approchant le million d’euros par an, et une machine de guerre dédiée, la base de données Oracle est à genoux. De plus, il souhaite avoir un traitement pour informer les agents sur le terrain directement sur leurs mobiles, pour avoir un tableau de bord capable de suivre jusqu'au chiffre d'affaires ou d’autres fonctionnalités proches du temps réel.

L'ambition de l'entreprise est très importante. Elle devra gérer bien plus d’événements qu'actuellement (positionnement GPS, nouveaux services, suivi plus fin des employés et des partenaires) et fournir des informations aux clients distributeurs ou destinataires de colis, le plus vite possible, afin de pouvoir ajuster rapidement les tournées.

La mission consiste à concevoir une architecture très rapide, qui ne sera pas limitée dans dix ans par des contraintes technologiques (vitesse des CPUs, taille des disques, vitesse des I/O, latence réseau, etc.). Au-delà de dix ans, c'est de la science-fiction.

3. La conception pas à pas

La première douleur constatée par le client est la limitation de la base de données Oracle. Dans l’architecture précédente, comme elle est arrivée à saturation, il n'y a plus rien à faire. Il faut revoir en profondeur l’architecture ou les applications pour que cela n’arrive plus jamais.

3.1 Gérer la base de données

Il faut donc choisir une base de données qui ne présente pas cet inconvénient. Les bases de données NoSQL [1] proposent généralement un modèle de persistance sans verrou. Le principe est « la consistance à terme »[2]. C'est-à-dire que les données finiront par être consistantes. Lors de l'exécution d'une requête, il est possible d'avoir des données un peu anciennes. Tous les serveurs ne voient pas exactement les mêmes valeurs au même moment.

C’est la même approche que la mise à jour d’un serveur DNS. Il faut un certain temps avant qu’un nouveau nom soit visible sur toute la planète.

Commençons par étudier ces solutions. Disons que nous envisageons d'utiliser Cassandra [3]. C’est une base de données bien implantée, avec une bonne réputation. Il y a également une implémentation plus rapide qui émerge : ScyllaDB. Elle est 10 fois plus rapide que l’implémentation Cassandra originale et nécessite donc dix fois moins de serveurs pour les mêmes capacités.

Il faut alors parcourir l'intégralité de la documentation d'architecture, de la documentation technique, des best-practices et chercher les questions fréquentes sur StackOverflow. Au fur et à mesure de la lecture, il faut rechercher les limites de la technologie. Il ne sert à rien de se focaliser sur le core-business, ce qui fonctionne, mais plutôt identifier les limites et les douleurs de la technologie.

C'est à l'architecte d'identifier les difficultés au plus vite, pour trouver un contournement en termes d'architecture ou de pattern d'utilisation. S'il loupe cela, l'architecture finale semblera pérenne, jusqu'au jour où le projet atteint les limites de la technologie qu’il a choisie.

Cassandra est une base de données élastique. Il suffit d'ajouter des serveurs pour augmenter la puissance de la base de données. C'est un bon point pour notre architecture.

Comment Cassandra fait pour gérer cela alors que les bases SQL n’en sont pas capables ? Il faut utiliser judicieusement des règles de distributions des données. C'est-à-dire qu'il faut identifier un attribut métier pour distribuer, le plus uniformément possible, les données sur les différents serveurs. Mais attention, cet attribut doit être valorisé dans chaque requête, afin d'identifier le serveur en charge d'y répondre. Cassandra sait retrouver des données si on lui indique la valeur de l’attribut utilisé pour la distribution. En gros et en simplifié, le client Cassandra applique un algorithme de hash sur la valeur de cet attribut. Il en déduit le serveur à qui s’adresser. Puis il lui envoie la requête pour retrouver les données.

Recherchons les limites de cette technologie.

Comment gérer les cas où la clé de répartition n'est pas connue lors d’une requête ?

Le modèle préconisé par Cassandra est « le design by query » [4]. C'est-à-dire que l'on va créer autant de tables que nécessaire, avec des clés primaires différentes, et plus ou moins les mêmes valeurs. Il faut donc dénormaliser les données. C'est-à-dire qu'il faut dupliquer les mêmes données dans différentes structures.

Par exemple, une table va utiliser le nom de l’utilisateur comme clé primaire. Une autre table va porter les mêmes données, mais en utilisant la ville de l’utilisateur comme clé primaire. Chaque table est conçue suivant la requête qu’on souhaite lui appliquer. Si l’on connaît le nom de l’utilisateur, on peut utiliser la première table. Si c’est la ville, on utilisera la deuxième table.

Le théorème CAP [5] indique que dans les architectures distribuées, il faut choisir entre la disponibilité et la consistance. En général, la disponibilité est alors préférée. Il n’y a donc plus de transactions ACID.

Cela va consommer du disque !

En effet, comme les mêmes données sont présentes dans plus d’une table, la place disque nécessaire est multipliée d’autant.

Comment garantir la cohérence des différentes tables possédant les mêmes données ? Quelle est la table qui fait référence ?

Cette question reste en suspens pour le moment. L’architecture devra y répondre.

Pour des raisons de résilience, Cassandra duplique chaque écriture sur plusieurs serveurs. Il n'y a donc pas de garantie que toutes les réplications soient visibles en même temps. Un serveur peut avoir des données un peu anciennes. Mais rapidement, tout rentre dans l'ordre.

Différentes stratégies permettent d'indiquer à partir de quel nombre de réplications confirmées, l'application peut sereinement continuer à travailler lors d'une écriture. Cela a un impact direct sur les performances lors des écritures. Combien faut-il attendre d’acquittements ?

Il est donc possible qu’un lecteur récupère une donnée qui n’a pas encore été modifiée, alors qu’elle l’est sur d’autres instances Cassandra.

Nous sommes en présence d’un modèle « Éventuellement consistant ». À terme, les modifications sont propagées, mais à un instant donné, il peut y avoir un décalage.

De même, lors des lectures, il est possible de définir un quorum de lecture cohérente pour se rassurer sur la valeur d'une donnée. Si n serveurs répondent la même valeur, alors il faut la considérer comme valide. Suivant les cas métiers, il faudra donc ajuster correctement ce paramètre, sans trop dégrader les performances.

Lors de la modification d'une donnée, il faut généralement lire une donnée, la modifier localement, puis écrire la version modifiée. C'est le pattern « Read-Modify-Write ».

Que se passe-t-il si deux serveurs différents fonctionnent simultanément sur la même donnée de la même table ?

Comme il n’est pas possible de poser un verrou lors de la lecture d’une donnée, deux traitements peuvent lire la même donnée, appliquer une modification comme ajouter 10€ au solde, avant d’écrire le résultat. Si deux traitements s’exécutent simultanément, le solde sera incrémenté de 10€ et non de 20€ au total.

Nous avons des données « éventuellement consistantes », répliquées dans différentes tables. Il est donc possible que cela arrive, avec parfois, les deux serveurs voyant les mêmes données, parfois des versions différentes des données.

C'est le dernier qui a écrit qui gagne.

Il est fort probable que des modifications simultanées risquent de casser la cohérence des données.

Cassandra propose un mécanisme de verrous optimiste pour gérer cela. C'est le pattern « Compare-and-Set » (CAS). En quelques mots, les serveurs se mettent d'accord sur une modification cohérente. L'utilisateur doit indiquer un critère qui permet de s'assurer que la modification est valide. Par exemple, « ajuste le solde à 110€, si et seulement si, le solde est à 100€ ». En cas d'erreur, la modification n'est pas effectuée et une erreur est retournée. L'application peut relire la nouvelle version du solde et recommencer « ajuste alors le solde à 120€, si et seulement si, le solde est à 110€ ».

À terme, la modification finira par passer. En cas de forte sollicitation, cela peut prendre pas mal de temps avant de réussir la modification. Il faut donc concevoir la base de données de telle sorte à éviter des contentions fortes sur certaines données, tel que le chiffre d’affaires total de l’entreprise.

Le CAS dégrade les performances.

Peut-on éviter les situations de CAS ?

Nous essayerons de trouver une solution via l’architecture.

Le problème est amplifié si les deux traitements écrivent sur plusieurs tables dénormalisées dans un ordre différent. Par exemple, le serveur Albert veut stocker l'information « client:123, nom:paul » dans une table et « nom:paul, client:123 » dans une autre (le premier champ sert de clé de répartition). Au même moment, le serveur Bernard fait de même, mais en inversant l'ordre des tables. Il y a inconsistance entre les deux versions des mêmes données. L'utilisation d'une stratégie CAS fonctionne sur une table à la fois. Elle ne fonctionne pas entre deux tables. C'est en écrivant la deuxième table que des modifications sont rejetées. Faut-il alors annuler la première modification ? Est-on certain de pouvoir le faire ?

Il existe bien une possibilité de batch dans Cassandra, pour rendre « atomique » la sauvegarde des différentes versions. Un algorithme de type Paxos (consensus distribué) permet aux différentes modifications de se stabiliser. Mais c'est encore au prix de dégradations notables des performances (30%).

Nous voulons centraliser les mutations pour garantir que les modifications s'effectueront toujours dans le même ordre, la première table puis la deuxième. Il est possible de faire un CAS sur la première table, puis un simple Set sur les autres tables.

Si on part de cette hypothèse, on peut imaginer que toute mutation du SI soit portée par un message et traitée par un consommateur d’une file de messages. Ainsi, c’est uniquement la consommation du message qui permet de modifier les données. Les mutations sont centralisées. Cela fait émerger un type d'architecture : l’Event-Sourcing.

Pattern Event-sourcing pour ne pas dépendre de l’ordre des écritures en base.

L’idée est de produire des messages qui décrivent les mutations à appliquer aux données et de les déposer dans un bus. Modifier ou effacer un objet est décrit dans un message.

Il est alors possible de reconstituer l’état d’un objet en rejouant ce flux, en mémoire ou pour le sauver dans Cassandra par exemple. Dans ce modèle, la base de données n’est qu’un cache du flux des mutations.

L’idéal est de ne jamais effacer un message afin de pouvoir rejouer l’intégralité de l’historique.

Bon, à ce stade, nous avons deux principes forts :

- « Design by query » via une « Dénormalisation des données persistées » ;

- « Event-sourcing ».

Et un premier composant à notre stack technique : Cassandra.

Nous avons quelques questions importantes à régler :

- Comment gérer les modifications simultanées sur les mêmes données ?

- Comment contenir la consommation disque ?

- Comment éviter les dégradations de performances lors de l’utilisation d’algorithme CAS ou Paxos ?

3.2 Au plus vite

Nous ne voulons plus fonctionner en mode batch. Un batch est en effet toujours en retard et nous voulons du temps réel. C’est une notion toute relative qui dépend du besoin métier.

Pour fonctionner en « temps réel », nous avons besoin d'un bus de message très rapide. En effet, nous voulons pouvoir traiter au plus vite un flux de commandes, via un nombre de serveurs variable. Idéalement, nous souhaitons une scalabilité linéaire. C'est-à-dire que le gain de performance via l'ajout d'un serveur est constant. Je peux ajouter autant de serveurs que nécessaire pour atteindre les performances attendues.

La technologie JMS avec différentes implémentations open source est une solution à envisager. Cette technologie propose conceptuellement deux modèles de file de messages : les queues et les topics. Les queues possèdent des messages consommés l'un après l'autre, éventuellement par plusieurs consommateurs. En cas de problème, la transaction peut échouer et le message retourne dans la file de messages comme si rien ne s'était passé. Les topics procèdent différemment. Le même message est envoyé à tous les processus à l'écoute du Topic (approche publish/subscribe).

Après étude, cette solution semble répondre à nos besoins au niveau fonctionnel, mais elle présente deux faiblesses :

- Comme la consommation des messages est gérée dans une transaction, le serveur JMS est le garant de la consommation du message. C'est un point de concentration de tous les traitements. Il n'est pas possible d'étendre les capacités du serveur JMS en ajoutant simplement un nouveau serveur. Si ce dernier tombe, il existe bien des solutions de type actif/passif, mais ce n'est pas suffisant. Dans les faits, les implémentations de files JMS sont limitées en nombre de messages pouvant rester dans la file et en nombre de clients à l'écoute des messages.

- La deuxième faiblesse concerne le mode Topic. Dans ce dernier, si un composant n'est pas disponible lors de la publication d'un message, le processus loupe un message et ne pourra jamais rattraper son retard. C'est très gênant pour la résilience de l'architecture. Comment mettre à jour à chaud une application qui écoute un topic, sans perdre des messages ? Il existe bien un mode permettant de garder les messages mêmes si les subscribers sont absents. Les messages sont alors gardés sur disque autant que nécessaire. Cela a un impact important en termes de capacité disque et de performance.

JMS ne semble pas être une solution viable pour notre architecture.

Quelles sont les autres technologies disponibles, compatibles avec une architecture scalable ?

La technologie Kafka [6] propose une autre approche. Elle est spécifiquement conçue pour gérer les architectures scalables, avec des centaines de serveurs en écoute des files de messages et un volume de message sans limite autre que la taille du disque.

Kafka peut être vu comme un énorme tampon circulaire dont chaque consommateur gère son curseur dans ce tampon. Après un certain temps, les anciennes données sont écrasées par les nouvelles.

Même si, à première vue, Kafka ressemble à JMS, le modèle est très différent. Lors du dépôt d'un message, il faut indiquer une clé de distribution (tiens, encore une). Celle-ci permet d'identifier la partition (le serveur kafka) en charge de sauvegarder la donnée dans la file. Ce dernier réplique dans un premier temps le message vers deux autres serveurs avant d'acquitter. En cas de perte d'un serveur, le driver client se charge d'en trouver un autre.

Côté consommateur, le modèle est également très différent. Contrairement à JMS, ce n'est pas Kafka qui gère les curseurs de lecture. C'est aux clients de le faire. Pour cela, Kafka n'autorise qu'un seul client à la fois sur une partition (pour un groupe donné). Un client peut se connecter sur plusieurs partitions, mais il ne peut pas y avoir plus de clients que de partitions.

Pourquoi donc, Kafka ne souhaite pas plus de clients sur chaque partition ? Et bien justement, pour garantir que les messages ne seront consommés qu'une seule fois, sans pour autant avoir besoin de synchronisation entre les instances Kafka. C'est une idée géniale permettant une scalabilité sans limite (voir figure 1).

Fig. 1 : Utilisation des partitions Kafka par les clients.

Il est possible d'avoir plusieurs groupes différents consommant les mêmes messages. Au sein d'un groupe, il y a la garantie que les messages ne seront consommés qu'une seule fois.

Cette notion de groupe est très importante. En effet, c'est comme cela que l'on peut simuler le mode publish/subscribe. Chaque subscriber utilise un nom de groupe différent. Si aucun composant de l'application B ne fonctionne lorsque l'application A émet un événement, ce n'est pas grave. Au redémarrage de B, il pourra rattraper son retard, tant que le message est toujours dans le tampon circulaire. Cela est indispensable pour permettre la mise à jour à chaud des applications, sans devoir arrêter intégralement le SI.

L'étude de l'architecture et de l'implémentation de Kafka montre que les performances sont optimales si les instances Kafka sont sur des machines physiques non virtualisées, avec des disques dédiés. La stratégie d'implémentation est le zero-copy. C'est-à-dire que tout est fait pour permettre de passer du disque vers le réseau, sans sortir du kernel. Il n'y a donc aucune copie des tampons entre l'espace noyau et l'espace utilisateur.

Il faut une clé de distribution pour chaque message dans Kafka.

Nous avons maintenant deux composants à notre architecture : Cassandra et Kafka.

Il existe une clé de distribution pour ces deux technologies. Peut-on utiliser la même ?

La clé de distribution de Kafka permet de répartir les messages. La clé de distribution de Cassandra n'est pas sous le contrôle de l'utilisateur. C'est un calcul de Hash qui permet la répartition des objets dans les différents nœuds Cassandra.

En théorie, il est possible d’utiliser l’algorithme de Cassandra pour répartir les messages dans Kafka. Ainsi, il est possible de proposer des chaînes de traitements complètement isolées. Sur le même serveur, on place un Kafka et un Cassandra. Les communications ne s’effectueront qu’au sein du même serveur, sans avoir besoin du réseau. Dans les faits, ce n’est généralement pas une bonne idée.

La clé de distribution utilisée par Kafka doit nous permettre de gérer les problèmes de mutations simultanées des objets par plusieurs serveurs. En effet, si on utilise la clé primaire de l'objet à manipuler comme clé de répartition pour déposer les messages Kafka, on se retrouve dans une situation intéressante.

Par exemple, utilisons l'id d'un client comme clé de répartition pour tous les messages. Par construction, tous les messages concernant ce client seront exécutés sur le même serveur, à l’écoute d'une des partitions Kafka. Le traitement associé a la garantie d'être le seul à manipuler l'objet Cassandra de ce client. Il n'est donc pas nécessaire d'utiliser des stratégies du type Compare-And-Set (CAS) ou autres transactions distribuées.

Oui mais, on a bien vu qu'il fallait un design by query. Pour le même objet, il peut y avoir plusieurs clés primaires, une par table dénormalisée. Par exemple, une table indexée par id et une autre par ville. Le mapping clé de distribution / clé primaire ne fonctionne pas.

À moins de s'organiser pour apporter toutes les modifications des différentes tables dans le même traitement, distribué par une des clés primaires ! Dans ce modèle, les messages sont distribués sur les serveurs Kafka suivant l'id du client par exemple (voir figure 2). Le traitement qui consomme ce message va être chargé d'apporter toutes les modifications sur toutes les tables portant les données du client. Ainsi, il est possible de bénéficier des performances optimales de Cassandra, sans synchronisation entre les serveurs. Il n’est pratiquement plus nécessaire d’avoir des Compare-And-Set ou des algorithmes de type Paxos.

Fig. 2 : Distribution des messages dans les partitions Kafka.

Il faut centraliser les modifications des données dans Cassandra en s'appuyant sur Kafka pour sérialiser les écritures du même objet.

Toute mutation du SI est portée par un message et traitée par un consommateur Kafka.

Nous pouvons alors sortir de notre besace, le modèle d’architecture CQRS (Command Query Responsibility Segregation). C'est un modèle d'architecture où les écritures et les lectures en bases de données sont séparées (voir figure 3). Les modèles de données peuvent évoluer différemment suivant les besoins (cf. Design by query), avec des structures et des technologies variées (base de données tabulaires, clés/valeurs, graphes, etc.). Une base de données sert de référence pour les écritures. Les modifications sont reportées vers les différents modèles de persistance.

Fig. 3 : CQRS : Séparation des modèles de sauvegardes et de requêtes.

De cette idée il semble possible de régler certains problèmes de notre liste. Comme chaque mutation est décrite par un message (Event Sourcing), c’est le traitement du message qui se chargera de l’écriture des données pour chaque table. Ce traitement est garant de l’ordre d’écriture dans les différentes tables dénormalisées (voir figure 4).

Fig. 4 : EventSource, CQRS : Dénormalisation centralisée.

De plus, si une nouvelle table dénormalisée est nécessaire, il ne faudra intervenir qu’à un seul endroit : dans le traitement du message qui décrit la mutation des données. Il est même envisageable d’utiliser un compte utilisateur à la base de données ayant les droits d’écriture pour ce traitement, et un compte n’ayant que le droit de lecture pour toutes les autres applications. Ainsi, nous avons la garantie que les mutations seront cohérentes entre les différentes technologies de persistance.

Une autre approche consiste à avoir plusieurs composants différents à l’écoute des messages décrivant les mutations (voir figure 5). Chacun est libre de persister la mutation dans la table et la base qu’il souhaite.

Fig. 5 : Dénormalisation par service.

Les règles de distributions de Kafka vont permettre de garantir qu’un même objet ne sera pas modifié dans une table de la base de données par deux serveurs différents.

Le modèle CQRS permet d’ajuster les capacités du cluster différemment pour les flux d’écriture et les flux de lecture. Par exemple, utiliser 3 serveurs pour les écritures et 20 pour les lectures.

Pattern CQRS pour optimiser les écritures par rapport aux lectures.

Nous avons encore quelques questions importantes à régler. Par exemple, comment contenir la consommation disque ?

L’utilisation d’un bus de message rend les mutations asynchrones. Tant que le message n’est pas consommé, la mutation n’est pas appliquée. Cela présente des avantages en termes de résilience et des inconvénients en termes de consistance. Sur le théorème CAP, nous avons sacrifié la cohérence au bénéfice de la disponibilité.

Nous avons finalement un modèle de consistance à terme, le temps que les modifications soient propagées vers tous les middlewares de persistance.

Le modèle qui se profile ressemble à la figure 6.

Fig. 6 : Appel synchrone et asynchrone.

Un flux de message décrit toutes les mutations du SI. Ce flux est traité par des clients Kafka via une règle de distribution garantissant qu’un seul serveur peut modifier la même donnée. Une donnée est modifiée en série, via l’écriture vers les différentes tables dénormalisées. Plusieurs données peuvent être modifiées en parallèle, mais sur des objets différents.

Avec ce modèle, il est possible de l'étendre en indiquant que les traitements des messages peuvent dénormaliser sur d'autres technologies comme un moteur d'indexation type ElasticSearch, une base de données Graph comme Neo4j, voire une base SQL. Néanmoins, utiliser Neo4j ou une base SQL risque de réintroduire un SPOF (Single Point Of Failure), car ces technologies utilisent une approche centralisée et non distribuée.

Comment faire pour lire les données présentes dans les bases de données ?

Nous avons deux solutions :

- La première est orientée requête : le consommateur invoque une API avec des critères de recherche pour obtenir une vue de l'information qu'il recherche.

- La deuxième approche est orientée flux. Un flux de sortie décrit les états consolidés des objets. « Tel client a maintenant 120€ sur son compte ». Les applications sont à l’écoute des évolutions des états.

C’est un modèle de développement en micro-services. Chaque service utilise une ou plusieurs bases de données privées, avec différentes technologies si besoin. Il expose des services web pour permettre l’accès à ses données. Le service se charge de choisir la meilleure technologie ou table conçue en interne pour répondre au plus vite aux requêtes des autres applications. De plus, chaque application publie des flux décrivant ses modifications d’états (les nouvelles synthèses consolidées, les alertes, etc.)

Les services communiquent entre eux via leurs services web et via le dépôt et l’émission de messages (voir figure 7). Les communications peuvent être synchrones et asynchrones.

Fig. 7 : Plusieurs services communiquent en synchrone et asynchrone.

Nous ajoutons dans l'architecture des serveurs web qui exposent des API WebService pour lire les données dans les différentes tables et les différentes technologies de persistances. Ces API n'ont besoin que d'un accès en lecture, car elles ne doivent pas modifier directement les données, au risque de casser la cohérence entre les tables dénormalisées, voire les données elles-mêmes lorsqu'un objet est modifié simultanément par plusieurs workers de middlewares (voir figure 8).

Fig. 8 : Plusieurs instances de middlewares.

Mais comment proposer des API pour modifier les objets ? Il faut pour cela que ces dernières construisent des messages décrivant les mutations à appliquer et les injectent dans le bus Kafka. Nous sommes conformes avec le modèle de cohérence à terme.

Les services web peuvent répondre avec un code http 200 pour confirmer l’écriture (même si elle n’est pas immédiatement appliquée) ou un code http 202 pour indiquer que la demande est prise en compte et sera appliquée dès que possible. C’est conforme avec la stratégie consistant à privilégier la disponibilité sur la cohérence.

Nous souhaitons que le serveur web utilise également une approche scalable et résiliente. Il faut donc prévoir en amont un répartiteur de charge.

Il faut un serveur web pour les lectures et un répartiteur de charge.

HAProxy est une bonne solution pour le répartiteur de charge et devrait nous aider à faire des migrations progressives (voir figure 9). Il faut au moins deux instances pour fonctionner en mode actif/passif et une VIP (Virtual IP).

Fig. 9 : Ajout d’une VIP et de HAProxy.

Toujours à la recherche de performance, nous voulons choisir un serveur web le plus rapide possible afin de réduire le nombre d'instances nécessaires pour tenir la charge. Les approches réactives semblent de bons candidats (Node.js, Play, etc.). Un serveur sans état semble une piste intéressante et compatible avec notre modèle.

Que peut-on identifier comme avantage dans l’approche CQRS ? Au niveau résilience, il est possible de perdre momentanément les composants en charge des mutations, à l’écoute des partitions Kafka, sans interruption des API. Les mutations continueront à être déposées dans les serveurs Kafka. Par contre, elles seront effectives un peu plus tard. Lorsque le service sera à nouveau disponible.

Nous avons donc la possibilité de mettre à jour indépendamment la partie écriture d'une application de la partie lecture. Il n'y a, à aucun moment, une indisponibilité du service.

En utilisant un middleware différent pour les services web et pour les traitements Kafka, nous pouvons ajuster différemment le nombre de serveurs entre la lecture et l’écriture. Cela peut représenter une économie pour le projet.

Comme nous l’avons décrit, des flux sont émis par les services pour exposer en temps réel les nouveaux états des objets. Ainsi, il est facile de propager les impacts d'une modification sur l'ensemble du SI le plus rapidement possible. Un flux de mutation se propage de service en service. Il est même possible de propager ce flux de mutation vers les navigateurs des utilisateurs, via des WebSocket par exemple.

Si pour une raison ou une autre, un autre service du SI est indisponible lors de la modification d'un objet, avec Kafka, il sera capable de rattraper son retard. C’est indispensable pour avoir une dépendance lâche entre les services.

Chaque service est découpé en deux :

- une partie à l’écoute de flux, en charge des écritures et de l'émission de flux d’événements ;

- et une autre partie en charge d'exposer l'état de l'application à la demande.

Les services consomment des flux, persistent des états, émettent des flux et exposent des API. Chaque service est responsable de ses propres bases de données. Il n'est pas possible d'y accéder directement sans passer par les API proposées par le service.

Un service peut envoyer une commande de mutation à un autre service, consulter des données ou être à l'écoute de l'impact de chaque mutation.

Bon, à ce stade, nous avons plusieurs principes forts :

- Design by query via une dénormalisation des données persistées ;

- modèle Event-Sourcing (toute mutation est portée par un message immuable) et CQRS ;

- un accès en lecture seule pour les API.

Nous avons identifié quelques composants à notre stack technique : Cassandra, Kafka, un consommateur Kafka à identifier, un serveur web résiliant, deux HAProxy et une VIP.

3.3 Choisir un consommateur de flux

Nous devons choisir un middleware pour consommer les flux Kafka. Ce dernier doit être scalable, résiliant et riche en fonctionnalités.

Il existe plusieurs familles de middlewares de ce type. Certains ont été conçus dès le départ pour le temps réel comme Flink, d'autres sont une évolution d'un modèle batch comme Spark Streaming. Il existe de nombreuses solutions.

Là, l'architecte va probablement utiliser son instinct et proposer un choix subjectif suivant son histoire et ses connaissances. Néanmoins, il faut faire une étude comparative entre différentes solutions, toujours à la recherche des limites des technologies.

Spark Streaming semble une technologie en pleine croissance. Le moteur de streaming utilise une approche micro-batch (des batchs réguliers à périodicité courte). Les données des flux sont accumulées, puis périodiquement, les données sont traitées comme si elles étaient une seule base de données. Et le processus recommence. Ce modèle impose donc une latence minimum de la durée du micro-batch.

Est-ce gênant pour notre projet ? L'échelle de temps ne nécessite pas un temps réel très rapide. Ce n'est pas un problème pour le projet. Nous ne traitons pas de flux financier.

Spark offre une programmation à deux niveaux :

- un modèle fonctionnel où les objets sont immuables dans le pipeline de traitement ;

- un modèle SQL Like (qui utilise le modèle fonctionnel en interne).

Les API permettent de joindre plusieurs flux de traitement, de les trier et les distribuer en peer-to-peer entre les nœuds, etc.

Spark est codé avec le langage Scala qui porte nativement les concepts de la programmation fonctionnelle. Ce langage est alors un candidat potentiel pour le développement.

Utiliser Scala avec Spark va nous faciliter la vie. Mais si on souhaite partager du code entre Spark et les serveurs web, Play s'impose comme le middleware pour les API. Play est sans état, ce qui est compatible avec notre architecture. Ainsi, nous avons le langage Scala dans tous les composants.

Comment Spark est-il capable de gérer les pics de charge ?

Il existe un algorithme de back-pressure qui permet de limiter le nombre d'objets à traiter le temps d'un micro-batch. Cela évite autant que possible d’accumuler un retard dans le traitement des messages. Un ajustement dynamique est effectué pour distribuer les traitements sur les workers Spark le mieux possible.

Donc, en cas d'indisponibilité de l’ensemble des instances Spark pour une mise à jour du middleware, lors de la reprise de l'activité, il n'y aura pas d'écroulement des Workers par surcharge de travail. Automatiquement, le flux en retard sera traité convenablement par rapport à la capacité de la plateforme.

Si besoin, il est possible d'ajouter de nouveaux Workers dynamiquement. Lors du prochain micro-batch, ce dernier sera ajouté aux capacités à faire du middleware Spark.

Comment Spark s'interface avec Kafka ?

Le dernier modèle d'intégration de Kafka dans Spark consiste à obtenir une connexion directe entre un Worker Spark et une partition Kafka. Donc, avoir plus de Workers que de partitions Kafka n'est pas optimal pour les premiers traitements (voir figure 10).

Fig. 10 : Une partition Kafka par worker Spark.

Comment Spark gère les crashs ?

Comme tout modèle distribué, il est difficile de traiter les cas de pertes de messages. La garantie que nous avons est que tous les messages seront traités au moins une fois.

Il faut gérer les cas des messages traités plusieurs fois.

Les messages de mutation doivent être idempotents. C'est-à-dire que le traitement du même message plusieurs fois n'a pas d'effet de bord différent que l'exécution du message une seule fois. Pour obtenir cela, il y a plusieurs approches :

- Maintenir une fenêtre temporelle permettant d'identifier le rejeu d'un message via un id unique dans chaque message. Cet id peut être injecté dans l’objet modifié.

- Concevoir des messages par nature idempotents : « Ajoute 10 au solde qui doit être à 100 pour obtenir 110 » à la place de « +10 au solde ». Le traitement peut vérifier que le solde correspond bien à ce qui est prévu par le message. Si ce n'est pas le cas, il peut envoyer une erreur ou finalement se rendre compte que la demande correspond à ce qui vient d'être effectué et retourner un acquittement.

Les traitements des messages de mutations doivent être idempotents.

3.4 Choisir un format d'échange

Nous avons des micro-services qui doivent communiquer entre eux, en synchrone ou en asynchrone. Il faut choisir un format de message.

Idéalement, un schéma doit décrire le contrat d'interface entre les composants afin de s'assurer qu'il n'y aura pas de message mal formé.

Comme chaque micro-service doit pouvoir évoluer indépendamment des autres, il doit être possible de monter de version d’un service sans devoir mettre à jour l'intégralité du SI.

Nous avons deux modèles de communication : un modèle à base de service web et un modèle asynchrone à base de flux. Il faut donc envisager les montées de versions sur tous ces contrats d’interfaces.

Un service A doit pouvoir envoyer un message en version 1 ou en version 2 à un service B, sans avoir besoin de savoir si le service B est déjà en capacité de gérer la version 2. De même, lorsqu'un service émet des événements, il ne peut pas connaître les versions prises en charge par l'intégralité des subscribers.

Si on étudie l'architecture, on constate que, par construction, les messages sont répliqués de nombreuses fois sur le réseau et sur disque. Par exemple, déposer un message dans une instance Kafka entraîne l'utilisation de trois versions sur le réseau pour communiquer avec les autres partitions lors des réplications et trois fois sur disque pour que chaque partition possède une copie. La lecture d'une partition entraîne une nouvelle version de plus sur le réseau. Spark peut mémoriser des Snapshots sur un disque distribué pour être en capacité de reprendre un job complexe, etc.

Ainsi, la taille des messages a un impact très important sur la consommation des ressources disques et réseaux. C’est un point à prendre en compte dès le début du projet.

XML permet de décrire un schéma via XML-Schema, mais n'est pas en capacité de gérer les montées et les descentes de versions. Une application qui est codée pour gérer un message en version 1 n’est pas capable de gérer un message en version 2. De plus, ce format n'est absolument pas compact.

JSON est plus compact, mais porte la description du schéma dans les données. La taille des noms des champs a un impact sur la taille des messages ! Il existe bien quelques initiatives pour proposer des Schémas-Json pour valider les messages, mais ce n'est pas idéal.

Les géants du Web utilisent en interne des formats binaires, bien plus efficaces que XML ou JSON. Mais dans ce cas, les schémas permettant de décrire le flux binaire doivent être portés par les applications. Les schémas ne sont pas présents dans les messages, mais dans les applications. Comment gérer les évolutions dans les schémas binaires ?

Une rapide recherche sur Internet nous amène sur la solution proposée par Confluent.IO. Il s'agit d'utiliser le format binaire AVRO de la fondation Apache et d'utiliser un Schema-Registry pour centraliser les différentes versions des schémas.

Les messages binaires peuvent posséder l'identifiant du schéma présent dans le Schema-Registry, puis le message en binaire. Si l'application ne connaît pas le schéma, elle le récupère puis demande à l'API AVRO de faire la conversion nécessaire pour exposer les données via le schéma qu'elle connaît.

AVRO permet ainsi d'avoir des valeurs par défaut, des alias pour les noms des champs ou des packages et propose de nombreux formats pour structurer les données (Map, séquence, Union, Enum, Structure, etc.).

Le format binaire peut servir également pour optimiser l'invocation des services web. Il suffit d'ajouter un : Content-Type: application/avro.

Nous avons maintenant identifié un format d'échange et un nouveau composant (voir figure 11).

Fig. 11 : Intégration de AVRO.

Nous avons identifié que le « design by query » proposé par Cassandra a tendance à manger du disque. En y réfléchissant, dans les structures complexes du métier, seuls quelques champs servent de critère de recherche dans la base. Au minimum la clé primaire, éventuellement un ou deux autres champs. Pour le reste, il est envisageable d'utiliser à nouveau le format AVRO pour persister les données dans Cassandra en binaire. Des colonnes servent pour les critères (design by query) et une colonne pour la donnée binaire complète. Lors de la lecture de la donnée binaire, AVRO se charge de reconstituer le graphe de l’objet, dans toute sa complexité.

Avec cette stratégie, on réduit la consommation disque et le trafic réseau lors de la communication avec Cassandra. Cela peut être une recommandation de l'architecture, mais pas une préconisation. À chaque micro-service d'identifier le meilleur compromis entre simplicité et efficacité dans la consommation des ressources.

À ce stade, nous avons plusieurs principes forts :

- Design by query ;

- Dénormalisation des données persistées, éventuellement en binaire AVRO ;

- Modèle Event-Sourcing et CQRS ;

- Toute mutation est portée par un message immuable ;

- Message en binaire AVRO avec un schéma dans un référentiel ;

- Un accès en lecture seule pour les API ;

- Les messages doivent être compatibles avec l'idempotence.

Nous avons identifié quelques composants à notre stack technique : Cassandra, Kafka, Spark Streaming, Play, Schema Registry, plusieurs HAProxy, un VIP et le langage de programmation : Scala.

3.5 Invocation des services web

L’approche micro-service ne permet pas d’avoir des transactions ou des jointures entre les données de différents micro-services. Tout cela doit être codé « à la main ».

Parmi les micro-services proposés, l'un est particulièrement exposé : le référentiel d'entreprise. Les serveurs Plays de ce micro-service vont subir une pression très forte de tous les autres micro-services.

Concrètement, si tous les jobs Spark Streaming invoquent le référentiel en parallèle, les piles IP vont exploser et supprimer pas mal de paquets. Des times-outs vont apparaître. Pour régler cela, nous avons plusieurs pistes :

- Écrire une bibliothèque d'invocation de service web qui :

 – exploite le protocole HTTP/1.1 pour recycler les connexions afin de limiter le nombre de connexions ;

 – ajuste dynamiquement le nombre de connexions avec un algorithme de « back-pressure » ;

 – est capable de rejouer une requête plusieurs fois avant d'en informer l'application (pour la tolérance à la panne d’un service web).

- Écrire une bibliothèque de cache du référentiel, branchée sur les flux de mutations du service, afin d'ajuster les valeurs des caches au plus tôt. Cette bibliothèque sera présente dans chaque service.

Nous devons sélectionner ou écrire un algorithme d'invocation de service web avec back pressure.

3.6 Intranet et Internet

Nous avons des services communiquant via des WS ou en flux. Mais il n'est pas question, pour des raisons de sécurité, de donner un accès direct à ces flux depuis Internet. Nous devons placer un serveur web en façade, qui se charge d'exposer un sous-ensemble des micro-services internes, de filtrer les requêtes et les réponses pour limiter le nombre de réponses par exemple (pour éviter le dump de la base client), etc. Un serveur dans la zone de sécurité Internet et un autre dans la zone de sécurité Intranet sont autorisés par le firewall à communiquer avec les API des services. Pour des raisons d'isolation des zones, nous devons ajouter des instances HAProxy devant chaque serveur Intranet et Internet et autant de VIP (voir figure 12).

Fig. 12 : Architecture d’exécution.

Nous avons maintenant pratiquement l'intégralité de l'architecture d'exécution.

Notez que nous n'utilisons pas de NAS ou autres réplications de disque. Les technologies sélectionnées sont parfaitement capables de gérer la résilience des données.

3.7 Mise à jour à chaud

Chaque composant de l'architecture doit pouvoir évoluer indépendamment des autres, sans interruption notable de service. Vérifions cela :

- Kafka est capable de faire un rolling-update à chaud. Chaque partition est arrêtée et migrée l'une après l'autre. Il est donc possible de monter de version de Kafka sans interruption. Quelques flux seront un peu en retard.

- Il est également possible d'ajouter dynamiquement de nouvelles partitions Kafka.

- Pour Spark streaming, c'est plus compliqué. On peut arrêter une application Spark, la mettre à jour, et la relancer. Kafka sert de tampon. Les messages auront un peu de retard, mais ce n'est pas problématique. Les interfaces de lecture continuent à fonctionner.

- Pour Cassandra, il est également possible de faire un rolling-update des différents nœuds, sans interrompre la base de données.

- Il est également possible d'ajouter de nouveaux serveurs Cassandra et/ou de nouveaux disques.

- Pour Play, les instances n'ont pas d'état. À l'aide d’un répartiteur de charge comme HAProxy, il est possible de migrer les instances progressivement à chaud.

- HAProxy peut également être mis à jour à chaud, en s'occupant du serveur passif avant le serveur actif.

3.8 Gestionnaire de cloud

Nous devons gérer de nombreux serveurs, pouvoir en arrêter et en ajouter à chaud. À la main, ce n'est pas raisonnable. Il faut une approche Devops complète de l'intégralité de la plateforme.

Là, intervient le côté commercial de l'architecture. En effet, il est possible de n'utiliser que les versions open source des composants ou d'utiliser des solutions commerciales. Les versions open source sont généralement moins bien équipées pour une mise en production. C'est à voir avec les budgets de chacun. Attention, il est très difficile d'automatiser la gestion d'une plateforme comme celle-ci à l'aide de scripts Ansible par exemple.

4. L'architecture finale

Nous avons un premier jet d'architecture à proposer. Elle fait apparaître des concepts forts, qu'il va falloir tenir le plus longtemps possible :

- chaque middleware doit être élastique et résilient ;

- il faut pouvoir mettre à jour à chaud les services et les composants ;

- les mutations doivent pouvoir traverser l’intégralité du SI le plus vite possible.

Conclusion

L’architecte peut maintenant présenter son analyse et justifier ces choix. Il a monté une application moderne, orientée événement, qui utilise un modèle de transaction éventuellement consistant.

L’architecture peut apporter par elle-même des solutions à des difficultés. Dans le scénario présenté, la séparation du flux de mutation est justifiée par la volonté de garantir la cohérence de la dénormalisation et éviter autant que possible les scénarios non performants de Compare-And-Set.

Cette simulation de la réflexion d’un architecte montre qu’il s’agit d’un travail d’analyse sensible. Il est nécessaire d’avoir en permanence un regard critique et d’identifier les faiblesses de chaque choix. Il ne faut pas occulter certaines difficultés pour simplifier l’architecture. La loi de Murphy garantit que tous les problèmes identifiés arriveront. Et bien d’autres encore, inconnus à ce stade. Le rôle de l’architecte est d’en prévoir le plus possible pour proposer des solutions les réduisant.

Nous espérons vous avoir éclairé sur la complexité du métier d’architecte en SI.

Références

[1] NoSQL : https://fr.wikipedia.org/wiki/NoSQL

[2] Consistance à terme : https://en.wikipedia.org/wiki/Eventual_consistency

[3] Cassandra : http://cassandra.apache.org/

[4] Desing by Query : https://docs.datastax.com/en/cql/3.1/cql/ddl/dataModelingApproach.html

[5] Théorème CAP : https://fr.wikipedia.org/wiki/Th%C3%A9or%C3%A8me_CAP

[6] Kafka : https://kafka.apache.org/