Кампіляваная канфігурацыя размеркаванай сістэмы

Хацелася б расказаць адзін цікавы механізм працы з канфігурацыяй размеркаванай сістэмы. Канфігурацыя прадстаўлена напрамую ў кампіляванай мове (Scala) з выкарыстаннем бяспечных тыпаў. У гэтым пасце разабраны прыклад такой канфігурацыі і разгледжаны розныя аспекты ўкаранення кампіляванай канфігурацыі ў агульны працэс распрацоўкі.

Кампіляваная канфігурацыя размеркаванай сістэмы

(англійская)

Увядзенне

Пабудова надзейнай размеркаванай сістэмы мае на ўвазе, што на ўсіх вузлах выкарыстоўваецца карэктная канфігурацыя, сінхранізаваная з іншымі вузламі. Звычайна выкарыстоўваецца тэхналогіі DevOps (terraform, ansible ці нешта накшталт) для аўтаматычнай генерацыі канфігурацыйных файлаў (часта сваіх для кожнага вузла). Нам таксама хацелася б быць упэўненымі ў тым, што на ўсіх узаемадзейных вузлах выкарыстоўваюцца ідэнтычныя пратаколы (у тым ліку, аднолькавай версіі). У адваротным выпадку ў нашай размеркаванай сістэме будзе закладзена несумяшчальнасць. У свеце JVM адным са следстваў такога патрабавання з'яўляецца неабходнасць выкарыстання ўсюды адной і той жа версіі бібліятэкі, утрымоўвальнай паведамленні пратаколу.

