Rails : le guide exhaustif de l'application multitenant efficace
L'architecture multitenant ! Un terme à la mode, qu'on retrouve sur toutes les langues dans les SI, mais que signifie-t-il concrètement et comment y parvenir dans notre code ?
Il s'agit dans notre contexte de concevoir une application Ruby on Rails capable de servir plusieurs clients tout en gardant une isolation optimale et sécurisée des données de chacun.
D'un point de vue applicatif, il s'agit de déployer une seule copie de l'application Rails, qui intercepte les requêtes de plusieurs domaines / sous-domaines ou sous-dossiers et personnalise la réponse en conséquence. Imaginez le confort en terme de maintenabilité qu'une telle situation vous offre.
Tout au long de cet article, nous allons concevoir une application fonctionnelle et testée qui met en oeuvre l'ensemble de techniques nécessaires.
Le contrat de cette application est le suivant :
Les données de chaque tenant seront placées dans une base de données qui lui est propre.
Les fichiers de chaque tenant doivent être inaccessibles à partir des autres
Les tenants pourront être identifiés par domaine ou sous-domaine.
La configuration
L'intérêt principal de notre approche est la possibilité de personnaliser indépendamment les tenants. Il nous faut donc un endroit où ces configurations seront stockées et facilement accessibles.
Parmi ces configurations, nous distinguons les informations propres à la détermination du tenant (domaine associé, nom de la base de données, locale, etc.). Il convient de séparer ces informations du reste de la configuration du tenant. Il existe beaucoup de logique propre au fonctionnement du tenant, ce qui encourage davantage cette séparation, mais nous verrons plus tard son intérêt réel.
Conceptuellement, nous auront alors deux tables : tenants et configurations.
create_tenants.rb
Il est très important de garder à jour votre fichier schema.rb au fil des migrations, car c'est à partir de celui-là que seront créées les bases de données des nouveaux tenants.
Notons que la table tenants sera identifiée par le nom du tenant et non par le `id` classique. Ce nom servira de clé étrangère à la table configurations (ligne 12) et fera partie du nom de la base de donnée, de l'index ElasticSearch et autres identifiants...
Les bases de données
Plusieurs choix techniques s'offrent à vous en fonction de votre système de gestion de base de données. Par exemple, certains SGBD proposent la notion de schemas qui permet d'avoir plusieurs ensembles de tables (et autres types d'objets SQL), dit "schema" dans une même base de données. Tous ces ensembles de données vous seront accessibles via la même connexion.
Vous avez la liberté d'explorer ces pistes en fonction de vos préférences et vos contraintes. Certains contrats IT exigent l'isolement "physique" des données des clients ce qui fait pencher la balance vers le choix de bases de données séparés, une solution qui peut s'avérer cependant plus couteuse, techniquement parlant.
Pour l'exemple de notre article nous allons opter pour l'approche multi-bases de données, car c'est la plus générique.
Nous utiliserons le gem "apartment" pour faire abstraction des opérations de création et de basculement entre bases de données. Si vous êtes intéressés par l'approche multi-schema, ce gem l'applique par défaut si votre application tourne sous PostgreSQL.
Gemfile
Nous lançons ensuite la commande d'installation de apartment `bundle exec rails generate apartment:install`
Cette commande crée l'initialiseur qui nous permet d'effectuer les configurations de base dont l'exclusion des modèles Tenant et Configuration afin que leurs données résident dans la base principale de l'application :
config/initializers/apartment.rb
Les tables des modèles exclues seront toutefois créées sur les bases de tous les tenants, car le schema.rb qui permettra de déployer votre application la première fois est le même qui servira à la création des bases des tenants.
Apartment propose plusieurs "ascenseurs" (elevators) pour basculer automatiquement de contexte. Il en existe pour les domaines, les sous-domaines, des hashes de domaines...
L'ascenseur des domaines peut être un bon compromis, car les clients finissent toujours par vouloir disposer de leur propre nom de domaine, surtout quand ils passent en "premium" ou en marque-blanche. Le subdomain elevator est donc à éviter.
Nous allons opter pour le plus générique des élévateurs et nous déportons la logique vers la classe Tenant.
apartment.rb
Dans la ligne 10, le middleware attend que la classe Tenant retourne un proc via to_proc. Ce que nous allons implémenter dans le module `requestable` (ligne 6).
tenant.rb requestable.rb
Nous sommes arrivés au point où il faut changer de contexte en invoquant Apartment et les mécanismes connexes. Faisons cela dans un autre module afin de départager les responsabilités :
tenant.rb contextable.rb
Cool, mais nous mettons ici les charrues avant les bœufs. La création de tenants doit d'abord être reliée à la création de bases de données. Assurons également à chaque tenant sa configuration.
tenant.rb configurable.rb creation.rb
Achevons de peaufiner notre système en prévoyant un préfixe pour les noms des bases de données. Si votre SGBD risque d'héberger des bases d'autres applications, cela réduira les risques de conflit de nommage.
tenant.rb prefixable.rb
Avant de passer à d'autres aspects, remarquons que les appels à `Tenant.current` (qui risquent d'être nombreux dans notre application) nécessitent à chaque fois des requêtes en base de données.
Nous allons donc mettre l'instance du tenant et sa configuration en cache :
tenant.rb cachable.rb
Le cache est ici double. D'un côté, nous stockons le temps d'une requête le nom du tenant dans le "thread" courant. Cet identifiant est utilisé comme clé pour lire dans le cache. L'instance de Tenant et sa configuration sont supprimées du cache dès que l'instance est sauvegardée ou "touchée" (.touch). Pour cela, une petite modification du modèle Configuration s'impose :
configuration.rb
Les migrations
Surchargé par Apartment, rake db:migrate migre toutes les bases de données des tenants figurant dans la configuration config.tenant_names de Apartment. Il migrate également (et surtout) la base de données principale.
Servons à ces tâches la liste des noms de bases de données préfixées correspondant à chaque tenant :
apartment.rb requestable.rb prefixable.rb
Seulement, ce modèle n'est pas compatible avec les migrations qui manipulent des données. En effet, les migrations s'exécutent sans middlewares ni requêtes et Apartment change de base de données sans changer le contexte ActiveRecord.
Nous allons donc prévoir un mécanisme pour changer le contexte en fonction du nom de la base de données. De ce nom, nous remontons vers le tenant correspondant et nous changeons vers son contexte.
D'ailleurs, profitons-en pour coder quelques méthodes qui nous seront utiles dans le code et la maintenance de l'application.
"run" permet de changer de contexte, exécuter un bloc de code et revenir au contexte précédent. Ça peut être utile si vous devez faire une lecture de données entre un tenant ordinaire et un tenant "spécial".
CODE : contextable.rb
`current` renvoie l'instance du tenant en cours, qui permet d'accéder à la configuration associée comme : > Tenant.current.config.name.
Dans l'utilisation de tous les jours dans votre code, il convient de raccourcir cette syntaxe.
CODE : application_controller.rb
Nous définissons ici deux méthodes, config et son raccourci `c`, toutes deux accessibles dans tous les contrôleurs (par héritage) et dans toutes les vues également (via helper_method)
Les sessions
Aucune intervention n'est nécessaire pour sécuriser les sessions. Rails limite par défaut les cookies et spécialement le cookie de session à un seul domaine. Si un même utilisateur est identifié sur un sous domaine de votre application multitenant puis accède pour la première fois à un autre sous-domaine, une nouvelle session vierge sera créée et les deux sessions n'interagiront pas.
De même que si vous persistez les sessions dans la base de données, chaque tenant aura sa propre table "sessions" et la lecture de ces données s'effectue après le changement de contexte du tenant.
Si vous héritez d'une application existante, assurez-vous que le paramètre domain: :all n'est pas défini dans l'initialiser session_store.rb ou ailleurs dans application.rb ou environments/production.rb.
CODE : session_store.rb
Gérer les tenants introuvables
L'utilisation du middleware suffit pour changer de contexte à chaque requête vers votre application. Mais que faire des requêtes dirigées vers des tenants inexistants ?
CODE : application_controller.rb
Dans l'environnement de production, nous redirigeons vers le site corp, celui qui vous permet d'inscrire vos clients. Vous pouvez pousser le bouchon plus loin en proposant à l'utilisateur de créer son propre tenant, dans le cas où votre business-model implique une création ouverte des tenants.
Nous utilisons les `custom configurations` de Rails 4, que nous placerons dans un initialiser pour ne pas polluer application.rb
CODE : config.rb
Les fichiers
Au même titre que les données en base, le cloisonnement des fichiers est important pour toute application multitenant.
Nous allons prendre en exemple l'utilisation du gem populaire paperclip.
Nous modifierons l'emplacement où les fichiers attachés seront stockés, en les incorporant dans un dossier qui correspond au nom du tenant.
Mais cela n'est pas suffisant, car les fichiers de tous les tenants peuvent être accédés via les domaines des autres tenants. Nous n'en pouvons pas grand-chose car tous les serveurs Web délivrent les contenus statiques en priorité par rapport aux contenus dynamiques. Inutile donc de chercher à occulter les fichiers des autres tenants via des routes catch-all.
Des solutions plutôt hardcore peuvent être envisagées, comme de ne pas stocker des fichiers dans le dossier public et les délivrer via send_file à partir d'un contrôleur dédié. Une autre solution, encore plus hardcore, peut être discutée, à savoir éditer —au moment de la création du tenant— le fichier de configuration du serveur Web pour ajouter des règles qui relient les domaines avec les dossiers.
Pour les besoins de cet article, nous nous contenteront d'une solution qui, au lieu de rendre les fichiers inaccessibles, brouille les pistes pour les trouver. Nous remplaceront l'id par un hash sécurisé et nous englobantsles fichiers d'un tenant dans un dossier commun afin de faciliter leur sauvegarde et exportation.
CODE : paperclip.rb
Les emails
L'envoi de courriels via ActionMailer implique d'éviter toute personnalisation au niveau de la déclaration de classe de vos Mailer.
Les principaux paramètres de la méthode "default" peuvent prendre soit des valeurs littérales ou des procs exécutables. On peut d'alors faire :
CODE : app/models/concerns/multitenant_mailer.rb CODE : app/models/main_mailer.rb
Mais il reste le problème épineux des default_url_options. Ce paramètre ne prend pas de valeur exécutable. Nous devons donc le (re) définir au moment du changement de contexte, au sein même de la classe Tenant.
CODE : app/models/tenant.rb CODE : app/models/tenant/mailing.rb
Tâches de fond (delayed_jobs)
Il existe un gem dédié à l'intégration de apartment avec sidekiq, le système de gestion de tâches de fond qui monte. Delayed Jobs bénéficiait d'une intégration approximative qui a été retirée depuis par faute de stabilité.
Le problème, vous l'auriez deviné, vient encore de l'absence de changement de contexte. Les workers delayed jobs se lancent en rake task et traitent les tâches de fond sans traverser la chaine des middlewares.
Pire encore, Delayed Job fonctionne de manière à charger automatiquement de la base de données les paramètres passés à la méthode delayée et serialisés. Ce chargement se fait hélas dans le contexte de la base de données principale de l'application.
Avant toute chose, ajoutons Delayed::Job aux modèles exclues du multitenant. Les tâches de tous les tenants seront toutes stockées dans la même base.
Gemfile CODE : config/initializers/apartment.rb
Ensuite, ajoutons une nouvelle colonne à la table delayed_jobs, qui véhiculera le nom du tenant dans le contexte duquel les tâches devront être exécutées.
CODE : db/migrations/
Place aux hacks maintenant. Assurons-nous de renseigner cette colonne à chaque fois qu'un job est créé. Nous devons le faire dans plusieurs endroits suivant les différentes manières qu'offre delayed_job pour rendre asynchrones les tâches.
La syntaxe : object.delay.some_method(arg...)
Nous surchargeons la méthode delay via la génialissime killer-feature de Ruby 2, prepend. Cette dernière nous évite de faire du sale monkey patcking via `alias_method_chain` ou renommage manuel de methods.
CODE : apartment.rb
`Prepend` mérite un article à part, mais, disons rapidement qu'elle permet d'insérer un module au tout début de la chaine des "ancestors", qui est traversée à chaque appel de fonction (message envoyé) pour trouver un "répondant" au message. Pour une classe, cette chaine commence par elle même ! Prepend insère votre module devant même cette classe. Ce qui a deux heureuses conséquences :
Une méthode de votre module est prioritaire à celle du même nom sur la classe originale.
Vous pouvez appeler la méthode de la classe originale depuis votre méthode du même nom, avec “super”
La syntaxe : Delayed::Job.enqueue(job_instance)
Nous utilisons la même technique, à la différence près que nous prependons notre module dans la classe singleton de Delayed::Job, afin de surcharger ses méthodes de classe :
CODE : apartment.rb
Nous passons à présent au changement de contexte, qui dois survenir à la première évocation de la méthode Delayed::Job#payload_object
CODE : apartment.rb
L'envoi d'emails en tâche de fond
Si votre application gère l'internationalisation, vous faites typiquement le changement de locale dans un before_filter de application_controller. Mais ce dernier ne s'exécute pas si vos emails sont envoyés d'une tâche de fond. C'est pourquoi nous avons prévu dès le début d'article le changement de locale au moment du changement de contexte (dans Tenant#switch)
Notons également que l'envoi d'email se fait avec Delayed Jobs via la classe ActiveMailer::Base. C'est donc sur celle-là que nous allons intervenir :
CODE : apartment.rb
Moteur de recherche avec ElasticSearch
Si vous utilisez ElasticSearch pour la persistance des données, nous vous recommandons d'envisager d'autres pistes de réflexion pour mieux cloisonner vos données.
Si par contre vous ne l'utilisez que pour indexer / trouver des contenus persistant en base déjà cloisonnées, la moins couteuse des solutions est d'utiliser l'index name pour séparer vos tenants. Avec la gem officielle `elasticsearch-model`, cela peut se faire très facilement.
Gemfile searchable.rb
Et c'est tout, il vous suffit ensuite d'inclure ce module dans tous vos modèles indexables et vos données iront automatiquement au nom index.
Mise en cache
Le cache étant également une information volatile, nous utiliserons le même mécanisme et le même "récipient" pour tous les tenants. Rails propose un paramètre namespace très pratique.
CODE : application.rb
Et c'est (à peu près) tout ce qu'il faut. Si pour une raison ou une autre vous devez vider tout le cache d'un tenant (pour un changement de langue par exemple), sachez que Rails.cache.clear est insensible au paramètre namespace. Il videra le cache pour tous les tenants, ce qui peut compromettre les performances globales de votre application.
Nous utiliserons plutôt Rails.cache.delete_matched(/.+/) qui fait du cas-par-cas.
cachable.rb
Ce code nous permet d'appeler Tenant.current.cache.clear, handy!
Notez cependant que cette méthode ne fonctionne que pour les cache stores qui supportent les expressions régulières sur la liste globale des entrées existantes.
Multi-serveurs / multi instances
Jusque-là, nous sommes arrivés à concevoir une application qui sert plusieurs clients et qui isole les données de chacun de manière satisfaisante.
Mais si votre business évolue et votre serveur atteint ses limites, vous devriez en prendre d'autres et la solution actuelle restreindra votre évolution.
Nous verrons dans un article prochain comment faire évoluer votre application multi-tenant en une véritable application SaaS scalable et multi-serveurs.











