分布式系统的可编译配置

在这篇文章中,我们想分享一种处理分布式系统配置的有趣方法。
配置以类型安全的方式直接用 Scala 语言表示。 详细描述了示例实现。 讨论了该提案的各个方面,包括对整体开发过程的影响。

分布式系统的可编译配置

(нарусском)

介绍

构建健壮的分布式系统需要在所有节点上使用正确且一致的配置。 典型的解决方案是使用文本部署描述(terraform、ansible 或类似的东西)和自动生成的配置文件(通常专用于每个节点/角色)。 我们还希望在每个通信节点上使用相同版本的相同协议(否则我们会遇到不兼容问题)。 在 JVM 世界中,这意味着至少消息传递库在所有通信节点上应该具有相同的版本。

测试系统怎么样? 当然,在进行集成测试之前,我们应该对所有组件进行单元测试。 为了能够在运行时推断测试结果,我们应该确保所有库的版本在运行时和测试环境中保持相同。

运行集成测试时,在所有节点上使用相同的类路径通常会更容易。 我们只需要确保在部署时使用相同的类路径。 (可以在不同的节点上使用不同的类路径,但表示此配置并正确部署它更加困难。)因此,为了保持简单,我们将只考虑所有节点上相同的类路径。

配置往往与软件一起发展。 我们通常使用版本来标识各种
软件演化的阶段。 将配置覆盖在版本管理下并用一些标签来识别不同的配置似乎是合理的。 如果生产中只有一种配置,我们可以使用单一版本作为标识符。 有时我们可能有多个生产环境。 对于每个环境,我们可能需要一个单独的配置分支。 因此,配置可能会标有分支和版本,以唯一标识不同的配置。 每个分支标签和版本对应于每个节点上的分布式节点、端口、外部资源、类路径库版本的单个组合。 在这里,我们将仅介绍单个分支并通过三分量十进制版本 (1.2.3) 来识别配置,与其他工件相同。

在现代环境中,不再手动修改配置文件。 通常我们生成
部署时的配置文件和 永远不要碰它们 然后。 那么有人会问为什么我们仍然使用文本格式的配置文件呢? 一个可行的选择是将配置放置在编译单元内,并从编译时配置验证中受益。

在这篇文章中,我们将研究将配置保留在已编译工件中的想法。

可编译配置

在本节中,我们将讨论静态配置的示例。 正在配置和实现两个简单的服务——echo 服务和echo 服务的客户端。 然后实例化具有这两种服务的两个不同的分布式系统。 一种用于单节点配置,另一种用于两个节点配置。

典型的分布式系统由几个节点组成。 可以使用某种类型来识别节点:

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

精致型

我们 图书馆。 简而言之,它允许向其他类型添加编译时间约束。 在这种情况下 Int 只允许有可以表示端口号的16位值。 这种配置方法不需要使用该库。 看起来非常合适。

对于 HTTP(REST),我们可能还需要服务的路径:

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

幻影型

为了在编译期间识别协议,我们使用 Scala 声明类型参数的功能 Protocol 课堂上没有使用它。 这是一个所谓的 幻影型。 在运行时我们很少需要协议标识符的实例,这就是我们不存储它的原因。 在编译过程中,这种幻像类型提供了额外的类型安全性。 我们无法通过协议不正确的端口。

最广泛使用的协议之一是带有 Json 序列化的 REST API:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

哪里 RequestMessage 是客户端可以发送到服务器的消息的基本类型 ResponseMessage 是来自服务器的响应消息。 当然,我们可以创建其他协议描述,以所需的精度指定通信协议。

出于本文的目的,我们将使用该协议的更简单版本:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

在此协议中,请求消息附加到 url 中,响应消息以纯字符串形式返回。

服务配置可以通过服务名称、端口集合和一些依赖项来描述。 有几种可能的方法可以在 Scala 中表示所有这些元素(例如, HList,代数数据类型)。 出于本文的目的,我们将使用蛋糕模式并将可组合的部分(模块)表示为特征。 (蛋糕模式不是这种可编译配置方法的要求。它只是该想法的一种可能的实现。)

