Configuration du système distribué compilé

Je voudrais vous présenter un mécanisme intéressant pour travailler avec la configuration d'un système distribué. La configuration est représentée directement dans un langage compilé (Scala) à l'aide de types sécurisés. Cet article fournit un exemple d'une telle configuration et aborde divers aspects de la mise en œuvre d'une configuration compilée dans le processus de développement global.

Configuration du système distribué compilé

(Anglais)

introduction

Construire un système distribué fiable signifie que tous les nœuds utilisent la configuration correcte, synchronisée avec les autres nœuds. Les technologies DevOps (terraform, ansible ou quelque chose comme ça) sont généralement utilisées pour générer automatiquement des fichiers de configuration (souvent spécifiques à chaque nœud). Nous aimerions également nous assurer que tous les nœuds communicants utilisent des protocoles identiques (y compris la même version). Sinon, une incompatibilité sera intégrée à notre système distribué. Dans le monde JVM, une conséquence de cette exigence est que la même version de la bibliothèque contenant les messages de protocole doit être utilisée partout.

Et si vous testiez un système distribué ? Bien entendu, nous supposons que tous les composants subissent des tests unitaires avant de passer aux tests d'intégration. (Pour pouvoir extrapoler les résultats des tests au moment de l'exécution, nous devons également fournir un ensemble identique de bibliothèques au stade du test et au moment de l'exécution.)

Lorsque vous travaillez avec des tests d'intégration, il est souvent plus facile d'utiliser le même chemin de classe partout sur tous les nœuds. Tout ce que nous avons à faire est de nous assurer que le même chemin de classe est utilisé au moment de l'exécution. (Bien qu'il soit tout à fait possible d'exécuter différents nœuds avec différents chemins de classe, cela ajoute de la complexité à la configuration globale et des difficultés avec les tests de déploiement et d'intégration.) Pour les besoins de cet article, nous supposons que tous les nœuds utiliseront le même chemin de classe.

La configuration évolue avec l'application. Nous utilisons des versions pour identifier les différentes étapes de l'évolution du programme. Il semble logique d'identifier également différentes versions de configurations. Et placez la configuration elle-même dans le système de contrôle de version. S’il n’y a qu’une seule configuration en production, alors on peut simplement utiliser le numéro de version. Si nous utilisons de nombreuses instances de production, nous en aurons besoin de plusieurs
branches de configuration et un label supplémentaire en plus de la version (par exemple, le nom de la branche). De cette façon, nous pouvons clairement identifier la configuration exacte. Chaque identifiant de configuration correspond de manière unique à une combinaison spécifique de nœuds distribués, de ports, de ressources externes et de versions de bibliothèque. Pour les besoins de cet article, nous supposerons qu'il n'y a qu'une seule branche et nous pouvons identifier la configuration de la manière habituelle à l'aide de trois chiffres séparés par un point (1.2.3).

Dans les environnements modernes, les fichiers de configuration sont rarement créés manuellement. Le plus souvent, ils sont générés lors du déploiement et ne sont plus touchés (afin que ne casse rien). Une question naturelle se pose : pourquoi utilisons-nous encore le format texte pour stocker la configuration ? Une alternative viable semble être la possibilité d'utiliser du code standard pour la configuration et de bénéficier de contrôles au moment de la compilation.

Dans cet article, nous explorerons l'idée de représenter une configuration à l'intérieur d'un artefact compilé.

Configuration compilée

Cette section fournit un exemple de configuration compilée statique. Deux services simples sont implémentés : le service d'écho et le client du service d'écho. Sur la base de ces deux services, deux options de système sont assemblées. Dans une option, les deux services sont situés sur le même nœud, dans une autre option, sur des nœuds différents.

Généralement, un système distribué contient plusieurs nœuds. Vous pouvez identifier les nœuds en utilisant des valeurs d'un certain type NodeId:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

ou

case class NodeId(hostName: String)

ou

object Singleton
type NodeId = Singleton.type

