Kompilovaná konfigurace distribuovaného systému

Rád bych vám řekl jeden zajímavý mechanismus pro práci s konfigurací distribuovaného systému. Konfigurace je reprezentována přímo v kompilovaném jazyce (Scala) pomocí bezpečných typů. Tento příspěvek poskytuje příklad takové konfigurace a pojednává o různých aspektech implementace zkompilované konfigurace do celkového procesu vývoje.

Kompilovaná konfigurace distribuovaného systému

(angličtina)

úvod

Vybudování spolehlivého distribuovaného systému znamená, že všechny uzly používají správnou konfiguraci, synchronizovanou s ostatními uzly. Technologie DevOps (terraform, ansible nebo něco podobného) se obvykle používají k automatickému generování konfiguračních souborů (často specifických pro každý uzel). Rádi bychom také měli jistotu, že všechny komunikující uzly používají identické protokoly (včetně stejné verze). V opačném případě bude do našeho distribuovaného systému zabudována nekompatibilita. Ve světě JVM je jedním z důsledků tohoto požadavku to, že všude musí být používána stejná verze knihovny obsahující zprávy protokolu.

A co testování distribuovaného systému? Samozřejmě předpokládáme, že všechny komponenty mají unit testy, než přejdeme k integračnímu testování. (Abychom mohli extrapolovat výsledky testů do běhového prostředí, musíme také poskytnout identickou sadu knihoven ve fázi testování a za běhu.)

Při práci s integračními testy je často jednodušší použít stejnou cestu ke třídě všude na všech uzlech. Jediné, co musíme udělat, je zajistit, aby se za běhu používala stejná cesta třídy. (I když je zcela možné provozovat různé uzly s různými cestami tříd, přidává to složitost celkové konfiguraci a potíže s testy nasazení a integrace.) Pro účely tohoto příspěvku předpokládáme, že všechny uzly budou používat stejnou cestu ke třídě.

Konfigurace se vyvíjí s aplikací. Verze používáme k identifikaci různých fází vývoje programu. Zdá se logické také identifikovat různé verze konfigurací. A samotnou konfiguraci umístěte do systému správy verzí. Pokud je ve výrobě pouze jedna konfigurace, pak můžeme jednoduše použít číslo verze. Pokud použijeme mnoho produkčních instancí, budeme jich potřebovat několik
konfigurační větve a dodatečný štítek kromě verze (například název větve). Tímto způsobem můžeme jasně identifikovat přesnou konfiguraci. Každý identifikátor konfigurace jednoznačně odpovídá specifické kombinaci distribuovaných uzlů, portů, externích zdrojů a verzí knihoven. Pro účely tohoto příspěvku budeme předpokládat, že existuje pouze jedna větev a konfiguraci můžeme identifikovat obvyklým způsobem pomocí tří čísel oddělených tečkou (1.2.3).

V moderních prostředích se konfigurační soubory zřídka vytvářejí ručně. Častěji se generují během nasazení a již se jich nedotýká (takže nic nezlomit). Vyvstává přirozená otázka: proč stále používáme textový formát k ukládání konfigurace? Jako životaschopná alternativa se zdá být možnost používat běžný kód pro konfiguraci a těžit z kontrol během kompilace.

V tomto příspěvku prozkoumáme myšlenku reprezentace konfigurace uvnitř kompilovaného artefaktu.

Kompilovaná konfigurace

Tato část poskytuje příklad statické kompilované konfigurace. Jsou implementovány dvě jednoduché služby - služba echo a klient služby echo. Na základě těchto dvou služeb jsou sestaveny dvě možnosti systému. V jedné možnosti jsou obě služby umístěny na stejném uzlu, v jiné možnosti - na různých uzlech.

Distribuovaný systém obvykle obsahuje několik uzlů. Uzly můžete identifikovat pomocí hodnot určitého typu NodeId:

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

nebo

case class NodeId(hostName: String)

nebo

object Singleton
type NodeId = Singleton.type

Uzly plní různé role, provozují služby a lze mezi nimi navazovat TCP/HTTP spojení.

K popisu TCP spojení potřebujeme alespoň číslo portu. Rádi bychom také zohlednili protokol, který je na tomto portu podporován, abychom zajistili, že klient i server používají stejný protokol. Zapojení popíšeme pomocí následující třídy:

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

kde Port - jen celé číslo Int udávající rozsah přijatelných hodnot:

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

