Kompilovatelná konfigurace distribuovaného systému

V tomto příspěvku bychom se rádi podělili o zajímavý způsob, jak se vypořádat s konfigurací distribuovaného systému.
Konfigurace je reprezentována přímo v jazyce Scala typově bezpečným způsobem. Příklad implementace je podrobně popsán. Diskutovány jsou různé aspekty návrhu, včetně vlivu na celkový proces vývoje.

Kompilovatelná konfigurace distribuovaného systému

(v ruštině)

Úvod

Budování robustních distribuovaných systémů vyžaduje použití správné a koherentní konfigurace na všech uzlech. Typickým řešením je použít textový popis nasazení (terraform, ansible nebo něco podobného) a automaticky generované konfigurační soubory (často – vyhrazené pro každý uzel/role). Také bychom chtěli používat stejné protokoly stejných verzí na všech komunikujících uzlech (jinak bychom měli problémy s nekompatibilitou). Ve světě JVM to znamená, že alespoň knihovna zpráv by měla mít stejnou verzi na všech komunikujících uzlech.

A co testování systému? Před integračními testy bychom samozřejmě měli mít testy jednotek pro všechny komponenty. Abychom mohli extrapolovat výsledky testů na běhové prostředí, měli bychom se ujistit, že verze všech knihoven jsou identické v běhovém i testovacím prostředí.

Při spouštění integračních testů je často mnohem jednodušší mít na všech uzlech stejnou cestu ke třídě. Musíme se jen ujistit, že při nasazení je použita stejná cesta třídy. (Je možné použít různé cesty ke třídám na různých uzlech, ale je obtížnější tuto konfiguraci znázornit a správně ji nasadit.) Abychom to zjednodušili, budeme uvažovat pouze stejné cesty tříd na všech uzlech.

Konfigurace má tendenci se vyvíjet společně se softwarem. Obvykle používáme verze k identifikaci různých
etapy vývoje softwaru. Zdá se rozumné pokrýt konfiguraci pod správou verzí a identifikovat různé konfigurace pomocí některých štítků. Pokud je ve výrobě pouze jedna konfigurace, můžeme jako identifikátor použít jednu verzi. Někdy můžeme mít více produkčních prostředí. A pro každé prostředí můžeme potřebovat samostatnou větev konfigurace. Konfigurace tedy mohou být označeny větví a verzí, aby bylo možné jednoznačně identifikovat různé konfigurace. Každé označení větve a verze odpovídá jediné kombinaci distribuovaných uzlů, portů, externích zdrojů, verzí knihovny classpath na každém uzlu. Zde pokryjeme pouze jednu větev a identifikujeme konfigurace pomocí třísložkové desítkové verze (1.2.3), stejně jako ostatní artefakty.

V moderních prostředích již nejsou konfigurační soubory upravovány ručně. Obvykle generujeme
konfigurační soubory v době nasazení a nikdy se jich nedotýkej později. Někdo by se tedy mohl ptát, proč stále používáme textový formát pro konfigurační soubory? Schůdnou možností je umístit konfiguraci do kompilační jednotky a těžit z ověřování konfigurace během kompilace.

V tomto příspěvku prozkoumáme myšlenku zachování konfigurace v kompilovaném artefaktu.

Kompilovatelná konfigurace

V této části probereme příklad statické konfigurace. Konfigurují se a implementují dvě jednoduché služby - služba echo a klient služby echo. Poté se vytvoří instance dvou různých distribuovaných systémů s oběma službami. Jeden je pro konfiguraci jednoho uzlu a druhý pro konfiguraci dvou uzlů.

Typický distribuovaný systém se skládá z několika uzlů. Uzly lze identifikovat pomocí některého typu:

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

nebo prostě

case class NodeId(hostName: String)

nebo dokonce

object Singleton
type NodeId = Singleton.type

Tyto uzly plní různé role, spouštějí některé služby a měly by být schopny komunikovat s ostatními uzly pomocí připojení TCP/HTTP.

