卷2:第18章 Puppet part 2

18.3. 组件分析

Agent (代理)

在 Puppet 运行过程之中,第一个提到的组件就是 agent 进程。历史上,这是一个独立的称为 puppedd 的可执行程序,但在 2.6 中,我们把 Puppet 变成了一个唯一的可执行程序,现在,可以使用 puppet agent 来调用它,和 Git 的使用方式很类似。这个代理本身的功能不多,主要是用于实现上面图中客户端的工作流的配置和代码。

Facter

在 agent 之后的下一个组件是一个称为 Facter 的外部工具,这是一个用于检测本机信息的非常简单的工具。这些信息包括操作系统、IP 地址、主机名等,但 Facter 非常易于扩展,很多机构在使用中会增加它们自己的插件来检测一些个性化信息。Facter 发现的信息会由 agent 发送给服务器,之后,服务器就接过了接力棒,继续工作流。

外部节点分类器

在服务端,第一个遇到的组件称为外部节点分类器(External Node Classifier),简称为 ENC。ENC 接受主机名作为输入,返回一个包含对应主机的高级配置信息的数据结构。ENC 通常是一个独立的服务或程序:可能是一个其他的开源项目,比如 Puppet Dashboard 或 Foreman,或者是继承的已有的数据存储,比如 LDAP。ENC 的目的是,确定一台主机从功能上属于那些类,以及需要用哪些参数来配置这些类。比如,一个给定的主机可能属于 debianwebserver 类,datacenter 参数应该设置为 atlanta

注意,在 Puppet 2.7 中,ENC 不是一个必选组件,用户可以在 Puppet 代码中直接指定节点的配置。大约在 Puppet 项目开始两年之后,ENC 才被添加进来,因为我们开始意识到,对节点的功能进行划分和配置节点从本质上说是不同的两件事,将它们划分到两个不同的程序中可能比扩展语言来支持两种功能要更合理。尽管不是必须,但 ENC 仍然是推荐配置,并且将来某天可能会成为必选组件(到那时,Puppet 会提供一个拥有足够必备功能的 ENC,不必担心)。

一旦服务器收到 ENC 的分类信息,或是来自 Facter (经过 agent)的系统信息后,它会将所有信息绑定到一个节点对象上,并将它送入编译器。

编译器(Compiler)

如前所述,Puppet 有一个自定义的语言,用于指定系统配置。它的编译起实际上有三个部分:一个 Yacc 风格的分析器生成器和一个定制的词法分析器;一组用于创建我们的抽象语法树(AST)的类;以及 Compiler 类,它用于处理所有这些类,以及实现编译器作为系统的一部分所提供的API的函数之间的交互。

编译器要处理的最复杂的事情是,大部分 Puppet 配置代码是在第一次被引用时才延迟加载的(减少加载时间,同时避免缺少一些实际不必要的依赖资源时产生的无关日志),这意味着不会有显式的调用来加载并分析代码。

Puppet 的解析器使用了一个使用开源的 Racc 构建的正常的 Yacc 风格的解析器生成器。可不幸的是,在 Puppet 项目开始时,没有可用的词法分析器生成器,所以只好使用了一个定制的词法分析器。

因为我们在 Puppet 中使用了 AST,所以 Puppet 语法中的每个语句都可以被求值为 Puppet AST 类的一个实例(Puppet::Parser::AST::Statement),这些 AST 实例不会被直接执行操作,而会被放入一个语法树之中,被一起执行。当一个服务器为很多不同节点服务时,使用 AST 会带来一些性能上的收益,因为这样可以一次解析,多次编译。同时这也给了我们一个机会,来对 AST 进行一些内省(introspection),让我们得到一些额外的信息和能力,如果直接解析执行是无法得到这些的。

在 Puppet 项目开始时,可参考的 AST 的例子并不多,这部分已经经过了很多的演化,发展到现在,我们的形式看起来是比较独一无二的。我们不会直接针对整个配置生成一个单独的AST,相反,我们创建很多小的 AST,按照名字切开。比如,如下代码:

class ssh {
    package { ssh: ensure => present }
}

会创建一个新的 AST,包含一个 Puppet::Parser::AST::Resource 实例,并将这个 AST 命名为 "ssh",存储在存储这个特定环境的所有类的哈希表中。(这里略过了构建类的细节,不过对于这里的讨论来说,这些是不必要的。)

给定 AST 和(来自 ENC 的)Node 对象,编译器取出 node 对象(如果存在的话)指定的类,查找并进行求值。在这个求值的过程中,编译器构建了不同不同的域的树,每个类有自己的作用域。这意味着 Puppet 是动态作用域的:如果一个 class include 了另一个类,那么里面的类就可以访问外面类的变量。这的确是一个噩梦,我们正在着手消除这个问题。

作用域树是临时数据结构,一旦编译完成就会被释放,但编译的输出也随着编译的过程逐渐完成。我们把这个输出产品称为 Catalog(目录),但它实际是一张资源和它们的关系构成的图。变量、控制结构或是函数都不会存在在 catalog 之中,catalog 是纯数据,并可以被转化为 JSON、YAML 或其他各种格式。

在编译过程中,我们会创建一些包含(containment)关系,一个类"包含(contains)"类中定义的所有资源(比如, 前面的例子中,ssh 类包含 ssh 包)。类可以包含一个定义,而这个定义本身也可以包含一个或多个定义,或其他独立的资源。一个 catalog 倾向于一个扁平的、彼此无连接的图:很多类,每个都有少数的几个层次。

这种图的一个别扭的方面是,它还包含了“依赖(dependency)”关系,比如一个服务依赖于一个包(可能因为安装了包才能创建服务),但这些依赖关系是由资源的参数指定的,而非图结构的边。我们的图类(由于历史原因,称为 SimpleGraph)不支持在同一张图里同时有“包含”边和“依赖”边,所以,我们不得不为了不同的需求在它们之间来回转换。

事务 (Transaction)

一旦 catalog 完全构建好了(假设没有失败),就会送给 Transaction。在一个区分客户机和服务器的系统中,Transaction 运行在客户机上,如图 18.2,它通过 HTTP 协议下载 Catalog。

Puppet 的 transaction 类提供了实际进行系统修改操作的框架,而我们讨论过的其他东西都构建于其上或是进行对象的分发传递。和数据库之类的一般系统中的事务不同 Puppet transaction 的行为并不具有原子性等特征。

transaction 的工作相当直接:在图中按照各种关系指定的顺序进行遍历,并确保各种资源保持同步。正如上面提到的,它不得不将图从包含边(比如 Class[ssh] 包含 Package[ssh])转换为依赖边(比如 Service[ssh] 依赖于 Package[ssh]),然后对图进行标准的拓扑排序,按顺序选择每种资源。

对于给定的资源,我们进行简单的三步操作:获取资源的当前状态,与期望的状态进行比较,进行必要的改动,以满足期望。比如,有如下代码:

file { "/etc/motd":
    ensure => file,
    content => "Welcome to the machine",
    mode => 644
}

transaction 会检查 /etc/motd 的内容,如果和指定的状态不匹配的话,会修复不一致的地方。如果 /etc/motd 是一个目录,那么它会备份其中的文件,再删除目录,然后创建一个内容和权限都符合要求的文件。

进行操作的过程实际上是通过一个简单的 ResourceHarness 类来控制的,这个类定义了事务和资源之间的接口。这样做可以减少类之间的连接,并可以让他们互相独立地进行修改。

资源抽象层 (Resource Abstraction Layer)

事务类是 Puppet 完成工作的核心,但所有的工作实际都是由资源抽象层(RAL)来完成的,从架构上讲,这一层也是 Puppet 中最有意思的组件。

RAL 是 Puppet 中创建的第一个组件,与语言部分不同,这部分对用户能做的事情进行了清晰的定义。RAL 的工作就是定义一个资源究竟是什么,要实现一个资源需要在系统中进行什么操作,而 Puppet 语言正是用来操作由 RAL 建模的资源的。正因如此,RAL 也是系统中最重要和最难改动的组件。我们希望能修改 RAL 中的很多东西,而且也在过去的多年中进行了很多重大改进(最难的莫过于增加 Provider 了),但是在 RAL 中,还是有很多工作需要在日后慢慢修改。

在编译器子系统中,我们将资源和资源类型分别进行了建模(分别命名为 Puppet::Resource 和 Puppet::Resource::Type)。我们的目标是让这些类也成为 RAL 的核心,不过,目前这两种行为(资源和类型)被封装到了同一个类之中 —— Puppet::Type。(这个类的命名十分糟糕,这是因为定义这个类的时间远早于我们开始使用“资源”这个名词的时间,在那时,我们在主机间进行数据通信时,是直接对内存类型进行序列化的,事到如今,想要再去重新调整命名已经非常困难了。)

Puppet::Type 被最早设计出来的时候,似乎把资源和类型的行为放到同一个类里是有道理的,毕竟资源是资源类型的实例。但随着时间的推移,越来越发现,资源及其类型不适合于放在一个传统的继承关系构成的模型里。比如,资源类型定义了资源可以有哪些参数,但不管资源接受哪些参数(可能全部接受)。这样,我们的 Puppet::Type 就拥有了类级别的行为——规定资源类型的行为,和实例级别的行为——规定资源实例如何行为。同时,它还负责管理注册和获取资源类型的功能,如果你需要 "user" 类型,你可以调用 Puppet::Type.type(:user).

这种混合的行为导致了 Puppet::Type 不太容易维护。整个类有不到 2000 行代码,但却在三个层面上工作 —— 资源、资源类型,和组员类型管理器 —— 这让它变得难以理解。这就是为什么这个模块是重构的主要目标的原因,不过它本身更多的是拼接在一起的代码,而不是面向用户的设计,所以,修正它要比直接根据功能重写更困难。

除了 Puppet::Type 之外,RAL 中还有两个重要的类,其中最有趣的一个我们称之为 Provider。在 RAL 刚刚被开发出来时,每种资源都是由参数定义和如何管理它们的代码混在一起构成的。比如,我们要定义 "content" 参数,然后提供一个方法来读取文件的内容,以及另一个用于修改内容的方法:

Puppet::Type.newtype(:file) do
    ...
    newproperty(:content) do
        def retrieve
            File.read(@resource[:name])
        end
        def sync
            File.open(@resource[:name], "w") { |f| f.print @resource[:content] }
        end
    end
end

这是个简化的例子(比如我们内部实际使用的校验和,而非读取全部内容),但这里可以大致了解处理思路。

这样就让事情变得非常难于管理了,因为我们需要为每个资源类型管理很多不同的属性。目前 Puppet 支持超过 30 种包管理工具,这样,很难在一个 Package 资源类型中去支持所有这些管理工具了。于是,我们提供了一种资源类型和管理相应类型的资源的方法之间的一个清晰接口 —— 实际上,资源类型是指资源类型的名字和它们支持的属性。 Provider 为所有的资源类型的属性定义了 getter 和 setter 方法,以清晰直观的方式命名。例如,这是一个 provider 实现上述属性的代码示例:

Puppet::Type.newtype(:file) do
    newproperty(:content)
end    
Puppet::Type.type(:file).provide(:posix) do
    def content
        File.read(@resource[:name])
    end
    def content=(str)
        File.open(@resource[:name], "w") { |f| f.print(str) }
    end
end

在这个简单的例子里,似乎还多了一点代码,但这更易于理解和维护,特别是当属性的数量或是属性提供者的数量变多的时候。

