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

( 俄语 )
引言
构建健壮的分布式系统需要在所有节点上使用正确且一致的配置。 一个典型的解决方案是使用文本部署描述(地形,ansible等)和自动生成的配置文件(通常-每个节点/角色专用)。 我们还希望在每个通信节点上使用相同版本的相同协议(否则我们将遇到不兼容问题)。 在JVM世界中,这意味着在所有通信节点上至少消息传递库应具有相同的版本。
那测试系统呢? 当然,在进行集成测试之前,我们应该对所有组件进行单元测试。 为了能够在运行时推断测试结果,我们应该确保在运行时和测试环境中所有库的版本保持相同。
运行集成测试时,在所有节点上具有相同的类路径通常会容易得多。 我们只需要确保在部署中使用相同的类路径即可。 (可以在不同的节点上使用不同的类路径,但是代表这种配置并正确部署它更加困难。)因此,为了使事情简单,我们仅在所有节点上考虑相同的类路径。
配置倾向于与软件一起发展。 我们通常使用版本来识别各种
软件发展的各个阶段。 在版本管理下覆盖配置并使用一些标签标识不同的配置似乎是合理的。 如果生产中只有一种配置,我们可以使用单一版本作为标识符。 有时我们可能有多个生产环境。 对于每种环境,我们可能需要一个单独的配置分支。 因此,配置可能会标有分支和版本,以唯一地标识不同的配置。 每个分支标签和版本对应于每个节点上的分布式节点,端口,外部资源和类路径库版本的单个组合。 在这里,我们将仅覆盖单个分支,并以与其他工件相同的方式,通过三部分十进制版本(1.2.3)识别配置。
在现代环境中,不再手动修改配置文件。 通常我们生成
部署时配置文件,以后再也不要碰它们 。 所以有人会问为什么我们仍然对配置文件使用文本格式? 一个可行的选择是将配置放置在编译单元中,并从编译时配置验证中受益。
在本文中,我们将研究将配置保留在已编译工件中的想法。
编译配置
在本节中,我们将讨论静态配置的示例。 正在配置和实现两个简单的服务-回声服务和回声服务的客户端。 然后,实例化具有两种服务的两个不同的分布式系统。 一个用于单节点配置,另一个用于两个节点配置。
典型的分布式系统由几个节点组成。 可以使用某种类型来标识节点:
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
,代数数据类型)。 出于本文的目的,我们将使用Cake Pattern并将可组合的块(模块)表示为特征。 (Cake Pattern不是这种可编译配置方法的要求。它只是该思想的一种可能实现。)
可以使用Cake Pattern作为其他节点的端点来表示依赖关系:
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允许抽象方法声明。 如果使用抽象方法,则编译器将需要在配置实例中实现。 在这里,我们提供了实现( 8081
),如果在具体配置中跳过它,它将用作默认值。
我们可以在echo服务客户端的配置中声明一个依赖项:
trait EchoClientConfig[A] { def testMessage: String = "test" def pollInterval: FiniteDuration def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]] }
依赖项与echoService
具有相同的类型。 特别是,它需要相同的协议。 因此,我们可以确定,如果我们连接这两个依赖关系,它们将正常工作。
服务实施服务需要启动和正常关闭的功能。 (关闭服务的能力对于测试至关重要。)同样,有一些选项可以为给定的配置指定这样的功能(例如,我们可以使用类型类)。 在这篇文章中,我们将再次使用Cake Pattern。 我们可以使用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
的同义词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](())) }
(请参阅其他服务实现的源代码 -echo服务 ,
回显客户端和生命周期控制器 。)
节点是运行几个服务的单个对象(通过Cake Pattern启用启动资源链):
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]] }
有几种可能的方法可以实现这种功能。
- 如果我们在部署前知道实际地址,则在节点主机实例化期间,我们可以使用实际地址生成Scala代码,然后运行构建(执行编译时检查,然后运行集成测试套件)。 在这种情况下,我们的映射功能是静态已知的,可以简化为
Map[NodeId, NodeAddress]
类的东西。 - 有时,我们仅在节点实际启动时才获取实际地址,或者我们没有尚未启动的节点地址。 在这种情况下,我们可能具有在所有其他节点之前启动的发现服务,并且每个节点可能会在该服务中通告其地址并订阅依赖项。
- 如果可以修改
/etc/hosts
,则可以使用预定义的主机名(例如my-project-main-node
和echo-backend
),只需在部署时将此名称与ip地址关联即可。
在这篇文章中,我们不会更详细地介绍这些情况。 实际上,在我们的玩具示例中,所有节点都将具有相同的IP地址127.0.0.1
。
在本文中,我们将考虑两种分布式系统布局:
- 单节点布局,其中所有服务都放置在单个节点上。
- 两节点布局,其中服务和客户端位于不同的节点上。
单节点布局的配置如下:
单节点配置 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 override def portNumber: PortNumber = 8088
在这里,我们创建一个扩展服务器和客户端配置的配置。 此外,我们还配置了生命周期控制器,该控制器通常会在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 {
看看我们如何指定依赖关系。 我们将另一个节点提供的服务称为当前节点的依赖项。 因为依赖项包含描述协议的幻像类型,所以检查了依赖项的类型。 在运行时,我们将获得正确的节点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客户端将在配置的有限持续时间之后终止。 有关详细信息,请参见入门应用程序 。
总体发展过程
让我们看看这种方法如何改变我们的配置工作方式。
作为代码的配置将被编译并产生工件。 将配置工件与其他代码工件分开似乎是合理的。 通常,我们可以在同一代码库上进行多种配置。 当然,我们可以具有各种配置分支的多个版本。 在配置中,我们可以选择特定版本的库,并且在部署此配置时,它将保持不变。
配置更改变为代码更改。 因此,它应该包含在相同的质量保证过程中:
工单->公关->评论->合并->持续集成->持续部署
该方法具有以下后果:
该配置对于特定系统的实例是一致的。 似乎没有办法在节点之间建立不正确的连接。
仅在一个节点上更改配置并不容易。 登录并更改一些文本文件似乎是不合理的。 因此,配置漂移变得不太可能。
较小的配置更改不容易进行。
大多数配置更改将遵循相同的开发过程,并且将通过一些审查。
我们需要用于生产配置的单独存储库吗? 生产配置可能包含一些敏感信息,我们希望这些信息不会被许多人接触。 因此,可能值得保留一个包含生产配置的受限制访问权限的单独存储库。 我们可以将配置分为两部分-一部分包含生产的大多数开放参数,而另一部分包含配置的秘密部分。 这将使大多数开发人员可以访问绝大多数参数,同时限制对真正敏感内容的访问。 使用带有默认参数值的中间特征来完成此操作很容易。
变体
让我们看看与其他配置管理技术相比,该方法的优缺点。
首先,我们将列出一些提议的配置处理方式的不同方面的替代方案:
- 目标计算机上的文本文件。
- 集中式键值存储(例如
etcd
/ zookeeper
)。 - 无需重新启动流程即可重新配置/重新启动的子流程组件。
- 在工件和版本控制之外进行配置。
文本文件在临时修复方面具有一定的灵活性。 系统管理员可以登录到目标节点,进行更改,然后只需重新启动服务即可。 对于较大的系统,这可能不太好。 更改后没有任何痕迹。 另一只眼睛没有审查此更改。 可能很难找出导致更改的原因。 尚未测试。 从分布式系统的角度来看,管理员可以简单地忘记更新其他节点之一中的配置。
(顺便说一句,如果最终需要开始使用文本配置文件,我们只需要添加解析器+验证器即可生成相同的Config
类型,并且足以开始使用文本配置。这也表明编译时配置的复杂度比基于文本的配置的复杂度小一点,因为在基于文本的版本中,我们需要一些其他代码。)
集中式键值存储是一种用于分发应用程序元参数的良好机制。 在这里,我们需要考虑什么我们认为是配置值以及什么是数据。 给定一个函数C => A => B
我们通常将很少更改的值C
称为“配置”,而经常更改的数据A
仅输入数据。 应早于数据A
为功能提供配置A
有了这个想法,我们可以说可以预期的更改频率可以将配置数据与仅数据区分开。 同样,数据通常来自一个来源(用户),而配置则来自其他来源(管理员)。 处理在流程初始化后可以更改的参数会导致应用程序复杂性增加。 对于此类参数,我们将不得不处理其传递机制,解析和验证,处理不正确的值。 因此,为了降低程序复杂性,我们最好减少运行时可以更改的参数数量(甚至完全消除它们)。
从这篇文章的角度来看,我们应该区分静态参数和动态参数。 如果服务逻辑在运行时需要很少更改某些参数,那么我们可以将它们称为动态参数。 否则,它们是静态的,可以使用建议的方法进行配置。 对于动态重新配置,可能需要其他方法。 例如,系统部分可以使用新的配置参数重新启动,类似于重新启动分布式系统的单独进程。
(我的愚见是避免重新配置运行时,因为这会增加系统的复杂性。
仅仅依靠操作系统对重启进程的支持可能会更直接。 不过,并非总是可能的。)
使用静态配置的一个重要方面(有时会导致人们考虑动态配置(无其他原因))是配置更新期间的服务停机时间。 确实,如果必须更改静态配置,则必须重新启动系统,以使新值生效。 停机时间要求因系统而异,因此可能并不那么关键。 如果这很关键,那么我们必须为所有系统重新启动进行提前计划。 例如,我们可以实现AWS ELB连接耗尽 。 在这种情况下,无论何时需要重新启动系统,我们都会并行启动系统的新实例,然后将ELB切换到该实例,同时让旧系统完成对现有连接的服务。
如何将配置保留在版本控制的工件内或外部? 将配置保留在工件中意味着在大多数情况下,此配置已通过与其他工件相同的质量保证过程。 因此,可以肯定的是,该配置质量良好且值得信赖。 相反,在一个单独的文件中进行配置意味着没有跟踪谁和为什么对该文件进行了更改的痕迹。 这重要吗? 我们认为,对于大多数生产系统而言,最好具有稳定和高质量的配置。
该工件的版本可以确定何时创建,包含哪些值,启用/禁用了哪些功能,谁负责配置中的每项更改。 可能需要付出一些努力才能将配置保留在工件中,这是设计选择。
利与弊
在这里,我们要强调一些优点,并讨论该方法的一些缺点。
优势优势
完整的分布式系统的可编译配置的功能:
- 静态检查配置。 在给定类型约束的情况下,此配置具有正确的置信度,这具有很高的可信度。
- 丰富的配置语言。 通常,其他配置方法最多限于变量替换。
使用Scala可以使用多种语言功能来改善配置。 例如,我们可以使用traits提供默认值,使用对象设置不同的作用域,我们可以引用在外部作用域(DRY)中仅定义一次的val
。 可以使用文字序列或某些类的实例( Seq
, Map
等)。 - DSL Scala对DSL编写器提供了不错的支持。 可以使用这些功能来建立一种更方便和最终用户友好的配置语言,以便最终配置至少对域用户可读。
- 跨节点的完整性和一致性。 在一个地方为整个分布式系统进行配置的好处之一是,仅严格定义一次所有值,然后在需要它们的所有地方重复使用。 同样,类型安全端口声明可确保在所有可能的正确配置中,系统节点将使用相同的语言。 节点之间存在明确的依赖关系,这使得很难忘记提供某些服务。
- 高质量的变更。 通过正常的PR过程传递配置更改的整体方法在配置中也建立了高质量的标准。
- 同时进行配置更改。 每当我们对配置进行任何更改时,自动部署都将确保所有节点都被更新。
- 简化应用程序。 该应用程序不需要解析和验证配置,也不需要处理错误的配置值。 这简化了整个应用程序。 (配置本身会增加一些复杂性,但这是对安全性的自觉权衡。)返回普通配置非常简单-只需添加缺少的部分即可。 开始进行编译配置和将其他部分的实现推迟到以后的时间比较容易。
- 版本化配置。 由于配置更改遵循相同的开发过程,因此我们获得了具有唯一版本的工件。 如果需要,它可以让我们将配置切换回去。 我们甚至可以部署一年前使用的配置,它的工作方式完全相同。 稳定的配置提高了分布式系统的可预测性和可靠性。 该配置在编译时是固定的,不能在生产系统上轻易篡改。
- 模块化 拟议的框架是模块化的,模块可以通过各种方式组合以实现
支持不同的配置(设置/布局)。 特别是,可能具有小规模的单节点布局和大规模的多节点设置。 具有多个生产布局是合理的。 - 测试中 为了进行测试,可以实现模拟服务,并以一种类型安全的方式将其用作依赖项。 可以同时维护一些不同的测试布局,其中各个部分被模拟替换。
- 集成测试。 有时在分布式系统中,很难运行集成测试。 使用所描述的方法来键入整个分布式系统的安全配置,我们可以以可控的方式在一台服务器上运行所有分布式部件。 模仿情况很容易
当其中一项服务不可用时。
缺点
编译后的配置方法与“常规”配置不同,它可能无法满足所有需求。 以下是编译后的配置的一些缺点:
- 静态配置。 它可能不适合所有应用程序。 在某些情况下,需要绕过所有安全措施在生产中快速固定配置。 这种方法使它更加困难。 进行任何配置更改后,都需要进行编译和重新部署。 这既是功能,又是负担。
- 配置生成。 当某些自动化工具生成config时,此方法需要后续编译(否则可能会失败)。 可能需要付出额外的努力才能将此额外的步骤集成到构建系统中。
- 仪器。 如今,有很多工具都依赖于基于文本的配置。 其中一些
编译配置时将不适用。 - 需要转变观念。 开发人员和DevOps熟悉文本配置文件。 对他们而言,编译配置的想法可能看起来很奇怪。
- 在引入可编译配置之前,需要高质量的软件开发过程。
实现的示例存在一些局限性:
- 如果我们提供了节点实现不需要的额外配置,则编译器将无法帮助我们检测缺少的实现。 这可以通过使用
HList
或ADT(案例类)进行节点配置(而不是特征和Cake Pattern)来解决。 - 我们必须在配置文件中提供一些样板:(
package
, import
, object
声明;
对具有默认值的参数override def
)。 使用DSL可以部分解决此问题。 - 在这篇文章中,我们不讨论类似节点集群的动态重新配置。
结论
在本文中,我们讨论了以类型安全的方式直接在源代码中表示配置的想法。 该方法可以在许多应用程序中用作xml和其他基于文本的配置的替代。 尽管我们的示例已在Scala中实现,但也可以将其翻译为其他可编译语言(例如Kotlin,C#,Swift等)。 可以在一个新项目中尝试这种方法,如果不合适,请改用老式的方法。
当然,可编译的配置需要高质量的开发过程。 作为回报,它承诺提供同样高质量的坚固配置。
该方法可以通过多种方式扩展:
- 一个人可以使用宏执行配置验证,并且在任何业务逻辑约束失败的情况下在编译时失败。
- 可以实现DSL,以域用户友好的方式表示配置。
- 具有自动配置调整功能的动态资源管理。 例如,当我们调整群集节点的数量时,我们可能希望(1)节点获得略微修改的配置; (2)集群管理器接收新的节点信息。
谢谢啦
我想对Andrey Saksonov,Pavel Popov和Anton Nehaev表示感谢,感谢他们对本帖子的草稿提供了鼓舞性的反馈,这使我更加清楚了。