Pro připojení TCP je vyžadováno alespoň číslo portu. Chceme se také ujistit, že klient a server mluví stejným protokolem. Abychom mohli modelovat spojení mezi uzly, deklarujme následující třídu:

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

kde Port je jen Int v povoleném rozsahu:

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

Rafinované typy

See rafinovaný knihovna. Stručně řečeno, umožňuje přidat omezení času kompilace do jiných typů. V tomto případě Int je povoleno mít pouze 16bitové hodnoty, které mohou představovat číslo portu. Pro tento konfigurační přístup není vyžadováno použití této knihovny. Zdá se, že to velmi dobře sedí.

Pro HTTP (REST) ​​můžeme také potřebovat cestu ke službě:

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

Fantomový typ

Abychom identifikovali protokol během kompilace, používáme funkci Scala deklarování argumentu typu Protocol který se ve třídě nepoužívá. Jde o tzv fantomový typ. Za běhu zřídka potřebujeme instanci identifikátoru protokolu, proto ji neukládáme. Během kompilace tento fantomový typ poskytuje další typovou bezpečnost. Nemůžeme předat port s nesprávným protokolem.

Jedním z nejpoužívanějších protokolů je REST API se serializací Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

kde RequestMessage je základní typ zpráv, které může klient odesílat na server a ResponseMessage je odpověď ze serveru. Samozřejmě můžeme vytvořit další popisy protokolů, které specifikují komunikační protokol s požadovanou přesností.

Pro účely tohoto příspěvku použijeme jednodušší verzi protokolu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

V tomto protokolu je zpráva požadavku připojena k adrese URL a zpráva odpovědi je vrácena jako prostý řetězec.

Konfigurace služby může být popsána názvem služby, kolekcí portů a některými závislostmi. Existuje několik možných způsobů, jak reprezentovat všechny tyto prvky ve Scale (např. HList, algebraické datové typy). Pro účely tohoto příspěvku budeme používat Cake Pattern a reprezentovat kombinovatelné kusy (moduly) jako vlastnosti. (Cake Pattern není podmínkou pro tento kompilovatelný konfigurační přístup. Je to jen jedna z možných implementací myšlenky.)

Závislosti lze reprezentovat pomocí vzoru koláče jako koncových bodů jiných uzlů:

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

Služba Echo potřebuje pouze nakonfigurovaný port. A prohlašujeme, že tento port podporuje protokol echo. Všimněte si, že v tuto chvíli nemusíme specifikovat konkrétní port, protože vlastnost umožňuje deklarace abstraktních metod. Pokud použijeme abstraktní metody, bude kompilátor vyžadovat implementaci v konfigurační instanci. Zde jsme poskytli implementaci (8081) a bude použita jako výchozí hodnota, pokud ji v konkrétní konfiguraci přeskočíme.

V konfiguraci klienta služby echo můžeme deklarovat závislost:

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

Závislost má stejný typ jako echoService. Zejména vyžaduje stejný protokol. Můžeme si tedy být jisti, že pokud tyto dvě závislosti propojíme, budou fungovat správně.

Implementace služeb

Služba potřebuje funkci ke spuštění a řádnému vypnutí. (Schopnost vypnout službu je pro testování kritická.) Opět existuje několik možností, jak takovou funkci specifikovat pro danou konfiguraci (například bychom mohli použít typové třídy). Pro tento příspěvek znovu použijeme Cake Pattern. Můžeme reprezentovat službu pomocí cats.Resource který již poskytuje bracketing a uvolnění zdrojů. Abychom získali zdroj, měli bychom poskytnout konfiguraci a nějaký kontext běhu. Funkce spuštění služby tedy může vypadat takto:

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

kde

  • Config — typ konfigurace, kterou tento startér služby vyžaduje
  • AddressResolver — runtime objekt, který má schopnost získat skutečné adresy jiných uzlů (podrobnosti čtěte dále).

