Kompilovateľná konfigurácia distribuovaného systému

V tomto príspevku by sme sa chceli podeliť o zaujímavý spôsob riešenia konfigurácie distribuovaného systému.
Konfigurácia je reprezentovaná priamo v jazyku Scala typovo bezpečným spôsobom. Príklad implementácie je podrobne opísaný. Diskutuje sa o rôznych aspektoch návrhu, vrátane vplyvu na celkový proces vývoja.

Kompilovateľná konfigurácia distribuovaného systému

(На русском)

úvod

Budovanie robustných distribuovaných systémov vyžaduje použitie správnej a koherentnej konfigurácie na všetkých uzloch. Typickým riešením je použiť textový popis nasadenia (terraform, ansible alebo niečo podobné) a automaticky generované konfiguračné súbory (často – vyhradené pre každý uzol/rolu). Chceli by sme tiež používať rovnaké protokoly rovnakých verzií na každom komunikujúcom uzle (inak by sme mali problémy s nekompatibilitou). Vo svete JVM to znamená, že aspoň knižnica správ by mala mať rovnakú verziu na všetkých komunikujúcich uzloch.

A čo testovanie systému? Pred integračnými testami by sme samozrejme mali mať testy jednotiek pre všetky komponenty. Aby sme mohli extrapolovať výsledky testov na runtime, mali by sme sa uistiť, že verzie všetkých knižníc sú identické v runtime aj testovacom prostredí.

Pri spúšťaní integračných testov je často oveľa jednoduchšie mať rovnakú cestu triedy na všetkých uzloch. Musíme sa len uistiť, že pri nasadení sa použije rovnaká cesta triedy. (Je možné použiť rôzne cesty k triedam na rôznych uzloch, ale je ťažšie reprezentovať túto konfiguráciu a správne ju nasadiť.) Aby sme to zjednodušili, budeme brať do úvahy iba rovnaké cesty k triedam na všetkých uzloch.

Konfigurácia má tendenciu sa vyvíjať spolu so softvérom. Zvyčajne používame verzie na identifikáciu rôznych
etapy vývoja softvéru. Zdá sa rozumné pokryť konfiguráciu pod správou verzií a identifikovať rôzne konfigurácie pomocou niektorých štítkov. Ak je vo výrobe iba jedna konfigurácia, môžeme ako identifikátor použiť jednu verziu. Niekedy môžeme mať viacero produkčných prostredí. A pre každé prostredie môžeme potrebovať samostatnú vetvu konfigurácie. Takže konfigurácie môžu byť označené vetvou a verziou na jedinečnú identifikáciu rôznych konfigurácií. Každé označenie vetvy a verzia zodpovedá jedinej kombinácii distribuovaných uzlov, portov, externých zdrojov, verzií knižnice classpath na každom uzle. Tu pokryjeme iba jednu vetvu a identifikujeme konfigurácie pomocou trojzložkovej desiatkovej verzie (1.2.3), rovnako ako ostatné artefakty.

V moderných prostrediach sa konfiguračné súbory už neupravujú manuálne. Zvyčajne vytvárame
konfiguračné súbory v čase nasadenia a nikdy sa ich nedotýkajte potom. Niekto by sa teda mohol opýtať, prečo stále používame textový formát pre konfiguračné súbory? Životaschopnou možnosťou je umiestniť konfiguráciu do kompilačnej jednotky a ťažiť z overenia konfigurácie počas kompilácie.

V tomto príspevku preskúmame myšlienku zachovania konfigurácie v kompilovanom artefakte.

Kompilovateľná konfigurácia

V tejto časti budeme diskutovať o príklade statickej konfigurácie. Konfigurujú sa a implementujú dve jednoduché služby – služba echo a klient služby echo. Potom sa vytvorí inštancia dvoch rôznych distribuovaných systémov s oboma službami. Jeden je pre konfiguráciu jedného uzla a druhý pre konfiguráciu dvoch uzlov.

Typický distribuovaný systém pozostáva z niekoľkých uzlov. Uzly možno identifikovať pomocou niektorého typu:

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

alebo len

case class NodeId(hostName: String)

alebo dokonca

object Singleton
type NodeId = Singleton.type

Tieto uzly vykonávajú rôzne úlohy, spúšťajú niektoré služby a mali by byť schopné komunikovať s ostatnými uzlami prostredníctvom pripojení TCP/HTTP.