Rafinované typy

Viz knihovna rafinovaný и můj zpráva. Stručně řečeno, knihovna vám umožňuje přidat omezení k typům, které jsou kontrolovány v době kompilace. V tomto případě jsou platnými hodnotami čísla portu 16bitová celá čísla. Pro zkompilovanou konfiguraci není použití vylepšené knihovny povinné, ale zlepšuje schopnost kompilátoru kontrolovat konfiguraci.

Pro HTTP (REST) ​​protokoly můžeme kromě čísla portu potřebovat také 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é typy

K identifikaci protokolu v době kompilace používáme parametr typu, který se v rámci třídy nepoužívá. Toto rozhodnutí je způsobeno skutečností, že za běhu nepoužíváme instanci protokolu, ale chtěli bychom, aby kompilátor zkontroloval kompatibilitu protokolu. Zadáním protokolu nebudeme moci předat nevhodnou službu jako závislost.

Jedním z běžných protokolů je REST API se serializací Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

kde RequestMessage - typ požadavku, ResponseMessage — typ odpovědi.
Samozřejmě můžeme použít jiné popisy protokolů, které zajistí přesnost popisu, kterou požadujeme.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Zde je požadavkem řetězec připojený k adrese URL a odpovědí je vrácený řetězec v těle odpovědi HTTP.

Konfigurace služby je popsána názvem služby, porty a závislostmi. Tyto prvky mohou být ve Scale reprezentovány několika způsoby (např. HList-s, algebraické datové typy). Pro účely tohoto příspěvku budeme používat Cake Pattern a reprezentovat moduly pomocí trait'ov. (Vzor koláče není povinným prvkem tohoto přístupu. Je to pouze jedna z možných implementací.)

Závislosti mezi službami lze reprezentovat jako metody, které vracejí porty EndPoint's další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)
  }

K vytvoření služby echo potřebujete pouze číslo portu a označení, že port podporuje protokol echo. Možná neuvedeme konkrétní port, protože... vlastnosti umožňují deklarovat metody bez implementace (abstraktní metody). V tomto případě by při vytváření konkrétní konfigurace kompilátor vyžadoval, abychom poskytli implementaci abstraktní metody a poskytli číslo portu. Protože jsme metodu implementovali, při vytváření konkrétní konfigurace nesmíme specifikovat jiný port. Použije se výchozí hodnota.

V konfiguraci klienta deklarujeme závislost na službě echo:

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

Závislost je stejného typu jako exportovaná služba echoService. Konkrétně v klientovi echo požadujeme stejný protokol. Při propojení dvou služeb si tedy můžeme být jisti, že vše bude fungovat správně.

Realizace služeb

Ke spuštění a zastavení služby je vyžadována funkce. (Schopnost zastavit službu je pro testování kritická.) Opět existuje několik možností implementace takové funkce (například bychom mohli použít typové třídy založené na typu konfigurace). Pro účely tohoto příspěvku použijeme Vzor dortu. Službu budeme reprezentovat pomocí třídy cats.Resource, protože Tato třída již poskytuje prostředky pro bezpečné zaručení uvolnění prostředků v případě problémů. Abychom získali zdroj, musíme poskytnout konfiguraci a připravený kontext běhu. Funkce spuštění služby 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 pro tuto službu
  • AddressResolver — runtime objekt, který vám umožní zjistit adresy jiných uzlů (viz níže)

a další typy z knihovny cats:

  • F[_] — typ efektu (v nejjednodušším případě F[A] může to být jen funkce () => A. V tomto příspěvku použijeme cats.IO.)
  • Reader[A,B] - víceméně synonymum funkce A => B
  • cats.Resource - zdroj, který lze získat a uvolnit
  • Timer — časovač (umožňuje na chvíli usnout a měřit časové intervaly)
  • ContextShift - analogový ExecutionContext
  • Applicative — třída typu efektu, která umožňuje kombinovat jednotlivé efekty (téměř monáda). Ve složitějších aplikacích se zdá být lepší použít Monad/ConcurrentEffect.

Pomocí tohoto podpisu funkce 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](()))
  }

(Cm. zdroj, ve kterém jsou implementovány další služby - echo služba, echo klienta
и doživotní ovladače.)

Uzel je objekt, který může spouštět několik služeb (spuštění řetězce zdrojů zajišťuje Cake Pattern):

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

