編譯的分散式系統配置

我想告訴您一種處理分散式系統配置的有趣機制。 配置使用安全性類型直接以編譯語言 (Scala) 表示。 這篇文章提供了此類配置的範例,並討論了在整個開發過程中實現編譯配置的各個方面。

編譯的分散式系統配置

(英語)

介紹

建立可靠的分散式系統意味著所有節點都使用正確的配置,並與其他節點同步。 DevOps 技術(terraform、ansible 或類似技術)通常用於自動產生設定檔(通常特定於每個節點)。 我們還希望確保所有通訊節點都使用相同的協定(包括相同的版本)。 否則,我們的分散式系統將出現不相容性。 在 JVM 世界中,這項要求的一個後果是必須在所有地方使用包含協定訊息的相同版本的函式庫。

測試分散式系統怎麼樣?當然,我們假設所有元件在進行整合測試之前都進行了單元測試。 (為了讓我們將測試結果推斷到運行時,我們還必須在測試階段和運行時提供一組相同的庫。)

在進行整合測試時,在所有節點上的任何地方使用相同的類路徑通常會更容易。我們所要做的就是確保在運行時使用相同的類路徑。 (雖然完全可以使用不同的類別路徑來運行不同的節點,但這確實增加了整體配置的複雜性以及部署和整合測試的難度。)出於本文的目的,我們假設所有節點都將使用相同的類別路徑。

配置隨應用程式而發展。 我們使用版本來識別程式演化的不同階段。 識別不同版本的配置似乎也是合乎邏輯的。 並將配置本身放入版本控制系統中。 如果生產中只有一種配置,那麼我們可以簡單地使用版本號。 如果我們使用許多生產實例,那麼我們將需要多個
配置分支和除版本之外的附加標籤(例如分支的名稱)。 這樣我們就可以清楚地辨識出準確的配置。 每個配置標識符唯一對應於分散式節點、連接埠、外部資源和庫版本的特定組合。 出於本文的目的,我們將假設只有一個分支,並且我們可以使用由點分隔的三個數字(1.2.3)以通常的方式識別配置。

在現代環境中,很少手動建立設定檔。 更常見的是,它們是在部署期間產生的並且不再被觸及(以便 不要破壞任何東西)。 一個自然的問題出現了:為什麼我們仍然使用文字格式來儲存配置? 一個可行的替代方案似乎是能夠使用常規程式碼進行配置並從編譯時檢查中受益。

在這篇文章中,我們將探討在編譯的工件中表示配置的想法。

編譯配置

本節提供靜態編譯配置的範例。 實作了兩個簡單的服務 - echo 服務和 echo 服務用戶端。 基於這兩項服務,組裝了兩個系統選項。 在一個選項中,兩個服務位於同一節點上,在另一個選項中,兩個服務位於不同的節點上。

通常,分散式系統包含多個節點。 您可以使用某種類型的值來識別節點 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 位元整數。 對於已編譯的配置,使用精煉函式庫不是強制性的,但它提高了編譯器檢查配置的能力。

對於HTTP(REST)協議,除了連接埠號碼之外,我們可能還需要服務的路徑:

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

幻影類型

為了在編譯時識別協議,我們使用類別中未使用的類型參數。 這個決定是因為我們在執行時間不使用協定實例,但我們希望編譯器檢查協定相容性。 透過指定協議,我們將無法將不適當的服務作為依賴項傳遞。

常見協定之一是帶有 Json 序列化的 REST API:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

哪裡 RequestMessage - 請求類型, ResponseMessage — 回應類型。
當然,我們可以使用其他協議描述來提供我們所需的描述準確性。

出於本文的目的,我們將使用該協議的簡化版本:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

這裡的請求是附加到 url 的字串,回應是 HTTP 回應正文中傳回的字串。

服務配置由服務名稱、連接埠和相依性描述。 這些元素可以在 Scala 中以多種方式表示(例如, HList-s,代數資料型態)。 出於本文的目的,我們將使用蛋糕模式並使用以下方式表示模組 trait'ov。 (蛋糕模式不是此方法的必需元素。它只是一種可能的實現。)

服務之間的依賴關係可以表示為返回連接埠的方法 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)
  }

若要建立回顯服務,您只需要一個連接埠號碼和該連接埠支援回顯協定的指示。 我們可能不會指定特定端口,因為... 特徵允許您聲明方法而不實現(抽象方法)。 在這種情況下,當建立特定配置時,編譯器將要求我們提供抽象方法的實作並提供連接埠號碼。 由於我們已經實作了該方法,因此在建立特定配置時,我們可能不會指定不同的連接埠。 將使用預設值。

在客戶端配置中,我們聲明對 echo 服務的依賴:

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