Pre pripojenie TCP sa vyžaduje aspoň číslo portu. Chceme sa tiež uistiť, že klient a server používajú rovnaký protokol. Aby sme mohli modelovať spojenie medzi uzlami, deklarujme nasledujúcu triedu:

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

kde Port je len Int v povolenom rozsahu:

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

Rafinované typy

Vidieť rafinovaný knižnica. Stručne povedané, umožňuje pridať časové obmedzenia kompilácie iným typom. V tomto prípade Int môže mať iba 16-bitové hodnoty, ktoré môžu predstavovať číslo portu. Neexistuje žiadna požiadavka na použitie tejto knižnice pre tento konfiguračný prístup. Zdá sa, že to veľmi dobre sedí.

Pre HTTP (REST) ​​môžeme potrebovať aj cestu k službe:

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

Fantómový typ

Na identifikáciu protokolu počas kompilácie používame funkciu Scala na deklarovanie argumentu typu Protocol ktorý sa v triede nepoužíva. Ide o tzv fantómový typ. Počas behu zriedka potrebujeme inštanciu identifikátora protokolu, preto ho neukladáme. Počas kompilácie tento fantómový typ poskytuje dodatočnú bezpečnosť typu. Nemôžeme prejsť port s nesprávnym protokolom.

Jedným z najpoužívanejších protokolov je REST API so serializáciou Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

kde RequestMessage je základný typ správ, ktoré môže klient posielať na server a ResponseMessage je odpoveď zo servera. Samozrejme, môžeme vytvoriť ďalšie popisy protokolov, ktoré špecifikujú komunikačný protokol s požadovanou presnosťou.

Na účely tohto príspevku použijeme jednoduchšiu verziu protokolu:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

V tomto protokole sa správa žiadosti pripojí k adrese URL a správa s odpoveďou sa vráti ako obyčajný reťazec.

Konfigurácia služby môže byť opísaná názvom služby, kolekciou portov a niektorými závislosťami. Existuje niekoľko možných spôsobov, ako reprezentovať všetky tieto prvky v Scale (napr. HListalgebraické dátové typy). Pre účely tohto príspevku budeme používať Cake Pattern a reprezentovať kombinovateľné kusy (moduly) ako vlastnosti. (Vzor koláča nie je podmienkou pre tento kompilovateľný konfiguračný prístup. Je to len jedna z možných implementácií myšlienky.)

Závislosti môžu byť reprezentované pomocou koláčového vzoru ako koncových bodov iných uzlov:

  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 potrebuje iba nakonfigurovaný port. A vyhlasujeme, že tento port podporuje protokol echo. Všimnite si, že v tejto chvíli nemusíme špecifikovať konkrétny port, pretože vlastnosť umožňuje deklarácie abstraktných metód. Ak použijeme abstraktné metódy, kompilátor bude vyžadovať implementáciu v konfiguračnej inštancii. Tu sme poskytli implementáciu (8081) a použije sa ako predvolená hodnota, ak ju v konkrétnej konfigurácii preskočíme.

V konfigurácii klienta služby echo môžeme deklarovať závislosť:

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

Závislosť má rovnaký typ ako echoService. Vyžaduje si to najmä rovnaký protokol. Preto si môžeme byť istí, že ak tieto dve závislosti spojíme, budú fungovať správne.

Implementácia služieb

Služba potrebuje funkciu na spustenie a bezproblémové vypnutie. (Schopnosť vypnúť službu je pre testovanie kritická.) Opäť existuje niekoľko možností špecifikovania takejto funkcie pre danú konfiguráciu (napríklad môžeme použiť triedy typu). Pre tento príspevok opäť použijeme Cake Pattern. Môžeme reprezentovať službu pomocou cats.Resource ktorý už poskytuje bracketing a uvoľnenie zdrojov. Aby sme získali zdroj, mali by sme poskytnúť konfiguráciu a určitý kontext behu. Takže funkcia spustenia služby môže vyzerať 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 konfigurácie, ktorý vyžaduje tento spúšťač služby
  • AddressResolver — objekt runtime, ktorý má schopnosť získať skutočné adresy iných uzlov (podrobnosti nájdete v čítaní).

