Configuration compilable d'un système distribué

Dans cet article, nous aimerions partager une manière intéressante de gérer la configuration d'un système distribué.
La configuration est représentée directement en langage Scala de manière sécurisée. Un exemple de mise en œuvre est décrit en détail. Divers aspects de la proposition sont discutés, notamment son influence sur le processus de développement global.

Configuration compilable d'un système distribué

(en anglais)

Introduction

Construire des systèmes distribués robustes nécessite l’utilisation d’une configuration correcte et cohérente sur tous les nœuds. Une solution typique consiste à utiliser une description textuelle du déploiement (terraform, ansible ou quelque chose de similaire) et des fichiers de configuration générés automatiquement (souvent – ​​dédiés à chaque nœud/rôle). Nous souhaiterions également utiliser les mêmes protocoles des mêmes versions sur chaque nœud communicant (sinon nous rencontrerions des problèmes d'incompatibilité). Dans le monde JVM, cela signifie qu'au moins la bibliothèque de messagerie doit être de la même version sur tous les nœuds communicants.

Et si on testait le système ? Bien sûr, nous devrions effectuer des tests unitaires pour tous les composants avant de procéder aux tests d'intégration. Pour pouvoir extrapoler les résultats des tests au moment de l'exécution, nous devons nous assurer que les versions de toutes les bibliothèques restent identiques dans les environnements d'exécution et de test.

Lors de l'exécution de tests d'intégration, il est souvent beaucoup plus facile d'avoir le même chemin de classe sur tous les nœuds. Nous devons simplement nous assurer que le même chemin de classe est utilisé lors du déploiement. (Il est possible d'utiliser différents chemins de classe sur différents nœuds, mais il est plus difficile de représenter cette configuration et de la déployer correctement.) Donc, afin de garder les choses simples, nous ne considérerons que des chemins de classe identiques sur tous les nœuds.

La configuration a tendance à évoluer avec le logiciel. Nous utilisons généralement des versions pour identifier divers
étapes de l’évolution du logiciel. Il semble raisonnable de couvrir la configuration sous gestion des versions et d'identifier les différentes configurations avec quelques étiquettes. S'il n'y a qu'une seule configuration en production, nous pouvons utiliser une version unique comme identifiant. Parfois, nous pouvons avoir plusieurs environnements de production. Et pour chaque environnement, nous pourrions avoir besoin d'une branche de configuration distincte. Ainsi, les configurations peuvent être étiquetées avec une branche et une version pour identifier de manière unique les différentes configurations. Chaque étiquette de branche et version correspond à une combinaison unique de nœuds distribués, de ports, de ressources externes et de versions de bibliothèque de chemins de classe sur chaque nœud. Ici, nous couvrirons uniquement la branche unique et identifierons les configurations par une version décimale à trois composants (1.2.3), de la même manière que les autres artefacts.

Dans les environnements modernes, les fichiers de configuration ne sont plus modifiés manuellement. Généralement, nous générons
fichiers de configuration au moment du déploiement et ne les touche jamais après. On pourrait donc se demander pourquoi utilisons-nous encore le format texte pour les fichiers de configuration ? Une option viable consiste à placer la configuration dans une unité de compilation et à bénéficier de la validation de la configuration au moment de la compilation.

Dans cet article, nous examinerons l'idée de conserver la configuration dans l'artefact compilé.

Configuration compilable

Dans cette section, nous discuterons d'un exemple de configuration statique. Deux services simples - le service d'écho et le client du service d'écho sont en cours de configuration et d'implémentation. Ensuite, deux systèmes distribués différents avec les deux services sont instanciés. L’un est destiné à une configuration à un seul nœud et un autre à une configuration à deux nœuds.

Un système distribué typique se compose de quelques nœuds. Les nœuds pourraient être identifiés à l'aide d'un certain type :

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

ou tout simplement

case class NodeId(hostName: String)

ou encore

object Singleton
type NodeId = Singleton.type