依賴項與匯出的服務類型相同 echoService。 特別是,在 echo 用戶端中我們需要相同的協定。 因此,當連接兩個服務時,我們可以確定一切都會正常運作。

服務實施

需要一個函數來啟動和停止服務。 (停止服務的能力對於測試至關重要。)同樣,有多種選項可以實現此類功能(例如,我們可以使用基於配置類型的類型類別)。 出於本文的目的,我們將使用蛋糕模式。 我們將使用類別來表示服務 cats.Resource, 因為此類已經提供了在出現問題時安全保證資源釋放的方法。 為了獲取資源,我們需要提供配置和現成的運行時上下文。 服務啟動函數可以如下所示:

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

(公分。 源代碼,其中實現了其他服務 - 迴聲服務, 回顯客戶端
и 壽命控制器.)

節點是一個可以啟動多個服務的物件(資源鏈的啟動由蛋糕模式保證):

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

請注意,我們正在指定該節點所需的確切配置類型。 如果我們忘記指定特定服務所需的組態類型之一,就會出現編譯錯誤。 此外,除非我們提供帶有所有必要資料的適當類型的對象,否則我們將無法啟動節點。

主機名解析

要連接到遠端主機,我們需要一個真實的 IP 位址。 該位址可能會晚於配置的其餘部分而被知曉。 所以我們需要一個將節點 ID 對應到位址的函數:

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

此功能的實作方式有以下幾種:

  1. 如果我們在部署之前知道這些位址,那麼我們可以使用以下命令產生 Scala 程式碼
    地址,然後運行建置。 這將編譯並運行測試。
    在這種情況下,該函數將是靜態已知的,並且可以在程式碼中表示為映射 Map[NodeId, NodeAddress].
  2. 在某些情況下,實際位址只有在節點啟動後才知道。
    在這種情況下,我們可以實現一個在其他節點之前運行的“發現服務”,所有節點都會向該服務註冊並請求其他節點的位址。
  3. 如果我們可以修改 /etc/hosts,那麼您可以使用預先定義的主機名稱(例如 my-project-main-node и echo-backend)並簡單地連結這些名稱
    在部署期間使用 IP 位址。

在這篇文章中,我們不會更詳細地考慮這些情況。 為了我們的
在玩具範例中,所有節點都將具有相同的 IP 位址 - 127.0.0.1.

接下來,我們考慮分散式系統的兩種選擇:

  1. 將所有服務放在一個節點上。
  2. 並將 echo 服務和 echo 用戶端託管在不同的節點上。

配置為 一個節點:

單節點配置

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 也可以正常工作並釋放所有資源。)

同一組配置和實作特徵可用於建立一個由以下組成的系統 兩個獨立的節點:

兩節點配置

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

重要的! 請注意服務是如何連結的。 我們指定一個節點實現的服務作為另一個節點依賴方法的實作。 依賴類型由編譯器檢查,因為包含協定類型。 運行時,依賴項將包含正確的目標節點 ID。 由於這個方案,我們只指定一次連接埠號,並且始終保證引用正確的連接埠。

兩個系統節點的實現

對於此配置,我們使用相同的服務實現,無需更改。 唯一的區別是我們現在有兩個實現不同服務集的物件:

  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'om,客戶端節點在一段時間後終止。 厘米。 啟動器應用程式.

一般開發流程

讓我們看看這種配置方法如何影響整個開發過程。

此配置將與其餘程式碼一起編譯,並產生一個工件 (.jar)。 將配置放在單獨的工件中似乎是有意義的。 這是因為我們可以基於相同的程式碼有多種配置。 同樣,可以產生對應於不同配置分支的工件。 對特定版本庫的依賴關係與配置一起保存,並且每當我們決定部署該版本的配置時,這些版本都會永久保存。

任何配置變更都會變成程式碼變更。 因此,每個
正常的品質保證流程將涵蓋變更:

bug追蹤器中的票證 -> PR -> 審核 -> 與相關分支合併 ->
整合->部署

實現編譯配置的主要後果是:

  1. 配置在分散式系統的所有節點上都是一致的。 由於所有節點都從單一來源接收相同的配置。

  2. 僅更改其中一個節點的配置是有問題的。 因此,「配置漂移」的可能性不大。

  3. 對配置進行小的更改變得更加困難。

  4. 大多數配置變更將作為整個開發過程的一部分進行,並將接受審查。

我是否需要一個單獨的儲存庫來儲存生產配置? 此配置可能包含我們希望限制存取的密碼和其他敏感資訊。 基於此,將最終配置儲存在單獨的儲存庫中似乎是有意義的。 您可以將配置分為兩部分:一部分包含可公開存取的配置設置,另一部分包含受限設定。 這將使大多數開發人員能夠存取通用設定。 使用包含預設值的中間特徵很容易實現這種分離。