ostatné typy pochádzajú z cats:

  • F[_] — typ efektu (v najjednoduchšom prípade F[A] môže byť spravodlivé () => A. V tomto príspevku použijeme cats.IO.)
  • Reader[A,B] — je viac-menej synonymom funkcie A => B
  • cats.Resource — má spôsoby, ako získať a uvoľniť
  • Timer — umožňuje spať/merať čas
  • ContextShift - analóg ExecutionContext
  • Applicative — obal funkcií v skutočnosti (takmer monáda) (môžeme ho prípadne nahradiť niečím iným)

Pomocou tohto rozhrania môžeme implementovať niekoľko služieb. Napríklad služba, ktorá nič nerobí:

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

(Pozri Zdrojový kód pre implementáciu iných služieb — echo službu,
echo klienta a doživotné ovládače.)

Uzol je jeden objekt, na ktorom je spustených niekoľko služieb (spustenie reťazca zdrojov umožňuje Cake Pattern):

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

Všimnite si, že v uzle špecifikujeme presný typ konfigurácie, ktorý tento uzol potrebuje. Kompilátor nám nedovolí postaviť objekt (Cake) s nedostatočným typom, pretože každá vlastnosť služby deklaruje obmedzenie na Config typu. Tiež nebudeme môcť spustiť uzol bez poskytnutia kompletnej konfigurácie.

Rozlíšenie adresy uzla

Na vytvorenie spojenia potrebujeme skutočnú adresu hostiteľa pre každý uzol. Môže byť známy neskôr ako ostatné časti konfigurácie. Preto potrebujeme spôsob, ako poskytnúť mapovanie medzi ID uzla a jeho skutočnou adresou. Toto mapovanie je funkcia:

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

Existuje niekoľko možných spôsobov implementácie takejto funkcie.

  1. Ak poznáme skutočné adresy pred nasadením, počas vytvárania inštancie hostiteľov uzla, potom môžeme vygenerovať kód Scala so skutočnými adresami a následne spustiť zostavenie (ktoré vykoná kontroly času kompilácie a potom spustí sadu testov integrácie). V tomto prípade je naša mapovacia funkcia známa staticky a možno ju zjednodušiť na niečo ako a Map[NodeId, NodeAddress].
  2. Niekedy skutočné adresy získame až neskôr, keď je uzol skutočne spustený, alebo nemáme adresy uzlov, ktoré ešte neboli spustené. V tomto prípade môžeme mať službu zisťovania, ktorá je spustená pred všetkými ostatnými uzlami a každý uzol môže inzerovať svoju adresu v tejto službe a prihlásiť sa na odber závislostí.
  3. Ak môžeme upraviť /etc/hosts, môžeme použiť preddefinované názvy hostiteľov (napr my-project-main-node a echo-backend) a jednoducho priraďte tento názov k IP adrese v čase nasadenia.

V tomto príspevku sa nebudeme týmito prípadmi podrobnejšie zaoberať. V skutočnosti v našom príklade hračky budú mať všetky uzly rovnakú IP adresu — 127.0.0.1.

V tomto príspevku zvážime dve rozloženia distribuovaného systému:

  1. Usporiadanie jedného uzla, kde sú všetky služby umiestnené na jednom uzle.
  2. Rozloženie dvoch uzlov, kde služba a klient sú na rôznych uzloch.

Konfigurácia pre a jediný uzol rozloženie je nasledovné:

Konfigurácia jedného uzla

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

Tu vytvoríme jednu konfiguráciu, ktorá rozširuje konfiguráciu servera aj klienta. Tiež nakonfigurujeme radič životného cyklu, ktorý zvyčajne ukončí klienta a server lifetime intervalové prejazdy.

Rovnakú sadu implementácií a konfigurácií služieb možno použiť na vytvorenie rozloženia systému s dvoma samostatnými uzlami. Potrebujeme len tvoriť dve samostatné konfigurácie uzlov s príslušnými službami:

Konfigurácia dvoch uzlov

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

Pozrite sa, ako špecifikujeme závislosť. Službu poskytovanú iným uzlom uvádzame ako závislosť aktuálneho uzla. Typ závislosti sa kontroluje, pretože obsahuje fantómový typ, ktorý popisuje protokol. A za behu budeme mať správne ID uzla. Toto je jeden z dôležitých aspektov navrhovaného konfiguračného prístupu. Poskytuje nám možnosť nastaviť port iba raz a uistiť sa, že odkazujeme na správny port.