Ces nœuds remplissent divers rôles, exécutent certains services et doivent pouvoir communiquer avec les autres nœuds au moyen de connexions TCP/HTTP.

Pour une connexion TCP, au moins un numéro de port est requis. Nous voulons également nous assurer que le client et le serveur utilisent le même protocole. Afin de modéliser une connexion entre nœuds déclarons la classe suivante :

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

De Port est juste un Int dans la plage autorisée :

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

Types raffinés

See raffiné bibliothèque. En bref, cela permet d'ajouter des contraintes de temps de compilation à d'autres types. Dans ce cas Int n'est autorisé qu'à avoir des valeurs de 16 bits pouvant représenter le numéro de port. Il n'est pas nécessaire d'utiliser cette bibliothèque pour cette approche de configuration. Cela semble très bien s'adapter.

Pour HTTP (REST), nous pourrions également avoir besoin d'un chemin du service :

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

Type fantôme

Afin d'identifier le protocole lors de la compilation, nous utilisons la fonctionnalité Scala de déclaration d'argument de type Protocol qui n'est pas utilisé en classe. C'est un soi-disant type fantôme. Au moment de l'exécution, nous avons rarement besoin d'une instance d'identifiant de protocole, c'est pourquoi nous ne la stockons pas. Lors de la compilation, ce type fantôme offre une sécurité de type supplémentaire. Nous ne pouvons pas passer le port avec un protocole incorrect.

L'un des protocoles les plus utilisés est l'API REST avec sérialisation Json :

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

De RequestMessage est le type de base de messages que le client peut envoyer au serveur et ResponseMessage est le message de réponse du serveur. Bien entendu, nous pouvons créer d’autres descriptions de protocole qui précisent le protocole de communication avec la précision souhaitée.

Pour les besoins de cet article, nous utiliserons une version plus simple du protocole :

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Dans ce protocole, le message de demande est ajouté à l'URL et le message de réponse est renvoyé sous forme de chaîne simple.

Une configuration de service peut être décrite par le nom du service, un ensemble de ports et certaines dépendances. Il existe plusieurs manières possibles de représenter tous ces éléments dans Scala (par exemple, HList, types de données algébriques). Pour les besoins de cet article, nous utiliserons Cake Pattern et représenterons les pièces combinables (modules) comme des traits. (Cake Pattern n'est pas une exigence pour cette approche de configuration compilable. Il s'agit simplement d'une implémentation possible de l'idée.)

Les dépendances peuvent être représentées en utilisant le Cake Pattern comme points de terminaison d'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)
  }

Le service Echo n’a besoin que d’un port configuré. Et nous déclarons que ce port prend en charge le protocole d'écho. Notez que nous n'avons pas besoin de spécifier un port particulier pour le moment, car les traits autorisent les déclarations de méthodes abstraites. Si nous utilisons des méthodes abstraites, le compilateur nécessitera une implémentation dans une instance de configuration. Ici, nous avons fourni l'implémentation (8081) et elle sera utilisée comme valeur par défaut si nous la sautons dans une configuration concrète.

On peut déclarer une dépendance dans la configuration du client du service echo :

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

La dépendance a le même type que le echoService. Il exige notamment le même protocole. Par conséquent, nous pouvons être sûrs que si nous connectons ces deux dépendances, elles fonctionneront correctement.

Implémentation des services

Un service a besoin d’une fonction pour démarrer et s’arrêter correctement. (La possibilité d'arrêter un service est essentielle pour les tests.) Encore une fois, il existe quelques options pour spécifier une telle fonction pour une configuration donnée (par exemple, nous pourrions utiliser des classes de types). Pour cet article, nous utiliserons à nouveau Cake Pattern. Nous pouvons représenter un service en utilisant cats.Resource qui prévoit déjà le bracketing et la libération des ressources. Afin d'acquérir une ressource, nous devons fournir une configuration et un contexte d'exécution. Ainsi, la fonction de démarrage du service pourrait ressembler à :

  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]
  }