Upozorňujeme, že specifikujeme přesný typ konfigurace, který je pro tento uzel vyžadován. Pokud zapomeneme specifikovat jeden z typů konfigurace požadovaných konkrétní službou, dojde k chybě kompilace. Také nebudeme schopni spustit uzel, pokud neposkytneme nějakému objektu odpovídajícího typu všechna potřebná data.

Rozlišení názvu hostitele

Pro připojení ke vzdálenému hostiteli potřebujeme skutečnou IP adresu. Je možné, že adresa bude známa později než zbytek konfigurace. Potřebujeme tedy funkci, která mapuje ID uzlu na adresu:

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

Tuto funkci lze implementovat několika způsoby:

  1. Pokud nám budou adresy známy před nasazením, můžeme vygenerovat kód Scala pomocí
    adresy a poté spusťte sestavení. Tím se zkompilují a spustí testy.
    V tomto případě bude funkce známa staticky a může být reprezentována v kódu jako mapování Map[NodeId, NodeAddress].
  2. V některých případech je skutečná adresa známa až po spuštění uzlu.
    V tomto případě můžeme implementovat „discovery service“, která běží před ostatními uzly a všechny uzly se u této služby zaregistrují a vyžádají si adresy ostatních uzlů.
  3. Pokud můžeme upravit /etc/hosts, pak můžete použít předdefinované názvy hostitelů (např my-project-main-node и echo-backend) a jednoduše tato jména propojte
    s IP adresami během nasazení.

V tomto příspěvku se nebudeme těmito případy podrobněji zabývat. Pro naše
v příkladu hraček budou mít všechny uzly stejnou IP adresu - 127.0.0.1.

Dále zvážíme dvě možnosti pro distribuovaný systém:

  1. Umístění všech služeb na jeden uzel.
  2. A hostování služby echo a klienta echo na různých uzlech.

Konfigurace pro jeden uzel:

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

Objekt implementuje konfiguraci klienta i serveru. Také se používá konfigurace time-to-live, takže po intervalu lifetime ukončit program. (Ctrl-C také funguje a uvolňuje všechny zdroje správně.)

Stejnou sadu konfiguračních a implementačních vlastností lze použít k vytvoření systému sestávajícího z dva samostatné uzly:

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

Důležité! Všimněte si, jak jsou služby propojeny. Službu implementovanou jedním uzlem specifikujeme jako implementaci metody závislosti jiného uzlu. Typ závislosti kontroluje kompilátor, protože obsahuje typ protokolu. Po spuštění bude závislost obsahovat správné ID cílového uzlu. Díky tomuto schématu uvedeme číslo portu přesně jednou a vždy zaručeně odkazujeme na správný port.

Implementace dvou systémových uzlů

Pro tuto konfiguraci používáme stejné implementace služeb beze změn. Jediný rozdíl je v tom, že nyní máme dva objekty, které implementují 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 serveru. Druhý uzel implementuje klienta a používá jinou část konfigurace. Oba uzly také potřebují správu životnosti. Serverový uzel běží neomezeně dlouho, dokud není zastaven SIGTERM'om a klientský uzel se po nějaké době ukončí. Cm. spouštěcí aplikace.

Obecný vývojový proces

Podívejme se, jak tento konfigurační přístup ovlivňuje celkový proces vývoje.

Konfigurace bude zkompilována spolu se zbytkem kódu a bude vygenerován artefakt (.jar). Zdá se, že dává smysl umístit konfiguraci do samostatného artefaktu. Je to proto, že můžeme mít více konfigurací založených na stejném kódu. Opět je možné generovat artefakty odpovídající různým konfiguračním větvím. Závislosti na konkrétních verzích knihoven se ukládají spolu s konfigurací a tyto verze se ukládají navždy, kdykoli se rozhodneme nasadit danou verzi konfigurace.

Jakákoli změna konfigurace se změní ve změnu kódu. A proto každý
změna bude pokryta běžným procesem zajištění kvality:

Vstupenka v bug trackeru -> PR -> recenze -> sloučení s relevantními pobočkami ->
integrace -> nasazení

Hlavní důsledky implementace zkompilované konfigurace jsou:

  1. Konfigurace bude konzistentní napříč všemi uzly distribuovaného systému. Vzhledem k tomu, že všechny uzly dostávají stejnou konfiguraci z jednoho zdroje.

  2. Je problematické změnit konfiguraci pouze v jednom z uzlů. Proto je „posun konfigurace“ nepravděpodobný.

  3. Je obtížnější provádět malé změny v konfiguraci.

  4. Většina konfiguračních změn proběhne jako součást celkového procesu vývoje a bude předmětem revize.