可能的變化

讓我們嘗試將編譯的配置與一些常見的替代方案進行比較:

  1. 目標機器上的文字檔案。
  2. 集中式鍵值儲存(etcd/zookeeper).
  3. 無需重新啟動流程即可重新配置/重新啟動的流程元件。
  4. 在工件和版本控制之外儲存配置。

文字檔案在小的更改方面提供了顯著的靈活性。 系統管理員可以登入遠端節點,更改相應的檔案並重新啟動服務。 然而,對於大型系統,這種靈活性可能並不理想。 所做的更改不會在其他系統中留下任何痕跡。 沒有人審查這些變化。 很難確定到底是誰做出了這些改變以及出於什麼原因。 更改未經過測試。 如果系統是分散式的,那麼管理員可能會忘記在其他節點上進行相應的變更。

(還應該注意的是,使用編譯的配置並不會消除將來使用文字檔案的可能性。添加一個產生與輸出相同類型的解析器和驗證器就足夠了 Config,並且您可以使用文字檔案。 由此可見,具有已編譯配置的系統的複雜性略低於使用文字檔案的系統的複雜性,因為文字檔案需要額外的程式碼。)

集中式鍵值儲存是分發分散式應用程式元參數的良好機制。 我們需要決定什麼是配置參數,什麼只是資料。 讓我們有一個函數 C => A => B,以及參數 C 很少改變,且數據 A - 經常。 在這種情況下我們可以說 C - 配置參數,以及 A - 數據。 看來配置參數與資料的不同之處在於它們通常比資料更不頻繁地改變。 此外,資料通常來自一個來源(來自使用者),而配置參數來自另一個來源(來自系統管理員)。

如果很少更改的參數需要在不重新啟動程式的情況下進行更新,那麼這通常會導致程式的複雜化,因為我們需要以某種方式傳遞參數、儲存、解析和檢查以及處理不正確的值。 因此,從降低程式複雜度的角度來看,減少程式運行過程中可以改變的參數數量(或根本不支援此類參數)是有意義的。

出於本文的目的,我們將區分靜態參數和動態參數。 如果服務的邏輯需要在程式運行過程中改變參數,那麼我們將這樣的參數稱為動態的。 否則,選項是靜態的,可以使用編譯的配置進行配置。 對於動態重新配置,我們可能需要一種使用新參數重新啟動部分程式的機制,類似於作業系統進程的重新啟動方式。 (我們認為,建議避免即時重新配置,因為這會增加系統的複雜性。如果可能,最好使用標準作業系統功能來重新啟動進程。)

使用靜態配置使人們考慮動態重新配置的一個重要方面是配置更新後系統重新啟動所需的時間(停機時間)。 事實上,如果我們需要對靜態配置進行更改,則必須重新啟動系統才能使新值生效。 不同系統的停機問題的嚴重程度有所不同。 在某些情況下,您可以安排在負載最小時重新啟動。 如果您需要提供持續的服務,您可以實施 AWS ELB 連線耗盡。 同時,當我們需要重新啟動系統時,我們啟動該系統的平行實例,將平衡器切換到它,並等待舊連線完成。 所有舊連線終止後,我們關閉系統的舊實例。

現在讓我們考慮將配置儲存在工件內部或外部的問題。 如果我們將配置儲存在工件內,那麼至少我們有機會在工件組裝期間驗證配置的正確性。 如果配置位於受控工件之外,則很難追蹤誰對此文件進行了更改以及原因。 它有多重要? 我們認為,對於許多生產系統來說,擁有穩定且高品質的配置非常重要。

工件的版本可讓您確定它的建立時間、包含哪些值、啟用/停用哪些功能以及誰負責配置中的任何變更。 當然,將配置儲存在工件中需要付出一些努力,因此您需要做出明智的決定。

優點和缺點

我想詳細談談所提出的技術的優點和缺點。

優點