Implementácia dvoch uzlov

Pre túto konfiguráciu používame presne tie isté implementácie služieb. Vôbec žiadne zmeny. Vytvárame však dve rôzne implementácie uzlov, ktoré obsahujú rôzne sady služieb:

  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
  }

Prvý uzol implementuje server a potrebuje iba konfiguráciu na strane servera. Druhý uzol implementuje klienta a potrebuje ďalšiu časť konfigurácie. Oba uzly vyžadujú určitú špecifikáciu životnosti. Na účely tohto uzla poštových služieb bude mať nekonečnú životnosť, ktorú je možné ukončiť pomocou SIGTERM, zatiaľ čo klient echo sa ukončí po nakonfigurovanom konečnom trvaní. Pozrite si štartovacia aplikácia podrobnosti.

Celkový proces vývoja

Pozrime sa, ako tento prístup mení spôsob, akým pracujeme s konfiguráciou.

Konfigurácia ako kód sa skompiluje a vytvorí artefakt. Zdá sa rozumné oddeliť konfiguračný artefakt od iných artefaktov kódu. Často môžeme mať množstvo konfigurácií na rovnakej kódovej báze. A samozrejme môžeme mať viacero verzií rôznych konfiguračných vetiev. V konfigurácii môžeme vybrať konkrétne verzie knižníc a toto zostane konštantné vždy, keď túto konfiguráciu nasadíme.

Zmena konfigurácie sa zmení na zmenu kódu. Preto by sa naň mal vzťahovať rovnaký proces zabezpečenia kvality:

Vstupenka -> PR -> recenzia -> zlúčenie -> nepretržitá integrácia -> nepretržité nasadenie

Tento prístup má nasledujúce dôsledky:

  1. Konfigurácia je koherentná pre konkrétnu inštanciu systému. Zdá sa, že neexistuje spôsob, ako mať nesprávne spojenie medzi uzlami.
  2. Nie je ľahké zmeniť konfiguráciu iba v jednom uzle. Zdá sa nerozumné prihlasovať sa a meniť niektoré textové súbory. Takže posun konfigurácie je menej možný.
  3. Malé zmeny v konfigurácii nie je ľahké vykonať.
  4. Väčšina zmien konfigurácie bude prebiehať podľa rovnakého vývojového procesu a prejde určitou kontrolou.

Potrebujeme samostatné úložisko na konfiguráciu produkcie? Produkčná konfigurácia môže obsahovať citlivé informácie, ktoré by sme chceli uchovávať mimo dosahu mnohých ľudí. Možno by sa teda oplatilo ponechať si samostatné úložisko s obmedzeným prístupom, ktoré bude obsahovať produkčnú konfiguráciu. Konfiguráciu môžeme rozdeliť na dve časti – jednu, ktorá obsahuje najotvorenejšie parametre výroby a jednu, ktorá obsahuje tajnú časť konfigurácie. To by väčšine vývojárov umožnilo prístup k veľkej väčšine parametrov a zároveň by obmedzilo prístup k skutočne citlivým veciam. Je ľahké to dosiahnuť pomocou prechodných vlastností s predvolenými hodnotami parametrov.

Varianty

Pozrime sa na výhody a nevýhody navrhovaného prístupu v porovnaní s inými technikami správy konfigurácie.

Najprv uvedieme niekoľko alternatív k rôznym aspektom navrhovaného spôsobu riešenia konfigurácie:

  1. Textový súbor na cieľovom počítači.
  2. Centralizované úložisko párov kľúč – hodnota (napr etcd/zookeeper).
  3. Komponenty podprocesu, ktoré je možné prekonfigurovať/reštartovať bez reštartovania procesu.
  4. Konfigurácia mimo kontroly artefaktov a verzií.

Textový súbor poskytuje určitú flexibilitu z hľadiska ad-hoc opráv. Administrátor systému sa môže prihlásiť do cieľového uzla, vykonať zmenu a jednoducho reštartovať službu. To nemusí byť také dobré pre väčšie systémy. Po zmene nezostali žiadne stopy. Zmena nie je preskúmaná iným párom očí. Môže byť ťažké zistiť, čo spôsobilo zmenu. Nebolo testované. Z pohľadu distribuovaného systému môže administrátor jednoducho zabudnúť aktualizovať konfiguráciu v jednom z ostatných uzlov.

