第十五章 配置细节

管理生产系统是SRE为组织提供价值的众多方式之一。在生产中配置和运行应用程序,需要深入了解这些系统如何组合在一起以及如何工作。当出现问题时,随时响应的工程师需要确切知道配置的位置以及如何更改配置。如果团队或组织未花精力去解决与配置相关的问题,则此责任可能成为负担。

本书详细介绍了琐事的主题(见第6章)。如果您的SRE团队承担了大量与配置相关的工作负担,我们希望实现本章中介绍的一些想法可以帮助您节省部分更改配置所花费的时间。

配置带来的琐事

在项目生命周期的开始阶段,配置通常相对轻量且简单。您可能有一些数据格式的文件,如INI,JSON,YAML或XML。管理这些文件几乎不需要辛劳。应用程序、服务器和变体的数量随着时间的推移而增加,配置可能变得非常复杂和冗长。例如,一开始可以通过单个配置文件来进行“配置更改”,但现在必须更新多个位置的配置文件。阅读这样的配置也很困难,因为重要的差异被无关且重复细节所淹没。这种与配置相关的琐事是重复的琐事:管理跨系统配置的无技术含量的任务。这种工作不仅限于大型组织和庞大的系统 –对于具有许多独立配置组件的微服务架构而言,这种情况尤为常见。

工程师通常通过构建自动化或配置框架来应对重复工作。它们旨在消除配置系统中的重复性,并使配置更易于理解和维护。重复利用软件工程中的技术,这种方法通常使用“配置语言” .Google SRE创建了许多配置语言,旨在减少我们最大和最复杂的生产系统的辛劳。

不幸的是,这种策略并不能根除配置导致的琐事。将你从大量的个人配置中解脱出来,该项目(及其配置资料库)以新的速度增长。不可避免地,你遇到了复杂的琐事:处理复杂自动化中出现的、有时是不受欢迎的挑战性行为和令人沮丧的任务。该琐事通常在较大的组织(10多名工程师)和一直增长的混合系统中出现。你越早解决就越好; 配置文件的大小和复杂性只会随着时间的推移而增长。

减少配置导致的琐事

如果项目充斥着与配置相关的工作,您可以采取一些基本策略来改善这种情况。

在极少数情况下,如果您的应用程序是自定义构建的,您可以选择完全删除配置。在处理配置的某些方面时,应用程序可能天然地就比配置语言更好:让应用程序使用默认值是有意义的,因为它可以访问有关机器的信息或者动态地改变某些值,或者可以根据负载进行扩展。

如果配置不可删除,并且重复琐事正在成为问题,请考虑自动化以减少配置文件中的重复性。可能需要集成新的配置语言,或者需要改进或替换现有的配置文件。接下来,第317页的“配置系统的关键属性和缺陷”一节提供了有关选择或设计系统的一些指导。

如果选择新的配置框架,则需要将配置语言与需要配置的应用程序集成。“集成现有应用程序:Kubernetes。(第322页)使用Kubernetes作为现有应用程序示例,要集成的应用程序和“集成自定义应用程序(内部软件)”(第326页)提供了一些更常规的建议。这些部分使用Jsonnet(作为代表进行说明)演示一些示例。

一旦有适当的配置系统 - 不管是否是现有解决方案,还是选择新的配置语言进行实施 。 “有效运行配置系统”(第329页)中的最佳实践,“何时评估配置”(第331页)和“防止滥用配置”(第331页)都有助于优化设置,无论使用哪种语言。采用这些流程和工具有助于最大限度地降低复杂程度。

配置系统的关键属性和缺陷

第14章概述了任何配置系统的一些关键属性。除了理想中的通用要求(如轻量级,易学,简单和富有表现力)之外,高效的配置系统必须:

  • 通过配置工具对配置文件进行管理(线程,调试器,格式化程序,IDE集成等)可以支持配置合法性检查,(增强)工程师信心和(提高)工作效率
  • 提供回滚配置和一般可重复性的密封评估。
  • 单独的配置和数据,以便于分析配置和一系列配置界面。

