Kompilerbar konfiguration av ett distribuerat system

I det hÀr inlÀgget vill vi dela med oss av ett intressant sÀtt att hantera konfigurationen av ett distribuerat system.
Konfigurationen representeras direkt i Scala-sprÄket pÄ ett typ-sÀkert sÀtt. En exempelimplementation beskrivs i detalj. Olika aspekter av förslaget diskuteras, inklusive inverkan pÄ den övergripande utvecklingsprocessen.

Kompilerbar konfiguration av ett distribuerat system

(pÄ ryska)

Introduktion

Att bygga robusta distribuerade system krÀver anvÀndning av korrekt och sammanhÀngande konfiguration pÄ alla noder. En typisk lösning Àr att anvÀnda en textuell distributionsbeskrivning (terraform, ansible eller nÄgot liknande) och automatiskt genererade konfigurationsfiler (ofta dedikerade för varje nod/roll). Vi vill Àven anvÀnda samma protokoll av samma version pÄ varje kommunicerande nod (annars skulle vi uppleva kompatibilitetsproblem). I JVM-vÀrlden innebÀr detta att Ätminstone mÄste meddelandehanteringsbiblioteket vara av samma version pÄ alla kommunicerande noder.

Vad sÀgs om att testa systemet? SjÀlvklart bör vi ha enhetstester för alla komponenter innan vi gÄr vidare till integrationstester. För att kunna extrapolera testresultat pÄ körningstid, mÄste vi sÀkerstÀlla att versionerna av alla bibliotek hÄlls identiska i bÄde körnings- och testmiljöer.

Vid körning av integrationstester Àr det ofta mycket lÀttare att ha samma classpath pÄ alla noder. Vi mÄste bara se till att samma classpath anvÀnds vid distribution. (Det Àr möjligt att anvÀnda olika classpaths pÄ olika noder, men det Àr svÄrare att representera denna konfiguration och korrekt distribuera den.) SÄ för att hÄlla saker enkla kommer vi endast att övervÀga identiska classpaths pÄ alla noder.

Konfiguration har en tendens att utvecklas tillsammans med programvaran. Vi brukar anvÀnda versioner för att identifiera olika
faser av programvaruutvecklingen. Det verkar rimligt att tÀcka konfiguration under versionshantering och identifiera olika konfigurationer med vissa etiketter. Om det bara finns en konfiguration i produktion kan vi anvÀnda en enda version som identifierare. Ibland kan vi ha flera produktionsmiljöer. Och för varje miljö kan vi behöva en separat gren av konfiguration. SÄ konfigurationer kan mÀrkas med gren och version för att unikt identifiera olika konfigurationer. Varje grennamn och version motsvarar en enda kombination av distribuerade noder, portar, externa resurser, classpath-biblioteksversioner pÄ varje nod. HÀr kommer vi endast att tÀcka en enda gren och identifiera konfigurationer med en tre komponenters decimalversion (1.2.3), pÄ samma sÀtt som andra artefakter.

I moderna miljöer modifieras inte konfigurationsfiler manuellt lÀngre. Typiskt genererar vi
konfigurationsfiler vid distributionstid och berör dem aldrig dÀrefter. SÄ man kan frÄga sig varför vi fortfarande anvÀnder textformat för konfigurationsfiler? Ett genomförbart alternativ Àr att placera konfigurationen inuti en kompilationsenhet och dra nytta av validering av konfigurationen vid kompileringstid.

I det hÀr inlÀgget kommer vi att granska idén att hÄlla konfigurationen i den kompilerade artefakten.

Kompilerbar konfiguration

I detta avsnitt kommer vi att diskutera ett exempel pĂ„ statisk konfiguration. TvĂ„ enkla tjĂ€nster — en echo-tjĂ€nst och klienten till echo-tjĂ€nsten konfigureras och implementeras. Sedan instansieras tvĂ„ olika distribuerade system med bĂ„da tjĂ€nsterna. Ett Ă€r för en enkel nodkonfiguration och det andra för en tvĂ„noderskonfiguration.