(Btw, ak nakoniec bude potrebné začať používať textové konfiguračné súbory, budeme musieť pridať iba parser + validátor, ktorý by mohol produkovať to isté Config typu a to by stačilo na začatie používania textových konfigurácií. To tiež ukazuje, že zložitosť konfigurácie v čase kompilácie je o niečo menšia ako zložitosť textových konfigurácií, pretože v textovej verzii potrebujeme ďalší kód.)

Centralizované úložisko kľúč-hodnota je dobrým mechanizmom na distribúciu metaparametrov aplikácie. Tu sa musíme zamyslieť nad tým, čo považujeme za konfiguračné hodnoty a čo sú len údaje. Daná funkcia C => A => B zvyčajne nazývame zriedkavo meniace sa hodnoty C „konfigurácia“, pričom často menené údaje A - stačí zadať údaje. Konfigurácia by mala byť funkcii poskytnutá skôr ako údaje A. Vzhľadom na túto myšlienku môžeme povedať, že je to očakávaná frekvencia zmien, ktorá by sa dala použiť na rozlíšenie konfiguračných údajov od samotných údajov. Údaje zvyčajne pochádzajú z jedného zdroja (používateľ) a konfigurácia pochádza z iného zdroja (správca). Zaobchádzanie s parametrami, ktoré je možné zmeniť po procese inicializácie, vedie k zvýšeniu zložitosti aplikácie. Pri takýchto parametroch budeme musieť zvládnuť ich mechanizmus doručenia, analýzu a validáciu, spracovanie nesprávnych hodnôt. Preto, aby sme znížili zložitosť programu, mali by sme radšej znížiť počet parametrov, ktoré sa môžu meniť za behu (alebo ich dokonca úplne odstrániť).

Z pohľadu tohto príspevku by sme mali rozlišovať medzi statickými a dynamickými parametrami. Ak servisná logika vyžaduje zriedkavú zmenu niektorých parametrov za behu, môžeme ich nazvať dynamické parametre. V opačnom prípade sú statické a mohli by byť nakonfigurované pomocou navrhovaného prístupu. Pre dynamickú rekonfiguráciu môžu byť potrebné iné prístupy. Časti systému môžu byť napríklad reštartované s novými konfiguračnými parametrami podobným spôsobom ako reštartovanie samostatných procesov distribuovaného systému.
(Môj skromný názor je vyhnúť sa rekonfigurácii runtime, pretože to zvyšuje zložitosť systému.
Môže byť jednoduchšie spoľahnúť sa na podporu OS pri reštartovaní procesov. Aj keď to nemusí byť vždy možné.)

Jedným z dôležitých aspektov používania statickej konfigurácie, ktorý niekedy núti ľudí zvážiť dynamickú konfiguráciu (bez iných dôvodov), je výpadok služby počas aktualizácie konfigurácie. V skutočnosti, ak musíme vykonať zmeny v statickej konfigurácii, musíme reštartovať systém, aby sa nové hodnoty stali účinnými. Požiadavky na prestoje sa pre rôzne systémy líšia, takže to nemusí byť také kritické. Ak je to kritické, potom musíme vopred naplánovať akékoľvek reštarty systému. Mohli by sme napríklad implementovať Vypúšťanie prípojky AWS ELB. V tomto scenári vždy, keď potrebujeme reštartovať systém, spustíme paralelne novú inštanciu systému, potom na ňu prepneme ELB, pričom necháme starý systém, aby dokončil obsluhu existujúcich pripojení.

Čo tak ponechať konfiguráciu vnútri artefaktu verzie alebo vonku? Ponechanie konfigurácie vnútri artefaktu vo väčšine prípadov znamená, že táto konfigurácia prešla rovnakým procesom zabezpečenia kvality ako ostatné artefakty. Takže si môžete byť istí, že konfigurácia je kvalitná a dôveryhodná. Naopak, konfigurácia v samostatnom súbore znamená, že neexistujú žiadne stopy toho, kto a prečo vykonal zmeny v tomto súbore. Je to dôležité? Sme presvedčení, že pre väčšinu výrobných systémov je lepšie mať stabilnú a vysokokvalitnú konfiguráciu.