普遍而言,人们不觉得这些属性是至关重要的,并且达到目前理解的程度已经是一个征程。(以至于)在此过程中,Google也发明了几种缺乏这些关键属性的配置系统。但这不仅止存在于我们之中,虽然流行的配置系统种类繁多,但你很难找到一个不会违反以下任意一条的系统。

缺陷一:未能将配置视为编程语言问题

如果你不是有意设计一门语言,那么最终得到的“语言”很可能不是一门好语言。

虽然配置语言描述数据而不是行为,但它们仍然具有编程语言的其他特征。如果我们的配置策略以仅使用数据格式为目标开始,那么编程语言功能往往会渗透到后门。该格式不是仅保留数据语言,而是一种深奥而复杂的编程语言。

例如,某些系统将count属性添加到正在配置的虚拟机(VM)的架构中。此属性不是VM本身的属性,而是表示需要多个属性。虽然有用,但这是编程语言的一个特性,而不是数据格式,因为它需要外部编译器或解释器。经典编程语言方法将使用工件外部的逻辑(例如for循环或列表理解)来根据需要生成更多VM。

另一个例子是一种配置语言,它产生字符串插值规则而不是支持通用表达式。这些字符串似乎只是“数据”,尽管它们实际上可以包含复杂的代码,包括数据结构操作,校验和,base64编码等。

流行的YAML+Jinja解决方案也有缺点。简单的纯数据格式(如XML,JSON,YAML和文本格式的协议缓冲区)是纯数据用例的绝佳选择。同样,文本模板引擎(如Jinja2或Go模板)非常适合HTML模板化。但是,当使用配置语言时,人类和工具难以维护和分析。在这些情况下,该缺陷将导致出现复杂的、深奥不适合工具的“语言”。

缺陷二:设计意外或特殊语言功能

在大规模操作系统中,SRE通常会觉察到配置可用性问题。新语言不具备良好的工具支持(IDE支持,良好的连接),如果开发语言具有未公开的、或深奥的语义特性,那么开发自定义工具则很痛苦。

随着时间的推移,将特殊编程语言功能添加到简单的配置格式可能会拥有一个功能完整的解决方案,但是临时语言更复杂,并且通常比其他经过设计的等价语言更不使用。这其中还会带来开发陷阱和特性风险,因为设计者无法提前考虑功能之间的相互作用。

如果不希望配置系统变得足够复杂,而是简单的编程结构就能解决的话,最好在初始设计阶段考虑好这些要求。

缺陷三:构建太多特定领域的优化

对于新的特定领域的解决方案,用户群越小则其出现的的时间就会越长, 因为其需要积累足够的用户来证明构建工具的合理性。工程师不愿意花时间理解该语言,因为它在该领域之外几乎没有适用性。像Stack Overflow这样的学习资源基本不可用。

缺陷四:混淆“配置评估”与“副作用”

副作用包括在配置运行期间更改外部系统或咨询带外数据源(DNS、VM ID、最新构建版本)。

允许这些副作用的系统会破坏密封性,影响配置与数据分离。在极端情况下,如果不花钱保留云资源,就无法调试配置。为了分离配置和数据,首先评估配置,然后将结果数据提供给用户进行分析,然后才考虑副作用。

缺陷五:使用现有的通用脚本语言,如Python、Ruby或Lua

这似乎是避免前四个陷阱的一种微不足道的方法,但使用通用脚本语言的实现是重量级的和/或需要侵入式沙盒来确保密封性。由于通用语言可以访问本地系统,基于安全性考虑也可能需要沙盒。

此外,无法保证维护配置人员熟悉所有语言。

为了避免这些陷阱,开发了用于配置的可重用特定语言(DSL),例如HOCON、Flabbergast、Dhall和Jsonnet。我们建议使用现有的DSL进行配置。即使DSL看起来太强大,无法满足你的需求,可能需要在某些时候使用附加功能,并且始终可以使用内部样式指南来限制语言的功能。

Jsonnet的简介

Jsonnet是一个密封的开源DSL,可以用作库或命令行工具为任何应用程序提供配置。在Google内外都广泛使用。