Potřebuji pro uložení produkční konfigurace samostatné úložiště? Tato konfigurace může obsahovat hesla a další citlivé informace, ke kterým bychom chtěli omezit přístup. Na základě toho se zdá, že má smysl uložit konečnou konfiguraci do samostatného úložiště. Konfiguraci můžete rozdělit na dvě části – jedna obsahuje veřejně přístupná nastavení konfigurace a druhá obsahuje omezená nastavení. To umožní většině vývojářů přístup k běžným nastavením. Tohoto oddělení lze snadno dosáhnout pomocí přechodných znaků obsahujících výchozí hodnoty.

Možné variace

Zkusme porovnat zkompilovanou konfiguraci s některými běžnými alternativami:

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

Textové soubory poskytují významnou flexibilitu z hlediska malých změn. Správce systému se může přihlásit ke vzdálenému uzlu, provést změny v příslušných souborech a restartovat službu. U velkých systémů však taková flexibilita nemusí být žádoucí. Provedené změny nezanechávají žádné stopy v jiných systémech. Nikdo změny nekontroluje. Je těžké určit, kdo přesně změny provedl a z jakého důvodu. Změny nejsou testovány. Pokud je systém distribuován, může správce zapomenout provést odpovídající změnu na jiných uzlech.

(Je třeba také poznamenat, že použití zkompilované konfigurace neuzavře možnost použití textových souborů v budoucnu. Bude stačit přidat parser a validátor, který produkuje stejný typ jako výstup Configa můžete použít textové soubory. Z toho okamžitě vyplývá, že složitost systému s přeloženou konfigurací je o něco menší než složitost systému využívajícího textové soubory, protože textové soubory vyžadují další kód.)

Centralizované úložiště klíč–hodnota je dobrým mechanismem pro distribuci meta parametrů distribuované aplikace. Musíme se rozhodnout, co jsou konfigurační parametry a co jsou jen data. Pojďme mít funkci C => A => Ba parametry C zřídka se mění a data A - často. V tomto případě to můžeme říci C - konfigurační parametry a A - údaje. Zdá se, že konfigurační parametry se liší od dat tím, že se obecně mění méně často než data. Data také obvykle pocházejí z jednoho zdroje (od uživatele) a konfigurační parametry z jiného (od správce systému).

Pokud je třeba aktualizovat zřídka se měnící parametry bez restartování programu, může to často vést ke komplikacím programu, protože budeme muset parametry nějak dodat, uložit, analyzovat a zkontrolovat a zpracovat nesprávné hodnoty. Z hlediska snížení složitosti programu má tedy smysl snížit počet parametrů, které se mohou během činnosti programu měnit (nebo takové parametry vůbec nepodporovat).

Pro účely tohoto příspěvku budeme rozlišovat statické a dynamické parametry. Pokud logika služby vyžaduje změnu parametrů během provozu programu, pak takové parametry nazveme dynamickými. Jinak jsou volby statické a lze je konfigurovat pomocí zkompilované konfigurace. Pro dynamickou rekonfiguraci můžeme potřebovat mechanismus pro restartování částí programu s novými parametry, podobně jako se restartují procesy operačního systému. (Podle našeho názoru je vhodné vyhnout se rekonfiguraci v reálném čase, protože to zvyšuje složitost systému. Pokud je to možné, je lepší použít standardní možnosti OS pro restartování procesů.)

Jedním z důležitých aspektů používání statické konfigurace, který nutí lidi zvážit dynamickou rekonfiguraci, je doba, kterou trvá restartování systému po aktualizaci konfigurace (prostoj). Ve skutečnosti, pokud potřebujeme provést změny ve statické konfiguraci, budeme muset restartovat systém, aby se nové hodnoty projevily. Závažnost problému s prostojem se u různých systémů liší. V některých případech můžete naplánovat restart v době, kdy je zatížení minimální. Pokud potřebujete poskytovat nepřetržitý servis, můžete implementovat Vypouštění přípojky AWS ELB. Zároveň, když potřebujeme restartovat systém, spustíme paralelní instanci tohoto systému, přepneme do ní balancer a počkáme na dokončení starých připojení. Po ukončení všech starých připojení ukončíme starou instanci systému.