Ett typiskt distribuerat system bestÄr av nÄgra noder. Noderna kan identifieras med nÄgon typ:

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

eller bara

case class NodeId(hostName: String)

eller till och med

object Singleton
type NodeId = Singleton.type

Dessa noder utför olika roller, kör olika tjÀnster och bör kunna kommunicera med andra noder via TCP/HTTP-anslutningar.

För TCP-anslutning krÀvs Ätminstone ett portnummer. Vi vill ocksÄ försÀkra oss om att klient och server pratar samma protokoll. För att modellera en anslutning mellan noder, lÄt oss deklarera följande klass:

fall klass TcpEndPoint[Protocol](nod: NodeId, port: Port[Protocol])

dÀr Port Àr bara en Int inom det tillÄtna intervallet:

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

Refined-typer

Se refinerad bibliotek. Kort sagt, det tillÄter att lÀgga till kompileringstidsbegrÀnsningar pÄ andra typer. I detta fall Int Àr det endast tillÄtet att ha 16-bitars vÀrden som kan representera portnummer. Det finns inget krav pÄ att anvÀnda detta bibliotek för denna konfigurationsmetod. Det verkar bara passa mycket bra.

För HTTP (REST) kan vi ocksÄ behöva en tjÀnstevÀg:

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

Phantomtyp

För att identifiera protokoll under kompileringen anvĂ€nder vi Scala-funktionen för att deklarera typargument — alla, om du anvĂ€nder HTTPS för kommunikationen mellan balansieraren och instanserna, mĂ„ste du ladda certifikatet pĂ„ dem. I det hĂ€r exemplet kommer vi inte att göra det, utan bara lĂ€mna port 80. som inte anvĂ€nds i klassen. Det Ă€r en sĂ„ kallad phantomtyp. Vid körtid behöver vi sĂ€llan en instans av protokollidentifieraren, dĂ€rför lagrar vi inte den. Under kompileringen ger denna phantomtyp ytterligare typ-sĂ€kerhet. Vi kan inte skicka port med fel protokoll.

En av de mest anvÀnda protokollen Àr REST API med Json-serialisering:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

dÀr RequestMessage Àr bas-typen av meddelanden som klienten kan skicka till servern och ResponseMessage Àr svaret frÄn servern. Naturligtvis kan vi skapa andra protokollbeskrivningar som specificerar kommunikationsprotokollet med önskad precision.

För syftena med detta inlÀgg anvÀnder vi en enklare version av protokollet:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

I detta protokoll lÀggs begÀran meddelande till url och svarmeddelandet returneras som en vanlig strÀng.

En tjÀnstekonfiguration kan beskrivas av tjÀnstnamn, en samling portar och nÄgra beroenden. Det finns flera möjliga sÀtt att representera alla dessa element i Scala (till exempel, HList, algebraiska datatyper). För syftena med detta inlÀgg anvÀnder vi Cake Pattern och representerar kombinerbara delar (moduler) som traits. (Cake Pattern Àr inte ett krav för denna kompileringbara konfigurationsmetod. Det Àr bara en möjlig implementering av idén.)

Beroenden kan representeras med Cake Pattern som slutpunkter för andra noder:

  typ 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)
  }

Ekokonfigurationen behöver bara en konfigurerad port. Och vi deklarerar att denna port stöder ekoprotokollet. Observera att vi inte behöver ange en specifik port vid detta tillfÀlle, eftersom traitens tillÄter deklarationer av abstrakta metoder. Om vi anvÀnder abstrakta metoder kommer kompilatorn att krÀva en implementering i en konfigurationsinstans. HÀr har vi tillhandahÄllit implementeringen (8081) och den kommer att anvÀndas som standardvÀrde om vi hoppar över det i en konkret konfiguration.

Vi kan deklarera ett beroende i konfigurationen av ekotjÀnstklienten:

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

Beroendet har samma typ som echoService. I synnerhet krÀver det samma protokoll. DÀrför kan vi vara sÀkra pÄ att om vi kopplar dessa tvÄ beroenden kommer de att fungera korrekt.

TjÀnsternas implementation

En tjĂ€nst behöver en funktion för att starta och stĂ€nga av pĂ„ ett ordnat sĂ€tt. (FörmĂ„gan att stĂ€nga av en tjĂ€nst Ă€r kritisk för testning.) Återigen finns det nĂ„gra alternativ för att specificera en sĂ„dan funktion för en given konfiguration (till exempel, vi skulle kunna anvĂ€nda typklasser). För detta inlĂ€gg kommer vi Ă„terigen att anvĂ€nda Cake Pattern. Vi kan representera en tjĂ€nst med cats.Resource som redan tillhandahĂ„ller inramning och resursfrisĂ€ttning. För att fĂ„ en resurs bör vi tillhandahĂ„lla en konfiguration och viss köruppstartsinformation. SĂ„ funktionen som startar tjĂ€nsten kan se ut som följer:

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

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

dÀr

  • Konfigurera — typ av konfiguration som krĂ€vs av denna tjĂ€nststartare
  • AddressResolver — ett körningsobjekt som har förmĂ„gan att erhĂ„lla riktiga adresser till andra noder (fortsĂ€tt lĂ€sa för detaljer).

de andra typerna kommer frÄn katter:

  • F[_] — effekttyp (I det enklaste fallet F[A] kan vara bara () => A. I detta inlĂ€gg kommer vi att anvĂ€nda katter.IO.)
  • Reader[A,B] — Ă€r mer eller mindre en synonym för en funktion A => B
  • cats.Resource — har sĂ€tt att erhĂ„lla och frigöra
  • Timer — gör det möjligt att sova/mĂ€ta tid
  • ContextShift — analog till ExecutionContext
  • Applicative — omslag av funktioner i effekt (nĂ€stan en monad) (vi kanske sĂ„ smĂ„ningom ersĂ€tter den med nĂ„got annat)

Genom att anvÀnda detta grÀnssnitt kan vi implementera ett par tjÀnster. Till exempel, en tjÀnst som inte gör nÄgonting:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    typ Config  Resource.pure[F, Unit](())
  }

(Se KĂ€llkod för andra tjĂ€nstimplementeringar — echo-tjĂ€nst,
echo-klient och livscykelkontroller.)

En nod Àr ett enda objekt som kör ett par tjÀnster (starta en kedja av resurser möjliggörs av Cake-mönstret):

objekt SingleNodeImpl extends ZeroServiceImpl[IO]
  med EchoServiceService
  med EchoClientService
  med FiniteDurationLifecycleServiceImpl
{
  typ Config = EchoConfig[String] med EchoClientConfig[String] med FiniteDurationLifecycleConfig
}

Observera att vi i noden specificerar den exakta typ av konfiguration som behövs av denna nod. Kompilatorn lÄter oss inte bygga objektet (Cake) med otillrÀcklig typ, eftersom varje tjÀnsttyp deklarerar en begrÀnsning pÄ den Konfigurera typ. Vi kommer inte heller kunna starta noden utan att tillhandahÄlla fullstÀndig konfiguration.

Nodens adressupplösning

För att upprÀtta en anslutning behöver vi en verklig vÀrdadress för varje nod. Den kan bli kÀnd senare Àn andra delar av konfigurationen. DÀrför behöver vi ett sÀtt att tillhandahÄlla en mappning mellan nod-id och dess faktiska adress. Denna mappning Àr en funktion:

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

Det finns nÄgra möjliga sÀtt att implementera en sÄdan funktion.

  1. Om vi vet de faktiska adresserna före distributionen, under nodvÀrdarens instansiering, dÄ kan vi generera Scala-kod med de faktiska adresserna och köra bygget dÀrefter (vilket utför kompileringstidskontroller och sedan kör integrationssviten). I detta fall Àr vÄr mappningsfunktion kÀnd statiskt och kan förenklas till nÄgot som en Map[NodeId, NodeAddress].
  2. Ibland fÄr vi de faktiska adresserna först vid ett senare tillfÀlle nÀr noden faktiskt startas, eller vi har inte adresser för noder som inte har startats Àn. I detta fall kan vi ha en upptÀcktsjÀnst som startas före alla andra noder och varje nod kan annonsera sin adress i den tjÀnsten och prenumerera pÄ beroenden.
  3. Om vi kan modifiera /etc/hosts, kan vi anvÀnda fördefinierade vÀrdnamn (som my-project-main-node och echo-backend) och koppla detta namn till ip-adressen vid distribution.

I detta inlĂ€gg tĂ€cker vi inte dessa fall mer detaljerat. I sjĂ€lva verket i vĂ„rt leksaksexempel kommer alla noder att ha samma IP-adress — 127.0.0.1.

I detta inlÀgg kommer vi att övervÀga tvÄ layouter för distribuerade system:

  1. Enkelnodlayout, dÀr alla tjÀnster placeras pÄ den enda noden.
  2. TvÄ nodlayout, dÀr tjÀnst och klient ligger pÄ olika noder.

Konfigurationen för en enkelnod layout Àr följande:

Enkelnodkonfiguration