ostatní typy pocházejí cats:

  • F[_] — typ efektu (v nejjednodušším případě F[A] mohl být spravedlivý () => A. V tomto příspěvku použijeme cats.IO.)
  • Reader[A,B] — je víceméně synonymem pro funkci A => B
  • cats.Resource — má způsoby, jak získat a uvolnit
  • Timer — umožňuje spát/měřit čas
  • ContextShift - analog ExecutionContext
  • Applicative — obal funkcí ve skutečnosti (téměř monáda) (můžeme to nakonec nahradit něčím jiným)

Pomocí tohoto rozhraní můžeme implementovat několik služeb. Například služba, která nic nedělá:

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

(Viz Zdrojový kód pro implementace dalších služeb — echo služba,
echo klient a doživotní ovladače.)

Uzel je jeden objekt, který spouští několik služeb (spuštění řetězce zdrojů je umožněno pomocí Cake Pattern):

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

Všimněte si, že v uzlu specifikujeme přesný typ konfigurace, který tento uzel potřebuje. Kompilátor nám nedovolí sestavit objekt (Cake) s nedostatečným typem, protože každá vlastnost služby deklaruje omezení na Config typ. Bez poskytnutí kompletní konfigurace také nebudeme moci spustit uzel.

Rozlišení adresy uzlu

Abychom mohli navázat spojení, potřebujeme skutečnou adresu hostitele pro každý uzel. Může to být známo později než jiné části konfigurace. Proto potřebujeme způsob, jak dodat mapování mezi ID uzlu a jeho skutečnou adresou. Toto mapování je funkce:

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

Existuje několik možných způsobů, jak takovou funkci implementovat.

  1. Pokud známe skutečné adresy před nasazením, během konkretizace hostitelů uzlů, můžeme vygenerovat kód Scala se skutečnými adresami a následně spustit sestavení (což provede kontroly doby kompilace a poté spustí sadu testů integrace). V tomto případě je naše mapovací funkce známá staticky a lze ji zjednodušit na něco jako a Map[NodeId, NodeAddress].
  2. Někdy získáme skutečné adresy až později, když je uzel skutečně spuštěn, nebo nemáme adresy uzlů, které ještě nebyly spuštěny. V tomto případě můžeme mít vyhledávací službu, která je spuštěna před všemi ostatními uzly, a každý uzel může inzerovat svou adresu v této službě a přihlásit se k odběru závislostí.
  3. Pokud můžeme upravit /etc/hosts, můžeme použít předdefinované názvy hostitelů (např my-project-main-node a echo-backend) a přiřaďte toto jméno k IP adrese v době nasazení.

V tomto příspěvku se těmito případy podrobněji nezabýváme. Ve skutečnosti v našem příkladu hračky budou mít všechny uzly stejnou IP adresu — 127.0.0.1.

V tomto příspěvku se podíváme na dvě rozložení distribuovaného systému:

  1. Uspořádání jednoho uzlu, kde jsou všechny služby umístěny na jednom uzlu.
  2. Rozložení dvou uzlů, kde jsou služba a klient na různých uzlech.

Konfigurace pro a jediný uzel rozložení je následující:

Konfigurace jednoho uzlu

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

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

  // configuration of client

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

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

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

Zde vytvoříme jedinou konfiguraci, která rozšiřuje konfiguraci serveru i klienta. Také konfigurujeme řadič životního cyklu, který normálně ukončí klienta a server poté lifetime intervalové průjezdy.

Stejnou sadu implementací a konfigurací služeb lze použít k vytvoření rozložení systému se dvěma samostatnými uzly. Musíme jen tvořit dvě samostatné konfigurace uzlů s příslušnými službami:

Konfigurace dvou uzlů

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

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

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

    def pollInterval: FiniteDuration = 1.second

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

    def testMessage: String = "dolly"
  }