这种语言对程序员来说很熟悉:它使用类似Python的语法、面向对象和功能构造。它是JSON的扩展,这意味着JSON文件只是一个输出自身的Jsonnet程序。在使用引号和逗号时,Jsonnet比JSON更方便,并支持注释。更重要的是,它增加了计算结构。

你不需要特别熟悉Jsonnet语法来完成本章的其余部分,只需花一些时间阅读在线教程就可以入门。

Google或我们的读者群中没有主流配置语言,但我们需要选择语言方便我们提供示例。本章使用Jsonnet来展示我们在第14章中所提供建议的示例。

如果你尚未使用特定配置语言并且想要使用Jsonnet,则可以直接应用本章中的示例。我们会尽力让你尽可能轻松地从代码示例中抽象出基础原理。

此外,一些示例探索了你可能希望在编程书中找到的概念(如图灵完整性)。我们只在必要时深入研究,以解释在生产中真正困扰我们的微妙之处。在大多数复杂的系统中 - 当然还有配置方面 - 故障处于边缘。

集成配置语言

本节使用Jsonnet讨论如何将配置语言与你需要配置的应用程序集成,相同的技术也适用其他配置语言。

以特定格式生成配置

配置语言可以以正确的格式本机输出。例如,Jsonnet输出JSON,它与许多应用程序兼容。对于扩展JSON的语言的消费者,JSON也是足够的,例如JavaScript、YAML或HashiCorp的配置语言。如果这是你面对的情况,则无需进行其他任何集成工作。

对于本机不支持的其他配置格式:

  1. 你需要找到一种在配置语言中表示配置数据的方法。这并不难,因为配置值(如图、列表、字符串和其他原始值)是通用的,并且可用于所有语言。
  2. 一旦用配置语言表示了这些数据,就可以使用该语言的结构来减少重复(减少工作量)。
  3. 你需要为必要的输出格式编写(或重用)序列化函数。例如,Jsonnet标准库具有从其内部类似JSON的表示中输出INI和XML的功能。如果配置数据无法在配置语言中表示(例如,Bash脚本),你可以使用基本的字符串模板技术作为最后手段。可以在http://bit.ly/2La0zDe找到使用示例。

推动多种应用

一旦可以从配置语言驱动任意现有应用程序,你就可以从同一配置中定位多个应用程序。如果你的应用程序使用不同的配置格式,则需要做一些转换工作。一旦能够以一定的格式生成配置,你就可以轻松统一、同步和消除整个配置文件库中的重复。鉴于JSON和基于JSON格式的普及,甚至可能不必生成不同的格式 - 例如,如果使用部署体系结构,使用GCP部署管理器,AWS CloudFormation或Terraform作为基础架构,以及Kubernetes作为容器,则情况确实如此。

此时,你可以:

  • 从单个Jsonnet评估中输出Nginx Web服务器配置和Terraform防火墙配置,该评估仅定义端口一次。
  • 从相同文件配置监控仪表盘,保留策略和警报通知管道。
  • 通过将初始化命令从一个列表移动到另一个列表,管理VM启动脚本和磁盘映像构建脚本之间的性能权衡。

在将不同的配置统一到之后,会有很多机会来优化和抽象配置。配置甚至可以嵌套 - 例如,Cassandra配置可以嵌入其基础架构的DeploymentManager配置内或Kubernetes ConfigMap内。一个优秀的配置语言可以处理任何笨拙的字符串引用,通常这个操作自然而简单。

为了便于为各种应用程序编写许多不同的文件,Jsonnet有一种模式,配置执行产生一个JSON对象,将文件名映射到文件内容(根据需要进行格式化)。可以在其他配置语言中模拟此功能,方法是在字符串之间产生映射,并使用后续处理步骤或脚本来编写文件。

集成现有应用程序:Kubernetes

Kubernetes提出了一个有趣的案例研究,原因如下:

  • 运行在Kubernetes上的作业需要配置文件,并且配置可能会很复杂。
  • Kubernetes没有附带绑定的配置语言(甚至没有专门的配置语言)。