Podívejme se nyní na otázku uložení konfigurace uvnitř nebo vně artefaktu. Pokud uložíme konfiguraci dovnitř artefaktu, tak jsme alespoň měli možnost ověřit správnost konfigurace při sestavování artefaktu. Pokud je konfigurace mimo kontrolovaný artefakt, je obtížné sledovat, kdo a proč provedl změny v tomto souboru. jak moc je to důležité? Podle našeho názoru je pro mnoho výrobních systémů důležité mít stabilní a kvalitní konfiguraci.

Verze artefaktu vám umožňuje určit, kdy byl vytvořen, jaké hodnoty obsahuje, jaké funkce jsou povoleny/deaktivovány a kdo je odpovědný za jakoukoli změnu v konfiguraci. Uložení konfigurace do artefaktu samozřejmě vyžaduje určité úsilí, takže musíte učinit informované rozhodnutí.

Výhody a nevýhody

Rád bych se pozastavil nad výhodami a nevýhodami navrhované technologie.

Výhody

Níže je uveden seznam hlavních funkcí zkompilované konfigurace distribuovaného systému:

  1. Kontrola statické konfigurace. Umožňuje vám to mít jistotu
    konfigurace je správná.
  2. Bohatý konfigurační jazyk. Jiné konfigurační metody jsou obvykle omezeny maximálně na substituci řetězcových proměnných. Při používání programu Scala je k dispozici široká škála jazykových funkcí pro vylepšení vaší konfigurace. Můžeme například použít
    vlastnosti pro výchozí hodnoty, pomocí objektů seskupujeme parametry, můžeme odkazovat na hodnoty deklarované pouze jednou (DRY) v přiloženém rozsahu. Jakékoli třídy můžete vytvořit instanci přímo v konfiguraci (Seq, Map, vlastní třídy).
  3. DSL. Scala má řadu jazykových funkcí, které usnadňují vytvoření DSL. Je možné využít těchto vlastností a implementovat konfigurační jazyk, který je pro cílovou skupinu uživatelů pohodlnější, aby byla konfigurace alespoň čitelná pro doménové experty. Specialisté se mohou například zúčastnit procesu kontroly konfigurace.
  4. Integrita a synchronizace mezi uzly. Jednou z výhod uložení konfigurace celého distribuovaného systému v jednom bodě je, že všechny hodnoty jsou deklarovány přesně jednou a poté znovu použity, kdekoli jsou potřeba. Použití fantomových typů k deklaraci portů zajišťuje, že uzly používají kompatibilní protokoly ve všech správných systémových konfiguracích. Výslovné povinné závislosti mezi uzly zajišťuje, že jsou všechny služby propojeny.
  5. Vysoce kvalitní změny. Provádění změn v konfiguraci pomocí společného vývojového procesu umožňuje dosáhnout vysokých standardů kvality i pro konfiguraci.
  6. Současná aktualizace konfigurace. Automatické nasazení systému po změnách konfigurace zajišťuje aktualizaci všech uzlů.
  7. Zjednodušení aplikace. Aplikace nepotřebuje analýzu, kontrolu konfigurace nebo zpracování nesprávných hodnot. Tím se snižuje složitost aplikace. (Některá složitost konfigurace pozorovaná v našem příkladu není atributem zkompilované konfigurace, ale pouze vědomým rozhodnutím řízeným touhou poskytnout větší bezpečnost typu.) Je docela snadné vrátit se k obvyklé konfiguraci – stačí implementovat chybějící díly. Můžete tedy například začít s kompilovanou konfigurací a odložit implementaci nepotřebných částí na dobu, kdy to bude skutečně potřeba.
  8. Ověřená konfigurace. Vzhledem k tomu, že změny konfigurace následují obvyklý osud jakýchkoli jiných změn, výstup, který získáme, je artefakt s jedinečnou verzí. To nám například umožňuje vrátit se v případě potřeby k předchozí verzi konfigurace. Můžeme dokonce použít konfiguraci před rokem a systém bude fungovat úplně stejně. Stabilní konfigurace zlepšuje předvídatelnost a spolehlivost distribuovaného systému. Vzhledem k tomu, že konfigurace je opravena ve fázi kompilace, je poměrně obtížné ji zfalšovat ve výrobě.
  9. Modularita. Navržený framework je modulární a moduly lze různě kombinovat a vytvářet tak různé systémy. Konkrétně můžete nakonfigurovat systém tak, aby běžel na jednom uzlu v jednom provedení a na více uzlech v jiném. Pro produkční instance systému můžete vytvořit několik konfigurací.
  10. Testování. Nahrazením jednotlivých služeb falešnými objekty můžete získat několik verzí systému, které jsou vhodné pro testování.
  11. Integrační testování. Jednotná konfigurace pro celý distribuovaný systém umožňuje provozovat všechny komponenty v kontrolovaném prostředí v rámci integračního testování. Je snadné napodobit například situaci, kdy se některé uzly zpřístupní.