Podívejte se, jak určujeme závislost. Uvádíme službu poskytovanou druhým uzlem jako závislost aktuálního uzlu. Typ závislosti je kontrolován, protože obsahuje fantomový typ, který popisuje protokol. A za běhu budeme mít správné ID uzlu. Toto je jeden z důležitých aspektů navrhovaného konfiguračního přístupu. Poskytuje nám možnost nastavit port pouze jednou a ujistit se, že odkazujeme na správný port.

Implementace dvou uzlů

Pro tuto konfiguraci používáme úplně stejné implementace služeb. Žádné změny. Vytváříme však dvě různé implementace uzlů, které obsahují různé sady služeb:

  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
  }

První uzel implementuje server a potřebuje pouze konfiguraci na straně serveru. Druhý uzel implementuje klienta a potřebuje další část konfigurace. Oba uzly vyžadují určitou specifikaci životnosti. Pro účely tohoto poštovního uzlu bude mít nekonečnou životnost, kterou lze ukončit pomocí SIGTERM, zatímco echo klient bude ukončen po nakonfigurované konečné době trvání. Viz startovací aplikace Podrobnosti.

Celkový proces vývoje

Podívejme se, jak tento přístup mění způsob, jakým pracujeme s konfigurací.

Konfigurace jako kód bude zkompilována a vytvoří artefakt. Zdá se rozumné oddělit konfigurační artefakt od ostatních artefaktů kódu. Často můžeme mít velké množství konfigurací na stejné kódové základně. A samozřejmě můžeme mít více verzí různých konfiguračních větví. V konfiguraci můžeme vybrat konkrétní verze knihoven a to zůstane konstantní, kdykoli tuto konfiguraci nasadíme.

Změna konfigurace se stane změnou kódu. Měl by tedy podléhat stejnému procesu zajišťování kvality:

Vstupenka -> PR -> recenze -> sloučení -> průběžná integrace -> průběžné nasazování

Přístup má následující důsledky:

  1. Konfigurace je koherentní pro konkrétní instanci systému. Zdá se, že neexistuje způsob, jak mít nesprávné spojení mezi uzly.
  2. Není snadné změnit konfiguraci pouze v jednom uzlu. Zdá se nerozumné přihlašovat se a měnit některé textové soubory. Takže posun konfigurace je méně možný.
  3. Malé změny konfigurace není snadné provést.
  4. Většina konfiguračních změn bude probíhat stejným vývojovým procesem a projde určitou kontrolou.

Potřebujeme samostatné úložiště pro konfiguraci produkce? Produkční konfigurace může obsahovat citlivé informace, které bychom rádi uchovali mimo dosah mnoha lidí. Možná by tedy stálo za to ponechat si samostatné úložiště s omezeným přístupem, které bude obsahovat produkční konfiguraci. Konfiguraci můžeme rozdělit na dvě části – jednu, která obsahuje nejotevřenější parametry výroby, a jednu, která obsahuje tajnou část konfigurace. To by většině vývojářů umožnilo přístup k naprosté většině parametrů a zároveň omezilo přístup k opravdu citlivým věcem. Je snadné toho dosáhnout pomocí mezilehlých vlastností s výchozími hodnotami parametrů.

Varianty

Podívejme se na výhody a nevýhody navrhovaného přístupu ve srovnání s jinými technikami správy konfigurace.

Nejprve uvedeme několik alternativ k různým aspektům navrhovaného způsobu řešení konfigurace:

  1. Textový soubor na cílovém počítači.
  2. Centralizované úložiště párů klíč–hodnota (např etcd/zookeeper).
  3. Komponenty podprocesu, které lze překonfigurovat/restartovat bez restartování procesu.
  4. Konfigurace mimo řízení artefaktů a verzí.

Textový soubor poskytuje určitou flexibilitu, pokud jde o opravy ad-hoc. Administrátor systému se může přihlásit k cílovému uzlu, provést změnu a jednoduše restartovat službu. To nemusí být tak dobré pro větší systémy. Po změně nezůstaly žádné stopy. Změna není přezkoumána jiným párem očí. Může být obtížné zjistit, co způsobilo změnu. Nebylo testováno. Z pohledu distribuovaného systému může administrátor jednoduše zapomenout aktualizovat konfiguraci v jednom z ostatních uzlů.