objekt SingleNodeConfig utökar EchoConfig[String] 
  med EchoClientConfig[String] och FiniteDurationLifecycleConfig
{
  fall objekt Singleton \/\/ identifierare för den enda noden 
  \/\/ konfiguration av server
  typ NodeId = Singleton.typ
  def nodeId = Singleton

  \/** Typ-sÀker tjÀnsteport specifikation. *\/ 
  överstyr def portNumber: PortNumber = 8088

  \/\/ konfiguration av klienten

  \/** Vi kommer att anvÀnda tjÀnsten som tillhandahÄlls av samma vÀrd. *\/ 
  def echoServiceDependency = echoService

  överstyr def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  \/\/ livscykel kontroller konfiguration
  def lifetime: FiniteDuration = 10500.milliseconds \/\/ ytterligare 0.5 sekunder sÄ att det finns 10 förfrÄgningar, inte 9.
}

HÀr skapar vi en enda konfiguration som utökar bÄde server- och klientkonfigurationen. Dessutom konfigurerar vi en livscykelkontroller som normalt kommer att avsluta klient och server efter livslÀngd intervallet har passerat.

Samma uppsÀttning tjÀnsteimplementationer och konfigurationer kan anvÀndas för att skapa en systems layout med tvÄ separata noder. Vi behöver bara skapa tvÄ separata nodkonfigurationer med lÀmpliga tjÀnster:

Konfiguration för tvÄ noder

  objekt NodeServerConfig utökar EchoConfig[String] med SigTermLifecycleConfig
  {
    typ NodeId = NodeIdImpl

    def nodeId = NodeServer

    överstyr def portNumber: PortNumber = 8080
  }

  objekt NodeClientConfig utökar EchoClientConfig[String] med FiniteDurationLifecycleConfig
  {
    \/\/ NB! beroendespecifikation
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds \/\/ ytterligare 0.5 sekunder sÄ att det finns 10 förfrÄgningar, inte 9.

    def testMessage: String = "dolly"
  }

Se hur vi specificerar beroendet. Vi nÀmner den andra nodens tillhandahÄllna tjÀnst som ett beroende för den aktuella noden. Typen av beroende kontrolleras eftersom den innehÄller en fantomtyp som beskriver protokollet. Och vid körning kommer vi att ha rÀtt nod-ID. Detta Àr en av de viktiga aspekterna av den föreslagna konfigurationsmetoden. Det ger oss möjlighet att stÀlla in porten endast en gÄng och se till att vi refererar till rÀtt port.

Implementering av tvÄ noder

För denna konfiguration anvÀnder vi exakt samma tjÀntimplementationer. Inga förÀndringar alls. Men vi skapar tvÄ olika nodimplementationer som innehÄller olika uppsÀttningar av tjÀnster:

  objekt TwoJvmNodeServerImpl utökar ZeroServiceImpl[IO] med EchoServiceService och SigIntLifecycleServiceImpl {
    typ Config = EchoConfig[String] med SigTermLifecycleConfig
  }

  objekt TwoJvmNodeClientImpl utökar ZeroServiceImpl[IO] med EchoClientService och FiniteDurationLifecycleServiceImpl {
    typ Config = EchoClientConfig[String] med FiniteDurationLifecycleConfig
  }

Den första noden implementerar servern och behöver endast serverns konfiguration. Den andra noden implementerar klienten och behöver en annan del av konfigurationen. BÄda noder krÀver nÄgon livslÀngdsspecifikation. För ÀndamÄlen av detta inlÀgg kommer tjÀnsten nod att ha oÀndlig livslÀngd som kan avslutas med SIGTERM, medan echo-klienten kommer att avslutas efter den konfigurerade tidsbegrÀnsade perioden. Se startapplikationen för detaljer.

Övergripande utvecklingsprocess

LÄt oss se hur detta tillvÀgagÄngssÀtt förÀndrar vÄrt arbete med konfiguration.

Konfigurationen som kod kommer att kompileras och producera en artefakt. Det verkar rimligt att separera konfigurationens artefakt frÄn andra kodartefakter. Ofta kan vi ha en mÀngd olika konfigurationer pÄ samma kodbas. Och sjÀlvklart kan vi ha flera versioner av olika konfigurationsgrenar. I en konfiguration kan vi vÀlja specifika versioner av bibliotek och detta kommer att förbli konstant varje gÄng vi distribuerar denna konfiguration.

En konfigurationsÀndring blir en kodÀndring. SÄ den bör tÀckas av samma kvalitetssÀkringsprocess:

Biljett -> PR -> granskning -> sammanslagning -> kontinuerlig integration -> kontinuerlig distribution

Det finns följande konsekvenser av tillvÀgagÄngssÀttet:

  1. Konfigurationen Àr sammanhÀngande för en specifik systemsinstans. Det verkar inte finnas nÄgot sÀtt att ha en felaktig koppling mellan noder.
  2. Det Àr inte lÀtt att Àndra konfigurationen i bara en nod. Det verkar orimligt att logga in och Àndra nÄgra textfiler. SÄ konfigurationsavvikelser blir mindre möjliga.
  3. SmÄ konfigurationsÀndringar Àr inte lÀtta att göra.
  4. De flesta av konfigurationsÀndringarna kommer att följa samma utvecklingsprocess och kommer att passera nÄgon form av granskning.

Behöver vi ett separat arkiv för produktionskonfigurationen? Produktionskonfigurationen kan innehÄlla kÀnslig information som vi vill hÄlla utom rÀckhÄll för mÄnga personer. SÄ det kan vara vÀrt att ha ett separat arkiv med begrÀnsad Ätkomst som innehÄller produktionskonfigurationen. Vi kan dela upp konfigurationen i tvÄ delar - en som innehÄller de flesta öppna parametrarna för produktion och en som innehÄller den hemliga delen av konfigurationen. Detta skulle möjliggöra Ätkomst för de flesta utvecklare till den stora majoriteten av parametrarna samtidigt som Ätkomsten till verkligen kÀnsliga saker begrÀnsas. Det Àr lÀtt att genomföra detta med hjÀlp av mellanliggande egenskaper med standardparametervÀrden.

Variationer

LÄt oss se fördelar och nackdelar med det föreslagna tillvÀgagÄngssÀttet jÀmfört med andra tekniker för konfigurationshantering.

Först och frÀmst listar vi nÄgra alternativ till de olika aspekterna av det föreslagna sÀttet att hantera konfiguration:

  1. Textfil pÄ mÄlmaskinen.
  2. Centraliserad nyckel-vÀrde lagring (som i den motsvarande Docker-bilden/zookeeper).
  3. UndernÀtskomponenter som kan omkonfigureras/omstartas utan att processerna startas om.
  4. Konfiguration utanför artefakt och versionskontroll.

Textfil ger en viss flexibilitet nĂ€r det gĂ€ller ad-hoc-fix. En systemadministratör kan logga in pĂ„ mĂ„lnoden, göra en Ă€ndring och helt enkelt starta om tjĂ€nsten. Detta kanske inte Ă€r lika bra för större system. Inga spĂ„r lĂ€mnas efter Ă€ndringen. Ändringen granskas inte av en annan person. Det kan vara svĂ„rt att ta reda pĂ„ vad som orsakade Ă€ndringen. Den har inte testats. FrĂ„n ett distribuerat systems perspektiv kan en administratör helt enkelt glömma att uppdatera konfigurationen i en av de andra noderna.

(Förresten, om det till slut blir nödvÀndigt att börja anvÀnda textkonfigurationsfiler, behöver vi bara lÀgga till en parser + validerare som kan producera samma Konfigurera typ och det skulle vara tillrÀckligt för att börja anvÀnda textkonfigurationer. Detta visar ocksÄ att komplexiteten i konfiguration vid kompilering Àr nÄgot mindre Àn komplexiteten hos textbaserade konfigurationer, eftersom vi i textversionen behöver viss extra kod.)

Centraliserad nyckel-vĂ€rde lagring Ă€r en bra mekanism för att distribuera applikationens meta-parametrar. HĂ€r behöver vi tĂ€nka pĂ„ vad vi betraktar som konfigurationsvĂ€rden och vad som bara Ă€r data. Givet en funktion C => A => B kallar vi normalt sĂ€llan förĂ€nderliga vĂ€rden C — «konfiguration», medan ofta förĂ€nderliga data A — bara indata. Konfiguration bör ges till funktionen tidigare Ă€n datan. A. Givet denna idĂ© kan vi sĂ€ga att det Ă€r den förvĂ€ntade frekvensen av förĂ€ndringar som kan anvĂ€ndas för att sĂ€rskilja konfigurationsdata frĂ„n bara data. Dessutom kommer data typiskt frĂ„n en kĂ€lla (anvĂ€ndare) och konfiguration frĂ„n en annan kĂ€lla (administratör). Att hantera parametrar som kan Ă€ndras efter processens initialisering leder till en ökning av applikationens komplexitet. För sĂ„dana parametrar mĂ„ste vi hantera deras leveransmekanism, parsa och validera, samt hantera felaktiga vĂ€rden. DĂ€rför, för att minska programkomplexiteten, Ă€r det bĂ€ttre att minska antalet parametrar som kan Ă€ndras vid körning (eller till och med eliminera dem helt).

From the perspective of this post we should make a distinction between static and dynamic parameters. If service logic requires rare change of some parameters at runtime, then we may call them dynamic parameters. Otherwise they are static and could be configured using the proposed approach. For dynamic reconfiguration other approaches might be needed. For example, parts of the system might be restarted with the new configuration parameters in a similar way to restarting separate processes of a distributed system.
(My humble opinion is to avoid runtime reconfiguration because it increases complexity of the system.
It’ might be more straightforward to just rely on OS support of restarting processes. Though, it might not always be possible.)

One important aspect of using static configuration that sometimes makes people consider dynamic configuration (without other reasons) is service downtime during configuration update. Indeed, if we have to make changes to static configuration, we have to restart the system so that new values become effective. The requirements for downtime vary for different systems, so it might not be that critical. If it is critical, then we have to plan ahead for any system restarts. For instance, we could implement AWS ELB connection draining. In this scenario whenever we need to restart the system, we start a new instance of the system in parallel, then switch ELB to it, while letting the old system to complete servicing existing connections.

What about keeping configuration inside versioned artifact or outside? Keeping configuration inside an artifact means in most of the cases that this configuration has passed the same quality assurance process as other artifacts. So one might be sure that the configuration is of good quality and trustworthy. On the contrary configuration in a separate file means that there are no traces of who and why made changes to that file. Is this important? We believe that for most production systems it’s better to have stable and high quality configuration.

Version of the artifact allows to find out when it was created, what values it contains, what features are enabled/disabled, who was responsible for making each change in the configuration. It might require some effort to keep configuration inside an artifact and it’s a design choice to make.

Pros & cons

Here we would like to highlight some advantages and to discuss some disadvantages of the proposed approach.

Advantages

Features of the compilable configuration of a complete distributed system:

  1. Static check of configuration. This gives a high level of confidence, that the configuration is correct given type constraints.
  2. Rich language of configuration. Typically other configuration approaches are limited to at most variable substitution.
    Using Scala one can use wide range of language features to make configuration better. For instance, we can use traits to provide default values, objects to set different scope, we can refer to vals defined only once in the outer scope (DRY). It’s possible to use literal sequences, or instances of certain classes (Seq, Karta, etc.).
  3. DSL. Scala has decent support for DSL writers. One can use these features to establish a configuration language that is more convenient and end-user friendly, so that the final configuration is at least readable by domain users.
  4. Integrity and coherence across nodes. One of the benefits of having configuration for the whole distributed system in one place is that all values are defined strictly once and then reused in all places where we need them. Also type safe port declarations ensures that in all possible correct configurations the system’s nodes will speak the same language. There are explicit dependencies between nodes which makes it hard to forget to provide some services.
  5. High quality of changes. The overall approach of passing configuration changes through normal PR process establishes high standards of quality also in configuration.
  6. Samtidig konfigurationsÀndringar. NÀr vi gör nÄgra Àndringar i konfigurationen sÀkerstÀller automatisk distribution att alla noder uppdateras.
  7. Förenkling av applikationen. Applikationen behöver inte parsa och validera konfigurationen och hantera felaktiga konfigurationsvĂ€rden. Detta förenklar den övergripande applikationen. (Viss ökning av komplexiteten finns i sjĂ€lva konfigurationen, men det Ă€r en medveten avvĂ€gning för sĂ€kerhet.) Det Ă€r ganska enkelt att Ă„tergĂ„ till vanlig konfiguration — bara lĂ€gga till de saknade delarna. Det Ă€r lĂ€ttare att börja med komplierad konfiguration och skjuta upp implementeringen av ytterligare delar till senare.
  8. Versionerad konfiguration. Eftersom konfigurationsÀndringar följer samma utvecklingsprocess fÄr vi ett artefakt med unik version. Detta gör att vi kan ÄtergÄ till en tidigare konfiguration vid behov. Vi kan till och med distribuera en konfiguration som anvÀndes för ett Är sedan och den kommer att fungera exakt likadant. Stabil konfiguration förbÀttrar förutsÀgbarheten och tillförlitligheten i det distribuerade systemet. Konfigurationen Àr fast vid kompileringstid och kan inte enkelt manipuleras pÄ ett produktionssystem.
  9. Modularitet. Den föreslagna ramverkslösningen Àr modulÀr och moduler kan kombineras pÄ olika sÀtt för
    att stödja olika konfigurationer (uppsÀttningar/layouter). I synnerhet Àr det möjligt att ha en liten enkel nodlayout och en stor flernodig instÀllning. Det Àr rimligt att ha flera produktionslayouter.
  10. Testning. För testÀndamÄl kan man implementera en mocktjÀnst och anvÀnda den som en beroende pÄ ett typsÀkert sÀtt. Flera olika testlayouter med olika delar ersatta av mocks kan upprÀtthÄllas samtidigt.
  11. Integreringstestning. Ibland kan det vara svÄrt att köra integrationstester i distribuerade system. Genom att anvÀnda den beskrivna metoden för typsÀker konfiguration av det kompletta distribuerade systemet kan vi köra alla distribuerade delar pÄ en enda server pÄ ett kontrollerbart sÀtt. Det Àr enkelt att efterlikna situationen
    nÀr en av tjÀnsterna blir otillgÀnglig.

Nackdelar

Den komplierade konfigurationsmetodiken skiljer sig frÄn "normal" konfiguration och den kanske inte passar alla behov. HÀr Àr nÄgra av nackdelarna med den komplierade konfigurationen:

  1. Statisk konfiguration. Den kanske inte Àr lÀmplig för alla applikationer. I vissa fall finns det ett behov av att snabbt fixa konfigurationen i produktion utan att följa sÀkerhetsÄtgÀrderna. Denna metod gör det mer komplicerat. Kompilering och omdistribution krÀvs efter varje förÀndring i konfigurationen. Detta Àr bÄde en funktion och en börda.
  2. Konfigurationsgenerering. NÀr konfigurationen genereras av ett automatiseringsverktyg krÀver denna metod efterföljande kompilering (som i sin tur kan misslyckas). Det kan krÀva ytterligare arbete för att integrera detta extra steg i byggsystemet.
  3. Verktyg. Det finns mÄnga verktyg som anvÀnds idag som förlitar sig pÄ textbaserade konfigurationer. Vissa av dem
    kommer inte att vara tillÀmpliga nÀr konfigurationen Àr komplierad.
  4. Ett tankesÀtt behöver förÀndras. Utvecklare och DevOps Àr vana vid textkonfigurationsfiler. Idén om att kompilera konfiguration kan verka mÀrklig för dem.
  5. Innan kompilering av konfiguration införs krÀvs en högkvalitativ mjukvaruutvecklingsprocess.

Det finns vissa begrÀnsningar i det implementerade exemplet:

  1. Om vi tillhandahÄller extra konfiguration som inte efterfrÄgas av nodimplementeringen, hjÀlper inte kompilatorn oss att upptÀcka den frÄnvarande implementeringen. Detta kan ÄtgÀrdas genom att anvÀnda HList eller ADTs (fallklasser) för nodkonfiguration istÀllet för traits och Cake-mönster.
  2. Vi mÄste tillhandahÄlla viss mall i konfigurationsfilen: (paket, import, object deklarationer;
    override def‘s för parametrar som har standardvĂ€rden). Detta kan delvis Ă„tgĂ€rdas med en DSL.
  3. I detta inlÀgg tÀcker vi inte dynamisk omkonfiguration av kluster av liknande noder.

Slutsats

I det hÀr inlÀgget har vi diskuterat idén att representera konfiguration direkt i kÀllkoden pÄ ett typ-sÀkert sÀtt. Denna metod kan anvÀndas i mÄnga applikationer som en ersÀttning för XML- och andra textbaserade konfigurationer. Trots att vÄrt exempel har implementerats i Scala kan det Àven översÀttas till andra kompilerbara sprÄk (som Kotlin, C#, Swift, etc.). Man kan prova denna metod i ett nytt projekt och, om den inte passar bra, byta till det gamla sÀttet.

SjÀlvklart krÀver kompilerbar konfiguration en utvecklingsprocess av hög kvalitet. I utbyte lovar den att ge en lika högkvalitativ och robust konfiguration.

Denna metod kan utvidgas pÄ olika sÀtt:

  1. Man kan anvÀnda makron för att utföra konfigurationsvalidering och misslyckas vid kompileringstid vid eventuella misslyckanden med affÀrslogikens begrÀnsningar.
  2. En DSL kan implementeras för att representera konfiguration pÄ ett domÀnanvÀndarvÀnligt sÀtt.
  3. Dynamisk resursförvaltning med automatiska justeringar av konfigurationen. Till exempel, nÀr vi justerar antalet klusternoder kan vi vilja (1) att noderna fÄr en nÄgot modifierad konfiguration; (2) att klusterchefen fÄr information om de nya noderna.

Tack

Jag vill tacka Andrey Saksonov, Pavel Popov, Anton Nehaev för deras inspirerande feedback pÄ utkastet till detta inlÀgg som hjÀlpte mig att göra det tydligare.

KĂ€lla: habr.com

Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar đŸ”„ Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster