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 directement reprĂ©sentĂ©e dans le langage Scala de maniĂšre sĂ»re en termes de type. Une mise en Ɠuvre d'exemple est dĂ©crite en dĂ©tail. Divers aspects de la proposition sont discutĂ©s, y compris l'influence sur l'ensemble du processus de dĂ©veloppement.

Configuration compilable d'un systÚme distribué

(en russe)

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 de dĂ©ploiement textuelle (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 souhaitons Ă©galement utiliser les mĂȘmes protocoles des mĂȘmes versions sur chaque nƓud communicant (sinon, nous rencontrerons 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.

Qu'en est-il des tests du systÚme ? Bien sûr, nous devrions avoir des tests unitaires pour tous les composants avant de passer aux tests d'intégration. Pour pouvoir extrapoler les résultats des tests sur 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 classpath sur tous les nƓuds. Nous devons simplement nous assurer que le mĂȘme classpath est utilisĂ© lors du dĂ©ploiement. (Il est possible d'utiliser diffĂ©rents classpaths sur diffĂ©rents nƓuds, mais il est plus difficile de reprĂ©senter cette configuration et de la dĂ©ployer correctement.) Ainsi, pour simplifier les choses, nous ne considĂ©rerons que des classpaths identiques sur tous les nƓuds.

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

Dans les environnements modernes, les fichiers de configuration ne sont plus modifiés manuellement. Typiquement, nous générons
des fichiers de configuration au moment du déploiement et nous ne les touchons ensuite. On pourrait donc se demander pourquoi nous utilisons encore un format texte pour les fichiers de configuration ? Une option viable est de placer la configuration à l'intérieur d'une unité de compilation et de bénéficier de la validation de configuration à la compilation.

Dans cet article, nous allons examiner 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 — un service d'Ă©cho et le client du service d'Ă©cho sont configurĂ©s et mis en Ɠuvre. Ensuite, deux systĂšmes distribuĂ©s diffĂ©rents avec les deux services sont instanciĂ©s. L'un est pour une configuration Ă  nƓud unique et l'autre pour une configuration Ă  deux nƓuds.

Un systĂšme distribuĂ© typique se compose de quelques nƓuds. Les nƓuds pourraient ĂȘtre identifiĂ©s en utilisant un type :

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

ou juste

case class NodeId(hostName: String)

ou mĂȘme

object Singleton
type NodeId = Singleton.type

Ces nƓuds exercent divers rĂŽles, exĂ©cutent certains services et doivent ĂȘtre capables de communiquer avec les autres nƓuds par le biais de connexions TCP/HTTP.

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

classe TcpEndPoint[Protocol](noeud: NodeId, port: Port[Protocol])

oĂč Port est juste un Int dans la plage autorisĂ©e :

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

types affinés

Voir affiné bibliothÚque. En résumé, cela permet d'ajouter des contraintes de temps de compilation à d'autres types. Dans ce cas Int n'est autorisé qu'à avoir des valeurs sur 16 bits pouvant représenter des numéros de port. Il n'est pas nécessaire d'utiliser cette bibliothÚque pour cette approche de configuration. Cela semble juste trÚs approprié.

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]]
classe PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

type fantĂŽme

Pour identifier le protocole lors de la compilation, nous utilisons la fonctionnalitĂ© Scala de dĂ©claration d'argument de type Protocole qui n'est pas utilisĂ©e dans la classe. C'est un type fantĂŽme fantĂŽme. À 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 un port avec un protocole incorrect.

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

trait scellé JsonHttpRestProtocol[RequestMessage, ResponseMessage]

oĂč RequestMessage est le type de base des messages que le client peut envoyer au serveur et ResponseMessage est le message de rĂ©ponse du serveur. Bien sĂ»r, nous pouvons crĂ©er d'autres descriptions de protocole qui spĂ©cifient le protocole de communication avec la prĂ©cision dĂ©sirĂ©e.

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

trait scellé SimpleHttpGetRest[RequestMessage, ResponseMessage]

Dans ce protocole, le message de demande est ajouté à l'url et le message de réponse est retourné sous forme de chaßne simple.

Une configuration de service pourrait ĂȘtre dĂ©crite par le nom du service, une collection de ports et certaines dĂ©pendances. Il existe quelques façons possibles de reprĂ©senter tous ces Ă©lĂ©ments en Scala (par exemple, HList, types de donnĂ©es algĂ©briques). Pour les besoins de cet article, nous utiliserons le Cake Pattern et reprĂ©senterons des piĂšces combinables (modules) comme des traits. (Le Cake Pattern n'est pas une exigence pour cette approche de configuration compilable. C'est juste une mise en Ɠuvre possible de l'idĂ©e.)

Les dĂ©pendances pourraient ĂȘ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écessite seulement un port configuré. Et nous déclarons que ce port prend en charge le protocole echo. Notez que nous n'avons pas besoin de spécifier un port particulier à ce moment, car le trait permet des déclarations de méthodes abstraites. Si nous utilisons des méthodes abstraites, le compilateur exigera 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 l'omettons dans une configuration concrÚte.

Nous pouvons 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. En particulier, elle exige 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 se fermer proprement. (La capacité de fermer 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 encore une fois le Cake Pattern. Nous pouvons représenter un service en utilisant cats.Resource qui fournit déjà un encapsulage et un relùchement des ressources. Pour 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]
  }

oĂč

  • Configuration — type de configuration requise par ce dĂ©marrage de service
  • AddressResolver — un objet d'exĂ©cution qui a la capacitĂ© d'obtenir de vraies adresses d'autres nƓuds (continuez Ă  lire pour plus de dĂ©tails).

les autres types proviennent de cats:

  • F[_] — type d'effet (dans le cas le plus simple F[A] pourrait ĂȘtre simplement () => A. Dans cet article, nous utiliserons cats.IO.)
  • Reader[A,B] — est plus ou moins un synonyme d'une fonction A => B
  • cats.Resource — a des moyens d'acquĂ©rir et de libĂ©rer
  • Timer — permet de dormir/mesurer le temps
  • ContextShift — analogue de ExecutionContext
  • Applicative — enveloppe de fonctions dans l'effet (presque un monade) (nous pourrions Ă©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  Resource.pure[F, Unit](()))
  }