(Btw, pokud nakonec bude potřeba začít používat textové konfigurační soubory, budeme muset přidat pouze parser + validátor, který by mohl vytvořit totéž Config zadejte a to by stačilo k tomu, abyste mohli začít používat textové konfigurace. To také ukazuje, že složitost konfigurace v době kompilace je o něco menší než složitost textových konfigurací, protože v textové verzi potřebujeme nějaký další kód.)

Centralizované úložiště párů klíč–hodnota je dobrým mechanismem pro distribuci meta parametrů aplikace. Zde se musíme zamyslet nad tím, co považujeme za konfigurační hodnoty a co jsou jen data. Daná funkce C => A => B obvykle nazýváme zřídka se měnící hodnoty C "konfiguraci", a přitom často měněná data A - stačí zadat data. Konfigurace by měla být poskytnuta funkci dříve než data A. Vzhledem k této myšlence můžeme říci, že právě očekávaná frekvence změn by mohla být použita k odlišení konfiguračních dat od pouhých dat. Také data obvykle pocházejí z jednoho zdroje (uživatel) a konfigurace pochází z jiného zdroje (admin). Zacházení s parametry, které lze změnit po procesu inicializace, vede ke zvýšení složitosti aplikace. U takových parametrů budeme muset zvládnout jejich doručovací mechanismus, analýzu a validaci, zpracování nesprávných hodnot. Abychom snížili složitost programu, měli bychom snížit počet parametrů, které se mohou za běhu měnit (nebo je dokonce úplně odstranit).

Z pohledu tohoto příspěvku bychom měli rozlišovat mezi statickými a dynamickými parametry. Pokud servisní logika vyžaduje vzácnou změnu některých parametrů za běhu, můžeme je nazývat dynamické parametry. Jinak jsou statické a mohly by být konfigurovány pomocí navrhovaného přístupu. Pro dynamickou rekonfiguraci mohou být zapotřebí jiné přístupy. Části systému mohou být například restartovány s novými konfiguračními parametry podobným způsobem jako restartování samostatných procesů distribuovaného systému.
(Můj skromný názor je vyhnout se překonfigurování runtime, protože to zvyšuje složitost systému.
Mohlo by být snazší spolehnout se pouze na podporu OS při restartování procesů. I když to nemusí být vždy možné.)

Jedním z důležitých aspektů používání statické konfigurace, který někdy nutí lidi zvažovat dynamickou konfiguraci (bez jiných důvodů), je výpadek služby během aktualizace konfigurace. Pokud musíme provést změny ve statické konfiguraci, musíme restartovat systém, aby se nové hodnoty staly účinnými. Požadavky na prostoje se u různých systémů liší, takže to nemusí být tak kritické. Pokud je to kritické, musíme předem naplánovat jakékoli restarty systému. Například bychom mohli implementovat Vypouštění přípojky AWS ELB. V tomto scénáři, kdykoli potřebujeme restartovat systém, spustíme paralelně novou instanci systému, poté na ni přepneme ELB a necháme starý systém, aby dokončil údržbu stávajících připojení.

A co ponechat konfiguraci uvnitř verzovaného artefaktu nebo vně? Uchování konfigurace uvnitř artefaktu ve většině případů znamená, že tato konfigurace prošla stejným procesem zajištění kvality jako ostatní artefakty. Člověk si tedy může být jistý, že konfigurace je kvalitní a důvěryhodná. Naopak konfigurace v samostatném souboru znamená, že neexistují žádné stopy toho, kdo a proč provedl změny v tomto souboru. Je to důležité? Věříme, že pro většinu produkčních systémů je lepší mít stabilní a vysoce kvalitní konfiguraci.