可以使用蛋糕模式作为其他节点的端点来表示依赖关系:

  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 服务只需要配置一个端口。 并且我们声明该端口支持echo协议。 请注意,我们此时不需要指定特定端口,因为特征允许抽象方法声明。 如果我们使用抽象方法,编译器将需要在配置实例中实现。 这里我们提供了实现(8081),如果我们在具体配置中跳过它,它将被用作默认值。

我们可以在 echo 服务客户端的配置中声明依赖项:

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

依赖项具有相同的类型 echoService。 特别是,它需要相同的协议。 因此,我们可以确定,如果我们连接这两个依赖项,它们将正常工作。

服务实施

服务需要一个函数来启动和正常关闭。 (关闭服务的能力对于测试至关重要。)同样,有一些选项可以为给定的配置指定此类函数(例如,我们可以使用类型类)。 在这篇文章中,我们将再次使用蛋糕图案。 我们可以使用以下方式表示服务 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)(我们最终可能会用其他东西替换它)

使用这个接口我们可以实现一些服务。 例如,一个不执行任何操作的服务:

  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
}

请注意,在节点中,我们指定了该节点所需的确切配置类型。 编译器不会让我们构建类型不足的对象(Cake),因为每个服务特征都声明了对 Config 类型。 此外,如果不提供完整的配置,我们将无法启动节点。

节点地址解析

为了建立连接,我们需要每个节点的真实主机地址。 它可能比配置的其他部分更晚才知道。 因此,我们需要一种方法来提供节点 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-nodeecho-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 间隔过去了。

同一组服务实现和配置可用于创建具有两个独立节点的系统布局。 我们只需要创建 两个独立的节点配置 提供适当的服务:

两个节点配置

  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,而 echo 客户端将在配置的有限持续时间后终止。 请参阅 启动应用程序 了解详情。

整体开发流程

让我们看看这种方法如何改变我们使用配置的方式。

配置即代码将被编译并生成一个工件。 将配置工件与其他代码工件分开似乎是合理的。 通常我们可以在同一个代码库上有多种配置。 当然,我们可以拥有各种配置分支的多个版本。 在配置中,我们可以选择特定版本的库,并且每当我们部署此配置时,这将保持不变。

配置更改变成代码更改。 因此,它应该包含在相同的质量保证流程中:

工单 -> PR -> 审核 -> 合并 -> 持续集成 -> 持续部署

该方法会产生以下后果:

  1. 该配置对于特定系统的实例是一致的。 看来没有办法让节点之间出现错误的连接。
  2. 仅在一个节点中更改配置并不容易。 登录并更改一些文本文件似乎不合理。 因此配置漂移的可能性变得较小。
  3. 小的配置更改并不容易。
  4. 大多数配置更改将遵循相同的开发流程,并且会通过一些审查。

我们是否需要一个单独的存储库来进行生产配置? 生产配置可能包含我们希望让许多人无法接触到的敏感信息。 因此,可能值得保留一个具有受限访问权限的单独存储库,其中包含生产配置。 我们可以将配置分为两部分 - 一部分包含最开放的生产参数,另一部分包含配置的秘密部分。 这将使大多数开发人员能够访问绝大多数参数,同时限制对真正敏感内容的访问。 使用带有默认参数值的中间特征可以很容易地实现这一点。

变化

让我们看看所提出的方法与其他配置管理技术相比的优缺点。

首先,我们将列出处理配置的建议方法的不同方面的一些替代方案:

  1. 目标机器上的文本文件。
  2. 集中式键值存储(例如 etcd/zookeeper).
  3. 可以在不重新启动进程的情况下重新配置/重新启动的子进程组件。
  4. 工件和版本控制之外的配置。

文本文件在临时修复方面提供了一定的灵活性。 系统管理员可以登录到目标节点,进行更改并简单地重新启动服务。 对于更大的系统来说,这可能不太好。 变化没有留下任何痕迹。 这一变化不会被另一双眼睛所审视。 可能很难找出导致这种变化的原因。 它还没有经过测试。 从分布式系统的角度来看,管理员可能只是忘记更新其他节点之一中的配置。