Nevýhody a omezení

Kompilovaná konfigurace se liší od jiných konfiguračních přístupů a nemusí být vhodná pro některé aplikace. Níže jsou uvedeny některé nevýhody:

  1. Statická konfigurace. Někdy je třeba rychle opravit konfiguraci ve výrobě a obejít všechny ochranné mechanismy. S tímto přístupem to může být složitější. Přinejmenším bude stále vyžadována kompilace a automatické nasazení. To je jak užitečná vlastnost přístupu, tak v některých případech nevýhoda.
  2. Generování konfigurace. V případě, že je konfigurační soubor generován automatickým nástrojem, může být zapotřebí další úsilí k integraci skriptu sestavení.
  3. Nástroje. V současné době jsou nástroje a techniky navržené pro práci s konfigurací založeny na textových souborech. Ne všechny takové nástroje/techniky budou dostupné v kompilované konfiguraci.
  4. Je nutná změna postojů. Vývojáři a DevOps jsou na textové soubory zvyklí. Samotná myšlenka kompilace konfigurace může být poněkud neočekávaná a neobvyklá a způsobit odmítnutí.
  5. Vyžaduje se vysoce kvalitní vývojový proces. Pro pohodlné používání zkompilované konfigurace je nezbytná plná automatizace procesu sestavení a nasazení aplikace (CI/CD). Jinak to bude dost nepohodlné.

Zastavme se také u řady omezení uvažovaného příkladu, která nesouvisejí s myšlenkou kompilované konfigurace:

  1. Pokud poskytneme zbytečné konfigurační informace, které uzel nepoužívá, pak nám kompilátor nepomůže odhalit chybějící implementaci. Tento problém lze vyřešit opuštěním koláčového vzoru a použitím pevnějších typů, např. HList nebo algebraické datové typy (třídy případů), které reprezentují konfiguraci.
  2. V konfiguračním souboru jsou řádky, které se netýkají samotné konfigurace: (package, import,deklarace objektů; override def's pro parametry, které mají výchozí hodnoty). Tomu se lze částečně vyhnout, pokud implementujete vlastní DSL. Jiné typy konfigurace (například XML) navíc ukládají určitá omezení na strukturu souborů.
  3. Pro účely tohoto příspěvku neuvažujeme dynamickou rekonfiguraci clusteru podobných uzlů.

Závěr

V tomto příspěvku jsme prozkoumali myšlenku reprezentace konfigurace ve zdrojovém kódu pomocí pokročilých schopností systému typu Scala. Tento přístup lze použít v různých aplikacích jako náhrada tradičních konfiguračních metod založených na xml nebo textových souborech. I když je náš příklad implementován ve Scale, stejné myšlenky lze přenést do jiných kompilovaných jazyků (jako je Kotlin, C#, Swift, ...). Tento přístup můžete vyzkoušet v jednom z následujících projektů, a pokud to nefunguje, přejděte k textovému souboru a přidejte chybějící části.

Kompilovaná konfigurace samozřejmě vyžaduje vysoce kvalitní vývojový proces. Na oplátku je zajištěna vysoká kvalita a spolehlivost konfigurací.

Uvažovaný přístup lze rozšířit:

  1. Pomocí maker můžete provádět kontroly během kompilace.
  2. Můžete implementovat DSL pro prezentaci konfigurace způsobem, který je přístupný koncovým uživatelům.
  3. Můžete implementovat dynamickou správu zdrojů s automatickou úpravou konfigurace. Například změna počtu uzlů v klastru vyžaduje, aby (1) každý uzel obdržel mírně odlišnou konfiguraci; (2) manažer clusteru obdržel informace o nových uzlech.

Poděkování

Rád bych poděkoval Andreji Saksonovovi, Pavlu Popovovi a Antonu Nekhaevovi za konstruktivní kritiku návrhu článku.

Zdroj: www.habr.com

Přidat komentář