Компільована конфігурація розподіленої системи

Хотілося б розповісти один цікавий механізм роботи зі зміною розподіленої системи. Конфігурація представлена ​​безпосередньо у компілюваній мові (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. Зберігання конфігурації поза артефактом та контролем версій.

Текстові файли надають значної гнучкості з погляду невеликих змін. Системний адміністратор може зайти на віддалений вузол, внести зміни до відповідних файлів та перезапустити сервіс. Для великих систем, однак, така гнучкість може бути небажаною. Від змін не залишається слідів в інших системах. Ніхто не здійснює перегляд змін. Важко встановити, хто саме вносив зміни і з якоїсь причини. Зміни не тестуються. Якщо система розподілена, адміністратор може забути внести відповідну зміну на інших вузлах.

(Також слід зауважити, що застосування компільованої конфігурації не закриває можливість використання текстових файлів у майбутньому. Достатньо буде додати парсер і валідатор, що дають на виході той самий тип 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

Додати коментар або відгук