(顺便说一句,如果最终需要开始使用文本配置文件,我们只需要添加可以产生相同结果的解析器+验证器 Config 输入,这足以开始使用文本配置。 这也表明编译时配置的复杂度比基于文本的配置的复杂度要小一些,因为在基于文本的版本中我们需要一些额外的代码。)

集中式键值存储是分发应用程序元参数的良好机制。 这里我们需要思考什么是我们认为的配置值,什么只是数据。 给定一个函数 C => A => B 我们通常称之为很少改变的值 C “配置”,而经常更改的数据 A - 只需输入数据。 配置应该早于数据提供给函数 A。 考虑到这个想法,我们可以说,预期的变化频率可用于区分配置数据和普通数据。 此外,数据通常来自一个来源(用户),而配置来自不同的来源(管理员)。 处理初始化过程后可以更改的参数会导致应用程序复杂性增加。 对于这些参数,我们必须处理它们的传递机制、解析和验证,以及处理不正确的值。 因此,为了降低程序复杂性,我们最好减少运行时可以更改的参数数量(甚至完全消除它们)。

从这篇文章的角度来看,我们应该区分静态参数和动态参数。 如果服务逻辑需要在运行时很少更改某些参数,那么我们可以将它们称为动态参数。 否则它们是静态的,可以使用建议的方法进行配置。 对于动态重新配置,可能需要其他方法。 例如,系统的某些部分可能会使用新的配置参数重新启动,方式与重新启动分布式系统的单独进程类似。
(我的拙见是避免运行时重新配置,因为它增加了系统的复杂性。
仅依靠操作系统对重新启动进程的支持可能会更直接。 不过,这可能并不总是可行。)

使用静态配置有时会让人考虑动态配置(没有其他原因)的一个重要方面是配置更新期间的服务停机。 确实,如果我们必须对静态配置进行更改,我们就必须重新启动系统以使新值生效。 不同系统对停机时间的要求有所不同,因此可能并不那么重要。 如果很重要,那么我们必须提前计划任何系统重新启动。 例如,我们可以实现 AWS ELB 连接耗尽。 在这种情况下,每当我们需要重新启动系统时,我们都会并行启动系统的一个新实例,然后将 ELB 切换到它,同时让旧系统完成现有连接的服务。

将配置保留在版本化工件内部还是外部怎么样? 将配置保留在工件内意味着在大多数情况下该配置已通过与其他工件相同的质量保证流程。 因此,人们可以确信配置质量良好且值得信赖。 相反,单独文件中的配置意味着没有任何痕迹表明谁以及为什么对该文件进行了更改。 这重要吗? 我们相信,对于大多数生产系统来说,拥有稳定且高质量的配置会更好。

工件的版本允许查明它的创建时间、包含哪些值、启用/禁用哪些功能、谁负责在配置中进行每个更改。 可能需要付出一些努力才能将配置保留在工件内,这是一个设计选择。

优点缺点

在这里,我们想强调一些优点并讨论所提出方法的一些缺点。

优势