(Voir Code source pour d'autres implĂ©mentations de services — service echo,
client echo and contrÎleurs de durée de vie.)

Un nƓud est un seul objet qui exĂ©cute quelques services (le dĂ©marrage d'une chaĂźne de ressources est facilitĂ© par le 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 laissera pas construire l'objet (Cake) avec un type insuffisant, car chaque trait de service dĂ©clare une contrainte sur le Configuration type. De plus, nous ne pourrons pas dĂ©marrer le nƓud sans fournir une configuration complĂšte.

RĂ©solution d'adresse du nƓud

Pour Ă©tablir une connexion, nous avons besoin d'une adresse hĂŽte rĂ©elle pour chaque nƓud. Cela peut ĂȘtre connu plus tard que d'autres parties de la configuration. Par consĂ©quent, nous avons besoin d'une façon 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 façons d'implémenter une telle fonction.

  1. Si nous connaissons les adresses rĂ©elles avant le dĂ©ploiement, pendant l'instanciation des hĂŽtes de nƓud, alors nous pouvons gĂ©nĂ©rer du code Scala avec les adresses rĂ©elles et exĂ©cuter la construction par la suite (ce qui effectue des vĂ©rifications Ă  la compilation, puis lance la suite de tests d'intĂ©gration). Dans ce cas, notre fonction de mappage est connue statiquement et peut ĂȘtre simplifiĂ©e Ă  quelque chose comme une Map[NodeId, NodeAddress].
  2. Parfois, nous n'obtenons les adresses rĂ©elles qu'Ă  un stade ultĂ©rieur 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 Ă  des dĂ©pendances.
  3. Si nous pouvons modifier /etc/hosts, nous pouvons utiliser des noms d'hÎte prédéfinis (comme my-project-main-node and echo-backend) et simplement associer ce nom avec l'adresse IP au moment du déploiement.

Dans cet article, nous ne couvrons pas ces cas plus en dĂ©tail. En fait, dans notre exemple simplifiĂ©, tous les nƓuds auront la mĂȘme adresse IP — 127.0.0.1.

Dans cet article, nous considérerons deux agencements de systÚmes distribués :

  1. Agencement Ă  nƓud unique, oĂč tous les services sont placĂ©s sur le mĂȘme nƓud.
  2. Agencement Ă  deux nƓuds, oĂč le service et le client se trouvent sur des nƓuds diffĂ©rents.