De

  • Config — type de configuration requis par ce démarreur de service
  • AddressResolver — un objet d'exécution qui a la capacité d'obtenir les adresses réelles d'autres nœuds (continuez à lire pour plus de détails).

les autres types viennent de cats:

  • F[_] — type d'effet (Dans le cas le plus simple F[A] pourrait être juste () => A. Dans cet article, nous utiliserons cats.IO.)
  • Reader[A,B] — est plus ou moins synonyme de fonction A => B
  • cats.Resource - a des moyens d'acquérir et de libérer
  • Timer — permet de dormir/mesurer le temps
  • ContextShift - analogique de ExecutionContext
  • Applicative — wrapper de fonctions en vigueur (presque une monade) (on pourrait éventuellement le remplacer par autre chose)

En utilisant cette interface, nous pouvons implémenter quelques 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](()))
  }

(Voir Répertoire de pour d'autres implémentations de services - service d'écho,
client d'écho ainsi que le contrôleurs à vie.)

Un nœud est un objet unique qui exécute quelques services (le démarrage d'une chaîne de ressources est activé par Cake Pattern) :

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

Notez que dans le nœud, nous spécifions le type exact de configuration requis par ce nœud. Le compilateur ne nous permet pas de construire l'objet (Cake) avec un type insuffisant, car chaque trait de service déclare une contrainte sur le Config taper. De plus, nous ne pourrons pas démarrer le nœud sans fournir une configuration complète.

Résolution d'adresse de nœud

Afin d'établir une connexion, nous avons besoin d'une véritable adresse d'hôte pour chaque nœud. Il se peut que cela soit connu plus tard que d’autres parties de la configuration. Par conséquent, nous avons besoin d'un moyen de fournir un mappage entre l'identifiant du nœud et son adresse réelle. Ce mappage est une fonction :

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

Il existe plusieurs manières possibles d’implémenter une telle fonction.

  1. Si nous connaissons les adresses réelles avant le déploiement, lors de l'instanciation des hôtes de nœud, nous pouvons alors générer du code Scala avec les adresses réelles et exécuter la construction par la suite (qui effectue des vérifications au moment de la compilation, puis exécute la suite de tests d'intégration). Dans ce cas, notre fonction de mappage est connue de manière statique et peut être simplifiée en quelque chose comme un Map[NodeId, NodeAddress].
  2. Parfois, nous obtenons les adresses réelles uniquement ultérieurement, lorsque le nœud est réellement démarré, ou nous n'avons pas les adresses des nœuds qui n'ont pas encore été démarrés. Dans ce cas, nous pourrions avoir un service de découverte qui est démarré avant tous les autres nœuds et chaque nœud pourrait annoncer son adresse dans ce service et s'abonner aux dépendances.
  3. Si nous pouvons modifier /etc/hosts, nous pouvons utiliser des noms d'hôtes prédéfinis (comme my-project-main-node ainsi que le echo-backend) et associez simplement ce nom à l'adresse IP au moment du déploiement.

Dans cet article, nous n'abordons pas ces cas plus en détail. En fait, dans notre exemple de jouet, tous les nœuds auront la même adresse IP : 127.0.0.1.

Dans cet article, nous considérerons deux configurations de systèmes distribués :

  1. Disposition à nœud unique, où tous les services sont placés sur un nœud unique.
  2. Disposition à deux nœuds, où le service et le client se trouvent sur des nœuds différents.

La configuration pour un nœud unique la disposition est la suivante :

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.
}

Ici, nous créons une configuration unique qui étend à la fois la configuration du serveur et du client. Nous configurons également un contrôleur de cycle de vie qui mettra normalement fin au client et au serveur après lifetime l'intervalle passe.

Le même ensemble d'implémentations et de configurations de services peut être utilisé pour créer la disposition d'un système avec deux nœuds distincts. Il nous suffit de créer deux configurations de nœuds distinctes avec les services adaptés :

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"
  }