完整的分布式系统的可编译配置的特点:

  1. 配置的静态检查。 这给出了高度的置信度,即在给定类型约束的情况下配置是正确的。
  2. 丰富的配置语言。 通常,其他配置方法最多仅限于变量替换。
    使用 Scala 可以利用多种语言特性来更好地进行配置。 比如我们可以使用traits提供默认值,使用objects来设置不同的作用域,我们可以参考 vals 在外部作用域中仅定义一次 (DRY)。 可以使用文字序列或某些类的实例(Seq, Map等)。
  3. DSL。 Scala 对 DSL 编写器提供了良好的支持。 人们可以利用这些特性建立一种更方便、对最终用户友好的配置语言,使得最终的配置至少对域用户是可读的。
  4. 节点间的完整性和一致性。 在一个地方对整个分布式系统进行配置的好处之一是所有值都严格定义一次,然后在我们需要它们的所有地方重用。 此外,类型安全端口声明可确保在所有可能的正确配置中,系统节点将使用相同的语言。 节点之间存在明确的依赖关系,这使得很难忘记提供某些服务。
  5. 高质量的变革。 通过正常 PR 流程传递配置更改的总体方法也在配置中建立了高质量标准。
  6. 同时更改配置。 每当我们对配置进行任何更改时,自动部署都会确保所有节点都得到更新。
  7. 应用程序简化。 应用程序不需要解析和验证配置以及处理不正确的配置值。 这简化了整体应用程序。 (配置本身会增加一些复杂性,但这是对安全性的有意识的权衡。)返回到普通配置非常简单 - 只需添加缺少的部分即可。 开始使用已编译的配置并将其他部分的实现推迟到以后会更容易。
  8. 版本化配置。 由于配置更改遵循相同的开发流程,因此我们得到了具有唯一版本的工件。 它允许我们在需要时切换回配置。 我们甚至可以部署一年前使用的配置,它的工作方式完全相同。 稳定的配置提高了分布式系统的可预测性和可靠性。 配置在编译时是固定的,并且在生产系统上不容易被篡改。
  9. 模块化。 所提出的框架是模块化的,模块可以以各种方式组合
    支持不同的配置(设置/布局)。 特别是,可以进行小规模的单节点布局和大规模的多节点设置。 多种生产布局是合理的。
  10. 测试。 出于测试目的,人们可以实现模拟服务并以类型安全的方式将其用作依赖项。 可以同时维护一些不同的测试布局,其中各个部分被模拟替换。
  11. 集成测试。 有时在分布式系统中很难运行集成测试。 使用所描述的方法对完整的分布式系统进行类型安全配置,我们可以以可控的方式在单个服务器上运行所有分布式部分。 很容易模拟情况
    当其中一项服务不可用时。

缺点

编译的配置方法与“正常”配置不同,它可能无法满足所有需求。 以下是编译配置的一些缺点:

  1. 静态配置。 它可能并不适合所有应用程序。 在某些情况下,需要绕过所有安全措施快速修复生产中的配置。 这种方法使事情变得更加困难。 对配置进行任何更改后都需要编译和重新部署。 这既是特征,也是负担。
  2. 配置生成。 当某些自动化工具生成配置时,此方法需要后续编译(这可能会失败)。 可能需要额外的努力才能将这个额外的步骤集成到构建系统中。
  3. 仪器。 目前使用的许多工具都依赖于基于文本的配置。 他们中有一些
    编译配置时将不适用。
  4. 需要转变思维方式。 开发人员和 DevOps 熟悉文本配置文件。 编译配置的想法对他们来说可能会显得很奇怪。
  5. 在引入可编译配置之前,需要高质量的软件开发过程。

实施的示例有一些限制:

  1. 如果我们提供节点实现不需要的额外配置,编译器将无法帮助我们检测缺少的实现。 这可以通过使用来解决 HList 或 ADT(案例类)用于节点配置,而不是特征和蛋糕模式。
  2. 我们必须在配置文件中提供一些样板:(package, import, object 声明;
    override def用于具有默认值的参数)。 使用 DSL 可以部分解决这个问题。
  3. 在这篇文章中,我们不讨论相似节点集群的动态重新配置。

结论

在这篇文章中,我们讨论了以类型安全的方式直接在源代码中表示配置的想法。 该方法可在许多应用程序中用作 xml 和其他基于文本的配置的替代。 尽管我们的示例是用 Scala 实现的,但它也可以翻译为其他可编译语言(如 Kotlin、C#、Swift 等)。 人们可以在新项目中尝试这种方法,如果它不适合,则切换到老式方法。

当然,可编译的配置需要高质量的开发过程。 作为回报,它承诺提供同样高质量的稳健配置。

这种方法可以通过多种方式扩展:

  1. 人们可以使用宏来执行配置验证,并在编译时失败,以防出现任何业务逻辑约束失败。
  2. 可以实现 DSL 来以域用户友好的方式表示配置。
  3. 具有自动配置调整的动态资源管理。 例如,当我们调整集群节点的数量时,我们可能希望(1)节点获得稍微修改的配置; (2)集群管理器接收新节点信息。

谢谢

我要感谢安德烈·萨克索诺夫 (Andrey Saksonov)、帕维尔·波波夫 (Pavel Popov)、安东·内哈耶夫 (Anton Nehaev) 对这篇文章的草稿提供了鼓舞人心的反馈,帮助我把它说得更清楚。

来源: habr.com