Што наконт тэсціравання размеркаванай сістэмы? Зразумела, мы мяркуем, што для ўсіх кампанентаў прадугледжаны unit-тэсты, перш, чым мы пяройдзем да інтэграцыйнага тэсціравання. (Каб мы маглі экстрапаляваць вынікі тэставання на runtime, мы таксама павінны забяспечыць ідэнтычны набор бібліятэк на этапе тэставання і ў runtime'е.)

Пры працы з інтэграцыйнымі тэстамі часта прасцей усюды выкарыстоўваць адзіных classpath на ўсіх вузлах. Нам застанецца толькі забясьпечыць, каб той жа самы classpath быў задзейнічаны і ў runtime. (Нягледзячы на ​​тое, што цалкам магчыма запускаць розныя вузлы з рознымі classpath'амі, гэта прыводзіць да ўскладнення ўсёй канфігурацыі і цяжкасцям з разгортваннем і інтэграцыйнымі тэстамі.) У рамках гэтай пасады мы зыходзім з таго, што на ўсіх вузлах будзе выкарыстоўвацца аднолькавы classpath.

Канфігурацыя развіваецца разам з дадаткам. Для ідэнтыфікацыі розных стадый эвалюцыі праграм мы выкарыстоўваем версіі. Па-відаць, лагічна таксама ідэнтыфікаваць і розныя версіі канфігурацый. А саму канфігурацыю змясціць у сістэму кантролю версій. Калі ў production'е існуе адзіная канфігурацыя, то мы можам выкарыстоўваць проста нумар версіі. Калі ж выкарыстоўваецца мноства асобнікаў production, то нам запатрабуецца некалькі
галін канфігурацыі і дадатковая пазнака апроч версіі (напрыклад, назва галінкі). Тым самым мы зможам адназначна ідэнтыфікаваць дакладную канфігурацыю. Кожны ідэнтыфікатар канфігурацыі адназначна адпавядае вызначанай камбінацыі размеркаваных вузлоў, партоў, вонкавых рэсурсаў, версій бібліятэк. У рамках гэтай пасады мы будзем зыходзіць з таго, што маецца толькі адна галіна, і мы можам ідэнтыфікаваць канфігурацыю звычайнай выявай з выкарыстаннем трох лікаў, падзеленых кропкай (1.2.3).

У сучасных асяроддзі канфігурацыйныя файлы ўручную ствараюцца даволі рэдка. Часцей яны генеруюцца падчас разгортвання і больш іх ужо не чапаюць (каб нічога не зламаць). Узнікае заканамернае пытанне, чаму мы ўсё яшчэ выкарыстоўваем тэкставы фармат для захоўвання канфігурацыі? Цалкам жыццяздольнай альтэрнатывай выглядае магчымасць выкарыстоўваць звычайны код для канфігурацыі і атрымаць перавагі за рахунак праверак падчас кампіляцыі.

У сапраўдным пасце мы як раз даследуем ідэю прадстаўлення канфігурацыі ўнутры кампіляванага артэфакта.

Кампіляваная канфігурацыя

У гэтым раздзеле разгледжаны прыклад статычнай кампіляванай канфігурацыі. Рэалізуюцца два простых сэрвісу - рэха сэрвіс і кліент рэха сэрвісу. На аснове гэтых двух сэрвісаў збіраюцца два варыянты сістэмы. У адным варыянце абодва сэрвісу размяшчаюцца на адным вузле, у іншым варыянце - на розных вузлах.

Звычайна размеркаваная сістэма змяшчае некалькі вузлоў. Можна ідэнтыфікаваць вузлы з дапамогай значэнняў некаторага тыпу NodeId:

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

або

case class NodeId(hostName: String)

ці нават

object Singleton
type NodeId = Singleton.type

Вузлы выконваюць розныя ролі, на іх запушчаны сэрвісы і паміж імі могуць быць усталяваны TCP/HTTP сувязі.

Для апісання TCP сувязі нам патрабуецца сама меней нумар порта. Мы таксама хацелі б адлюстраваць той пратакол, які падтрымліваецца на гэтым порце, каб гарантаваць, што і кліент і сервер карыстаюцца адным і тым жа пратаколам. Будзем апісваць злучэнне з дапамогай такога класа:

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

дзе Port - проста цэлы лік Int з указаннем дыяпазону дапушчальных значэнняў:

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

Удакладненыя тыпы

Глядзіце бібліятэку рафінаваны и мой даклад. Сцісла, бібліятэка дазваляе дадаваць да тыпаў абмежаванні, якія правяраюцца на этапе кампіляцыі. У дадзеным выпадку дапушчальнымі значэннямі нумара порта з'яўляюцца цэлыя 16-бітныя лікі. Для кампіляванай канфігурацыі выкарыстанне бібліятэкі refined не з'яўляецца абавязковым, але дазваляе палепшыць магчымасці кампілятара па праверцы канфігурацыі.

Для HTTP (REST) ​​пратаколаў акрамя нумара порта нам таксама можа запатрабавацца шлях да сэрвісу:

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

Фантомныя тыпы

Для ідэнтыфікацыі пратакола на этапе кампіляцыі мы выкарыстоўваем параметр тыпу, які не выкарыстоўваецца ўнутры класа. Такое рашэнне злучана з тым, што ў runtime'е мы асобнік пратаколу не выкарыстаны, але жадалі бы, каб кампілятар правяраў сумяшчальнасць пратаколаў. Дзякуючы ўказанню пратакола мы не зможам перадаць непрыдатны сэрвіс у якасці залежнасці.

Адным з распаўсюджаных пратаколаў з'яўляецца REST API з Json-серыялізацыяй:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

дзе RequestMessage - Тып запыту, ResponseMessage - Тып адказу.
Зразумела, можна выкарыстоўваць і іншыя апісанні пратаколаў, якія забяспечваюць якая патрабуецца нам дакладнасць апісання.

Для мэт сапраўднай пасады мы будзем выкарыстоўваць спрошчаную версію пратаколу:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Тут запыт уяўляе сабой радок, які дадаецца да url, а адказ — які вяртаецца радок у целе HTTP адказу.

Канфігурацыя сэрвісу апісваецца імем сэрвісу, партамі і залежнасцямі. Гэтыя элементы можна ўявіць у Scala некалькімі спосабамі (напрыклад, HList-амі, алгебраічнымі тыпамі дадзеных). Для мэт сапраўднага паста мы будзем выкарыстоўваць Cake Pattern і прадстаўляць модулі з дапамогай traitТоў. (Cake Pattern не з'яўляецца абавязковым элементам апісванага падыходу. Гэта проста адна з магчымых рэалізацый.)

Залежнасці паміж сэрвісамі можна прадставіць у выглядзе метадаў, якія вяртаюць парты EndPointТы іншых вузлоў:

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

Для стварэння рэха-сэрвісу дастаткова толькі нумары порта і ўказанні, што гэты порт падтрымлівае рэха-пратакол. Мы маглі б і не паказваць пэўны порт, т.я. trait'ы дазваляюць аб'яўляць метады без рэалізацыі (абстрактныя метады). У гэтым выпадку пры стварэнні канкрэтнай канфігурацыі кампілятар запатрабаваў бы ад нас падаць рэалізацыю абстрактнага метаду і падаць нумар порта. Бо мы рэалізавалі метад, то пры стварэнні пэўнай канфігурацыі мы можам не паказваць іншы порт. Будзе скарыстана значэнне па змаўчанні.

У канфігурацыі кліента мы аб'яўляем залежнасць ад рэха-сэрвісу:

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

Залежнасць мае той жа тып, што і экспартуемы сэрвіс echoService. У прыватнасці, у рэха-кліенце мы патрабуем той жа пратакол. Таму пры злучэнні двух сэрвісаў мы можам быць упэўнены, што ўсё будзе працаваць карэктна.

Рэалізацыя сэрвісаў

Для запуску і спыненні сэрвісу патрабуецца функцыя. (Магчымасць прыпынку сэрвісу крытычна важная для тэставання.) Ізноў-ткі ёсць некалькі варыянтаў рэалізацыі такой функцыі (напрыклад, мы маглі б выкарыстоўваць класы тыпаў на аснове тыпу канфігурацыі). Для мэт сапраўднага паста мы скарыстаемся Cake Pattern'ом. Мы будзем прадстаўляць сэрвіс з дапамогай класа cats.Resource, т.я. у гэтым класе ўжо прадугледжаны сродкі бяспечнага гарантаванага вызваленне рэсурсаў у выпадку праблем. Каб атрымаць рэсурс нам трэба падаць канфігурацыю і гатовы runtime-кантэкст. Функцыя запуску сэрвісу можа мець наступны выгляд:

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

дзе

  • Config - Тып канфігурацыі для гэтага сэрвісу
  • AddressResolver - аб'ект часу выканання, які дазваляе даведацца адрасы іншых вузлоў (гл. далей)

і астатнія тыпы з бібліятэкі cats:

  • F[_] - Тып эфекту (у найпростым выпадку F[A] можа быць проста функцыяй () => A. У гэтым пасце мы будзем выкарыстоўваць cats.IO.)
  • Reader[A,B] - Больш-менш сінонім функцыі A => B
  • cats.Resource - рэсурс, які можа быць атрыманы і вызвалены
  • Timer - таймер (дазваляе засыпаць на некаторы час і вымяраць інтэрвалы часу)
  • ContextShift - аналаг ExecutionContext
  • Applicative - Клас тыпу эфекту, які дазваляе аб'ядноўваць асобныя эфекты (амаль манада). У больш складаных прыкладаннях, відаць, лепш выкарыстоўваць Monad/ConcurrentEffect.

Карыстаючыся гэтай сігнатурай функцыі, мы можам рэалізаваць некалькі сэрвісаў. Напрыклад, сэрвіс, які нічога не робіць:

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

(Гл. зыходны код, у якім рэалізаваны іншыя сэрвісы рэха-сэрвіс, рэха кліент
и кантролеры часу жыцця.)

Вузел уяўляе сабой аб'ект, які можа стартаваць некалькі сэрвісаў (запуск ланцужка рэсурсаў забяспечваецца за кошт Cake Pattern'а):

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

Звярніце ўвагу, што мы паказваем дакладны тып канфігурацыі, якая патрабуецца для гэтага вузла. Калі мы забудземся паказаць нейкі з тыпаў канфігурацыі, патрабаваных асобным сэрвісам, то будзе памылка кампіляцыі. Таксама мы не зможам стартаваць вузел, калі не дамо нейкі аб'ект, які мае прыдатны тып з усімі неабходнымі дадзенымі.

Дазвол імён вузлоў

Каб злучыцца з выдаленым вузлом, нам патрабуецца рэальны IP-адрас. Магчыма, што адрас стане вядомы пазней, чым астатнія часткі канфігурацыі. Таму нам патрэбна функцыя, якая адлюстроўвае ідэнтыфікатар вузла на адрас:

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

Можна прапанаваць некалькі спосабаў рэалізацыі такой функцыі:

  1. Калі адрасы нам становяцца вядомыя да разгортвання, то мы можам згенераваць Scala-код з
    адрасамі і затым запусціць зборку. Пры гэтым будзе праведзена кампіляцыя і выкананы тэсты.
    У такім выпадку функцыя будзе вядома статычна і можа быць прадстаўлена ў кодзе ў выглядзе адлюстравання Map[NodeId, NodeAddress].
  2. У некаторых выпадках сапраўдны адрас становіцца вядомы толькі пасля запуску вузла.
    У гэтым выпадку мы можам рэалізаваць "сэрвіс выяўлення" (discovery), які запускаецца да астатніх вузлоў і ўсе вузлы будуць рэгістравацца ў гэтым сэрвісе і запытваць адрасы іншых вузлоў.
  3. Калі мы можам мадыфікаваць /etc/hosts, то можна выкарыстоўваць наканаваныя імёны хастоў (накшталт my-project-main-node и echo-backend) і проста звязваць гэтыя імёны
    з IP-адрасамі падчас разгортвання.

У рамках гэтай пасады мы не будзем разглядаць гэтыя выпадкі больш падрабязна. Для нашага
цацачнага прыкладу ўсе вузлы будуць мець адзін IP-адрас 127.0.0.1.

Далей разгледзім два варыянты размеркаванай сістэмы:

  1. Размяшчэнне ўсіх сэрвісаў на адным вузле.
  2. І размяшчэнне рэха-сэрвісу і рэха-кліента на розных вузлах.

Канфігурацыя для аднаго вузла:

Канфігурацыя для аднаго вузла

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

Аб'ект рэалізуе канфігурацыю і кліента і сервера. Таксама выкарыстоўваецца канфігурацыя часу жыцця, каб па сканчэнні інтэрвалу lifetime завяршыць працу праграмы. (Ctrl-C таксама працуе і карэктна вызваляе ўсе рэсурсы.)

Той жа самы набор trait'аў канфігурацыі і рэалізацый можна выкарыстоўваць для стварэння сістэмы, якая складаецца з двух асобных вузлоў:

Канфігурацыя для двух вузлоў

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

Важна! Звярніце ўвагу, як выконваецца звязванне сэрвісаў. Мы паказваем сэрвіс, які рэалізуецца адным вузлом у якасці рэалізацыі метаду-залежнасці іншага вузла. Тып залежнасці правяраецца кампілятарам, т.я. змяшчае тып пратакола. Пры запуску залежнасць будзе змяшчаць карэктны ідэнтыфікатар мэтавага вузла. Дзякуючы такой схеме мы паказваем нумар порта роўна адзін раз і заўсёды гарантавана спасылаемся на правільны порт.

Рэалізацыя двух вузлоў сістэмы

Для гэтай канфігурацыі мы выкарыстоўваем тыя ж рэалізацыі сэрвісаў без змен. Адзінае адрозненне заключаецца ў тым, што зараз у нас два аб'екты, якія рэалізуюць розныя наборы сэрвісаў:

  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
  }

Першы вузел рэалізуе сервер і мае патрэбу толькі ў сервернай канфігурацыі. Другі вузел рэалізуе кліент і выкарыстоўвае іншую частку канфігурацыі. Таксама абодва вузла маюць патрэбу ў кіраванні часам жыцця. Серверны вузел працуе неабмежавана доўга, пакуль не будзе спынены SIGTERMТым, а кліенцкі вузел завяршаецца праз некаторы час. Глядзі. дадатак запуску.

Агульны працэс распрацоўкі

Паглядзім, як гэты падыход да канфігуравання ўплывае на агульны працэс распрацоўкі.

Канфігурацыя будзе скампіляваная разам з астатнім кодам і будзе згенераваны артэфакт (.jar). Відаць, мае сэнс змясціць канфігурацыю ў асобны артэфакт. Гэта злучана з тым, што ў нас можа быць мноства канфігурацый на аснове аднаго і таго ж кода. Ізноў-ткі, можна генераваць артэфакты, якія адпавядаюць розным галінкам канфігурацыі. Разам з канфігурацыяй захоўваюцца залежнасці ад канкрэтных версій бібліятэк і гэтыя версіі захоўваюцца назаўжды, калі б мы ні вырашылі разгарнуць гэтую версію канфігурацыі.

Любая змена канфігурацыі ператвараецца ў змену кода. А значыць, кожнае такое
змена будзе ахоплена звычайным працэсам забеспячэння якасці:

Тыкет у багтрэкеры -> PR -> рэўю -> зліццё з адпаведнымі галінкамі ->
інтэграцыя -> разгортванне

Асноўныя наступствы ўкаранення кампіляванай канфігурацыі:

  1. Канфігурацыя будзе ўзгоднена на ўсіх вузлах размеркаванай сістэмы. У сілу таго, што ўсе вузлы атрымліваюць адну і тую ж канфігурацыю з адзінай крыніцы.

  2. Праблематычна змяніць канфігурацыю толькі ў адным з вузлоў. Таму "рассінхранізацыя канфігурацыі" (configuration drift) малаверагодная.

  3. Становіцца цяжэй уносіць невялікія змены ў канфігурацыю.

  4. Вялікая частка змен канфігурацыі будуць адбывацца ў рамках агульнага працэсу распрацоўкі і будзе падвергнутая рэўю.

Ці патрэбен асобны рэпазітар для захоўвання production-канфігурацыі? У такой канфігурацыі могуць змяшчацца паролі і іншая сакрэтная інфармацыя, доступ да якой мы хацелі б абмежаваць. Таму, відаць, мае сэнс захоўваць канчатковую канфігурацыю ў асобным рэпазітары. Можна падзяліць канфігурацыю на дзве часткі - адну, якая змяшчае агульнадаступныя параметры канфігурацыі, і іншую, якая змяшчае параметры абмежаванага доступу. Гэта дазволіць большасці распрацоўшчыкаў мець доступ агульным параметрам. Такога падзелу нескладана дасягнуць, выкарыстоўваючы прамежкавыя trait'ы, утрымоўвальныя значэнні па змаўчанні.

Магчымыя варыяцыі

Паспрабуем параўнаць кампіляваную канфігурацыю з некаторымі распаўсюджанымі альтэрнатывамі:

  1. Тэкставы файл на мэтавай машыне.
  2. Цэнтралізаванае сховішча ключ-значэнне (etcd/zookeeper).
  3. Кампаненты працэсу, якія могуць быць рэканфігураваны/перазапушчаны без перазапуску працэсу.
  4. Захоўванне канфігурацыі па-за артэфактам і кантролем версій.

Тэкставыя файлы падаюць значную гнуткасць з пункта гледжання невялікіх змен. Сістэмны адміністратар можа зайсці на выдалены вузел, унесці змены ў адпаведныя файлы і перазапусціць сэрвіс. Для вялікіх сістэм, аднак, такая гнуткасць можа быць непажаданай. Ад унесеных змен не застаецца слядоў у іншых сістэмах. Ніхто не ажыццяўляе review змен. Цяжка высветліць, хто менавіта ўносіў змены і па якой прычыне. Змяненні не тэстуюцца. Калі сістэма размеркаваная, то адміністратар можа забыцца ўнесці адпаведную змену на іншых вузлах.

(Таксама варта заўважыць, што ўжыванне кампіляванай канфігурацыі не зачыняе магчымасць выкарыстання тэкставых файлаў у будучыні. Досыць будзе дадаць парсер і валідатар, давальныя на вынахадзе той жа тып Config, і можна карыстацца тэкставымі файламі. Адгэтуль непасрэдна вынікае, што складанасць сістэмы з кампіляванай канфігурацыяй некалькі менш, чым складанасць сістэмы, выкарыстоўвалай тэкставыя файлы, т.к. для тэкставых файлаў патрабуецца дадатковы код.)

Цэнтралізаванае сховішча ключ-значэнне з'яўляецца добрым механізмам для размеркавання мета-параметраў размеркаванага прыкладання. Нам варта вызначыцца, што такое канфігурацыйныя параметры, а што - проста дадзеныя. Няхай у нас ёсць функцыя C => A => B, прычым параметры C рэдка мяняюцца, а дадзеныя A - Часта. У гэтым выпадку мы можам сказаць, што C - канфігурацыйныя параметры, а A - дадзеныя. Падобна, што канфігурацыйныя параметры адрозніваюцца ад дадзеных тым, што яны мяняюцца ў агульным выпадку радзей, чым дадзеныя. Таксама дадзеныя звычайна паступаюць з адной крыніцы (ад карыстача), а канфігурацыйныя параметры - з іншай (ад адміністратара сістэмы).

Калі рэдка-якія змяняюцца параметры патрабуецца абнаўляць без перазапуску праграмы, то часцяком гэта можа прыводзіць да ўскладнення праграмы, бо нам запатрабуецца нейкім чынам дастаўляць параметры, захоўваць, парсіць і правяраць, апрацоўваць некарэктныя значэнні. Таму, з пункту гледжання зніжэння складанасці праграмы, мае сэнс памяншаць колькасць параметраў, якія могуць мяняцца падчас працы праграмы (або зусім не падтрымліваць такія параметры).

З пункту гледжання сапраўднага посту, мы будзем адрозніваць статычныя і дынамічныя параметры. Калі логіка працы сэрвісу патрабуе змены параметраў падчас дзеянняў праграмы, то мы будзем зваць такія параметры дынамічнымі. У адваротным выпадку параметры з'яўляюцца статычнымі і могуць быць сканфігураваны з выкарыстаннем кампіляванай канфігурацыі. Для дынамічнай рэканфігурацыі нам можа запатрабавацца механізм перазапуску частак праграмы з новымі параметрамі аналагічна таму, як адбываецца перазапуск працэсаў аперацыйнай сістэмы. (На нашу думку, пажадана пазбягаць рэканфігурацыі ў рэальным часе, бо пры гэтым складанасць сістэмы ўзрастае. Калі магчыма, лепш карыстацца стандартнымі магчымасцямі АС па перазапуску працэсаў.)

Адным з важных аспектаў выкарыстання статычнай канфігурацыі, які прымушае людзей разглядаць дынамічнае рэканфігураванне, з'яўляецца час, які патрабуе сістэме для перазагрузкі пасля абнаўлення канфігурацыі (downtime). Сапраўды, калі нам трэба ўнесці змены ў статычную канфігурацыю, нам давядзецца перазапусціць сістэму, каб новыя значэнні ўступілі ў сілу. Праблема downtime'а мае розную вастрыню для розных сістэм. У некаторых выпадках можна запланаваць перазагрузку на такі час, калі нагрузка мінімальная. У выпадку, калі патрабуецца забяспечыць бесперапынны сервіс, можна рэалізаваць "дрэнаж злучэнняў" (AWS ELB connection draining). Пры гэтым, калі нам трэба перазагрузіць сістэму, мы запускаем раўналежны асобнік гэтай сістэмы, перамыкаем балансавальнік на яе, і чакаем, пакуль старыя злучэнні завершацца. Пасля таго, як усе старыя злучэнні завяршыліся, мы выключаем стары асобнік сістэмы.

Разгледзім зараз пытанне захоўвання канфігурацыі ўсярэдзіне артэфакта ці па-за ім. Калі мы захоўваем канфігурацыю ўсярэдзіне артэфакта, то, прынамсі, мы мелі магчымасць падчас зборкі артэфакта пераканацца ў карэктнасці канфігурацыі. У выпадку, калі канфігурацыя знаходзіцца па-за кантраляваным артэфактам, цяжка адсачыць хто і навошта ўносіў змены ў гэты файл. Наколькі важна? На наш погляд, для шматлікіх production-сістэм важна мець стабільную і высакаякасную канфігурацыю.

Версія артэфакта дазваляе вызначыць калі ён быў створаны, якія значэнні ўтрымоўвае, якія функцыі ўключаны/адключаныя, хто нясе адказнасць за любую змену ў канфігурацыі. Зразумела, захоўванне канфігурацыі ўнутры артэфакта патрабуе некаторых намаганняў, таму трэба прымаць усвядомленае рашэнне.

За і супраць

Хацелася б спыніцца на плюсах і мінусах прапанаванай тэхналогіі.

Перавагі

Ніжэй прыведзены спіс асноўных магчымасцяў кампіляванай канфігурацыі размеркаванай сістэмы:

  1. Статычная праверка канфігурацыі. Дазваляе быць упэўненымі ў тым, што
    канфігурацыя карэктная.
  2. Багатая мова канфігурацыі. Звычайна іншыя спосабы канфігуравання абмежаваныя максімум падстаноўкай радковых зменных. Пры выкарыстанні Scala становіцца даступны шырокі спектр магчымасцей мовы, каб палепшыць канфігурацыю. Напрыклад, мы можам выкарыстоўваць
    trait'ы для значэнняў па змаўчанні, з дапамогай аб'ектаў групаваць параметры, можам спасылацца на val'ы, абвешчаныя адзіны раз (DRY) у якая ахоплівае вобласці бачнасці. Можна непасрэдна ўнутры канфігурацыі інстанцаваць любыя класы (Seq, Map, карыстацкія класы).
  3. DSL. У Scala ёсць шэраг моўных магчымасцяў, якія палягчаюць стварэнне DSL. Можна скарыстацца гэтымі магчымасцямі і рэалізаваць мову канфігурацыі, якая была б зручнейшай для мэтавай групы карыстачоў, так, што канфігурацыя была бы па меншай меры чытэльнай спецыялістамі прадметнай вобласці. Спецыялісты могуць, напрыклад, удзельнічаць у працэсе рэўю канфігурацыі.
  4. Цэласнасць і сінхроннасць паміж вузламі. Адным з пераваг таго, што канфігурацыя цэлай размеркаванай сістэмы захоўваецца ў адзінай кропцы з'яўляецца тое, што ўсе значэнні аб'яўляюцца роўна адзін раз, а затым перавыкарыстоўваюцца ўсюды, дзе яны патрабуюцца. Выкарыстанне фантомных тыпаў для аб'явы партоў дазваляе гарантаваць, што ва ўсіх карэктных канфігурацыях сістэмы вузлы выкарыстоўваюць сумяшчальныя пратаколы. Наяўнасць відавочных абавязковых залежнасцяў паміж вузламі гарантуе, што ўсе сэрвісы будуць злучаны паміж сабой.
  5. Высокая якасць унясення змен. Унясенне змен у канфігурацыю, карыстаючыся агульным працэсам распрацоўкі, робіць даступнымі высокія стандарты якасці і для канфігурацыі.
  6. Адначасовае абнаўленне канфігурацыі. Аўтаматычнае разгортванне сістэмы пасля занясення змен у канфігурацыю дазваляе гарантаваць, што ўсе вузлы будуць абноўленыя.
  7. Спрашчэнне прыкладання. Прыкладанне не мае патрэбы ў парсігне, праверцы канфігурацыі і апрацоўцы некарэктных значэнняў. Тым самым складанасць прыкладання змяншаецца. (Некаторае ўскладненне канфігурацыі, якое назіраецца ў нашым прыкладзе, не з'яўляецца атрыбутам кампіляванай канфігурацыі, а толькі ўсвядомленым рашэннем, выкліканым жаданнем забяспечыць вялікую тыпа-бяспеку.) Досыць лёгка вярнуцца да звычайнай канфігурацыі – проста рэалізаваць адсутныя часткі. Таму можна, напрыклад, пачаць з кампіляванай канфігурацыі, адклаўшы рэалізацыю лішніх частак на той час, калі гэта сапраўды запатрабуецца.
  8. Версіянаваная канфігурацыя. Бо канфігурацыйныя змены вынікаюць звычайнаму лёсу любых іншых змен, то на вынахадзе мы атрымліваем артэфакт з унікальнай версіяй. Гэта дазваляе нам, напрыклад, вярнуцца да папярэдняй версіі канфігурацыі ў выпадку неабходнасці. Мы нават можам скарыстацца канфігурацыяй гадавой даўнасці і пры гэтым сістэма будзе працаваць у дакладнасці таксама. Стабільная канфігурацыя паляпшае прадказальнасць і надзейнасць размеркаванай сістэмы. Бо канфігурацыя зафіксаваная на этапе кампіляцыі, тое яе даволі цяжка падрабіць у production'е.
  9. Модульнасць. Прапанаваны фрэймворк з'яўляецца модульным, і модулі могуць быць скамбінаваны ў розных варыянтах для атрымання розных сістэм. У прыватнасці, можна ў адным варыянце сканфігураваць сістэму для запуску на адным вузле, а ў іншым - на некалькіх вузлах. Можна стварыць некалькі канфігурацый для production-экзэмпляраў сістэмы.
  10. Тэсціраванне. Замяніўшы асобныя сэрвісы на mock-аб'екты, можна атрымаць некалькі версій сістэмы, зручных для тэсціравання.
  11. Інтэграцыйнае тэсціраванне. Наяўнасць адзінай канфігурацыі ўсёй размеркаванай сістэмы забяспечвае магчымасць запуску ўсіх кампанентаў у кантраляваным асяроддзі ў рамках інтэграцыйнага тэсціравання. Лёгка эмуляваць, напрыклад, сітуацыю, калі некаторыя вузлы становяцца даступныя.

Недахопы і абмежаванні

Кампіляваная канфігурацыя адрозніваецца ад іншых падыходаў да канфігуравання і для некаторых прыкладанняў можа не падыходзіць. Ніжэй прыведзены некаторыя недахопы:

  1. Статычная канфігурацыя. Часам патрабуецца хутка паправіць канфігурацыю ў production'е, абыходзячы ўсе ахоўныя механізмы. У рамках гэтага падыходу гэта можа быць больш складана. Прынамсі кампіляцыя і аўтаматычнае разгортванне ўсё роўна запатрабуюцца. Гэта адначасова і карысная асаблівасць падыходу і недахоп у некаторых выпадках.
  2. Генерацыя канфігурацыі. У выпадку, калі канфігурацыйны файл генеруецца аўтаматычным інструментам, могуць запатрабавацца дадатковыя высілкі па інтэграцыі скрыпту зборкі.
  3. Інструментарый. У цяперашні час утыліты і методыкі, прызначаныя для працы з канфігурацыяй, заснаваны на тэкставых файлах. Не ўсе такія ўтыліты/методыкі будуць даступныя ў выпадку кампіляванай канфігурацыі.
  4. Патрабуецца змена поглядаў. Распрацоўнікі і DevOps абвыклі да тэкставых файлаў. Сама ідэя кампіляцыі канфігурацыі можа быць некалькі нечаканай і нязвыклай і выклікаць абурэнне.
  5. Патрабуецца высокаякасны працэс распрацоўкі. Каб з камфортам карыстацца кампіляванай канфігурацыяй неабходна поўная аўтаматызацыя працэсу зборкі і разгортванні прыкладання (CI/CD). У адваротным выпадку будзе дастаткова няёмка.

Спынімся таксама на шэрагу абмежаванняў разгледжанага прыкладу, не звязаных з ідэяй кампіляванай канфігурацыі:

  1. Калі мы даем лішнюю канфігурацыйную інфармацыю, якая не выкарыстоўваецца вузлом, то кампілятар не дапаможа нам выявіць адсутнасць рэалізацыі. Гэтую праблему можна вырашыць, калі адмовіцца ад Cake Pattern'а і выкарыстоўваць больш цвёрдыя тыпы, напрыклад, HList ці алгебраічныя тыпы дадзеных (case class'ы) для падання канфігурацыі.
  2. У файле канфігурацыі маюцца радкі, якія не адносяцца ўласна да канфігурацыі: (package, import, аб'явы аб'ектаў; override def'ы для параметраў, якія маюць значэння па змаўчанні). Часткова можна гэтага пазбегнуць, калі рэалізаваць свой DSL. Акрамя таго, іншыя віды канфігурацыі (напрыклад, XML) таксама накладваюць пэўныя абмежаванні на структуру файла.
  3. У рамках гэтай пасады мы не разглядаем дынамічную рэканфігурацыю кластара падобных вузлоў.

Заключэнне

У гэтым пасце мы разгледзелі ідэю падання канфігурацыі ў зыходным кодзе з выкарыстаннем развітых магчымасцяў сістэмы тыпаў Scala. Такі падыход можа знайсці прымяненне ў розных прыкладаннях у якасці замены традыцыйным спосабам канфігуравання на аснове xml-ці тэкставых файлаў. Нягледзячы на ​​тое, што наш прыклад рэалізаваны на Scala, тыя ж ідэі можна перанесці на іншыя кампіляваныя мовы (такія як Kotlin, C#, Swift, …). Гэты падыход можна апрабаваць у адным з наступных праектаў, і, у выпадку, калі ён не падыдзе, перайсці да тэкставых файла, дадаўшы адсутныя дэталі.

Натуральна, кампіляваная канфігурацыя патрабуе высакаякаснага працэсу распрацоўкі. Узамен забяспечваецца высокая якасць і надзейнасць канфігурацый.

Разгледжаны падыход можа быць пашыраны:

  1. Можна выкарыстоўваць макрасы для выканання праверак падчас кампіляцыі.
  2. Можна рэалізаваць DSL для прадстаўлення канфігурацыі ў даступным канчатковым карыстальнікам выглядзе.
  3. Можна рэалізаваць дынамічнае кіраванне рэсурсамі з аўтаматычнай падладкай канфігурацыі. Напрыклад, пры змене колькасці вузлоў у кластары патрабуецца, каб (1) кожны вузел атрымаў крыху адрозную канфігурацыю; (2) менеджэр кластара атрымліваў звесткі аб новых вузлах.

падзякі

Хацелася б падзякаваць Андрэю Саксонову, Паўлу Папову і Антону Няхаеву за канструктыўную крытыку чарнавіка артыкула.

Крыніца: habr.com

Дадаць каментар