La configuration pour un agencement à nƓud unique est la suivante :

Configuration d'un nƓud unique

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton \/\/ identifiant du nƓud unique 
  \/\/ configuration du serveur
  type NodeId = Singleton.type
  def nodeId = Singleton

  \/** Spécification du port de service en toute sécurité de type. *\/ 
  override def portNumber: PortNumber = 8088

  \/\/ configuration du client

  \/** Nous utiliserons le service fourni par le mĂȘme hĂŽte. *\/ 
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  \/\/ configuration du contrĂŽleur de cycle de vie
  def lifetime: FiniteDuration = 10500.milliseconds \/\/ 0,5 seconde supplĂ©mentaire pour que 10 requĂȘtes soient effectuĂ©es, pas 9.
}

Ici, nous créons une configuration unique qui étend à la fois la configuration du serveur et celle du client. Nous configurons également un contrÎleur de cycle de vie qui terminera normalement le client et le serveur aprÚs la durée de vie le passage de l'intervalle.

Le mĂȘme ensemble d'implĂ©mentations de services et de configurations peut ĂȘtre utilisĂ© pour crĂ©er la disposition d'un systĂšme avec deux nƓuds distincts. Nous devons simplement crĂ©er deux configurations de nƓudes distinctes avec les services appropriĂ©s :

Configuration de 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! spécification de dépendance
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds \/\/ 0,5 seconde supplĂ©mentaire pour que 10 requĂȘtes soient effectuĂ©es, pas 9.

    def testMessage: String = "dolly"
  }

Voyez comment nous spĂ©cifions la dĂ©pendance. Nous mentionnons le service fourni par l'autre nƓud comme une 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 Ă  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 donne la possibilitĂ© de dĂ©finir le port une seule fois et de nous assurer que nous rĂ©fĂ©rencions le bon port.

ImplĂ©mentation de deux nƓuds

Pour cette configuration, nous utilisons exactement les mĂȘmes implĂ©mentations de services. Pas de modifications du tout. Cependant, nous crĂ©ons deux implĂ©mentations de nƓud diffĂ©rentes qui contiennent un ensemble diffĂ©rent 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 il a seulement besoin de la 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. Pour les besoins de cet article, le nƓud de service aura une durĂ©e de vie infinie qui pourrait ĂȘtre terminĂ©e en utilisant SIGTERM, tandis que le client echo se terminera aprĂšs la durĂ©e finie configurĂ©e. Voir le l'application de dĂ©marrage pour plus de dĂ©tails.

Processus de développement global

Voyons comment cette approche modifie notre façon de travailler avec la configuration.

La configuration en tant que code sera compilĂ©e et produira un artefact. Il semble raisonnable de sĂ©parer l'artefact de configuration d'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Ă©ployons 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 -> examen -> fusion -> intégration continue -> déploiement continu

Les conséquences de cette approche sont les suivantes :

  1. La configuration est cohĂ©rente pour une instance particuliĂšre du systĂšme. Il semble qu'il n'y ait pas de moyen d'avoir une connexion incorrecte entre les nƓuds.
  2. Il n'est pas facile de changer la configuration d'un seul nƓud. Il semble dĂ©raisonnable de se connecter et de modifier des fichiers texte. Ainsi, la dĂ©rive de configuration devient moins possible.
  3. Les petites modifications de configuration ne sont pas faciles à réaliser.
  4. La plupart des modifications de configuration suivront le mĂȘme processus de dĂ©veloppement et passeront par un examen.

Avons-nous besoin d'un dĂ©pĂŽt 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 pourrait donc ĂȘtre judicieux de conserver un dĂ©pĂŽt sĂ©parĂ© avec un accĂšs restreint qui contiendra la configuration de production. Nous pouvons diviser la configuration en deux parties : celle qui contient la plupart des paramĂštres ouverts de production et celle qui contient la partie secrĂšte de la configuration. Cela permettrait d'accorder l'accĂšs Ă  la majoritĂ© des paramĂštres Ă  la plupart des dĂ©veloppeurs tout en restreignant l'accĂšs Ă  des choses vraiment sensibles. Il est facile d'accomplir cela en utilisant des traits intermĂ©diaires avec des valeurs par dĂ©faut pour les paramĂštres.

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 allons énumérer quelques alternatives aux différents aspects de la façon proposée de traiter la configuration :

  1. Fichier texte sur la machine cible.
  2. Stockage centralisé de clés-valeurs (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 de l'artéfact et du contrÎle de version.

Le fichier texte offre une certaine flexibilitĂ© en termes de correctifs ad hoc. Un administrateur systĂšme peut se connecter au nƓud cible, effectuer une modification et simplement redĂ©marrer le service. Cela peut ne pas ĂȘtre aussi bon pour des systĂšmes plus vastes. Aucune trace n'est laissĂ©e de la modification. Le changement n'est pas revu par une autre paire d'yeux. Il peut ĂȘtre difficile de dĂ©couvrir ce qui a causĂ© le 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 y a un besoin de commencer Ă  utiliser des fichiers de configuration texte, il nous suffira d'ajouter un analyseur + un validateur qui pourrait produire le mĂȘme Configuration type et ce serait suffisant pour commencer Ă  utiliser des configurations texte. Cela montre Ă©galement que la complexitĂ© de la configuration Ă  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Ă© de clĂ©s-valeurs est un bon mĂ©canisme pour la distribution des 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 n'est que des donnĂ©es. Étant donnĂ© une fonction C => A => B nous appelons gĂ©nĂ©ralement des valeurs rarement changĂ©es C «configuration», tandis que les donnĂ©es frĂ©quemment changĂ©es A ne sont que des donnĂ©es. La configuration doit ĂȘtre fournie Ă  la fonction avant les donnĂ©es. A. Étant donnĂ© 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 seule source (utilisateur) et la configuration provient d'une source diffĂ©rente (administrateur). Traiter des paramĂštres qui peuvent ĂȘtre modifiĂ©s aprĂšs l'initialisation du processus 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, ainsi que la gestion des valeurs incorrectes. Par consĂ©quent, afin de rĂ©duire la complexitĂ© du programme, il vaut mieux rĂ©duire le nombre de paramĂštres susceptibles de changer en cours d'exĂ©cution (ou mĂȘme les Ă©liminer complĂštement).

D'un point de vue de ce post, nous devons faire une distinction entre les paramĂštres statiques et dynamiques. Si la logique de service nĂ©cessite un changement rare de certains paramĂštres Ă  l'exĂ©cution, nous pouvons les appeler paramĂštres dynamiques. Sinon, ce sont des paramĂštres statiques qui peuvent ĂȘtre configurĂ©s selon l'approche proposĂ©e. D'autres approches pourraient ĂȘtre nĂ©cessaires pour la reconfiguration dynamique. Par exemple, certaines parties du systĂšme pourraient ĂȘtre redĂ©marrĂ©es avec les nouveaux paramĂštres de configuration d'une maniĂšre similaire au redĂ©marrage de processus sĂ©parĂ©s d'un systĂšme distribuĂ©.
(À mon humble avis, il vaut mieux Ă©viter la reconfiguration Ă  l'exĂ©cution car cela augmente la complexitĂ© du systĂšme.
Il pourrait ĂȘtre plus simple de compter uniquement sur le support du systĂšme d'exploitation pour redĂ©marrer les processus. Toutefois, cela pourrait ne pas toujours ĂȘtre possible.)

Un aspect important de l'utilisation de la configuration statique qui fait parfois rĂ©flĂ©chir les gens Ă  la configuration dynamique (sans d'autres raisons) est le temps d'arrĂȘt du service pendant la mise Ă  jour de la configuration. En effet, si nous devons apporter des modifications Ă  la configuration statique, nous devons redĂ©marrer le systĂšme pour que les nouvelles valeurs prennent effet. Les exigences en matiĂšre de temps d'arrĂȘt varient selon les systĂšmes, donc cela ne peut pas ĂȘtre si critique. Si c'est critique, alors nous devons planifier Ă  l'avance pour tout redĂ©marrage du systĂšme. Par exemple, nous pourrions mettre en Ɠuvre le drainage de 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 nous passons l'ELB Ă  cette instance, tout en laissant l'ancien systĂšme terminer la gestion des connexions existantes.

Qu'en est-il de garder la configuration Ă  l'intĂ©rieur d'un artefact versionnĂ© ou Ă  l'extĂ©rieur ? Garder 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. Ainsi, on peut ĂȘtre certain que la configuration est de bonne qualitĂ© et digne de confiance. En revanche, une configuration dans un fichier sĂ©parĂ© signifie qu'il n'y a aucune trace de qui et pourquoi des modifications ont Ă©tĂ© apportĂ©es Ă  ce fichier. Est-ce important ? Nous croyons 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 demander un certain effort pour garder la configuration à l'intérieur d'un artefact et c'est un choix de conception à faire.

Avantages et inconvénients

Ici, nous souhaitons 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 de confiance élevé, que la configuration est correcte compte tenu des contraintes de type.
  2. Langage riche de configuration. En général, d'autres approches de configuration sont limitées à au maximum la substitution de variables.
    En utilisant Scala, on peut utiliser une vaste gamme de fonctionnalités du 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 différents portées, nous pouvons nous référer à vals définis uniquement une fois dans la portée extérieure (DRY). Il est possible d'utiliser des séquences littérales ou des instances de certaines classes (Seq, Carte, etc.).
  3. DSL. Scala a un bon support pour les écrivains de DSL. On peut utiliser ces caractéristiques pour établir un langage de configuration qui est plus pratique et convivial pour l'utilisateur final, de sorte que la configuration finale soit au moins lisible par les utilisateurs du domaine.
  4. IntĂ©gritĂ© et cohĂ©rence Ă  travers les nƓuds. Un des avantages d'avoir la configuration pour l'ensemble du systĂšme distribuĂ© au mĂȘme endroit est que toutes les valeurs sont dĂ©finies strictement une fois et puis rĂ©utilisĂ©es dans tous les endroits oĂč nous en avons besoin. De plus, les dĂ©clarations de ports typĂ©es garantissent que dans toutes les configurations correctes possibles, les nƓuds du systĂšme parleront le mĂȘme langage. Il y a des dĂ©pendances explicites entre les nƓuds, ce qui rend difficile d'oublier de fournir certains services.
  5. Haute qualité des changements. L'approche générale consistant à faire passer les changements de configuration par le processus normal de PR établit des normes élevées de qualité également dans la configuration.
  6. Modifications de configuration simultanĂ©es. Chaque fois que nous effectuons des modifications dans la configuration, le dĂ©ploiement automatique garantit que tous les nƓuds sont mis Ă  jour.
  7. Simplification de l’application. L’application n’a pas besoin de parser et de valider la configuration et de gĂ©rer les valeurs de configuration incorrectes. Cela simplifie l’application dans son ensemble. (Certaines complexitĂ©s augmentent dans la configuration elle-mĂȘme, mais c’est un choix conscient en faveur de la sĂ©curitĂ©.) Il est assez simple de revenir Ă  une configuration ordinaire — il suffit d’ajouter les Ă©lĂ©ments manquants. Il est plus facile de commencer avec une configuration compilĂ©e et de reporter la mise en Ɠuvre d'Ă©lĂ©ments supplĂ©mentaires Ă  un moment ultĂ©rieur.
  8. Configuration versionnĂ©e. Étant donnĂ© que les modifications de configuration suivent le mĂȘme processus de dĂ©veloppement, nous obtenons un artefact avec une version unique. Cela nous permet de revenir Ă  une configuration antĂ©rieure si nĂ©cessaire. Nous pouvons mĂȘme dĂ©ployer une configuration qui a Ă©tĂ© 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 manipulĂ©e sur un systĂšme de production.
  9. ModularitĂ©. Le cadre proposĂ© est modulaire et les modules peuvent ĂȘtre combinĂ©s de diffĂ©rentes maniĂšres pour
    supporter diffĂ©rentes configurations (installations / mises en page). En particulier, il est possible d'avoir une installation Ă  nƓud unique Ă  petite Ă©chelle et un paramĂ©trage multi-nƓuds Ă  grande Ă©chelle. Il est raisonnable d’avoir plusieurs mises en production.
  10. Tests. Pour des besoins de test, on peut implĂ©menter un service simulĂ© et l’utiliser comme dĂ©pendance de maniĂšre typĂ©e. DiffĂ©rents environnements de test avec diverses parties remplacĂ©es par des simulations pourraient ĂȘtre maintenus simultanĂ©ment.
  11. Tests d’intĂ©gration. Il est parfois difficile d’exĂ©cuter des tests d’intĂ©gration dans des systĂšmes distribuĂ©s. En utilisant l’approche dĂ©crite pour une configuration de systĂšme distribuĂ© complĂštement typĂ©e, nous pouvons exĂ©cuter toutes les parties distribuĂ©es sur un seul serveur de maniĂšre contrĂŽlable. Il est facile d’émuler la situation
    lorsqu’un des services devient indisponible.

Inconvénients

L’approche de configuration compilĂ©e est diffĂ©rente de la configuration « normale » et elle pourrait ne pas convenir Ă  tous les besoins. Voici quelques-uns des inconvĂ©nients de la configuration compilĂ©e :

  1. Configuration statique. Elle pourrait ne pas convenir à toutes les applications. Dans certains cas, il est nécessaire de corriger rapidement la configuration en production en contournant toutes les mesures de sécurité. Cette approche rend cela plus difficile. La compilation et le redéploiement sont nécessaires aprÚs avoir effectué une modification de la configuration. C'est à la fois une fonctionnalité et un fardeau.
  2. GĂ©nĂ©ration de configuration. Lorsque la configuration est gĂ©nĂ©rĂ©e par un outil d’automatisation, cette approche nĂ©cessite une compilation subsĂ©quente (qui pourrait Ă©chouer en retour). Cela pourrait nĂ©cessiter un effort supplĂ©mentaire pour intĂ©grer cette Ă©tape supplĂ©mentaire dans le systĂšme de construction.
  3. Instruments. Il existe de nombreux outils aujourd'hui qui reposent sur des configurations basées sur du texte. Certains d'entre eux
    ne seront pas applicables lorsque la configuration est compilée.
  4. Un changement de mentalitĂ© est nĂ©cessaire. Les dĂ©veloppeurs et les DevOps sont familiers avec les fichiers de configuration texte. L’idĂ©e de compiler la configuration pourrait leur sembler Ă©trange.
  5. Avant d’introduire une configuration compilable, un processus de dĂ©veloppement logiciel de haute qualitĂ© est requis.

Il existe certaines limitations de l’exemple implĂ©mentĂ© :

  1. Si nous fournissons une configuration supplĂ©mentaire qui n'est pas demandĂ©e par l'implĂ©mentation du nƓud, le compilateur ne pourra pas nous aider Ă  dĂ©tecter l'implĂ©mentation absente. Cela pourrait ĂȘtre rĂ©solu en utilisant HList ou des ADTs (classes de cas) pour la configuration du nƓud au lieu de traits et du Cake Pattern.
  2. Nous devons fournir un certain code standard dans le fichier de configuration : (package, importer, objet déclarations ;
    surcharge de defpour les paramĂštres qui ont des valeurs par dĂ©faut). Cela pourrait ĂȘtre en partie abordĂ© en utilisant un DSL.
  3. Dans ce post, nous ne couvrons pas la reconfiguration dynamique des 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Ă»re en termes de type. Cette approche pourrait ĂȘtre utilisĂ©e dans de nombreuses applications comme remplacement des configurations basĂ©es sur XML et d'autres formats 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, si elle ne convient pas, revenir Ă  la mĂ©thode traditionnelle.

Bien sûr, la configuration compilable nécessite un processus de développement de haute qualité. En retour, elle promet de fournir une configuration robuste tout aussi de haute qualité.

Cette approche pourrait ĂȘtre Ă©tendue de diverses maniĂšres :

  1. On pourrait utiliser des macros pour effectuer des validations de configuration et échouer à la compilation en cas de défaillances de contraintes de logique métier.
  2. Un DSL pourrait ĂȘtre mis en Ɠuvre pour reprĂ©senter la configuration de maniĂšre conviviale pour l'utilisateur du domaine.
  3. Gestion dynamique des ressources avec ajustements automatiques de configuration. Par exemple, lorsque nous ajustons le nombre de nƓuds de cluster, nous pourrions vouloir (1) que les nƓuds obtiennent une configuration lĂ©gĂšrement modifiĂ©e ; (2) que le gestionnaire de cluster reçoive des informations sur les nouveaux nƓuds.

Merci

Je voudrais remercier Andrey Saksonov, Pavel Popov, Anton Nehaev pour leurs retours inspirants sur le brouillon de cet article qui m'ont aidé à le rendre plus clair.

Source : habr.com

Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS đŸ”„ Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster