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.

()
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 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 NodeIdou juste
case class NodeId(hostName: String)ou mĂȘme
object Singleton
type NodeId = Singleton.typeCes 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 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 serviceAddressResolverâ 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 simpleF[A]pourrait ĂȘtre simplement() => A. Dans cet article, nous utiliseronscats.IO.)Reader[A,B]â est plus ou moins un synonyme d'une fonctionA => Bcats.Resourceâ a des moyens d'acquĂ©rir et de libĂ©rerTimerâ permet de dormir/mesurer le tempsContextShiftâ analogue deExecutionContextApplicativeâ 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 pour d'autres implĂ©mentations de services â ,
and .)
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.
- 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]. - 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.
- Si nous pouvons modifier
/etc/hosts, nous pouvons utiliser des noms d'hÎte prédéfinis (commemy-project-main-nodeandecho-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 :
- Agencement Ă nĆud unique, oĂč tous les services sont placĂ©s sur le mĂȘme nĆud.
- Agencement Ă deux nĆuds, oĂč le service et le client se trouvent sur des nĆuds diffĂ©rents.
La configuration pour un 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 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 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 :
- 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.
- 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.
- Les petites modifications de configuration ne sont pas faciles à réaliser.
- 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 :
- Fichier texte sur la machine cible.
- Stockage centralisé de clés-valeurs (comme
etcd/zookeeper). - Composants de sous-processus qui pourraient ĂȘtre reconfigurĂ©s/redĂ©marrĂ©s sans redĂ©marrer le processus.
- 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 . 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 :
- Vérification statique de la configuration. Cela donne un niveau de confiance élevé, que la configuration est correcte compte tenu des contraintes de type.
- 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.). - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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. - 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.
- 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 :
- 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.
- 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.
- 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. - 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.
- 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Ă© :
- 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
HListou des ADTs (classes de cas) pour la configuration du nĆud au lieu de traits et du Cake Pattern. - Nous devons fournir un certain code standard dans le fichier de configuration : (
package,importer,objetdéclarations ;
surcharge de defpour les paramĂštres qui ont des valeurs par dĂ©faut). Cela pourrait ĂȘtre en partie abordĂ© en utilisant un DSL. - 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 :
- 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.
- Un DSL pourrait ĂȘtre mis en Ćuvre pour reprĂ©senter la configuration de maniĂšre conviviale pour l'utilisateur du domaine.
- 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