Verze artefaktu umožňuje zjistit, kdy byl vytvořen, jaké hodnoty obsahuje, jaké funkce jsou povoleny/deaktivovány, kdo byl zodpovědný za provedení jednotlivých změn v konfiguraci. Udržet konfiguraci uvnitř artefaktu může vyžadovat určité úsilí a je to volba designu.

Klady a zápory

Zde bychom rádi zdůraznili některé výhody a diskutovali o některých nevýhodách navrhovaného přístupu.

Výhody

Vlastnosti kompilovatelné konfigurace kompletního distribuovaného systému:

  1. Statická kontrola konfigurace. To poskytuje vysokou úroveň jistoty, že konfigurace je správná s danými omezeními typu.
  2. Bohatý jazyk konfigurace. Typicky jsou jiné konfigurační přístupy omezeny maximálně na variabilní substituci.
    Pomocí Scaly lze využít širokou škálu jazykových funkcí pro lepší konfiguraci. Například můžeme použít vlastnosti k poskytnutí výchozích hodnot, objektů k nastavení jiného rozsahu, na které se můžeme odkazovat valje definováno pouze jednou ve vnějším rozsahu (DRY). Je možné použít doslovné sekvence nebo instance určitých tříd (Seq, Map, Atd.).
  3. DSL. Scala má slušnou podporu pro DSL zapisovače. Tyto funkce lze použít k vytvoření konfiguračního jazyka, který je pohodlnější a přívětivější pro koncového uživatele, takže konečná konfigurace je alespoň čitelná pro uživatele domény.
  4. Integrita a koherence mezi uzly. Jednou z výhod konfigurace celého distribuovaného systému na jednom místě je, že všechny hodnoty jsou definovány striktně jednou a poté znovu použity na všech místech, kde je potřebujeme. Deklarace bezpečného portu také zajistí, že ve všech možných správných konfiguracích budou uzly systému mluvit stejným jazykem. Mezi uzly existují explicitní závislosti, takže je těžké zapomenout na poskytování některých služeb.
  5. Vysoká kvalita změn. Celkový přístup k předávání změn konfigurace běžným procesem PR vytváří vysoké standardy kvality také v konfiguraci.
  6. Simultánní změny konfigurace. Kdykoli provedeme jakékoli změny v konfiguraci, automatické nasazení zajistí aktualizaci všech uzlů.
  7. Zjednodušení aplikace. Aplikace nemusí analyzovat a ověřovat konfiguraci a zpracovávat nesprávné konfigurační hodnoty. To zjednodušuje celkovou aplikaci. (Jistý nárůst složitosti je v konfiguraci samotné, ale jde o vědomý kompromis směrem k bezpečnosti.) Návrat k běžné konfiguraci je docela jednoduchý – stačí přidat chybějící části. Je snazší začít s kompilovanou konfigurací a odložit implementaci dalších částí na později.
  8. Verzovaná konfigurace. Vzhledem k tomu, že změny konfigurace probíhají stejným vývojovým procesem, získáme artefakt s unikátní verzí. V případě potřeby nám umožňuje přepnout konfiguraci zpět. Můžeme dokonce nasadit konfiguraci, která byla používána před rokem a bude fungovat úplně stejně. Stabilní konfigurace zlepšuje předvídatelnost a spolehlivost distribuovaného systému. Konfigurace je pevná v době kompilace a nelze ji snadno zfalšovat v produkčním systému.
  9. Modularita. Navržený framework je modulární a moduly lze různě kombinovat
    podpora různých konfigurací (nastavení/rozvržení). Zejména je možné mít rozvržení jednoho uzlu v malém měřítku a nastavení více uzlů ve velkém měřítku. Je rozumné mít více rozvržení výroby.
  10. Testování. Pro účely testování je možné implementovat falešnou službu a používat ji jako závislost typově bezpečným způsobem. Několik různých testovacích uspořádání s různými částmi nahrazenými maketami by mohlo být udržováno současně.
  11. Integrační testování. Někdy je v distribuovaných systémech obtížné spustit integrační testy. Pomocí popsaného přístupu k typově bezpečné konfiguraci kompletního distribuovaného systému můžeme ovladatelně provozovat všechny distribuované části na jediném serveru. Je snadné napodobit situaci
    když některá ze služeb přestane být dostupná.