Verzia artefaktu umožňuje zistiť, kedy bol vytvorený, aké hodnoty obsahuje, aké funkcie sú povolené/deaktivované, kto bol zodpovedný za vykonanie jednotlivých zmien v konfigurácii. Udržať konfiguráciu vnútri artefaktu môže vyžadovať určité úsilie a je to voľba dizajnu.

Výhody nevýhody

Tu by sme chceli zdôrazniť niektoré výhody a diskutovať o niektorých nevýhodách navrhovaného prístupu.

výhody

Vlastnosti kompilovateľnej konfigurácie kompletného distribuovaného systému:

  1. Statická kontrola konfigurácie. To dáva vysokú úroveň istoty, že konfigurácia je správna vzhľadom na obmedzenia typu.
  2. Bohatý jazyk konfigurácie. Typicky sú iné konfiguračné prístupy obmedzené nanajvýš na variabilnú substitúciu.
    Pomocou programu Scala je možné použiť širokú škálu jazykových funkcií na zlepšenie konfigurácie. Napríklad môžeme použiť vlastnosti na poskytnutie predvolených hodnôt, objektov na nastavenie iného rozsahu, na ktoré sa môžeme odvolávať vals definované iba raz vo vonkajšom rozsahu (DRY). Je možné použiť doslovné sekvencie alebo inštancie určitých tried (Seq, Map, Atď).
  3. DSL. Scala má slušnú podporu pre DSL zapisovače. Tieto funkcie je možné použiť na vytvorenie konfiguračného jazyka, ktorý je pohodlnejší a príjemnejší pre koncového používateľa, takže konečná konfigurácia je aspoň čitateľná pre používateľov domény.
  4. Integrita a súdržnosť medzi uzlami. Jednou z výhod konfigurácie pre celý distribuovaný systém na jednom mieste je to, že všetky hodnoty sú striktne definované raz a potom znova použité na všetkých miestach, kde ich potrebujeme. Deklarácie bezpečného portu tiež zaisťujú, že vo všetkých možných správnych konfiguráciách budú uzly systému hovoriť rovnakým jazykom. Medzi uzlami existujú explicitné závislosti, čo sťažuje zabudnutie na poskytovanie niektorých služieb.
  5. Vysoká kvalita zmien. Celkový prístup prechodu zmien konfigurácie cez normálny proces PR vytvára vysoké štandardy kvality aj v konfigurácii.
  6. Simultánne zmeny konfigurácie. Vždy, keď vykonáme akékoľvek zmeny v konfigurácii, automatické nasadenie zaistí, že sa aktualizujú všetky uzly.
  7. Zjednodušenie aplikácie. Aplikácia nemusí analyzovať a overovať konfiguráciu a spracovávať nesprávne konfiguračné hodnoty. To zjednodušuje celkovú aplikáciu. (Istý nárast zložitosti je v samotnej konfigurácii, ale je to vedomý kompromis smerom k bezpečnosti.) Návrat k bežnej konfigurácii je celkom jednoduchý – stačí pridať chýbajúce časti. Je jednoduchšie začať s skompilovanou konfiguráciou a odložiť implementáciu ďalších častí na neskôr.
  8. Verzia konfigurácie. Vzhľadom na to, že zmeny konfigurácie sa riadia rovnakým vývojovým procesom, výsledkom je artefakt s jedinečnou verziou. V prípade potreby nám umožňuje prepnúť konfiguráciu späť. Môžeme dokonca nasadiť konfiguráciu, ktorá bola používaná pred rokom a bude fungovať úplne rovnako. Stabilná konfigurácia zlepšuje predvídateľnosť a spoľahlivosť distribuovaného systému. Konfigurácia je fixná v čase kompilácie a nie je možné ju ľahko sfalšovať v produkčnom systéme.
  9. Modularita. Navrhovaný rámec je modulárny a moduly je možné rôznymi spôsobmi kombinovať
    podporujú rôzne konfigurácie (nastavenia/rozloženia). Najmä je možné mať rozloženie jedného uzla v malom meradle a nastavenie viacerých uzlov vo veľkom meradle. Je rozumné mať viacero výrobných rozložení.
  10. Testovanie. Na testovacie účely je možné implementovať simulovanú službu a použiť ju ako závislosť typovo bezpečným spôsobom. Súčasne by sa mohlo zachovať niekoľko rôznych testovacích rozložení s rôznymi časťami nahradenými falošnými.
  11. Integračné testovanie. Niekedy je v distribuovaných systémoch ťažké spustiť integračné testy. Použitím opísaného prístupu k typovo bezpečnej konfigurácii kompletného distribuovaného systému môžeme riadiť všetky distribuované časti na jednom serveri. Je ľahké napodobniť situáciu
    keď sa niektorá zo služieb stane nedostupnou.