本节开始处曾经提到,Transaction 并不直接改动系统,而是通过 RAL 来完成的。现在,我们可以清楚地看到,是 provider 来进行的这想具体工作。事实上,总体上讲,provider 是 Puppet 当中,唯一真正直接触及系统的部分。transaction 请求文件内容,provider 就会为它读取,transaction 要求文件的内容要被改动,provider 就去改动它。注意,尽管如此,provider 从不决定如何影响系统 —— 如何影响系统这个问题是由 Transaction 来决定的,provider 仅仅是执行任务。这种架构可以让 Transaction 能够完全控制系统,却不需要了解文件、用户和包这些具体细节,而且,这个划分可以让 Puppet 可以拥有一个完全模拟执行的模式,在这种模式下,我们可以保证系统完全不会受到任何影响。

RAL之中的另一个主要的类型负责参数本身。我们支持三种类型的参数: metaparameters, 这种参数影响所有资源类型(比如,是否在模拟模式中运行);参数,它们是不直接写入到磁盘上的一些值(比如,是否在查找文件时进入符号链接);还有属性(property),它们规范了你要修改磁盘上的资源的哪方面的内容(比如文件的内容,或者一个服务是否在运行着)。区分属性和参数的不同十分困难,但你可以这么想,属性是哪些 provider 中有 getter 和 setter 方法的,这样就易于分辨了。

报告 (Reporting)

随着 transaction 遍历整张图,并使用 RAL 来修改系统的配置,Puppet 同时也会同时生成一份报告。这份报告包含了在对系统应用修改的过程中发生的事件。这些事件也完整地饭赢了工作进行的情况:它们会在资源改变时记录时间戳,已有的值和新的值,以及所有产生的信息,以及变动成功或是失败(或者实在模拟运行模式)。

这些事件封装在 ResourceStatus 对象之中,映射到相应的资源。这样,对于一个给定的 Transaction,你可以知道其中运行的所有资源,这些改变是否成功,以及你可能希望知道的关于这些改动的元数据。

一旦 transaction 完成了,一些基本的性能参数也会被计算出来,并存储到报告中,之后发送给服务器(如果配置了服务器的话)。当报告被发送出去的时候,配置过程就完成了,agent 会回到睡眠模式,或者进程退出。

18.4. 基础架构

现在我们已经从整体上理解了 Puppet 做了什么,如何做到的,值得再花一点看看其他部分了,这些部分并没有显示出什么过人之处,但对于完成工作也是十分必要的。

Plugins

Puppet 的一个突出优点是它非常易于扩展。在 Puppet 里,至少有 12 类扩展,大部分扩展都可以被所有人使用。比如,你可以在这些方面写出你自己的扩展:

  • 资源类型和自定义 provider
  • 报告处理程序,比如存储报告到专用的数据库中
  • Indirector,用于和已有数据存储交互
  • 用于获取你的主机上的额外信息的 facts

不过,Puppet 的分布式本质意味着 agent 需要某种方式来取回并加载新的扩展。为此,在每次 Puppet 启动之前,第一件事请就是找到可用的服务器,下载所有 plugin。其中可能包括新的资源类型或 provider,新 facts,或者是新的报告处理器。

这意味着我们可以在不改动核心 Puppet 包的同时,升级大部分的 Puppet agent 功能。对于一些自定义的 Puppet 部署,这更是特别有用。

Indirector

到目前为止,你可能已经发现,Puppet 的开发历史中有一些坏名字的类,而对于大部分人来说,这一个是最无法容忍的。Indirector 是一个极具扩展性的控制反转(IoC)框架。控制反转系统允许你讲功能的开发和如何控制使用什么功能独立开。在 Puppet 的例子中,这允许我们使用很多插件,来提供非常不同的功能,比如可以通过 HTTP 访问编译器,也可以直接在进程中加载,这些可以通过一个笑得配置改变而不需要修改代码就可以实现。换句话说,按照 Wikipedia 的 “控制反转” 页面的描述,Puppet Indirector 是一个服务定位器的实现。所有从一个类到另一个类的切换都经由 Indirector,通过一个标准的类 REST 接口完成(比如,我们支持 find, search, save 以及 destroy 方法),这样,讲 Puppet 从无服务器模式切到客户机/服务器模式的操作,很大程度上说,是一个配置 agent 使用 HTTP 作为获取 catalog 的方法,而非直接访问 compiler 的问题。

