编译的分布式系统配置

我想告诉您一种处理分布式系统配置的有趣机制。 配置使用安全类型直接以编译语言 (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)集群管理器收到新节点的信息。

致谢

我要感谢安德烈·萨克索诺夫、帕维尔·波波夫和安东·涅哈耶夫对本条草案提出的建设性批评。

来源: habr.com

添加评论