Nevýhody

Kompilovaný konfiguračný prístup sa líši od „normálnej“ konfigurácie a nemusí vyhovovať všetkým potrebám. Tu sú niektoré z nevýhod skompilovanej konfigurácie:

  1. Statická konfigurácia. Nemusí byť vhodný pre všetky aplikácie. V niektorých prípadoch je potrebné rýchlo opraviť konfiguráciu vo výrobe, pričom sa obídu všetky bezpečnostné opatrenia. Tento prístup to sťažuje. Po vykonaní akejkoľvek zmeny v konfigurácii sa vyžaduje kompilácia a opätovné nasadenie. Toto je vlastnosť aj záťaž.
  2. Generovanie konfigurácie. Keď je konfigurácia generovaná nejakým automatizačným nástrojom, tento prístup vyžaduje následnú kompiláciu (čo môže zase zlyhať). Integrácia tohto dodatočného kroku do systému zostavovania môže vyžadovať ďalšie úsilie.
  3. Nástroje. V súčasnosti sa používa množstvo nástrojov, ktoré sa spoliehajú na textové konfigurácie. Niektorí z nich
    nebude možné použiť pri kompilácii konfigurácie.
  4. Je potrebná zmena myslenia. Vývojári a DevOps poznajú textové konfiguračné súbory. Myšlienka kompilácie konfigurácie sa im môže zdať zvláštna.
  5. Pred zavedením kompilovateľnej konfigurácie je potrebný proces vývoja softvéru vysokej kvality.

Implementovaný príklad má určité obmedzenia:

  1. Ak poskytneme dodatočnú konfiguráciu, ktorú implementácia uzla nevyžaduje, kompilátor nám nepomôže odhaliť chýbajúcu implementáciu. Toto by sa dalo riešiť použitím HList alebo ADT (triedy prípadov) pre konfiguráciu uzla namiesto vlastností a vzoru koláča.
  2. Musíme poskytnúť nejaký štandardný kód v konfiguračnom súbore: (package, import, object vyhlásenia;
    override def's pre parametre, ktoré majú predvolené hodnoty). Toto môže byť čiastočne vyriešené pomocou DSL.
  3. V tomto príspevku sa nezaoberáme dynamickou rekonfiguráciou zhlukov podobných uzlov.

záver

V tomto príspevku sme diskutovali o myšlienke reprezentovať konfiguráciu priamo v zdrojovom kóde typovo bezpečným spôsobom. Tento prístup by sa dal použiť v mnohých aplikáciách ako náhrada xml a iných textových konfigurácií. Napriek tomu, že náš príklad bol implementovaný v Scale, mohol byť preložený aj do iných kompilovateľných jazykov (ako Kotlin, C#, Swift atď.). Dalo by sa vyskúšať tento prístup v novom projekte a v prípade, že nebude dobre sedieť, prejsť na staromódny spôsob.

Kompilovateľná konfigurácia si samozrejme vyžaduje vysokokvalitný vývojový proces. Na oplátku sľubuje rovnako kvalitnú robustnú konfiguráciu.

Tento prístup je možné rozšíriť rôznymi spôsobmi:

  1. Dalo by sa použiť makrá na vykonanie overenia konfigurácie a zlyhanie pri kompilácii v prípade zlyhania akýchkoľvek obmedzení obchodnej logiky.
  2. DSL by sa mohlo implementovať na reprezentáciu konfigurácie spôsobom, ktorý je užívateľsky prívetivý pre doménu.
  3. Dynamická správa zdrojov s automatickými úpravami konfigurácie. Napríklad, keď upravíme počet klastrových uzlov, môžeme chcieť (1) aby uzly získali mierne upravenú konfiguráciu; (2) manažér klastra na získanie informácií o nových uzloch.

Vďaka

Rád by som sa poďakoval Andrey Saksonovovi, Pavlovi Popovovi, Antonovi Nehaevovi za inšpiratívnu spätnú väzbu na návrh tohto príspevku, ktorý mi pomohol objasniť ho.

Zdroj: hab.com