因为作为一个控制反转框架,Indirector 的配置必须严格地和代码的路径分开,这个类本身非常难于理解,特别是你在 debug 为什么使用给定的代码路径时。

网络

Puppet 的原型写于 2004 年,当时的关于 RPC 的问题是 XMLRPC 与 SOAP 之争。我们选择了 XMLRPC,它工作得很好,但是有一个大部分其他方法都有的问题:不鼓励在模块间使用标准接口,而且对于获取简单的一个结果这样的操作有些过于复杂了。因为 XMLRPC 编码的需要,导致了几乎每个对象都在内存中至少出现两次,对于大文件来说代价很高,我们也为此遇到过很严重的内存问题。

从 0.25 发布开始(开始于 2008 年),我们开始了将网络通信向类 REST 模型迁移的过程,但我们没有直接修改网络,而是选择了一种更复杂的方案。我们开发了 Indirector 作为组件间通信的标准框架,然后构建了一个 REST 端点作为一个可选方案。我们用了两个 Release 来完整支持 REST,目前还没有完全完成(从使用 YAML)到使用 JSON 作为序列化方案的转换。我们进行 YAML 到 JSON 的转换有两点主要的原因:首先,Ruby 之中,处理 YAML 非常慢,而 Ruby 处理 JSON 则快很多;其次,大部分 web 应用都转向了 JSON,这让 JSON 看起来更加可移植一些。当然,对于 Puppet 的情况来说,使用 YAML 并非是为了语言间可移植性考虑的,,而且 YAML 配置对于不同版本的 Puppet 可能都经常不兼容,因为它本质上是用来序列化 Ruby 内部对象的。

我们的下一个主版本更新将完全移除 XMLRPC 的支持。

18.5. 经验教训

从实现的角度讲,我们对于 Puppet 中实现的各种解耦非常自豪:描述语言与 RAL 是完全解耦的,Transaction 无法直接访问系统,而 RAL 本身不会做任何策略判断。这些抽象和解耦让应用的开发者可以更加专注于工作流的开发,并可以获得很多关于发生了什么、为什么发生的信息。

Puppet 的可扩展性和可配置性也是它的一个主要优点,任何人都可以在 Puppet 的基础上,轻易地开发应用而无需修改其内核。我们也使用提供给用户的同样的接口来开发各种功能。

Puppet 的简单和易用性一直是它的主要优点。虽然让它跑起来还是有点困难,不过可以强出市场上的其他产品好几里地了。这些简单性是以增加了很多工程量为代价的,特别是在维护和更多的设计工作方面的工作量,但是,如果能让用户可以更加集中在他们的问题上,而非工具上的话,这些开销也是值得的。

Puppet 的可配执性是个非常好的特性,不过我们做得好像有点过了。你可以有很多方法来让 Puppet 工作起来,并且,在 Puppet 上太容易构建工作流了,这在有的时候可能让人赶到困窘。我们的一个短期目标是,减少你可以调整的 Puppet 配置,避免用户很容易地把 Puppet 调坏,而且,这样我们在升级的时候也可以考虑更少的边界情况了。

我们的改变也有些慢了。有很多重构都已经等待数年却仍然没有进行。对用户来说,短期内这意味着更稳定的系统,但却更难于维护,而且用户也更难于向社区回馈代码。

最后,我们花费了很长时间来认识到,描述我们的设计语言的最恰当的词就是简单性。我们现在已经开始在考虑简单性之外的设计目标了,我们开始采用更好的决策框架来决定是否增加或移除特性,开始通过考虑背后的深层次原因来做出决定。

18.6. Conclusion

Puppet 是一个简单的系统,也是一个复杂的系统。它由很多的部分组成,但各个部分之间的耦合非常松,每个部分从2005年至今都发生了很多变化。它是一个可以用于处理各种配置问题的框架,但作为一个应用,它非常简单易用。

在未来,我们的成功将依赖于更加坚实、更加简单的框架,并让应用在增强能力的同时,保持易用性。