以下是已編譯的分散式系統配置的主要功能清單:

  1. 靜態配置檢查。 讓您確定
    配置正確。
  2. 豐富的配置語言。 通常,其他配置方法最多僅限於字串變數替換。 使用 Scala 時,可以使用多種語言功能來改善您的配置。 例如我們可以使用
    預設值的特徵,使用物件對參數進行分組,我們可以引用在封閉範圍內僅聲明一次(DRY)的值。 您可以直接在配置中實例化任何類別(Seq, Map,自訂類別)。
  3. DSL。 Scala 具有許多語言功能,可以更輕鬆地建立 DSL。 可以利用這些特性,實現更方便目標使用者群的配置語言,使得配置至少是領域專家可讀的。 例如,專家可以參與配置審核過程。
  4. 節點之間的完整性和同步性。 將整個分散式系統的配置儲存在單點的優點之一是所有值都只聲明一次,然後在需要的地方重複使用。 使用幻像類型聲明連接埠可確保節點在所有正確的系統配置中使用相容的協定。 節點之間具有明確的強制依賴關係可確保所有服務都已連線。
  5. 高品質的變革。 使用通用開發流程對配置進行更改也可以實現配置的高品質標準。
  6. 同時更新配置。 配置變更後自動進行系統部署,確保所有節點均已更新。
  7. 簡化應用程式。 應用程式不需要解析、配置檢查或處理不正確的值。 這降低了應用程式的複雜性。 (在我們的範例中觀察到的一些配置複雜性並不是編譯配置的屬性,而只是由提供更高類型安全性的願望驅動的有意識的決定。)返回到通常的配置非常容易- 只需實現缺失的配置即可部分。 因此,例如,您可以從編譯的配置開始,將不必要的部分的實作推遲到真正需要的時候。
  8. 已驗證配置。 由於配置變更遵循任何其他變更的通常命運,因此我們得到的輸出是具有唯一版本的工件。 例如,這允許我們在必要時返回到配置的先前版本。 我們甚至可以使用一年前的配置,系統的工作方式將完全相同。 穩定的配置可以提高分散式系統的可預測性和可靠性。 由於配置在編譯階段是固定的,因此在生產中很難偽造它。
  9. 模組化。 所提出的框架是模組化的,模組可以以不同的方式組合以創建不同的系統。 特別是,您可以將系統配置為在一個實施例中在單一節點上運行,而在另一個實施例中在多個節點上運行。 您可以為系統的生產實例建立多種配置。
  10. 測試。 透過模擬物件取代單一服務,您可以獲得多個便於測試的系統版本。
  11. 集成測試。 整個分散式系統採用單一配置,可在受控環境中運行所有元件,作為整合測試的一部分。 例如,很容易模擬某些節點變得可存取的情況。

缺點和限制

編譯配置與其他配置方法不同,可能不適合某些應用程式。 以下是一些缺點:

  1. 靜態配置。 有時您需要在生產中快速修正配置,繞過所有保護機制。 使用這種方法可能會更加困難。 最起碼還是需要編譯自動部署。 這既是該方法的一個有用特性,但在某些情況下也是一個缺點。
  2. 配置生成。 如果設定檔是由自動工具產生的,則可能需要額外的工作來整合建置腳本。
  3. 工具。 目前,設計用於配置的實用程式和技術基於文字檔案。 並非所有此類實用程式/技術都可以在編譯的配置中使用。
  4. 需要改變態度。 開發人員和 DevOps 習慣於文字檔案。 編譯配置的想法可能有點出乎意料和不尋常,並導致拒絕。
  5. 需要高品質的開發流程。 為了舒適地使用編譯後的配置,建置和部署應用程式 (CI/CD) 流程的完全自動化是必要的。 不然的話會很不方便。

讓我們也詳細討論所考慮的範例的一些與編譯配置的想法無關的限制:

  1. 如果我們提供了節點未使用的不必要的配置訊息,那麼編譯器將無法幫助我們檢測遺失的實作。 這個問題可以透過放棄 Cake Pattern 並使用更嚴格的類型來解決,例如, HList 或代數資料型態(案例類)來表示配置。
  2. 設定檔中有一些與設定本身無關的行:(package, import,對象聲明; override def用於具有預設值的參數)。 如果您實作自己的 DSL,則可以部分避免這種情況。 此外,其他類型的配置(例如XML)也對檔案結構施加了一定的限制。
  3. 出於本文的目的,我們不考慮動態重新配置類似節點的叢集。

結論

在這篇文章中,我們探討了使用 Scala 類型系統的高階功能在原始程式碼中表示配置的想法。 這種方法可以在各種應用程式中使用,作為基於 xml 或文字檔案的傳統配置方法的替代。 儘管我們的範例是用 Scala 實現的,但相同的想法可以轉移到其他編譯語言(例如 Kotlin、C#、Swift 等)。 您可以在以下項目之一中嘗試此方法,如果不起作用,請繼續處理文字文件,並添加缺少的部分。

當然,編譯的配置需要高品質的開發過程。 作為回報,確保了配置的高品質和可靠性。

所考慮的方法可以擴展:

  1. 您可以使用巨集來執行編譯時檢查。
  2. 您可以實施 DSL,以最終使用者可以存取的方式呈現配置。
  3. 您可以透過自動配置調整來實現動態資源管理。 例如,更改叢集中的節點數量需要 (1) 每個節點接收略有不同的配置; (2)叢集管理器收到新節點的資訊。

致謝

我要感謝安德烈·薩克索諾夫、帕維爾·波波夫和安東·涅哈耶夫對本條草案提出的建設性批評。

來源: www.habr.com

添加評論