Voyez comment nous spécifions la dépendance. Nous mentionnons le service fourni par l'autre nœud comme dépendance du nœud actuel. Le type de dépendance est vérifié car il contient un type fantôme qui décrit le protocole. Et au moment de l'exécution, nous aurons le bon identifiant de nœud. C'est l'un des aspects importants de l'approche de configuration proposée. Cela nous offre la possibilité de définir le port une seule fois et de nous assurer que nous faisons référence au bon port.

Implémentation de deux nœuds

Pour cette configuration, nous utilisons exactement les mêmes implémentations de services. Aucun changement du tout. Cependant, nous créons deux implémentations de nœuds différentes qui contiennent un ensemble de services différent :

  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 côté serveur. Le deuxième nœud implémente le client et nécessite une autre partie de la configuration. Les deux nœuds nécessitent une spécification de durée de vie. Aux fins de ce post-service, le nœud aura une durée de vie infinie qui pourra être interrompue en utilisant SIGTERM, tandis que le client echo se terminera après la durée finie configurée. Voir le demande de démarrage pour en savoir plus.

Processus de développement global

Voyons comment cette approche change la façon dont nous travaillons avec la configuration.

La configuration sous forme de code sera compilée et produira un artefact. Il semble raisonnable de séparer les artefacts de configuration des autres artefacts de code. Souvent nous pouvons avoir une multitude de configurations sur la même base de code. Et bien sûr, nous pouvons avoir plusieurs versions de différentes branches de configuration. Dans une configuration, nous pouvons sélectionner des versions particulières de bibliothèques et cela restera constant chaque fois que nous déploierons cette configuration.

Un changement de configuration devient un changement de code. Il doit donc être couvert par le même processus d’assurance qualité :

Ticket -> PR -> révision -> fusion -> intégration continue -> déploiement continu

Cette approche a les conséquences suivantes :

  1. La configuration est cohérente pour une instance d'un système particulier. Il semble qu'il n'y ait aucun moyen d'avoir une connexion incorrecte entre les nœuds.
  2. Il n'est pas facile de modifier la configuration d'un seul nœud. Il semble déraisonnable de se connecter et de modifier certains fichiers texte. La dérive de configuration devient donc moins possible.
  3. Les petites modifications de configuration ne sont pas faciles à effectuer.
  4. La plupart des modifications de configuration suivront le même processus de développement et seront soumises à un certain examen.

Avons-nous besoin d’un référentiel séparé pour la configuration de production ? La configuration de production peut contenir des informations sensibles que nous souhaitons garder hors de portée de nombreuses personnes. Il peut donc être intéressant de conserver un référentiel séparé à accès restreint qui contiendra la configuration de production. Nous pouvons diviser la configuration en deux parties : une qui contient les paramètres de production les plus ouverts et une qui contient la partie secrète de la configuration. Cela permettrait à la plupart des développeurs d'accéder à la grande majorité des paramètres tout en restreignant l'accès aux choses vraiment sensibles. Il est facile d'y parvenir en utilisant des traits intermédiaires avec des valeurs de paramètres par défaut.

Variations

Voyons les avantages et les inconvénients de l'approche proposée par rapport aux autres techniques de gestion de configuration.

Tout d'abord, nous énumérerons quelques alternatives aux différents aspects de la manière proposée de gérer la configuration :

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

Le fichier texte offre une certaine flexibilité en termes de correctifs ad hoc. L'administrateur d'un système peut se connecter au nœud cible, apporter une modification et simplement redémarrer le service. Cela pourrait ne pas être aussi efficace pour les systèmes plus gros. Aucune trace n'est laissée derrière le changement. Le changement n’est pas examiné par une autre paire d’yeux. Il peut être difficile de découvrir la cause de ce changement. Il n'a pas été testé. Du point de vue d'un système distribué, un administrateur peut simplement oublier de mettre à jour la configuration dans l'un des autres nœuds.

(Au fait, si finalement il devient nécessaire de commencer à utiliser des fichiers de configuration texte, nous n'aurons qu'à ajouter un analyseur + un validateur qui pourrait produire le même résultat. Config tapez et cela suffirait pour commencer à utiliser des configurations de texte. Cela montre également que la complexité de la configuration au moment de la compilation est un peu plus petite que la complexité des configurations basées sur du texte, car dans la version basée sur du texte, nous avons besoin de code supplémentaire.)

Le stockage centralisé des valeurs-clés est un bon mécanisme pour distribuer les méta-paramètres d’application. Ici, nous devons réfléchir à ce que nous considérons comme des valeurs de configuration et à ce qui ne sont que des données. Étant donné une fonction C => A => B nous appelons habituellement des valeurs qui changent rarement C "configuration", alors que les données sont fréquemment modifiées A - il suffit de saisir des données. La configuration doit être fournie à la fonction avant les données A. Compte tenu de cette idée, nous pouvons dire que c'est la fréquence attendue des changements qui pourrait être utilisée pour distinguer les données de configuration des simples données. De plus, les données proviennent généralement d'une source (utilisateur) et la configuration provient d'une source différente (administrateur). La gestion de paramètres pouvant être modifiés après le processus d'initialisation entraîne une augmentation de la complexité de l'application. Pour de tels paramètres, nous devrons gérer leur mécanisme de livraison, leur analyse et leur validation, en gérant les valeurs incorrectes. Par conséquent, afin de réduire la complexité du programme, nous ferions mieux de réduire le nombre de paramètres pouvant changer au moment de l'exécution (ou même de les éliminer complètement).