Les nœuds remplissent divers rôles, ils exécutent des services et des connexions TCP/HTTP peuvent être établies entre eux.

Pour décrire une connexion TCP, nous avons besoin d'au moins un numéro de port. Nous aimerions également refléter le protocole pris en charge sur ce port pour garantir que le client et le serveur utilisent le même protocole. Nous allons décrire la connexion en utilisant la classe suivante :

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

Port - juste un entier Int indiquant la plage de valeurs acceptables :

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Types raffinés

Voir la bibliothèque raffiné и ma rapport. En bref, la bibliothèque vous permet d'ajouter des contraintes aux types vérifiés au moment de la compilation. Dans ce cas, les valeurs de numéro de port valides sont des entiers de 16 bits. Pour une configuration compilée, l'utilisation de la bibliothèque raffinée n'est pas obligatoire, mais elle améliore la capacité du compilateur à vérifier la configuration.

Pour les protocoles HTTP (REST), en plus du numéro de port, nous pouvons également avoir besoin du chemin d'accès au service :

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

Types fantômes

Pour identifier le protocole au moment de la compilation, nous utilisons un paramètre de type qui n'est pas utilisé dans la classe. Cette décision est due au fait que nous n'utilisons pas d'instance de protocole au moment de l'exécution, mais nous aimerions que le compilateur vérifie la compatibilité du protocole. En spécifiant le protocole, nous ne pourrons pas faire passer un service inapproprié comme dépendance.

L'un des protocoles courants est l'API REST avec sérialisation Json :

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

RequestMessage - type de demande, ResponseMessage — type de réponse.
Bien entendu, nous pouvons utiliser d’autres descriptions de protocoles qui fournissent l’exactitude de description dont nous avons besoin.

Pour les besoins de cet article, nous utiliserons une version simplifiée du protocole :

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Ici, la requête est une chaîne ajoutée à l'URL et la réponse est la chaîne renvoyée dans le corps de la réponse HTTP.

La configuration du service est décrite par le nom du service, les ports et les dépendances. Ces éléments peuvent être représentés en Scala de plusieurs manières (par exemple, HList-s, types de données algébriques). Pour les besoins de cet article, nous utiliserons le Cake Pattern et représenterons les modules en utilisant trait'ov. (Le Cake Pattern n’est pas un élément obligatoire de cette approche. Il s’agit simplement d’une implémentation possible.)

Les dépendances entre services peuvent être représentées sous forme de méthodes qui renvoient des ports EndPointceux des autres nœuds :

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

Pour créer un service d'écho, tout ce dont vous avez besoin est un numéro de port et une indication que le port prend en charge le protocole d'écho. Nous ne spécifierons peut-être pas un port spécifique, car... les traits vous permettent de déclarer des méthodes sans implémentation (méthodes abstraites). Dans ce cas, lors de la création d'une configuration concrète, le compilateur nous demanderait de fournir une implémentation de la méthode abstraite et de fournir un numéro de port. Puisque nous avons implémenté la méthode, lors de la création d’une configuration spécifique, nous ne pouvons pas spécifier un port différent. La valeur par défaut sera utilisée.

Dans la configuration du client nous déclarons une dépendance au service echo :

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

La dépendance est du même type que le service exporté echoService. En particulier, dans le client echo, nous avons besoin du même protocole. Par conséquent, lors de la connexion de deux services, nous pouvons être sûrs que tout fonctionnera correctement.

Mise en œuvre des prestations

Une fonction est requise pour démarrer et arrêter le service. (La possibilité d'arrêter le service est essentielle pour les tests.) Encore une fois, il existe plusieurs options pour implémenter une telle fonctionnalité (par exemple, nous pourrions utiliser des classes de types basées sur le type de configuration). Pour les besoins de cet article, nous utiliserons le modèle de gâteau. Nous allons représenter le service en utilisant une classe cats.Resource, parce que Cette classe fournit déjà des moyens pour garantir en toute sécurité la libération des ressources en cas de problème. Pour obtenir une ressource, nous devons fournir une configuration et un contexte d'exécution prêt à l'emploi. La fonction de démarrage du service peut ressembler à ceci :

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

  • Config — type de configuration pour ce service
  • AddressResolver — un objet d'exécution qui permet de connaître les adresses des autres nœuds (voir ci-dessous)

et d'autres types de la bibliothèque cats:

  • F[_] — type d'effet (dans le cas le plus simple F[A] ça pourrait juste être une fonction () => A. Dans cet article, nous utiliserons cats.IO.)
  • Reader[A,B] - plus ou moins synonyme de fonction A => B
  • cats.Resource - une ressource qui peut être obtenue et libérée
  • Timer — minuterie (vous permet de vous endormir pendant un moment et de mesurer les intervalles de temps)
  • ContextShift - analogique ExecutionContext
  • Applicative — une classe de types d'effets qui vous permet de combiner des effets individuels (presque une monade). Dans des applications plus complexes, il semble préférable d'utiliser Monad/ConcurrentEffect.

En utilisant cette signature de fonction, nous pouvons implémenter plusieurs services. Par exemple, un service qui ne fait rien :

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(Cm. code source, dans lequel d'autres services sont implémentés - service d'écho, client d'écho
и contrôleurs à vie.)

Un nœud est un objet pouvant lancer plusieurs services (le lancement d'une chaîne de ressources est assuré par le Cake Pattern) :

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

Veuillez noter que nous spécifions le type exact de configuration requis pour ce nœud. Si nous oublions de spécifier l'un des types de configuration requis par un service particulier, il y aura une erreur de compilation. De plus, nous ne pourrons démarrer un nœud que si nous fournissons un objet du type approprié avec toutes les données nécessaires.

Résolution du nom d'hôte

Pour nous connecter à un hôte distant, nous avons besoin d’une véritable adresse IP. Il est possible que l'adresse soit connue plus tard que le reste de la configuration. Nous avons donc besoin d'une fonction qui mappe l'ID du nœud à une adresse :

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

Il existe plusieurs manières d'implémenter cette fonction :

  1. Si les adresses nous sont connues avant le déploiement, nous pouvons alors générer du code Scala avec
    adresses, puis exécutez la construction. Cela compilera et exécutera des tests.
    Dans ce cas, la fonction sera connue statiquement et pourra être représentée dans le code sous forme de mappage Map[NodeId, NodeAddress].
  2. Dans certains cas, l'adresse réelle n'est connue qu'après le démarrage du nœud.
    Dans ce cas, nous pouvons implémenter un « service de découverte » qui s'exécute avant les autres nœuds et tous les nœuds s'enregistreront auprès de ce service et demanderont les adresses des autres nœuds.
  3. Si nous pouvons modifier /etc/hosts, vous pouvez alors utiliser des noms d'hôtes prédéfinis (comme my-project-main-node и echo-backend) et liez simplement ces noms
    avec des adresses IP lors du déploiement.

Dans cet article, nous n’examinerons pas ces cas plus en détail. Pour notre
dans un exemple de jouet, tous les nœuds auront la même adresse IP - 127.0.0.1.

Ensuite, nous considérons deux options pour un système distribué :

  1. Placer tous les services sur un seul nœud.
  2. Et héberger le service echo et le client echo sur différents nœuds.

Configuration pour un nœud:

Configuration à nœud unique

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

L'objet implémente la configuration du client et du serveur. Une configuration de durée de vie est également utilisée afin qu'après l'intervalle lifetime terminer le programme. (Ctrl-C fonctionne également et libère correctement toutes les ressources.)

Le même ensemble de caractéristiques de configuration et d'implémentation peut être utilisé pour créer un système composé de deux nœuds distincts:

Configuration à deux nœuds

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    // NB! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Important! Remarquez comment les services sont liés. Nous spécifions un service implémenté par un nœud comme implémentation de la méthode de dépendance d'un autre nœud. Le type de dépendance est vérifié par le compilateur, car contient le type de protocole. Une fois exécutée, la dépendance contiendra l'ID de nœud cible correct. Grâce à ce schéma, nous indiquons le numéro de port exactement une fois et sommes toujours assurés de faire référence au bon port.

Implémentation de deux nœuds système

Pour cette configuration, nous utilisons les mêmes implémentations de service sans modifications. La seule différence est que nous avons maintenant deux objets qui implémentent différents ensembles de services :

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

Le premier nœud implémente le serveur et n'a besoin que d'une configuration du serveur. Le deuxième nœud implémente le client et utilise une partie différente de la configuration. Les deux nœuds nécessitent également une gestion à vie. Le nœud du serveur s'exécute indéfiniment jusqu'à ce qu'il soit arrêté SIGTERM'om, et le nœud client se termine après un certain temps. Cm. application de lancement.

Processus de développement général

Voyons comment cette approche de configuration affecte le processus de développement global.

La configuration sera compilée avec le reste du code et un artefact (.jar) sera généré. Il semble logique de placer la configuration dans un artefact distinct. En effet, nous pouvons avoir plusieurs configurations basées sur le même code. Là encore, il est possible de générer des artefacts correspondant à différentes branches de configuration. Les dépendances sur des versions spécifiques des bibliothèques sont enregistrées avec la configuration, et ces versions sont enregistrées pour toujours chaque fois que nous décidons de déployer cette version de la configuration.

Tout changement de configuration se transforme en changement de code. Et donc, chacun
le changement sera couvert par le processus normal d’assurance qualité :

Ticket dans le bug tracker -> PR -> review -> fusionner avec les branches concernées ->
intégration -> déploiement

Les principales conséquences de l'implémentation d'une configuration compilée sont :

  1. La configuration sera cohérente sur tous les nœuds du système distribué. En raison du fait que tous les nœuds reçoivent la même configuration d’une seule source.

  2. Il est problématique de modifier la configuration dans un seul des nœuds. Par conséquent, une « dérive de configuration » est peu probable.

  3. Il devient plus difficile d'apporter de petites modifications à la configuration.

  4. La plupart des modifications de configuration interviendront dans le cadre du processus de développement global et seront soumises à un examen.

Ai-je besoin d’un référentiel séparé pour stocker la configuration de production ? Cette configuration peut contenir des mots de passe et d'autres informations sensibles auxquelles nous souhaitons restreindre l'accès. Sur cette base, il semble logique de stocker la configuration finale dans un référentiel distinct. Vous pouvez diviser la configuration en deux parties : l'une contenant les paramètres de configuration accessibles au public et l'autre contenant les paramètres restreints. Cela permettra à la plupart des développeurs d'avoir accès aux paramètres communs. Cette séparation est facile à réaliser en utilisant des traits intermédiaires contenant des valeurs par défaut.

Variations possibles

Essayons de comparer la configuration compilée avec quelques alternatives courantes :

  1. Fichier texte sur la machine cible.
  2. Magasin de valeurs-clés centralisé (etcd/zookeeper).
  3. Composants de processus qui peuvent être reconfigurés/redémarrés sans redémarrer le processus.
  4. Stockage de la configuration en dehors du contrôle des artefacts et des versions.

Les fichiers texte offrent une flexibilité importante en termes de petites modifications. L'administrateur système peut se connecter au nœud distant, apporter des modifications aux fichiers appropriés et redémarrer le service. Toutefois, pour les grands systèmes, une telle flexibilité n’est peut-être pas souhaitable. Les modifications apportées ne laissent aucune trace dans les autres systèmes. Personne ne examine les changements. Il est difficile de déterminer qui a exactement apporté ces modifications et pour quelle raison. Les modifications ne sont pas testées. Si le système est distribué, l'administrateur peut oublier d'apporter la modification correspondante sur d'autres nœuds.

(Il convient également de noter que l'utilisation d'une configuration compilée ne ferme pas la possibilité d'utiliser des fichiers texte à l'avenir. Il suffira d'ajouter un analyseur et un validateur qui produiront le même type en sortie. Config, et vous pouvez utiliser des fichiers texte. Il s'ensuit immédiatement que la complexité d'un système avec une configuration compilée est légèrement inférieure à la complexité d'un système utilisant des fichiers texte, car les fichiers texte nécessitent du code supplémentaire.)

Un magasin clé-valeur centralisé est un bon mécanisme pour distribuer les méta-paramètres d’une application distribuée. Nous devons décider ce que sont les paramètres de configuration et ce qui ne sont que des données. Ayons une fonction C => A => B, et les paramètres C change rarement et les données A - souvent. Dans ce cas on peut dire que C - les paramètres de configuration, et A - données. Il semble que les paramètres de configuration diffèrent des données dans la mesure où ils changent généralement moins fréquemment que les données. De plus, les données proviennent généralement d'une source (de l'utilisateur) et les paramètres de configuration d'une autre (de l'administrateur système).

Si des paramètres rarement modifiés doivent être mis à jour sans redémarrer le programme, cela peut souvent entraîner une complication du programme, car nous devrons d'une manière ou d'une autre fournir des paramètres, stocker, analyser et vérifier et traiter des valeurs incorrectes. Par conséquent, du point de vue de la réduction de la complexité du programme, il est logique de réduire le nombre de paramètres pouvant changer pendant le fonctionnement du programme (ou de ne pas prendre en charge ces paramètres du tout).

Pour les besoins de cet article, nous ferons la différence entre les paramètres statiques et dynamiques. Si la logique du service nécessite de modifier les paramètres pendant le fonctionnement du programme, nous appellerons alors ces paramètres dynamiques. Sinon, les options sont statiques et peuvent être configurées à l'aide de la configuration compilée. Pour une reconfiguration dynamique, nous pouvons avoir besoin d'un mécanisme pour redémarrer des parties du programme avec de nouveaux paramètres, similaire à la façon dont les processus du système d'exploitation sont redémarrés. (À notre avis, il est conseillé d'éviter la reconfiguration en temps réel, car cela augmente la complexité du système. Si possible, il est préférable d'utiliser les capacités standard du système d'exploitation pour redémarrer les processus.)

Un aspect important de l'utilisation de la configuration statique qui incite les gens à envisager une reconfiguration dynamique est le temps nécessaire au système pour redémarrer après une mise à jour de la configuration (temps d'arrêt). En fait, si nous devons apporter des modifications à la configuration statique, nous devrons redémarrer le système pour que les nouvelles valeurs prennent effet. Le problème de temps d'arrêt varie en gravité selon les différents systèmes. Dans certains cas, vous pouvez planifier un redémarrage à un moment où la charge est minime. Si vous devez fournir un service continu, vous pouvez mettre en œuvre Drainage de la connexion AWS ELB. Dans le même temps, lorsque nous devons redémarrer le système, nous lançons une instance parallèle de ce système, y basculons l'équilibreur et attendons que les anciennes connexions soient terminées. Une fois toutes les anciennes connexions terminées, nous arrêtons l'ancienne instance du système.

Considérons maintenant la question du stockage de la configuration à l'intérieur ou à l'extérieur de l'artefact. Si nous stockons la configuration à l'intérieur d'un artefact, nous avons au moins la possibilité de vérifier l'exactitude de la configuration lors de l'assemblage de l'artefact. Si la configuration se situe en dehors de l'artefact contrôlé, il est difficile de savoir qui a apporté des modifications à ce fichier et pourquoi. Dans quelle mesure est-ce important ? À notre avis, pour de nombreux systèmes de production, il est important de disposer d'une configuration stable et de haute qualité.

La version d'un artefact vous permet de déterminer quand il a été créé, quelles valeurs il contient, quelles fonctions sont activées/désactivées et qui est responsable de toute modification de la configuration. Bien entendu, stocker la configuration dans un artefact nécessite un certain effort, vous devez donc prendre une décision éclairée.

Avantages et inconvénients

Je voudrais m'attarder sur les avantages et les inconvénients de la technologie proposée.

avantages

Vous trouverez ci-dessous une liste des principales fonctionnalités d'une configuration de système distribué compilée :

  1. Vérification de la configuration statique. Permet d'être sûr que
    la configuration est correcte.
  2. Langage de configuration riche. En règle générale, les autres méthodes de configuration se limitent au maximum à la substitution de variables de chaîne. Lorsque vous utilisez Scala, un large éventail de fonctionnalités linguistiques sont disponibles pour améliorer votre configuration. Par exemple, nous pouvons utiliser
    traits pour les valeurs par défaut, en utilisant des objets pour regrouper les paramètres, nous pouvons nous référer aux vals déclarés une seule fois (DRY) dans la portée englobante. Vous pouvez instancier n'importe quelle classe directement dans la configuration (Seq, Map, cours personnalisés).
  3. DSL. Scala possède un certain nombre de fonctionnalités linguistiques qui facilitent la création d'un DSL. Il est possible de profiter de ces fonctionnalités et de mettre en œuvre un langage de configuration plus pratique pour le groupe cible d'utilisateurs, afin que la configuration soit au moins lisible par les experts du domaine. Les spécialistes peuvent, par exemple, participer au processus de révision de la configuration.
  4. Intégrité et synchronisation entre les nœuds. L'un des avantages de stocker la configuration de l'ensemble d'un système distribué en un seul point est que toutes les valeurs sont déclarées exactement une fois, puis réutilisées partout où elles sont nécessaires. L'utilisation de types fantômes pour déclarer les ports garantit que les nœuds utilisent des protocoles compatibles dans toutes les configurations système correctes. Le fait d'avoir des dépendances obligatoires explicites entre les nœuds garantit que tous les services sont connectés.
  5. Modifications de haute qualité. Apporter des modifications à la configuration à l'aide d'un processus de développement commun permet également d'atteindre des normes de qualité élevées pour la configuration.
  6. Mise à jour simultanée de la configuration. Le déploiement automatique du système après les modifications de configuration garantit que tous les nœuds sont mis à jour.
  7. Simplification de l'application. L'application n'a pas besoin d'analyse, de vérification de configuration ou de gestion de valeurs incorrectes. Cela réduit la complexité de l'application. (Une partie de la complexité de configuration observée dans notre exemple n'est pas un attribut de la configuration compilée, mais seulement une décision consciente motivée par le désir de fournir une plus grande sécurité de type.) Il est assez facile de revenir à la configuration habituelle - il suffit d'implémenter les éléments manquants. les pièces. Ainsi, vous pouvez, par exemple, partir d'une configuration compilée, en différant l'implémentation des parties inutiles jusqu'au moment où cela est réellement nécessaire.
  8. Configuration versifiée. Étant donné que les modifications de configuration suivent le sort habituel de toute autre modification, le résultat que nous obtenons est un artefact avec une version unique. Cela nous permet par exemple de revenir à une version précédente de la configuration si nécessaire. Nous pouvons même utiliser la configuration d’il y a un an et le système fonctionnera exactement de la même manière. Une configuration stable améliore la prévisibilité et la fiabilité d'un système distribué. La configuration étant fixée au stade de la compilation, il est assez difficile de la simuler en production.
  9. Modularité. Le cadre proposé est modulaire et les modules peuvent être combinés de différentes manières pour créer différents systèmes. En particulier, vous pouvez configurer le système pour qu'il s'exécute sur un seul nœud dans un mode de réalisation, et sur plusieurs nœuds dans un autre. Vous pouvez créer plusieurs configurations pour les instances de production du système.
  10. Essai. En remplaçant les services individuels par des objets fictifs, vous pouvez obtenir plusieurs versions du système pratiques pour les tests.
  11. Tests d'intégration. Disposer d'une configuration unique pour l'ensemble du système distribué permet d'exécuter tous les composants dans un environnement contrôlé dans le cadre des tests d'intégration. Il est facile d'émuler, par exemple, une situation dans laquelle certains nœuds deviennent accessibles.