对于最简单的结构,Kubernetes用户只需使用YAML即可。对于具有较大的基础结构,Kubernetes用户可以使用Jsonnet等语言扩展其工作流程,以其作为该规模所需的抽象工具。

Kubernetes提供什么

Kubernetes是一个开源系统,用于在一组机器上编排容器化工作负载。它的API允许你自己管理容器和许多重要的细节,例如容器之间的通信、集群内外的通信、负载平衡、存储、渐进式部署和自动扩展。每个配置项都用一个JSON对象表示,该对象可以通过API进行管理。命令行工具kubectl允许从磁盘读取这些对象并将它们发送到API。

在磁盘上,JSON对象实际上被编码为YAML流,YAML易于读取,并可以通过常用库轻松转换为JSON。开箱即用的用户体验包括编写代表Kubernetes对象的YAML文件并运行kubectl以将它们部署到集群。

要了解配置Kubernetes的最佳实践,请参阅有关该主题的Kubernetes文档。

Kubernetes配置示例

YAML是Kubernetes配置的用户界面,它提供了一些简单的功能,如注释,并且具有大多数人中所意的简洁的原始JSON语法。然而,YAML在抽象方面不尽如人意:它只提供锚点,这在实践中很少有用,而且Kubernetes不支持。

假设你要使用不同的命名空间、标签和其他微小差异,将Kubernetes对配置文件复制四次。遵循基础结构不变的最佳实践,可以存储所有四个文件的配置,复制配置的相同处。以下代码片段提供了其中一个文件(为简洁起见,我们省略了其他三个文件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# example1.yaml
apiVersion: v1
kind: Service
metadata:
     labels:
        app: guestbook
        tier: frontend
     name: frontend
     namespace: prod
spec:
    externalTrafficPolicy: Cluster
    ports:
    - port: 80
       protocol: TCP
       targetPort: 80
    selector:
       app: guestbook
       tier: frontend
    sessionAffinity: None
    type: NodePort

此格式难以阅读和维护,因为重要的差异被模糊了。

集成配置语言

如第315页上的“配置引发的操作”中所述,管理大量YAML文件可能会花费大量时间。配置语言可以帮助简化此任务。最直接的方法是从Jsonnet的每次执行中发出一个Kubernetes对象,然后将生成的JSON直接传递给kubectl,kubectl处理JSON,就好像它是YAML一样。或者,您可以发出YAML流(一系列此类对象或单个kubectl列表对象,或让Jsonnet从同一配置发出多个文件。有关进一步的讨论,请参阅Jsonnet网站)。

开发人员应该意识到,通常,YAML允许您编写JSON中无法表达的配置(因此,Jsonnet无法生成)。YAML配置可以包含异常的IEEE浮点值,如NaN,或者包含非字符串字段的对象,如数组、其他对象或null。实际上,这些功能很少使用并且Kubernetes不允许使用它们,因为配置在发送到API时必须进行JSON编码。

以下代码段显示了我们的示例Kubernetes配置在Jsonnet中的样子:

请注意以下事项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// templates.libsonnet
{
    MyTemplate:: {
          local service = self,
          tier:: error 'Needs tier',
          apiVersion: 'v1',
          kind: 'Service',
          local selector_labels = { app: 'guestbook', tier:service.tier },
          metadata: {
                labels: selector_labels,
                name: 'guestbook-' + service.tier,
                namespace: 'default',
           },
           spec: {
                externalTrafficPolicy: 'Cluster',
                ports: [{
                    port: 80,
                    protocol: 'TCP',
                    targetPort: 80,
                }],
            selector: selector_labels,
            sessionAffinity: 'None',
            type: 'NodePort',
        },
    },
}
// example1.jsonnet
local templates = import 'templates.libsonnet';
templates.MyTemplate {
    tier: 'frontend',
}
// example2.jsonnet
local templates = import 'templates.libsonnet';
templates.MyTemplate {
    tier: 'backend',
    metadata+: {
        namespace: 'prod',
    },
}
// example3.jsonnet
local templates = import 'templates.libsonnet';
templates.MyTemplate {
    tier: 'frontend',
    metadata+: {
        namespace: 'prod',
        labels+: { foo: 'bar' },
},
}
// example4.jsonnet
local templates = import 'templates.libsonnet';
templates.MyTemplate {
    tier: 'backend',
}

注意以下几点:

  • 我们通过四次实例化抽象来表达所有四种变体,但你也可以使用功能抽象。
  • 虽然我们为每个实例使用单独的Jsonnet文件,但你也可以将它们合并到一个文件中。
  • 在抽象模板中,空间命名为默认值,并且必须被覆盖。
  • 乍一看,Jsonnet略显冗长,但随着模板实例化数量的增加而降低了工作量。

在MyTemplate中,local关键字定义了一个变量服务,该服务初始化为self(对最近的封闭对象的引用)。这允许您从嵌套对象中引用对象,其中self被重新定义。

tier字段有两个冒号(而不是常规的JSON单冒号),并且在生成的JSON中隐藏(不输出)。否则,Kubernetes将拒绝tier为无法识别的字段。隐藏字段仍然可以被覆盖和引用 - 在本例中为service.tier。

模板本身不能使用,因为引用service.tier会触发错误构造,这会引发给定文本的运行时错误。为避免错误,模板的每个实例都会使用其他表达式覆盖tier字段。换句话说,这种模式表达类似于纯虚拟/抽象方法的东西。

使用抽象函数意味着配置只能参数化。相反,模板允许您覆盖父项中的任何字段。如第14章所述,虽然简洁性应该是您设计的基础,但简单易行的能力非常重要。模板覆盖提供了一个有用的避开方式,可以更改通常被认为太低级别的特定细节。例如:

1
2
3
4
5
6
templates.MyTemplate {
    tier: 'frontend',
    spec+: {
        sessionAffinity: 'ClientIP',
    },
}

这是将现有模板转换为Jsonnet的典型工作流程:

  1. 将其中一个YAML格式转换为JSON。
  2. 通过Jsonnet格式化程序运行生成的JSON。
  3. 手动添加Jsonnet构造以抽象和实例化代码(如示例中所示)。

该示例显示了如何在保留不同的某些字段的同时删除重复内容。随着差异越来越微妙(例如,字符串只是略有不同)或表达具有挑战性(例如,配置具有结构差异,如阵列中的附加元素,或者应用于阵列的所有元素的相同差异),使用配置语言变得更加引人注目。

通常,抽象不同配置的共性可以促进关注点的分离,并且与编程语言中的模块化具有相同的好处。您可以针对许多不同的用例利用抽象功能:

  • 单个团队可能需要创建几乎(但不完全)相同的多个配置版本。例如,在跨不同环境(prod/stage/dev/test)管理部署,调整不同体系结构上的部署或调整时区不同地区的能力。

  • 组织可能拥有一个基础架构团队,负责维护可重用组件-API服务框架、缓存服务器或MapReduces。对于每个组件,基础结构团队可以维护一个模板,该模板定义大规模运行该组件所需的Kubernetes对象。每个应用程序团队都可以实例化该模板以添加其应用程序的详细信息。

集成自定义应用程序(内部软件)

如果你的基础架构使用任何自定义应用程序(即内部开发的软件,而不是现成的解决方案),那么可以将这些应用程序设计为与可重用的配置语言共存。用于编写配置文件或与生成的配置数据交互时(例如,出于调试目的或与其他工具集成时),本节中的建议应改善整体用户配置体验。它们还应简化应用程序的设计并将配置与数据分开。

处理自定义应用程序的通用策略应该是:

  • 让配置语言处理它的设计目的:语言问题的方面。
  • 让应用程序处理所有其他功能。 以下最佳实践包括使用Jsonnet的示例,但相同的建议也适用于其他语言:

  • 使用单个纯数据文件,然后让配置语言使用import将配置拆分为文件。这意味着配置语言实现只需要发出(并且应用程序只需要使用)单个文件。此外,由于应用程序可以以不同方式组合文件,因此该策略明确且清晰地描述了如何组合文件以形成应用程序配置。
  • 使用对象表示命名实体的集合,其中字段包含对象名称,值包含实体的其余部分。避免使用每个元素都有名称字段的对象数组。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      Bad JSON:
          [
              { "name": "cat", ... },
              { "name": "dog", ... }
          ]
      Good JSON:
          {
               "cat": { ... },
               "dog": { ... }
       }
    

    该策略使得集合(和单个动物)更容易扩展,并且您可以通过名称引用实体(例如,animals.cat)而不是引用索引(例如,动物[0])。

  • 避免在顶级按类型对实体进行分组。构造JSON,以便将逻辑相关的配置分组到同一子树中。这允许抽象(在配置语言级别)遵循功能边界。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
      Bad JSON:
          {
              "pots": { "pot1": { ... },"pot2": { ... } },
              "lids": { "lid1": { ... }, "lid2":{ ... } }
          }
      Good JSON:
          {
              "pot_assembly1": { "pot": { ... },"lid": { ... } },
              "pot_assembly2": { "pot": { ... },"lid": { ... } }
          }
    

    在配置语言级别,此策略支持以下抽象:

    1
    2
    3
    4
    5
      local kitchen = import 'kitchen.libsonnet';
      {
      pot_assembly1: kitchen.CrockPot,
      pot_assembly2: kitchen.SaucePan { pot+: { color: 'red' }},
      }
    
  • 保持数据一直简单::
    • –避免在数据表示中嵌入语言功能(如“陷阱1:无法将配置识别为编程语言问题”(第317页)中所述)。这些类型的抽象将只会产生混淆,因为它们会强制用户决定是使用数据表示形式还是配置语言中的抽象功能。
    • –不要担心过于冗长的数据表示。减少冗长的解决方案会带来复杂性,并且这个问题可以使用配置语言管理。
    • –避免在应用程序中解释自定义字符串插值语法,例如字符串中的条件或占位符引用。有时解释是不可避免的 - 例如,当您需要描述在生成配置的纯数据版本(报警,处理程序等)之后执行的操作时。但除此之外,让配置语言尽可能多地完成语言级别的工作。

如前所述,如果您可以完全删除配置,那么这样做始终是您的最佳选择。虽然配置语言可以通过使用具有默认值的模板来隐藏底层模型的复杂性,但生成的配置数据并不是完全隐藏的 - 它可以由工具处理、由人检查或加载到配置数据库中。出于同样的原因,不要依赖配置语言来修复模型本身中底层modelfix中不一致的命名、复数或错误。如果您无法修复模型中的不一致性,最好在语言级别使用它们以避免更多的不一致。

根据我们的经验,配置更改往往会在系统中随着时间的推移成为导致停机的根本原因(请参阅附录C中的停机的主要原因列表)。验证配置更改是保持可靠性的关键步骤。我们建议在配置执行后立即验证生成的配置数据。单独的语法验证(即,检查JSON是否可解析)将不会发现许多错误。在通用模式验证之后,检查特定于应用程序域的属性 - 例如,是否存在必填字段、是否存在引用的文件名,以及提供的值是否在允许的范围内。

您可以使用JSONschema验证Jsonnet的JSON。对于使用协议缓冲区的应用程序,您可以从Jsonnet轻松生成这些缓冲区的规范JSON格式,协议缓冲区实现将在反序列化期间进行验证。

无论您决定如何验证,都不要忽略无法识别的字段名称,因为它们可能表示配置语言级别的拼写错误。Jsonnet可以使用::语法屏蔽不应输出的字段。在precommit hook中执行相同的验证也是一个好主意。

有效地运行配置系统

在以任何语言实现“配置为代码”时,我们建议遵循通常有助于软件工程的规程和流程。

版本

配置语言通常会触发工程师编写模板库和实用程序函数。通常,一个团队维护这些库,但许多其他团队可能会使用它们。当您需要对库进行重大更改时,您有两种选择:

  • 提交所有客户端代码的全局更新,重构代码以使其仍然有效(这可能在组织上不可行)。
  • 使用版本库,以便不同的消费者可以使用不同的版本并独立迁移。选择使用弃用版本的消费者将无法获得新版本的好处,并将产生技术债务 - 有一天,他们将不得不重构他们的代码以使用新库。

大多数语言,包括Jsonnet,都没有为版本控制提供任何特定的支持; 相反,你可以轻松使用目录。有关Jsonnet中的实际示例,请参阅ksonnet-lib存储库,其中版本是导入路径的第一个组件:

1
    local k = import 'ksonnet.beta.2/k.libsonnet';

源控制

第14章主张保留配置更改的历史记录(包括谁创建它们)并确保回滚简单可靠。检查配置到源代码控制中可以带来所有这些功能,还可以编写查看配置更改的代码。

工具

考虑如何强制执行样式和lint配置,并调查是否有一个编辑器插件将这些工具集成到您的工作流程中。您的目标是在所有作者之间保持一致的风格、提高可读性并检测错误。有些编辑器支持可以为您运行格式化程序和其他外部工具的写后hook.。您还可以使用预先挂钩来运行相同的工具,以确保签入的配置具有高质量。

测试

我们建议对上游模板库实施单元测试。确保库在实例化时生成预期的具体配置。与之对应的,为了便于维护,函数库也应该包括单元测试

在Jsonnet中,你可以将测试编写为Jsonnet文件:

  1. 导入要测试的库。
  2. 应用库
  3. 使用assert语句或标准库assertEqual函数来验证其输出。后者在其错误消息中显示所有不匹配的值。

以下示例为测试joinName函数和MyTemplate:

1
2
3
4
// utils_test.jsonnet
local utils = import 'utils.libsonnet';
std.assertEqual(utils.joinName(['foo', 'bar']),'foo-bar') &&
std.assertEqual(utils.MyTemplate { tier: 'frontend' }, {... })

对于较大的测试套件,你可以使用Jsonnet社区成员开发的更全面的单元测试框架。你可以用此框架以结构化方式定义和运行测试套件-例如,报告所有失败测试的集合,而不是在第一个失败的断点中中止执行。

何时评估配置

我们的关键属性包括封闭性;也就是说,无论它们在何时何地执行,相同的配置语言必须生成相同的配置数据。如第14章所述,如果系统依赖于其密封环境之外可以改变的资源,则系统可能很难或无法回滚。通常,封闭性意味着Jsonnet代码始终可以与它所代表的扩展JSON互换。因此,你可以在任何时间从Jsonnet生成JSON。

我们建议在版本控制中存储配置。你可以在注册之前验证配置。此外,应用程序在需要JSON数据时可以评估配置。同样,你可以在构建时进行评估。你可以根据用例的具体情况对每个选项进行评估优化。

初期:检查JSON

你可以在检查版本控制之前从Jsonnet代码生成JSON。典型的工作流程如下:

  1. 修改Jsonnet文件。
  2. 运行Jsonnet命令行工具(可能包装在脚本中)以重新生成JSON文件。
  3. 勾选预先提交确保Jsonnet代码和JSON输出始终一致。
  4. 将所有内容打包成拉取请求以进行代码审查。

优点

  • 审阅者可以检查具体的更改 - 例如,重构不应该影响生成的JSON。
  • 可以在生成和抽象级别上跨不同版本检查多个作者的行注释。同样利于对变更的审核。
  • 在运行时你无需运行Jsonnet,这样可以降低复杂性、二进制大小和风险。

缺点

  • 生成的JSON不一定是可读的 - 例如,如果它嵌入了长字符串。
  • 由于其他原因,JSON可能不适合检查版本控制 - 例如,如果它太大或包含隐私。
  • 如果单个Jsonnet文件的许多并发编辑同时合并到单个JSON文件,则可能会出现合并冲突。

中期:构建时评估

你可以通过在构建时运行Jsonnet命令行实用程序并将生成的JSON嵌入到发布工件中(例如,作为tarball)来避免将JSON检入源控件。应用程序代码只是在初始化时从磁盘读取JSON文件。如果你使用的是Bazel,则可以使用Jsonnet Bazel规则轻松实现此目的。在谷歌,我们通常使用这种方法,该方法有如下优点。

优点

  • 你可以控制运行时的复杂性、二进制大小和风险,而无需在每个拉取请求中重建JSON文件。
  • 原始Jsonnet代码与生成的JSON之间不存在去同步的风险。

缺点

  • 构建更复杂。
  • 在代码审查期间评估具体变化更加困难。

后期:在运行时评估

链接Jsonnet库允许应用程序本身随时解释配置,产生的JSON配置的内存中表示。

优点

  • 它更简单,不需要事先评估。
  • 可以在执行期间评估用户提供的Jsonnet代码。

缺点

  • 任何链接库都会增加覆盖范围和风险。
  • 可能会在运行时发现配置错误,为时已晚。
  • 如果Jsonnet代码不稳定,则必须特别小心。(我们在第333页的“防止滥用配置”中讨论了原因)

按照我们的运行示例,如果你正在生成Kubernetes对象,何时应该运行Jsonnet?

答案取决于你的具体实施。如果你正在构建类似ksonnet(从本地文件系统运行Jsonnet代码的客户端命令行工具),最简单的解决方案是将Jsonnet库链接到该工具并评估正在进行的Jsonnet。这样做是安全的,因为代码在作者自己的机器上运行。

Box.com的基础架构使用Git将配置更改推送到生产环境。为了避免在服务器上执行Jsonnet,Git会对保存在存储库中的生成的JSON进行操作。对于像Helm或Spinnaker这样的部署管理守护程序,唯一的选择是在运行时评估服务器上的Jsonnet(下一节中将介绍注意事项)。

防止滥用配置

与长时间运行的服务不同,配置执行应该随着配置生成快速终止。然而,由于错误或恶意攻击,配置可能会占用任意数量的CPU时间或内存。为了说明原因,请考虑以下非终止Jsonnet程序:

1
    local f(x) = f(x +1); f(0)

使用无界内存的程序与之类似:

1
    local f(x) = f(x +[1]); f([])

你可以使用对象而不是函数或其他配置语言编写等效示例。

可以尝试通过限制语言来避免过度消耗资源,以使其不再是图灵计算机完成的。但是,强制所有配置终止并不一定能防止过度消耗资源。编写一个耗费足够时间或内存的程序而实际是不终止的程序是很容易的。例如:

1
    local f(x) = if x== 0 then [] else [f(x - 1), f(x - 1)]; f(100)

实际上,即使使用简单的配置格式(如XML和YAML),也存在此类程序。

当然,这些情景的风险取决于具体情况。问题较少的情况,假设命令行工具使用Jsonnet构建Kubernetes对象,然后部署这些对象。在这种情况下,Jsonnet代码是可信的:很少产生非终止的事故,你可以使用Ctrl-C来缓解它们。很少会产生内存耗尽。另一个极端情况,使用类似Helm或Spinnaker这样的服务,它接受来自最终用户的任意配置代码并在请求处理程序中对其进行评估,你需要避免占用请求处理程序或耗尽内存的DOS攻击。

如果在请求处理程序中评估不受信任的Jsonnet代码,则可以通过沙盒化Jsonnet执行来避免此类攻击。一个简单的策略是使用单独的进程和ulimit(或其非UNIX等价物)。通常,你需要fork到命令行可执行文件而不是链接Jsonnet库。因此,未在给定资源内完成的程序将执行失败并通知最终用户。为了进一步防御C++内存漏洞利用,你可以使用Jsonnet的本机Go实现。

结论

无论是使用Jsonnet、采用其他配置语言还是开发自己的配置语言,我们都希望你能够应用这些最佳实践来管理配置生产系统所需的复杂性和操作负载。

至少,配置语言的关键属性是优秀的工具,封闭配置以及配置和数据的分离。

你的系统可能不至于复杂到使用配置语言。过渡到像Jsonnet这样的特定于域的语言是一种在你的系统复杂度增加时可以考虑的策略。它会提供一致且结构良好的界面,让你的SRE团队有更多时间来处理其他重要项目。