Nevýhody

Kompilovaný konfigurační přístup se liší od „normální“ konfigurace a nemusí vyhovovat všem potřebám. Zde jsou některé z nevýhod zkompilované konfigurace:

  1. Statická konfigurace. Nemusí být vhodný pro všechny aplikace. V některých případech je potřeba rychle opravit konfiguraci ve výrobě a obejít všechna bezpečnostní opatření. Tento přístup to ztěžuje. Kompilace a opětovné nasazení jsou vyžadovány po provedení jakékoli změny v konfiguraci. To je vlastnost i zátěž.
  2. Generování konfigurace. Když je konfigurace generována nějakým automatizačním nástrojem, vyžaduje tento přístup následnou kompilaci (která může zase selhat). Integrace tohoto dodatečného kroku do systému sestavování může vyžadovat další úsilí.
  3. Nástroje. Dnes se používá spousta nástrojů, které se spoléhají na textové konfigurace. Někteří z nich
    nebude použitelné při kompilaci konfigurace.
  4. Je potřeba změna myšlení. Vývojáři a DevOps znají textové konfigurační soubory. Myšlenka kompilace konfigurace se jim může zdát zvláštní.
  5. Před zavedením kompilovatelné konfigurace je vyžadován vysoce kvalitní proces vývoje softwaru.

Implementovaný příklad má určitá omezení:

  1. Pokud poskytneme další konfiguraci, kterou implementace uzlu nevyžaduje, kompilátor nám nepomůže chybějící implementaci odhalit. To by se dalo řešit pomocí HList nebo ADT (třídy případů) pro konfiguraci uzlů namísto vlastností a vzoru koláče.
  2. V konfiguračním souboru musíme poskytnout nějaký standard: (package, import, object prohlášení;
    override def's pro parametry, které mají výchozí hodnoty). To může být částečně vyřešeno pomocí DSL.
  3. V tomto příspěvku se nezabýváme dynamickou rekonfigurací shluků podobných uzlů.

Proč investovat do čističky vzduchu?

V tomto příspěvku jsme diskutovali o myšlence reprezentovat konfiguraci přímo ve zdrojovém kódu typově bezpečným způsobem. Tento přístup by mohl být použit v mnoha aplikacích jako náhrada xml a dalších textových konfigurací. Navzdory tomu, že náš příklad byl implementován ve Scale, mohl být také přeložen do jiných kompilovatelných jazyků (jako Kotlin, C#, Swift atd.). Tento přístup by bylo možné vyzkoušet v novém projektu a v případě, že nebude dobře zapadat, přejít na staromódní způsob.

Kompilovatelná konfigurace samozřejmě vyžaduje vysoce kvalitní vývojový proces. Na oplátku slibuje poskytnout stejně kvalitní robustní konfiguraci.

Tento přístup lze rozšířit různými způsoby:

  1. Dalo by se použít makra k provedení ověření konfigurace a selhání při kompilaci v případě selhání jakýchkoli omezení obchodní logiky.
  2. DSL by mohlo být implementováno tak, aby reprezentovalo konfiguraci uživatelsky přívětivým způsobem domény.
  3. Dynamická správa zdrojů s automatickými úpravami konfigurace. Když například upravíme počet uzlů clusteru, můžeme chtít (1) aby uzly získaly mírně upravenou konfiguraci; (2) správce clusteru pro příjem informací o nových uzlech.

Díky

Rád bych poděkoval Andrey Saksonovovi, Pavlu Popovovi a Antonu Nehaevovi za poskytnutí inspirativní zpětné vazby k návrhu tohoto příspěvku, která mi pomohla to objasnit.

Zdroj: www.habr.com