Inconvénients et limites

La configuration compilée diffère des autres approches de configuration et peut ne pas convenir à certaines applications. Voici quelques inconvénients :

  1. Configuration statique. Parfois, vous devez corriger rapidement la configuration en production, en contournant tous les mécanismes de protection. Avec cette approche, cela peut être plus difficile. À tout le moins, la compilation et le déploiement automatique seront toujours nécessaires. C’est à la fois une caractéristique utile de l’approche et un inconvénient dans certains cas.
  2. Génération de configurations. Dans le cas où le fichier de configuration est généré par un outil automatique, des efforts supplémentaires peuvent être nécessaires pour intégrer le script de build.
  3. Outils. Actuellement, les utilitaires et techniques conçus pour fonctionner avec la configuration sont basés sur des fichiers texte. Tous ces utilitaires/techniques ne seront pas disponibles dans une configuration compilée.
  4. Un changement d’attitude est nécessaire. Les développeurs et DevOps sont habitués aux fichiers texte. L'idée même de compiler une configuration peut être quelque peu inattendue et inhabituelle et provoquer un rejet.
  5. Un processus de développement de haute qualité est requis. Afin d'utiliser confortablement la configuration compilée, une automatisation complète du processus de création et de déploiement de l'application (CI/CD) est nécessaire. Sinon, ce sera très gênant.

Attardons-nous également sur un certain nombre de limitations de l'exemple considéré qui ne sont pas liées à l'idée d'une configuration compilée :

  1. Si nous fournissons des informations de configuration inutiles qui ne sont pas utilisées par le nœud, le compilateur ne nous aidera pas à détecter l'implémentation manquante. Ce problème peut être résolu en abandonnant le modèle Cake et en utilisant des types plus rigides, par exemple : HList ou des types de données algébriques (classes de cas) pour représenter la configuration.
  2. Il y a des lignes dans le fichier de configuration qui ne sont pas liées à la configuration elle-même : (package, import,déclarations d'objet ; override def(pour les paramètres qui ont des valeurs par défaut). Cela peut être partiellement évité si vous implémentez votre propre DSL. De plus, d'autres types de configuration (par exemple XML) imposent également certaines restrictions sur la structure des fichiers.
  3. Pour les besoins de cet article, nous n'envisageons pas la reconfiguration dynamique d'un cluster de nœuds similaires.

Conclusion

Dans cet article, nous avons exploré l'idée de représenter la configuration dans le code source en utilisant les capacités avancées du système de types Scala. Cette approche peut être utilisée dans diverses applications en remplacement des méthodes de configuration traditionnelles basées sur des fichiers XML ou texte. Même si notre exemple est implémenté en Scala, les mêmes idées peuvent être transférées vers d'autres langages compilés (comme Kotlin, C#, Swift, ...). Vous pouvez essayer cette approche dans l'un des projets suivants et, si cela ne fonctionne pas, passer au fichier texte en ajoutant les parties manquantes.

Naturellement, une configuration compilée nécessite un processus de développement de haute qualité. En retour, la haute qualité et la fiabilité des configurations sont assurées.

L’approche envisagée peut être élargie :

  1. Vous pouvez utiliser des macros pour effectuer des vérifications au moment de la compilation.
  2. Vous pouvez implémenter un DSL pour présenter la configuration de manière accessible aux utilisateurs finaux.
  3. Vous pouvez mettre en œuvre une gestion dynamique des ressources avec un ajustement automatique de la configuration. Par exemple, modifier le nombre de nœuds dans un cluster nécessite que (1) chaque nœud reçoive une configuration légèrement différente ; (2) le gestionnaire de cluster a reçu des informations sur les nouveaux nœuds.

Remerciements

Je voudrais remercier Andrei Saksonov, Pavel Popov et Anton Nekhaev pour leurs critiques constructives du projet d'article.

Source: habr.com

Ajouter un commentaire