Du point de vue de cet article, nous devons faire une distinction entre les paramètres statiques et dynamiques. Si la logique de service nécessite une modification rare de certains paramètres au moment de l'exécution, nous pouvons alors les appeler paramètres dynamiques. Sinon, ils sont statiques et pourraient être configurés en utilisant l'approche proposée. Pour une reconfiguration dynamique, d’autres approches pourraient être nécessaires. Par exemple, certaines parties du système peuvent être redémarrées avec les nouveaux paramètres de configuration de la même manière que pour redémarrer des processus distincts d'un système distribué.
(Mon humble opinion est d'éviter la reconfiguration du runtime car elle augmente la complexité du système.
Il pourrait être plus simple de s'appuyer uniquement sur la prise en charge du système d'exploitation pour le redémarrage des processus. Mais cela n'est pas toujours possible.)

Un aspect important de l’utilisation de la configuration statique qui incite parfois les gens à envisager une configuration dynamique (sans autre raison) est le temps d’arrêt du service lors de la mise à jour de la configuration. En effet, si l'on doit apporter des modifications à la configuration statique, il faut redémarrer le système pour que les nouvelles valeurs deviennent effectives. Les exigences en matière de temps d'arrêt varient selon les systèmes, ce n'est donc peut-être pas si critique. Si cela est critique, nous devons alors planifier à l’avance tout redémarrage du système. Par exemple, nous pourrions mettre en œuvre Drainage de la connexion AWS ELB. Dans ce scénario, chaque fois que nous devons redémarrer le système, nous démarrons une nouvelle instance du système en parallèle, puis basculons ELB dessus, tout en laissant l'ancien système terminer la maintenance des connexions existantes.

Qu’en est-il de conserver la configuration à l’intérieur de l’artefact versionné ou à l’extérieur ? Conserver la configuration à l'intérieur d'un artefact signifie dans la plupart des cas que cette configuration a passé le même processus d'assurance qualité que les autres artefacts. On peut donc être sûr que la configuration est de bonne qualité et digne de confiance. Au contraire, la configuration dans un fichier séparé signifie qu'il n'y a aucune trace de qui et pourquoi a apporté des modifications à ce fichier. Est-ce important ? Nous pensons que pour la plupart des systèmes de production, il est préférable d'avoir une configuration stable et de haute qualité.

La version de l'artefact permet de savoir quand il a été créé, quelles valeurs il contient, quelles fonctionnalités sont activées/désactivées, qui était responsable de chaque modification de la configuration. Cela peut nécessiter un certain effort pour conserver la configuration à l'intérieur d'un artefact et c'est un choix de conception à faire.

Avantages et inconvénients

Nous souhaitons ici souligner certains avantages et discuter de certains inconvénients de l’approche proposée.

Avantages

Caractéristiques de la configuration compilable d'un système distribué complet :

  1. Vérification statique de la configuration. Cela donne un niveau élevé de confiance dans le fait que la configuration est correcte compte tenu des contraintes de type.
  2. Langage de configuration riche. Généralement, d'autres approches de configuration se limitent au maximum à la substitution de variables.
    En utilisant Scala, on peut utiliser un large éventail de fonctionnalités de langage pour améliorer la configuration. Par exemple, nous pouvons utiliser des traits pour fournir des valeurs par défaut, des objets pour définir une portée différente, nous pouvons nous référer à valest défini une seule fois dans la portée externe (DRY). Il est possible d'utiliser des séquences littérales ou des instances de certaines classes (Seq, Map, Etc).
  3. DSL. Scala prend en charge de manière décente les rédacteurs DSL. On peut utiliser ces fonctionnalités pour établir un langage de configuration plus pratique et plus convivial pour l'utilisateur final, afin que la configuration finale soit au moins lisible par les utilisateurs du domaine.
  4. Intégrité et cohérence entre les nœuds. L'un des avantages d'avoir une configuration pour l'ensemble du système distribué en un seul endroit est que toutes les valeurs sont définies strictement une fois puis réutilisées partout où nous en avons besoin. Tapez également des déclarations de port sûr pour garantir que dans toutes les configurations correctes possibles, les nœuds du système parleront la même langue. Il existe des dépendances explicites entre les nœuds, ce qui rend difficile l'oubli de fournir certains services.
  5. Haute qualité des changements. L'approche globale consistant à faire passer les modifications de configuration par le processus normal de relations publiques établit des normes élevées de qualité également en matière de configuration.
  6. Modifications de configuration simultanées. Chaque fois que nous apportons des modifications à la configuration, le déploiement automatique garantit que tous les nœuds sont mis à jour.
  7. Simplification des candidatures. L'application n'a pas besoin d'analyser et de valider la configuration ni de gérer les valeurs de configuration incorrectes. Cela simplifie l’application globale. (Une certaine complexité augmente dans la configuration elle-même, mais il s'agit d'un compromis conscient en matière de sécurité.) Il est assez simple de revenir à une configuration ordinaire : il suffit d'ajouter les pièces manquantes. Il est plus facile de démarrer avec une configuration compilée et de reporter la mise en œuvre d'éléments supplémentaires à des dates ultérieures.
  8. Configuration versionnée. Étant donné que les modifications de configuration suivent le même processus de développement, nous obtenons ainsi un artefact avec une version unique. Cela nous permet de revenir en arrière sur la configuration si nécessaire. Nous pouvons même déployer une configuration utilisée il y a un an et elle fonctionnera exactement de la même manière. Une configuration stable améliore la prévisibilité et la fiabilité du système distribué. La configuration est fixée au moment de la compilation et ne peut pas être facilement falsifiée sur un système de production.
  9. Modularité. Le cadre proposé est modulaire et les modules pourraient être combinés de diverses manières pour
    prendre en charge différentes configurations (configurations/mises en page). En particulier, il est possible d'avoir une configuration à nœud unique à petite échelle et une configuration à nœuds multiples à grande échelle. Il est raisonnable d'avoir plusieurs configurations de production.
  10. Essai. À des fins de test, on peut implémenter un service fictif et l'utiliser comme dépendance de manière sécurisée. Quelques configurations de test différentes avec diverses pièces remplacées par des simulations pourraient être maintenues simultanément.
  11. Tests d'intégration. Parfois, dans les systèmes distribués, il est difficile d'exécuter des tests d'intégration. En utilisant l'approche décrite pour la configuration sécurisée du système distribué complet, nous pouvons exécuter toutes les parties distribuées sur un seul serveur de manière contrôlable. Il est facile d'imiter la situation
    lorsqu'un des services devient indisponible.

Inconvénients

L’approche de configuration compilée est différente de la configuration « normale » et peut ne pas répondre à tous les besoins. Voici quelques-uns des inconvénients de la configuration compilée :

  1. Configuration statique. Il se peut que cela ne convienne pas à toutes les applications. Dans certains cas, il est nécessaire de réparer rapidement la configuration en production en contournant toutes les mesures de sécurité. Cette approche rend les choses plus difficiles. La compilation et le redéploiement sont requis après toute modification de configuration. C'est à la fois la fonctionnalité et le fardeau.
  2. Génération de configurations. Lorsque la configuration est générée par un outil d'automatisation, cette approche nécessite une compilation ultérieure (qui peut à son tour échouer). L'intégration de cette étape supplémentaire dans le système de build peut nécessiter des efforts supplémentaires.
  3. Instruments. Il existe aujourd’hui de nombreux outils utilisés qui reposent sur des configurations basées sur du texte. Certains d'entre eux
    ne sera pas applicable une fois la configuration compilée.
  4. Un changement de mentalité est nécessaire. Les développeurs et DevOps connaissent les fichiers de configuration texte. L’idée de compiler une configuration peut leur paraître étrange.
  5. Avant d'introduire une configuration compilable, un processus de développement logiciel de haute qualité est requis.

Il y a quelques limites à l'exemple implémenté :

  1. Si nous fournissons une configuration supplémentaire qui n'est pas requise par l'implémentation du nœud, le compilateur ne nous aidera pas à détecter l'implémentation absente. Cela pourrait être résolu en utilisant HList ou ADT (classes de cas) pour la configuration des nœuds au lieu des traits et du Cake Pattern.
  2. Nous devons fournir un passe-partout dans le fichier de configuration : (package, import, object déclarations ;
    override def(pour les paramètres qui ont des valeurs par défaut). Ce problème pourrait être partiellement résolu à l’aide d’un DSL.
  3. Dans cet article, nous ne couvrons pas la reconfiguration dynamique de clusters de nœuds similaires.

Conclusion

Dans cet article, nous avons discuté de l'idée de représenter la configuration directement dans le code source de manière sécurisée. L'approche pourrait être utilisée dans de nombreuses applications en remplacement des configurations XML et autres configurations basées sur du texte. Bien que notre exemple ait été implémenté en Scala, il pourrait également être traduit dans d'autres langages compilables (comme Kotlin, C#, Swift, etc.). On pourrait essayer cette approche dans un nouveau projet et, au cas où elle ne conviendrait pas, passer à l'ancienne méthode.

Bien entendu, une configuration compilable nécessite un processus de développement de haute qualité. En retour, il promet de fournir une configuration robuste de qualité tout aussi élevée.

Cette approche pourrait être étendue de différentes manières :

  1. On pourrait utiliser des macros pour effectuer la validation de la configuration et échouer au moment de la compilation en cas d'échec des contraintes de logique métier.
  2. Un DSL pourrait être implémenté pour représenter la configuration de manière conviviale pour le domaine.
  3. Gestion dynamique des ressources avec ajustements automatiques de la configuration. Par exemple, lorsque nous ajustons le nombre de nœuds de cluster, nous pourrions souhaiter (1) que les nœuds obtiennent une configuration légèrement modifiée ; (2) gestionnaire de cluster pour recevoir les informations sur les nouveaux nœuds.

Merci

Je voudrais remercier Andrey Saksonov, Pavel Popov et Anton Nehaev pour leurs commentaires inspirants sur la version préliminaire de cet article qui m'ont aidé à le rendre plus clair.

Source: habr.com