Puppet5-精要第四版-全-

Puppet5 精要第四版(全)

原文:annas-archive.org/md5/0b74b7be627fd3c4b5fe6c50377e5ac9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Puppet 是一款配置管理工具,它能够自动化管理你所有的 IT 配置,赋予你对每个 Puppet 代理在网络中的控制权,以及何时和如何进行操作。在这个数字化交付和无处不在的互联网时代,实施可扩展且可移植的解决方案变得越来越重要,这不仅限于软件,还包括运行这些软件的系统。

本书旨在传授所需的知识,不仅包括 Puppet 的基础,还涉及其核心。书中将探讨并解释基于 Puppet 的设计理念和基本原则。通过使用一个高效且富有生产力的工具来实现目标。

本书内容

第一章,编写你的第一个清单,讲解了基于资源的 Puppet 声明式配置管理,以及如何实现它们。

第二章,Puppet 服务器与代理,涵盖了 Puppet 服务器的安装与配置,以及如何将代理连接到服务器。

第三章,深入了解 Puppet 的 Ruby 部分——Facts、Types 和 Providers,解释了 Facter 及其 Facts、Types 和 Providers 在 Puppet 中的基本功能。

第四章,在类和定义类型中组合资源,讲解了自定义资源的使用,帮助你简化重复的代码。

第五章,将类、配置文件和扩展结合到模块中,解释了 Puppet 环境和节点分类的概念。

第六章,Puppet 初学者的高级部分,涵盖了 Puppet 的一些特性,如可读性、灵活性、EPP 模板、虚拟与导出的资源以及资源默认值等改进。

第七章,Puppet 4 和 5 的新特性,解释了 Puppet 环境和节点分类的概念。

第八章,通过 Hiera 实现代码和数据的分离,介绍了一种 Puppet 方式来分离代码和数据,从而可以管理数据。

9 章Puppet 角色与配置文件,提供了一种工作流,允许独立升级上游模块和本地 Puppet 实现。

本书所需条件

你需要 Debian 8+ 或 Ubuntu 14+,以及物理/虚拟的 x86 系统。

本书适用对象

本书面向有经验的 IT 专业人员和新的 Puppet 用户。本书将帮助你从安装到高级自动化全面了解 Puppet。你将快速入门,并学习如何建立 Puppet 的最佳实践,进行高级自动化。

约定

本书中,您将看到多种文本样式,用来区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名等会以如下方式显示:“文件资源类型将返回mtimectime。”

代码块会如下所示:

node 'agent' {
     $packages = [ 'apache2', 'libapache2-mod-php5', 'libapache2-mod-passenger', ]
     package { $packages:
       ensure => 'installed',
       before => Service['apache2'],
      }
     service { 'apache2':
       ensure => 'running',
       enable => true,
     }
   }

当我们希望特别提醒您注意代码块中的某部分时,相关的行或项目会以粗体显示:

service { 'puppet': enable => false }
cron { 'puppet-agent-run':
  user    => 'root',
  command =>
    'puppet agent --no-daemonize --onetime --
     logdest=syslog',
   minute => fqdn_rand(60),
   hour   => absent,
} 

任何命令行输入或输出都如下所示:

puppet:///modules/ntp/ntp.conf puppet:///modules/my_app/opt/scripts/find_my_app.sh

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的内容,会像这样出现在文本中:“如果您正在寻找一个能通过额外的资源类型和提供程序增强您的代理的模块,请在模块详情页上查找 Types 标签。”

警告或重要的说明会像这样显示。

提示和技巧会以这种方式出现。

读者反馈

我们欢迎读者的反馈。让我们知道您对本书的看法——您喜欢或不喜欢的内容。读者反馈对我们非常重要,因为它帮助我们开发出您真正能从中受益的书籍。如果您有任何意见或建议,直接通过邮件发送给feedback@packtpub.com,并在邮件主题中提及书名。如果您在某个领域有专业知识,并且有兴趣撰写或为书籍贡献内容,请参考我们的作者指南:www.packtpub.com/authors

客户支持

现在,作为一本 Packt 书籍的骄傲拥有者,我们为您提供了许多帮助,帮助您从购买中获得最大收益。

下载示例代码

您可以从您的账户下载本书的示例代码文件,访问www.packtpub.com。如果您是在其他地方购买的本书,您可以访问www.packtpub.com/support并注册,直接将文件发送到您的邮箱。您可以按照以下步骤下载代码文件:

  1. 使用您的邮箱地址和密码登录或注册我们的网站。

  2. 将鼠标悬停在顶部的 SUPPORT 标签上。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买本书的地方。

  7. 点击“代码下载”。

一旦文件下载完成,请确保使用最新版本的工具解压或提取文件夹:

  • Windows 版 WinRAR / 7-Zip

  • Mac 版 Zipeg / iZip / UnRarX

  • Linux 版 7-Zip / PeaZip

本书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Puppet-Essentials-Third-Edition。我们还有其他来自我们丰富书籍和视频目录的代码包,您可以在 github.com/PacktPublishing/ 查看!

下载本书的彩色图片

我们还为您提供了一份包含本书中截图/图表彩色图片的 PDF 文件。这些彩色图片将帮助您更好地理解输出中的变化。您可以从 www.packtpub.com/sites/default/files/downloads/PuppetEssentialsThirdEdition_ColorImages.pdf 下载此文件。

勘误

尽管我们已尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书籍中发现任何错误——可能是文本错误或代码错误——我们将非常感激您能向我们报告。通过这样做,您可以帮助其他读者避免困扰,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata 进行报告,选择您的书籍,点击勘误提交表格链接,输入您的勘误详细信息。一旦您的勘误被验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该书籍的勘误列表中。要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索栏中输入书名。所需信息将出现在勘误部分。

盗版

互联网版权材料的盗版问题在各类媒体中普遍存在。在 Packt,我们非常重视版权和许可证的保护。如果您在互联网上发现任何我们作品的非法复制品,请立即向我们提供该位置地址或网站名称,以便我们采取措施。请通过 copyright@packtpub.com 联系我们,并提供涉嫌盗版材料的链接。感谢您的帮助,保护我们的作者以及我们为您带来有价值内容的能力。

问题

如果您在本书的任何方面遇到问题,可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章:编写你的第一个清单

配置管理已经成为 IT 世界中至关重要的一部分。使用敏捷方法进行更快的开发对 IT 运营产生了巨大影响,IT 运营需要跟上更快的系统部署速度。没有强大的管理基础设施,服务器操作几乎是不可行的。在众多可用工具中,Puppet 已经确立了自己作为最受欢迎和最广泛应用的解决方案之一。最初由 Luke Kanies 编写,这个工具现在在 Apache 2.0 许可证下分发,并由 Luke 的公司 Puppet Inc 维护。它拥有一个庞大且充满活力的社区,丰富的插件 API 和支持工具,出色的在线文档,以及基于 SSL 认证的强大安全模型。

和所有配置管理系统一样,Puppet 允许你维护一个基础设施定义的中央仓库,以及一个工具链,用于在受管系统上强制执行所需的状态。整个功能集非常令人印象深刻。本书将引导你通过一些步骤,快速掌握 Puppet 最重要的方面和原则。

在本章中,我们将涵盖以下主题:

  • 开始使用

  • 介绍资源、参数和属性

  • 解释puppet apply命令的输出

  • 使用变量

  • 在清单中添加控制结构

  • 控制执行顺序

  • 实现资源交互

  • 检查 Puppet 核心资源类型

开始使用

安装 Puppet 很简单。在大型 Linux 发行版上,你可以通过apt-getyum直接安装 Puppet 软件包。

Puppet 的安装可以通过以下方式完成:

  • 从默认的操作系统仓库安装

  • 来自 Puppet Inc

前一种方式通常更简单。第二章,Puppet 服务器和代理,提供了简单的安装 Puppet Inc 软件包的说明。安装 Puppet 的跨平台方式是获取puppet Ruby gem。这种方式适合用于测试和管理单一系统,但不推荐用于生产环境。

安装 Puppet 后,你可以立即使用它。Puppet 由清单驱动,清单相当于脚本或程序,使用 Puppet 的领域特定语言DSL)编写。我们从必备的Hello, world!清单开始:

# hello_world.pp
notify { 'Hello, world!':
} 

下载示例代码:

你可以从你在www.packtpub.com的账户中下载你购买的所有 Packt Publishing 书籍的示例代码文件。如果你在其他地方购买了本书,可以访问www.packtpub.com/support,并注册自己以便将文件直接通过电子邮件发送给你。

为了使清单生效,请使用以下命令(我们特意避免使用execute这个术语——清单不能被执行;更多细节将在本章中段介绍):

root@puppetmaster:~# puppet apply hello_world.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.45 seconds
Notice: Hello, world!
Notice: /Stage[main]/Main/Notify[Hello, world!]/message: defined 'message' as 'Hello, world!'
Notice: Applied catalog in 0.03 seconds 

Puppet Inc. 提供的软件包包含所有必需的软件组件,并安装到 /opt/puppetlabs。如果找不到 puppet 命令,你可以指定完整路径(/opt/puppetlabs/bin/puppet),或者刷新你的 shell 环境(exec bash,或注销并重新登录)。

在我们查看清单的结构和 puppet apply 命令的输出之前,先做一些有用的事情,作为示例。Puppet 自带其背景服务。假设你想在让它干扰系统之前先了解基本原理。你可以编写一个清单,让 Puppet 确保该服务当前没有运行,并且不会在系统启动时启动:

# puppet_service.pp
service { 'puppet':
  ensure => 'stopped',
  enable => false,
}

要控制系统进程、启动选项、软件安装等,Puppet 需要以 root 权限运行。这是调用该工具最常见的方式,因为 Puppet 通常会管理操作系统级别的设施。通过 sudo 或从 root shell 中,以 root 权限应用你的新清单,如下所示:

root@puppetmaster:~# puppet apply puppet_service.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.61 seconds
Notice: /Stage[main]/Main/Service[puppet]/ensure: ensure changed 'running' to 'stopped'
Notice: Applied catalog in 0.15 seconds 

现在,Puppet 已为你禁用了其后台服务的自动启动。再次应用相同的清单没有效果,因为所需的步骤已经完成:

root@puppetmaster:~# puppet apply puppet_service.pp
Notice: Compiled catalog for puppetmaster.example.net in environment 
production in 0.62 seconds
Notice: Applied catalog in 0.07 seconds 

这反映了 Puppet 中的一个标准行为:Puppet 资源是幂等的,这意味着每个资源首先会将实际(系统)状态与期望(Puppet)状态进行比较,只有在存在差异(配置漂移)时才会启动操作。

你经常会看到 Puppet 输出这样的内容。它告诉你一切都按预期进行。因此,这是一个理想的结果,就像 git status 输出的“清洁”一样。

介绍资源、参数和属性

你在前一部分编写的每个清单都声明了一个相应的资源。资源是清单的基本构建块。每个资源都有一个类型(在此案例中分别为 notifyservice)和一个名称或标题(Hello, world!puppet)。每个资源对于清单来说是唯一的,可以通过其类型和名称的组合来引用,例如 Service["puppet"]。最后,一个资源还包括一个零个或多个属性的列表。属性是键值对,例如 "enable => false"

属性名称不能随意选择。它们是 Puppet 资源类型的一部分。Puppet 区分两种不同的属性:参数和特性。每种资源类型支持一组特定的属性。参数描述 Puppet 应该如何处理资源类型,特性描述资源的特定设置。某些参数适用于所有资源类型(元参数),而一些名称则非常常见,例如 ensureservice 类型支持 ensure 特性,它表示受管理进程的状态。另一方面,它的 enabled 特性与系统引导配置相关(针对特定服务)。

我们已经使用了属性、特性和参数这几个术语,看似可以互换使用。不要被迷惑——它们之间有重要的区别。特性和参数是 Puppet 使用的两种不同的属性。

你已经看过两个特性的实际应用。现在让我们来看一个参数:

service { 'puppet':
  ensure   => 'stopped',
  enable   => false,
  provider => 'upstart',
}

provider 参数告诉 Puppet 需要与 upstart 子系统交互来控制其后台服务,而不是与 systemdinit 交互。如果不指定该参数,Puppet 会做出智能猜测。系统上有很多支持的工具来管理服务。稍后你将了解更多关于提供者及其自动选择的内容。

参数和属性的区别在于,参数仅指示 Puppet 应该如何管理资源,而不是期望的状态是什么。Puppet 只会对属性值进行操作。在这个例子中,它们是 ensure => 'stopped'enable => false。对于每个这样的属性,Puppet 将执行以下任务:

  • 测试资源是否已经与目标状态同步。

  • 如果资源未同步,它将触发同步操作。

当由给定资源管理的系统实体(在此例中是 Puppet 的 upstart 服务配置)处于与属性值在清单中描述的状态一致时,属性被认为是同步的。在这个例子中,只有当 puppet 服务未运行时,ensure 属性才会是同步的。如果 upstart 没有配置为在系统启动时启动 Puppet,则 enable 属性是同步的。

作为一个帮助记忆的提示,记住,特性可以不同步,而参数则不可以。

Puppet 还允许你通过使用 puppet resource 命令读取现有的系统状态:

root@puppetmaster:~# puppet resource user root
user { 'root':
 ensure           => 'present',
 comment          => 'root',
 gid              => '0',
 home             => '/root',
 password         => '$6$17/7FtU/$TvYEDtFgGr0SaS7xOVloWXVTqQxxDUgH.
 eBKJ7bgHJ.hdoc03Xrvm2ru0HFKpu1QSpVW/7o.rLdk/9MZANEGt/',
 password_max_age => '99999',
 password_min_age => '0',
 shell            => '/bin/bash',
 uid              => '0',
} 

请注意,某些资源类型会返回只读属性(例如,文件资源类型会返回 mtimectime)。请参阅相关类型的文档。

解释 puppet apply 命令的输出。

正如你已经看到的,Puppet 输出的信息相当冗长。随着你对工具的熟悉,你会迅速学会识别关键信息。让我们首先看看信息性消息。再一次应用 service.pp 清单:

root@puppetmaster:~# puppet apply puppet_service.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.48 seconds
Notice: Applied catalog in 0.05 seconds

Puppet 没有采取任何特定的行动。你只会得到两个时间戳:一个来自清单的编译阶段,另一个来自目录应用阶段。目录是编译清单的全面表示。Puppet 基于当前目录的内容来评估和同步资源。

现在,为了快速强制 Puppet 显示一些更有趣的输出,可以直接从 shell 传递一行清单。Ruby 或 Perl 的常用用户会认出这种调用语法:

# puppet apply -e'service { "puppet": enable => true, }'
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.62 seconds
Notice: /Stage[main]/Main/Service[puppet]/enable: enable changed 'false' to 'true'
Notice: Applied catalog in 0.12 seconds. 

我们更倾向于在作为命令行参数传递的清单中使用双引号,因为在 shell 中,清单应整体用单引号括起来。

你指示 Puppet 对 Puppet 服务执行另一次更改。输出反映了实际执行的更改。让我们分析一下这个日志消息:

  • 行首的 Notice: 关键字表示日志级别。其他级别包括 Warning(警告)、Error(错误)和 Debug(调试)

  • 更改的属性通过完整路径进行引用,从 Stage[main] 开始。Stages 超出了本书的范围,因此你在这里总是看到默认的 main

  • 下一个路径元素是 Main,它是另一个默认值。它表示声明该资源的类。你将在 第四章《在类和定义类型中组合资源》中学习类。

  • 接下来是资源。你已经学到,Service[puppet] 是它的唯一引用

  • 最后,enable 是相关属性的名称。当多个属性不同步时,通常每个属性都会有一行输出,表示该属性已同步。

  • 日志行的其余部分表示 Puppet 认为合适应用的更改类型。表述方式取决于属性的性质。它可以像 created(已创建)一样简单,表示新添加到管理系统的资源,或者像 changed false to true(更改 false 为 true)这样简短的短语。

干运行你的清单

puppet apply 的另一个有用的命令行选项是 --noop

它指示 Puppet 在资源不同步时不执行任何操作。

相反,你只会得到一条日志输出,表明在没有该选项时将会发生什么变化。这对于判断清单是否可能会破坏系统上的任何内容非常有用:

root@puppetmaster:~# puppet apply puppet_service.pp --noop
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.63 seconds
Notice: /Stage[main]/Main/Service[puppet]/enable: current_value true, should be false (noop)
Notice: Class[Main]: Would have triggered 'refresh' from 1 events
Notice: Stage[main]: Would have triggered 'refresh' from 1 events
Notice: Applied catalog in 0.06 seconds 

输出格式与之前相同,日志末尾带有一个标记为 (noop) 的标记,表示同步操作。这个日志可以视为应用清单时的预览,假如不使用 --noop 选项。

关于触发刷新时的额外通知将在后面描述,目前可以忽略。完成本章和第四章,类和定义类型中的资源组合后,你将更好地理解它们的意义。

使用变量

变量赋值的工作方式与大多数脚本语言相同。任何变量名都始终以 $ 符号开头:

$download_server = 'img2.example.net'
$url = "https://${download_server}/pkg/example_source.tar.gz" 

同样,像大多数脚本语言一样,Puppet 在双引号中的字符串内执行变量值替换,但在单引号字符串中不进行任何插值。

变量对于使清单更简洁和易懂非常有用。它们帮助你实现保持源代码不冗余的总体目标。与命令式编程和脚本语言中的变量的一个重要区别是,Puppet 清单中的变量是不可变的。一旦一个值被赋值,它就不能被覆盖。

如果它是常量,为什么还叫做变量呢?人们不应将 Puppet 仅视为一个管理单一系统的工具。对于单一系统,Puppet 变量可能看起来像一个常量,但 Puppet 管理着多个操作系统不同的系统。在所有这些系统中,变量将会不同,而不是常量。

变量类型

从 Puppet 3.x 开始,只有四种变量类型:字符串、数组、哈希和布尔值。Puppet 4 引入了一个丰富的数据类型系统。新数据类型系统将在第七章,Puppet 4 和 5 的新特性的最后部分进行解释。这些基本的变量类型与其他语言中的相应类型工作方式类似。根据你的背景,你可能已经熟悉使用关联数组或字典作为 Puppet 的哈希类型的语义等效物:

$a_bool = true
$a_string = 'This is a string value'
$an_array = [ 'This', 'forms', 'an', 'array' ]
$a_hash = { 
  'subject'   => 'Hashes',
  'predicate' => 'are written',
  'object'    => 'like this',
  'note'      => 'not actual grammar!',
  'also note' => [ 'nesting is',
{ 'allowed'   => ' of course' } ], 
}

访问值同样简单。请注意,hash语法类似于 Ruby,而不是 Perl:

$x = $a_string
$y = $an_array[1]
$z = $a_hash['object'] 

字符串可以作为资源属性值使用,但值得注意的是,资源标题也可以是变量引用:

package { $apache_package:
  ensure => 'installed'
}

在这个上下文中,字符串值的含义直观清晰。但你也可以在此传递数组,用一条语句声明一组资源。以下清单管理三个包,确保它们都已安装:

$packages = [
  'apache2',
  'libapache2-mod-php5',
  'libapache2-mod-passenger',
 ]
package { $packages:
  ensure => 'installed'
}

你将在后面的章节中学习如何高效地使用哈希值。

数组不需要存储在变量中就可以使用,但在某些情况下,将其存储在变量中是一个好习惯。

数据类型

Puppet 4 中的数据类型系统允许你检查和验证一个变量是否属于特定的数据类型。这可以防止代码在(例如)预期为数组却接收到布尔值时表现不正确。

数据类型的完整功能将在 第七章,《Puppet 4 和 5 的新特性》中详细解释。在 Puppet 清单中,可以使用正则表达式控制结构来检查数据类型。

Puppet 有核心数据类型和抽象数据类型。核心数据类型是最常用的数据类型,例如字符串或整数,而抽象数据类型允许进行更复杂的类型验证,如可选类型或变体类型。

在处理数据类型之前,我们必须理解 Puppet 清单中的控制结构概念。

在清单中添加控制结构

到目前为止,你已经根据本章的指引编写了三个简单的清单。每个清单只包含一个资源,其中一个是通过命令行的 -e 选项传入的。当然,你不可能为每一种情况都编写不同的清单。相反,就像 Ruby 或 Perl 脚本会根据不同的条件分支出不同的代码路径一样,Puppet 代码也有一些结构,使其能够根据不同的情况变得灵活和可重用。

最常见的控制结构是 if/else 块。它与许多编程语言中的相应结构非常相似:

if 'mail_lda' in $needed_services {
  service { 'dovecot': enable => true }
} else {
  service { 'dovecot': enable => false }
}

Puppet 的 DSL 也有一个 case 语句,类似于其他语言中的 case

case $role {
  ‘imap_server’: {
    package { ‘dovecot’: ensure => installed, }
    service { ‘dovecot’: ensure => running, }
  }
  /_webservers$/: {
    service { [‘apache’, ‘ssh’]: ensure => running, }
  }
  default: {
    service { ‘ssh’: ensure => running, }
  }
}

在第二个匹配器中,你可以看到如何使用正则表达式。

case 语句还可以根据变量的数据类型切换到特定的代码:

case $role {
  Array: {
    include $role[0]
  }
  String: {
    include $role
  }
  default: {
    notify { 'This nodes $role variable is neither an 
    Array nor a String':}
  }
}

case 语句的一种变体是选择器。它是一个表达式,而不是语句,可以像 C 类语言中的三元 if/else 操作符一样使用:

package { 'dovecot':
  ensure => $role ? {
    'imap_server' => 'installed',
    /desktop$/    => 'purged',
    default       => 'removed',
  },
}

类似于 case 语句,选择器也可以根据数据类型返回结果:

package { 'dovecot':
  ensure  => $role ? {
    Boolean => 'installed',
    String  => 'purged',
    default => 'removed',
  },
}

选择器的使用需要小心,因为在更复杂的清单中,这种语法会影响可读性。

控制执行顺序

到目前为止,你可能已经有了这样的印象:Puppet 的 DSL 是一种专业化的脚本语言。但实际上,这种印象是错误的。清单(manifest)不是脚本或程序。该语言是一种工具,用于通过一组资源(包括文件、软件包和定时任务等)来建模系统状态。

整个范式与脚本语言不同。Ruby 或 Perl 是命令式语言,基于一系列按严格顺序执行的语句。而 Puppet 的 DSL 是声明式的,这意味着清单声明了一组期望具有特定属性的资源。这些资源被放入一个目录中,Puppet 然后尝试在所有声明的资源中构建一条路径。编译器按顺序解析清单,但配置器以非常不同的方式应用资源。

换句话说,清单应该始终描述你期望的最终结果。达到这个结果所需的具体操作由 Puppet 决定。

为了更清楚地说明这一点,让我们看一个例子:

package { 'haproxy':
  ensure => 'installed',
}
file {'/etc/haproxy/haproxy.cfg':
  ensure => file,
  owner  => 'root',
  group  => 'root',
  mode   => '0644',
  source => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
}
service { 'haproxy':
  ensure  => 'running',
}

通过这个清单,Puppet 将确保以下状态被实现:

  1. 安装了HAproxy包。

  2. haproxy.cfg文件有特定的内容,这些内容已准备好并保存在/etc/puppet/modules/目录下的文件中。

  3. 启动了HAproxy

为了使这个工作生效,重要的是必须按照顺序执行必要的步骤:

  • 配置文件通常无法在包之前安装,因为还没有目录来容纳它。

  • 服务在安装之前无法启动。如果它在配置完成之前变为活动状态,它将使用来自包的默认设置。

强调这一点是因为前面的清单实际上并未包含 Puppet 指示严格排序的提示。没有显式的依赖关系,Puppet 可以自由地按其认为合适的顺序排列资源。

Puppet 的最新版本允许一种基于本地清单的排序方式,因此呈现的例子实际上会按原样工作。基于清单的排序可以在puppet.conf配置文件中按如下方式配置:

ordering = manifest

这个设置是 Puppet 4 的默认设置。仍然重要的是要了解排序原则,因为在更复杂的清单中,隐式顺序很难确定,而且正如你很快将学到的那样,还有其他因素会影响顺序。

声明依赖关系

将这种简单的清单整理成有序的方式最简单的方法是资源链式连接。其语法是在两个资源之间使用一个简单的 ASCII 箭头:

package { 'haproxy':
  ensure => 'installed',
}
->
file { '/etc/haproxy/haproxy.cfg':
  ensure => file,
  owner  => 'root',
  group  => 'root',
  mode   => '0644',
  source => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
}
->
service {'haproxy':
  ensure => 'running',
}

只有当所有相关资源可以彼此紧挨着写时,这种方法才可行。换句话说,如果依赖关系的图形表示并不是一条直链,而是一个树形、星形或其他任何形状,这种语法就不够用了。

在内部,Puppet 构建一个有序的资源图,并在遍历该图时同步它们。

一种更通用且灵活的声明依赖关系的方法是通过特殊的元参数——这些参数可以与任何资源类型一起使用。有不同的元参数,其中大多数与排序无关(你在前面的例子中见过provider)。对于资源排序,Puppet 提供了requirebefore元参数。

两者都将一个或多个已声明资源的引用作为其值。如前所述,Puppet 引用有一个特殊的语法:

Type['title']
e.g.
Package['haproxy'] 

你只能构建指向在目录中声明的资源的引用。即使在被管理的系统中存在,你也不能构建和使用指向未被 Puppet 管理的东西的引用。

这是使用require元参数进行排序的HAproxy清单:

package { 'haproxy':
  ensure  => 'installed',
}
file {'/etc/haproxy/haproxy.cfg':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  source  => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
  require => Package['haproxy'],
}
 service {'haproxy':
  ensure  => 'running',
  require => File['/etc/haproxy/haproxy.cfg'],
}

以下清单在语义上是相同的,但它依赖的是 before 元参数,而不是 require

package { 'haproxy':
  ensure => 'installed',
  before => File['/etc/haproxy/haproxy.cfg'],
}
file { '/etc/haproxy/haproxy.cfg':
  ensure => file,
  owner  => 'root',
  group  => 'root',
  mode   => '0644',
  source => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
  before => Service['haproxy'],
}
service { 'haproxy':
  ensure => 'running',
}

当然,清单也可以混合使用这两种符号风格。这部分留给读者练习,没有专门的描述。

require 元参数通常能生成更易于理解的代码,因为它表达了被注解资源对另一个资源的依赖关系。另一方面,before 参数则暗示了一个引用资源对当前资源所形成的依赖关系。这可能会让人感到不直观,特别是对于经常使用软件包系统的人(这些系统通常实现了 require 风格的依赖声明)。

有时,决定是使用 require 还是 before 可能会很困难。在简单的情况下,大多数人更倾向于使用 require。在某些情况下,使用 before 更容易。想想那些有多个配置文件的服务。将有关配置文件和需求的信息保存在一个地方,可以减少在添加或移除附加配置文件时忘记同时修改服务所造成的错误。看一下以下示例代码:

file { '/etc/apache2/apache2.conf':
  ensure => file,
  before => Service['apache2'],
}
file { '/etc/apache2/httpd.conf':
  ensure => file,
  before => Service['apache2'],
}
service { 'apache2':
  ensure => running,
  enable => true,
}

在这个例子中,所有依赖关系都在文件资源声明中进行了声明。如果你改用 require 参数,那么在发生变化时,你将始终需要修改至少两个资源:

file { '/etc/apache2/apache2.conf':
  ensure => file,
}
file { '/etc/apache2/httpd.conf':
  ensure => file,
}
service { 'apache2':
  ensure => running,
  enable => true,
 require => [
    File['/etc/apache2/apache2.conf'],
    File['/etc/apache2/httpd.conf'],
  ],
}

每当你添加一个新文件并由 Puppet 管理时,你会记得更新服务资源声明吗?考虑另一个更简单的例子:

if $os_family == 'Debian' {
  file { '/etc/apt/preferences.d/example.net.prefs':
    content => '...',
    before  => Package['apache2'],
  }
}
package { 'apache2':
  ensure    => 'installed',
} 

preferences.d 目录中的文件仅对类似 Debian 的系统有意义;这就是为什么该软件包不能安全地 require 它的原因。如果清单应用到其他操作系统,例如 CentOS,apt 配置文件不会出现在目录中,感谢 if 条件语句。如果该软件包无论如何都将其作为依赖,最终的目录将不一致,Puppet 也无法应用它。在文件资源中指定 before 是安全的,并且语义上是等效的。

在像这种情况中,before 元参数是必不可少的,并且在其他情况下,它能使清单代码更加优雅和直接。熟悉 beforerequire 两者是很有益的。

错误传播

定义需求有另一个重要的目的。声明的资源上的引用,只有在依赖的资源成功完成时才会被验证为成功引用。如果所需资源未能成功同步,可以视为 Puppet DSL 代码中的一个停止点。

例如,如果 source 文件的 URL 被破坏,file 资源会失败:

file { '/etc/haproxy/haproxy.cfg':
  ensure => file,
  source => 'puppet:///modules/haproxy/etc/haproxy.cfg',
}  

这里缺少了一个路径段。Puppet 会报告文件资源未能同步:

root@puppetmaster:~# puppet apply typo.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.62 seconds
Error: /Stage[main]/Main/File[/etc/haproxy/haproxy.cfg]: Could not evaluate: Could not retrieve information from environment production source(s) puppet:///modules/haproxy/etc/haproxy.cfg
Notice: /Stage[main]/Main/Service[haproxy]: Dependency File[/etc/haproxy/haproxy.cfg] has failures: true
Warning: /Stage[main]/Main/Service[haproxy]: Skipping because of failed dependencies
Notice: Applied catalog in 0.06 seconds 

在这个例子中,Error 行描述了由破损 URL 引起的错误。错误传播通过下面的 NoticeWarning 行表示。

Puppet 未能应用对配置文件的更改;它无法将当前状态与不存在的源进行比较。由于服务依赖于配置文件,Puppet 甚至不会尝试启动它。这是出于安全考虑:如果任何依赖关系无法进入定义的状态,Puppet 必须假定系统不适合应用该依赖资源。

这是另一个利用资源依赖的重要原因。记住,链式箭头和before元参数也暗示着错误传播。

避免循环依赖。

在了解资源之间另一种可能的相互关系之前,有一个问题你应该注意:依赖关系不能形成循环。让我们通过一个例子来可视化这个问题:

file { '/etc/haproxy':
  ensure  => 'directory',
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
}
file { '/etc/haproxy/haproxy.cfg':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  source  => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
}
service { 'haproxy':
  ensure  => 'running',
  require => File['/etc/haproxy/haproxy.cfg'],
  before  => File['/etc/haproxy'],
}

这个清单中的依赖循环有些隐藏(这很可能是你在使用 Puppet 时遇到的许多循环的情况)。

它由以下关系构成:

  • File['/etc/haproxy/haproxy.cfg']自动依赖父目录File['/etc/haproxy']。这是一个隐式的、内置的依赖关系。

  • 父目录File['/etc/haproxy']由于其before元参数,要求Service['haproxy']

  • Service['haproxy']服务依赖File['/etc/haproxy/haproxy.cfg']配置文件。

以下资源组合存在隐式依赖关系,等等:

  • 如果声明了一个目录和该目录中的一个文件,Puppet 将首先创建目录,然后创建文件。

  • 如果声明了用户及其主组,Puppet 将首先创建该组,然后创建用户。

  • 如果声明了文件和所有者(用户),Puppet 将首先创建用户,然后创建文件。

诚然,前面的例子是人为设定的——在配置目录之前管理服务是没有意义的。尽管如此,即使是看似合理的清单设计,也可能导致循环依赖。这就是 Puppet 如何响应这种设计:

root@puppetmaster:~# puppet apply circle.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.62 seconds
Error: Failed to apply catalog: Found 1 dependency cycle:
(File[/etc/haproxy/haproxy.cfg] => 
               Service[haproxy] => 
             File[/etc/haproxy] => File[/etc/haproxy/haproxy.cfg])
Try the '--graph' option and opening the resulting '.dot' file in OmniGraffle or GraphViz

输出帮助你定位有问题的关系。如果依赖循环非常大,涉及大量资源,那么文本渲染就很难分析。因此,Puppet 还提供了通过--graph选项获取依赖图的图形表示的机会。

如果你这样做,Puppet 将在其输出中包含新创建的.dot文件的完整路径。它的内容看起来类似于 Puppet 的输出:

digraph Resource_Cycles {
label = "Resource Cycles"
"File[/etc/haproxy/haproxy.cfg]" ->"Service[haproxy]" ->"File[/etc/haproxy]" ->"File[/etc/haproxy/haproxy.cfg]"
}  

这本身没有太大帮助,但它可以直接输入到像dotty这样的工具中,生成实际的图表。

总结一下,资源依赖有助于防止 Puppet 在意外或不可控的情况下对资源进行操作。它们还可以限制资源评估的顺序。

实现资源交互。

除了依赖关系外,资源还可以进入类似但不同的相互关系。记住我们之前跳过的输出部分。它们如下:

root@puppetmaster:~# puppet apply puppet_service.pp --noop
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.62 seconds
Notice: /Stage[main]/Main/Service[puppet]/ensure: current_value running, should be stopped (noop)
Notice: Class[Main]: Would have triggered 'refresh' from 1 events
Notice: Stage[main]: Would have triggered 'refresh' from 1 events
Notice: Applied catalog in 0.05 seconds 

Puppet 提到刷新会因为事件的原因而触发。每当 Puppet 需要同步操作时,资源会发出此类事件。如果没有显式的代码来接收并响应这些事件,它们将被丢弃。

设置此类事件接收器的机制是通过类比通用的发布/订阅队列来命名的;资源通过 subscribe 元参数配置为响应事件。没有 publish 关键字或参数,因为每个资源在技术上都是事件(消息)的发布者。相应的 subscribe 元参数的对立面叫做 notify,它明确地将生成的事件指向被引用的资源。

事件系统的一个常见实际应用是重新加载服务配置。当 service 资源接收事件(通常来自配置文件的更改)时,Puppet 会执行适当的操作以重启该服务。

如果你指示 Puppet 执行此操作,可能会由于重启操作导致短暂的服务中断。请注意,如果新配置导致错误,服务可能无法启动并保持离线状态。

以下代码示例展示了 haproxy 包、相应的 haproxy 配置文件和 haproxy 服务之间的关系:

file { '/etc/haproxy/haproxy.cfg':
  ensure  => file,
  owner   => ‘root’,
  group   => ‘root’
  mode    => ‘0644’
  source  => ‘puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
  require => Package['haproxy'],
}
service { 'haproxy':
  ensure    => 'running',
  subscribe => File['/etc/haproxy/haproxy.cfg'],
}

如果要使用 notify 元参数,它必须指定给发出事件的资源:

file { '/etc/haproxy/haproxy.cfg':
  ensure  => file,
  owner   => 'root',
  group   => 'root',
  mode    => '0644',
  source  => 'puppet:///modules/haproxy/etc/haproxy/haproxy.cfg',
  require => Package['haproxy'],
  notify  => Service['haproxy'],
}
service { 'haproxy':
  ensure  => 'running',
}

这可能会让你想起 beforerequire 元参数,它们提供了对一对资源关系的对称表达方式。这并非巧合,这些元参数是紧密相关的:

  • 订阅另一个资源的资源隐式地需要它。

  • 通知另一个资源的资源在依赖图中会隐式地排在后一个资源之前。

换句话说,subscriberequire 相同,只是依赖资源接收来自同伴的事件。notifybefore 也是如此。

链接语法也可以用于信号传递。为了在相邻资源之间建立信号关系,请使用带波浪符的 ASCII 箭头 ~>,而不是 -> 中的破折号:

file { '/etc/haproxy/haproxy.cfg': … }
~>
service { 'haproxy': … }

service 资源类型是两种在资源收到通知时支持刷新的重要类型之一(另一个将在下一节讨论)。还有其他类型,但它们没有这么普遍。

检查 Puppet 核心资源类型

为了完整了解清单的基本元素,我们来仔细看看你已经使用过的资源类型,以及一些你尚未遇到但属于 Puppet 基础安装的重要资源类型。

你可能已经对 file 类型有了很好的了解,它将确保文件和目录的存在及其权限。通过 source 参数从仓库(通常是 Puppet 模块)拉取文件也是一个常见的用例。

对于非常短的文件,将所需内容直接包含在清单中更加经济:

file { '/etc/modules':
  ensure  => file,
  content => "# Managed by Puppet!\n\ndrbd\n",
}

双引号允许扩展转义序列,例如\n

另一个有用的功能是管理符号链接:

file { '/etc/apache2/sites-enabled/001-puppet-lore.org':
  ensure => 'link',
  target => '../sites-available/puppet-lore.org',
}

你应该知道,文件资源类型需要绝对路径和文件名。如果在标题中使用相对路径,Puppet 会报错:

file { '../demo.txt':
  ensure => file,
}
puppet apply file_error.pp
Notice: Compiled catalog for puppetmaster.demo.example42.com in environment production in 0.09 seconds
Error: Parameter path failed on File[../demo.txt]: File paths must be fully qualified, not '../demo.txt' at /root/file_error.pp:1

你已经知道的下一个类型是package,它的典型用法相当直观。确保包被安装或删除。你还没有看到的一个显著用例是使用基本的包管理器而不是aptyum/zypper。如果某个包在仓库中不可用,这是非常有用的:

package { 'haproxy':
  ensure   => present,
  provider => 'dpkg',
  source   => '/opt/packages/haproxy-1.5.1_amd64.dpkg',
}

如果你努力设置一个简单的仓库,通常会提高效率,这样就可以使用主要的包管理器了。

最后但同样重要的是,有一个service类型,它的最重要的属性你已经了解。值得指出的是,在你不希望添加完整的init脚本或类似脚本的情况下,它可以作为一个简单的快捷方式。如果提供了足够的信息,service类型的base提供者将为你管理简单的后台进程:

service { 'count-logins':
  provider    => 'base',
  ensure      => 'running',
  enable      => true,
  binary      => '/usr/local/bin/cnt-logins',
  start       => '/usr/local/bin/cnt-logins –daemonize',
  has_status  => true,
  has_restart => true,
  subscribe   => File['/usr/local/bin/cnt-logins'],
}

Puppet 不仅会在某些原因导致脚本未运行时重启它,还会在引用的配置文件内容发生变化时重新启动它。

这仅在 Puppet 管理文件内容并且所有更改仅通过 Puppet 传播时有效。

如果 Puppet 更改了脚本文件的其他属性(例如文件模式),这也会导致进程重启。

让我们来看看你可能需要的其他类型。

用户和组类型

尤其是在缺乏中央注册表(如 LDAP)的情况下,能够管理每台机器上的用户账户是非常有用的。所有受支持平台都有提供者;然而,提供的属性有所不同。在 Linux 上,useradd提供者是最常见的。它允许管理/etc/passwd中的所有字段,如uidshell,以及组成员关系:

group { 'proxy-admins':
  ensure     => present,
  gid        => 4002,
}
user { 'john':
  ensure     => present,
  uid        => 2014,
  home       => '/home/john',
  managehome => true, # <- adds -m to useradd
  gid        => 1000,
  shell      => '/bin/zsh',
  groups     => [ 'proxy-admins' ],
}

与所有资源一样,Puppet 不仅会确保用户和组存在,还会修复任何不同的属性,比如home目录。

即使用户依赖于组:(因为它不能在组存在之前被添加),也不需要在清单中显式表达。用户会自动要求所有必要的组,类似于文件自动要求其父目录。

Puppet 也可以愉快地管理你的 LDAP 用户账户。

前面提到过,根据操作系统的不同,提供了不同的属性。Linux(以及useradd提供者)支持设置密码,而在 HP-UX(使用hp-ux提供者)上,无法通过 Puppet 设置用户密码。

在这种情况下,Puppet 只会显示一个警告,指出用户资源类型正在使用不受支持的属性,并继续管理所有其他属性。换句话说,在 Puppet DSL 代码中使用不受支持的属性不会破坏 Puppet 的运行。

exec 资源类型

在 Puppet 核心中有一种特殊的资源类型。记住我们之前提到的,Puppet 不是一个专业的脚本引擎,而是一个允许你用引人注目的 DSL 模型化系统部分状态的工具,并且能够根据定义的目标改变系统。这就是为什么你声明 usergroup,而不是依次调用 groupadduseradd。你之所以能这样做,是因为 Puppet 内置了对这些实体的管理支持。这非常有利,因为 Puppet 还知道在不同的平台上,使用的是不同的命令进行账户管理,并且某些系统上的参数可能会略有不同。

当然,Puppet 并不掌握任何受支持系统的所有细节。假设你希望管理一个 OpenAFS 文件服务器,Puppet 没有专门的资源类型来帮助你。理想的解决方案是利用 Puppet 的插件系统,编写你自己的类型和提供程序,这样你的清单就能直接反映 AFS 特定的配置。然而,这并不简单,而且在某些情况下,当你只需要 Puppet 在清单的少数地方调用一些特殊的命令时,这也不值得去做。

对于这种情况,Puppet 提供了 exec 资源类型,允许执行自定义命令来替代抽象的同步操作。

例如,它可以在没有合适的包时用来解压 tar 包:

exec { 'tar cjf /opt/packages/homebrewn-3.2.tar.bz2':
  cwd     => '/opt',
  path    => '/bin:/usr/bin',
  creates => '/opt/homebrewn-3.2',
}

creates 参数对于 Puppet 判断命令是否需要执行非常重要。一旦指定的路径存在,资源就视为已同步。对于那些不会创建明显文件或目录的命令,还有其他参数 onlyifunless,它们允许 Puppet 查询同步状态:

exec { 'perl -MCPAN -e "install YAML"':
  path   => '/bin:/usr/bin',
  unless => 'cpan -l | grep -qP ^YAML\\b',
}

查询命令的退出代码决定了状态。在 unless 的情况下,查询失败时会执行 exec 命令。这就是 exec 类型保持幂等性的方法。Puppet 对大多数资源类型自动执行这一操作,但对于 exec 来说,这不可行,因为同步是如此任意地定义的。作为用户,定义每个资源的适当查询就成了你的责任。

最后,exec 类型的资源是使用 notifysubscribe 接收事件的第二个显著案例:

exec { 'apt-get update':
  path        => '/bin:/usr/bin',
  subscribe   => File['/etc/apt/sources.list.d/jenkins.list'],
  refreshonly => true,
}

你甚至可以以这种方式将多个 exec 资源链接在一起,使得每次调用都会触发下一个命令。然而,这种做法并不推荐,它会使 Puppet 降级为一个(相当不完善的)脚本引擎。尽可能避免使用 exec 资源,而应优先使用常规资源。一些不属于核心的资源类型可以作为插件从 Puppet Forge 获取。你将在第五章中了解更多关于这个主题的内容,将类、配置文件和扩展结合成模块

由于 exec 资源可以用于执行几乎 任何 操作,它们有时被滥用来代替更合适的资源类型。这是 Puppet 清单中的典型反模式。最好将 exec 资源视为最后的手段或紧急出口,只有在所有其他选择都已用尽的情况下才使用。

理想情况下,你的 exec 资源类型应该构建为一次性命令。

所有 Puppet 安装都有类型文档内置在代码中,可以通过 puppet describe 命令在命令行打印出来:

puppet describe <type> [-s] 如果你不确定某个类型是否存在,可以通过 puppet describe 命令返回所有可用资源类型的完整列表:

**puppet describe --list**

让我们简要讨论一下另两个内置支持的类型。它们分别允许管理 cron 任务、挂载的分区和共享资源,这些都是服务器操作中常见的需求。

cron 资源类型

一个 cron 任务主要由一个命令和指定运行该命令的时间和日期组成。Puppet 将命令和每个日期元素建模为一个资源的属性,资源类型为 cron

cron { 'clean-files':
  ensure      => present,
  user        => 'root',
  command     => '/usr/local/bin/clean-files',
  minute      => '1',
  hour        => '3',
  weekday     => [ '2', '6' ],
  environment => 'MAILTO=felix@example.net',
}

environment 属性允许你为 cron 指定一个或多个变量绑定,以添加到任务中。

挂载资源类型

最后,Puppet 将为你管理所有可挂载的文件系统,包括它们的基本属性,如源设备和挂载点、挂载选项及当前状态。fstab 文件中的一行几乎可以直接转换为 Puppet 清单:

mount { '/media/gluster-data':
  ensure  => 'mounted',
  device  => 'gluster01:/data',
  fstype  => 'glusterfs',
  options => 'defaults,_netdev',
  dump    => 0,
  pass    => 0,
}

对于此资源,Puppet 会确保文件系统在运行后确实被挂载。当然,也可以确保文件系统处于 unmounted 状态;Puppet 还可以确保该条目在 fstab 文件中是 present 的,或在系统中完全 absent

总结

在系统上安装 Puppet 后,你可以通过编写和应用清单来使用它。这些清单使用 Puppet 的 DSL 编写,并包含你系统所需状态的描述。尽管它们看起来像脚本,但不应视其为脚本。首先,它们由资源组成,而非命令。这些资源通常不是按它们编写的顺序进行评估的。相反,应通过 requirebefore 元参数来显式定义顺序。

每个资源都有一些属性:parametersproperties。每个属性都会单独进行评估;Puppet 会检测是否需要对系统进行更改,以将某个属性同步到清单中定义的状态,并且会执行这些更改。这被称为同步资源或属性。

requirebefore 这两个排序参数非常重要,因为它们建立了一个资源对一个或多个其他资源的依赖关系。这使得 Puppet 可以跳过某些目录中的部分内容,如果一个重要资源无法同步。必须避免循环依赖。

清单中的每个资源都有一个资源类型,用来描述所管理的系统实体的性质。一些最常用的类型包括文件(file)、软件包(package)和服务(service)。Puppet 提供了许多类型用于方便的系统管理,并且有很多插件可供选择以扩展功能。某些任务需要使用 exec 资源,但应谨慎使用。

在 第二章,Puppet 服务器与代理 中,我们将介绍主机/代理的设置。

第二章:Puppet 服务器和代理

到目前为止,你处理了一些简明的 Puppet 清单,这些清单被构建来模拟一些非常具体的目标。通过 puppet apply 命令,你可以在基础设施中的任何机器上使用这些片段。然而,这并不是使用 Puppet 的最常见方式,本章将介绍流行的服务器/代理结构。不过,值得注意的是,应用与整体 Puppet 设计无关的独立清单仍然是很有用的。

在服务器/代理范式下,通常会在你管理的所有节点上安装 Puppet 代理软件,并使它们调用服务器,而服务器本身也是一个 Puppet 安装。服务器将编译适当的清单,并有效地远程控制代理。代理和服务器都会使用受信任的 SSL 证书进行身份验证。

本章涵盖以下内容:

  • Puppet 服务器

  • 设置 Puppet 代理

  • 性能优化

  • 使用 PuppetDB 完善堆栈

  • Puppet CA

Puppet 服务器

许多基于 Puppet 的工作流集中在服务器上,服务器是配置数据和权限的中央源。服务器将指令发送到基础架构中的所有计算机系统(代理已安装的地方)。它在 Puppet 组件的分布式系统中起着多重作用。

服务器将执行以下任务:

  • 存储清单并编译目录

  • 作为 SSL 证书颁发机构

  • 处理来自代理机器的报告

  • 收集并存储有关代理的信息

因此,服务器机器的安全性至关重要。其加固要求与 Kerberos 密钥分发中心的要求类似。

在首次初始化时,Puppet 服务器会生成 CA 证书。这个自签名证书将被分发到你的基础设施中的所有组件,并被信任。因此,它的私钥必须非常小心地保护。新的代理机器请求个人证书,这些证书会使用 CA 证书进行签名。

在操作系统提供过程中包含 CA 证书的副本是个好主意,这样代理在请求其个人证书之前就能验证主服务器的真实性。

关于主服务器软件的术语可能有些令人困惑。因为Puppet masterPuppet server这两个术语都有出现,而且它们之间也有密切关系。为了帮助你更好地理解,我们来了解一些技术背景。

Puppet 的主服务主要由 RESTful HTTP API 组成。代理启动 HTTPS 事务,双方通过受信任的 SSL 证书相互识别。在 Puppet 3 及更早版本为最先进版本时,HTTPS 层通常由 Apache 处理。Puppet 的 Ruby 核心通过Passenger模块调用。这种方式提供了良好的稳定性和可扩展性。

Puppet Inc.通过名为puppetserver的专用软件改进了这一标准解决方案。主服务器的 Ruby 核心基本保持不变,尽管它现在运行在 JRuby 上,而不是 Ruby 的原生 MRI。HTTPS 层由 Jetty 运行,与主服务器共享同一个 Java 虚拟机。

通过去除一些中间层,puppetserver比 Passenger 解决方案更快且具有更好的可扩展性,而且配置起来也显著更简单。

设置服务器机器

puppetserver软件安装到 Linux 机器上与安装代理包一样简单(正如您在第一章中所做的那样,编写您的第一个清单)。这些包适用于 Red Hat Enterprise Linux 及其衍生版、Debian 和 Ubuntu,以及任何支持运行 Puppet 服务器的操作系统。

到目前为止,Puppet 服务器必须运行在基于 Linux 的操作系统上,无法在 Windows 或其他 Unix 系统上运行。将 Puppet Inc.的软件包获取到任何平台的一个好方法是 Puppet 集合。在 Puppet 4 发布后不久,Puppet Inc.创建了这种新的软件供应方式。这可以视为一个独立的发行版。与 Linux 发行版不同,它不包含内核、系统工具或库,而是包含 Puppet 生态系统中的各种软件。从同一 Puppet 集合中提供的软件版本保证能够良好兼容。

使用以下命令在 Debian 8 机器上从第一个Puppet 集合PC1)安装puppetserver(截至本文编写时,Debian 9 的集合尚未包含puppetserver包):

root@puppetmaster# wget http://apt.puppetlabs.com/puppetlabs-release-pc1-jessie.deb
root@puppetmaster# dpkg -i puppetlabs-release-pc1-jessie.deb
root@puppetmaster# apt-get update
root@puppetmaster# apt-get install puppetserver 

puppetserver包仅包含 Jetty 服务器和 Clojure API,但所有功能合一的puppet-agent包作为依赖被拉取。

包名puppet-agent具有误导性。这个 AIO 包包含了 Puppet 的所有部分,包括主核心、一个定制的 Ruby 构建和几个附加软件组件。

具体来说,您可以在主节点上使用puppet命令。很快您将了解到这如何派上用场。然而,当使用 Puppet Labs 的包时,所有内容都会安装在/opt/puppetlabs目录下。建议确保您的PATH变量始终包括/opt/puppetlabs/bin目录,以便能够找到puppet命令。

尽管如此,一旦puppetserver包安装完成,您可以启动主服务:

root@puppetmaster# systemctl start puppetserver 

根据你的机器性能,启动过程可能需要几分钟。一旦初始化完成,服务器将非常流畅地运行。只要主控端端口8140开放,你的 Puppet 主控端就准备好服务请求。

如果服务未能启动,可能是证书生成出现问题(我们在某些软件版本中观察到过此类问题)。检查/var/log/puppetlabs/puppetserver/puppetserver-daemon.log日志文件。如果日志显示在查找证书文件时出现问题,你可以通过临时运行独立的主控端来规避问题,方法如下:

puppet master --no-daemonize

初始化后,你可以停止该进程。现在证书已可用,puppetserver应该也能够启动。

启动失败的另一个原因可能是内存不足。Puppet 服务器进程需要 2 GB 的内存。

创建主控端清单

当你在第一章《编写你的第一个清单》一节中本地使用 Puppet 时,你指定了一个清单文件,puppet apply应该编译该文件。主控端为多台机器编译清单,但代理无法选择使用哪个源文件;这完全由主控端决定。主控端任何编译的起点始终是站点清单,位于/opt/puppetlabs/code/environments/production/manifests/

environments/production部分的重要性将在第五章《将类、配置文件和扩展整合为模块》一节中探讨。在 4.0 版本之前的 Puppet 中,站点清单位于另一个位置/etc/puppet/manifests/site.pp,并且只包含一个文件。

每个连接的代理将使用此处找到的所有清单。当然,你不希望在所有机器上只管理一套相同的资源。为了专门为某个特定代理定义一份清单,可以将其放入node区块中。此区块的内容只有在调用代理的 SSL 证书中有匹配的通用名称时才会被考虑。你可以为名为agent的机器专门定义一部分清单,例如:

node 'agent' {
  $packages = [ 'apache2',
    'libapache2-mod-php5',
    'libapache2-mod-passenger', ]
  package { $packages:
    ensure => 'installed',
    before => Service['apache2'],
  }
  service { 'apache2':
    ensure => 'running',
    enable => true,
  }
}

给出的示例并没有展示最佳的节点分类实践,仅作为示例使用。我们将在第九章《Puppet 角色与配置文件》一节中展示现代最佳的节点分类实践。

在设置并连接第一个代理到主控端之前,先退后一步,思考主控端应如何进行地址解析。默认情况下,代理会尝试解析未限定的puppet主机名,以获取主控端的地址。如果你的机器正在搜索默认域名,你可以将其用作默认域名,并为puppet添加一个记录作为子域名(例如puppet.example.net)。

否则,选择一个适合你的域名,比如master.example.netadm01.example.net。关键点在于:

  • 所有你的代理机器都能将名称解析为地址

  • 主控进程正在监听该地址上的连接

  • 主控使用选择的证书名称作为 CN 或 DNS Alt Names

解析模式取决于你的情况;每台机器上的hosts文件是一个常见的选择。Puppet 服务器默认会监听所有可用的地址。

这就剩下了创建合适证书的任务,这非常简单。配置主控端使用合适的证书名称并重启服务。如果证书尚不存在,Puppet 会采取必要的步骤来创建它。将以下设置放入主控机的/etc/puppetlabs/puppet/puppet.conf文件中:

[main] 
certname=puppetmaster.example.net 

在 Puppet 4.0 之前的版本中,配置文件的默认位置是/etc/puppet/puppet.conf

在下次启动时,主控端将为所有 SSL 连接使用合适的证书。即使在现有设置下,SSL 数据的自动传播也不危险,唯一的例外是认证机构。如果主控端在任何时间生成新的 CA 证书,它将破坏所有现有代理的信任。

确保 CA 数据不会丢失或被泄露。每当 Puppet 需要创建新的认证机构时,所有先前签署的证书都会变得无效。Puppet 4.0 及更高版本的默认存储位置为/etc/puppetlabs/puppet/ssl/ca,较早版本的默认存储位置为/var/lib/puppet/ssl/ca

检查配置设置

所有主控参数的自定义都可以在puppet.conf文件中进行。操作系统包带有一些由相应维护者认为合适的设置。除了这些显式设置外,Puppet 还依赖于内建的或从环境中派生的默认设置(关于如何运作的细节,请参见第三章,Puppet 的 Ruby 部分探秘 - Facts、Types 和 Providers):

root@puppetmaster # puppet master --configprint manifest
/etc/puppetlabs/code/environments/production/manifests

大多数用户会希望尽可能依赖这些默认设置。这是可行的且没有任何缺点,因为 Puppet 通过--configprint参数使所有设置完全透明。例如,你可以找出主控端清单文件的位置。

要查看所有可用设置及其值,请使用以下命令:

root@puppetmaster# puppet master --configprint all | less 

虽然这个命令在主控端尤其有用,但相同的自省功能也适用于puppet applypuppet agent

可以使用puppet config命令设置特定的配置项:

root@puppetmaster # puppet config set –-section main certname puppetmaster.example.net

设置 Puppet 代理

如前所述,主节点主要通过从清单编译而来的目录为代理提供指令。你也已经为你的第一个代理在主节点清单中准备了一个 node 块。

安装代理软件很简单;你在第一章开始时就做过这件事,编写你的第一个清单。允许应用本地清单的普通 Puppet 包包含了操作正常代理所需的所有必要部分。

如果你使用的是 Puppet Labs 的包,按照本章前面部分的指示操作。在代理机器上,无需安装 puppetserver 包,只需安装 puppet-agent 即可。

在成功安装包之后,需要指定 puppet agent 可以找到 puppet server 的位置:

root@puppetmaster # puppet config set –-section agent server pup-petmaster.example.net 

然后,以下的调用就足以进行初步测试:

root@agent# puppet agent --test
Info: Creating a new SSL key for agent
Error: Could not request certificate: getaddrinfo: Name or service not known
Exiting; failed to retrieve certificate and waitforcert is disabled

Puppet 首先为自己创建了一个新的 SSL 证书密钥。它为自己的名字选择了 agent,这是机器的主机名。现在这样就可以了。发生错误是因为 puppet 名称当前无法解析。将此添加到 /etc/hosts 文件中,以便 Puppet 能够联系到主节点:

root@agent# puppet agent --test
Info: Caching certificate for ca
Info: csr_attributes file loading from /etc/puppetlabs/puppet/csr_attributes.yaml
Info: Creating a new SSL certificate request for agent
Info: Certificate Request fingerprint (SHA256): 52:65:AE:24:5E:2A:C6:17:E2:5D:0A:C9: 86:E3:52:44:A2:EC:55:AE:3D:40:A9:F6:E1:28:31:50:FC:8E:80:69
Exiting; failed to retrieve certificate and waitforcert is disabled

Puppet 如何方便地下载并缓存了 CA 证书。代理今后将基于此证书建立信任。

Puppet 创建了一个证书请求并将其发送到主节点。然后它立即尝试下载签名的证书。这预计会失败,因为主节点不会为它收到的任何请求签发证书。这种行为对于确保安全至关重要。

有一个配置设置可以启用这种自动签名,但通常不建议用户使用此设置,因为它允许任何具有网络访问权限的用户为任意数量的请求生成签名证书(因此被信任)。

要授权代理,使用 puppet cert 命令在主节点上查找 CSR:

root@puppetmaster# puppet cert --list
"agent" (SHA256) 52:65:AE:24:5E:2A:C6:17:E2:5D:0A:C9:86:E3:52:44:A2:EC:55:AE: 3D:40:A9:F6:E1:28:31:50:FC:8E:80:69 

这看起来没问题,现在你可以为代理签署一个新证书:

root@puppetmaster# puppet cert --sign agent
Notice: Signed certificate request for agent
Notice: Removing file Puppet::SSL::CertificateRequest agent at '/etc/puppetlabs/ puppet/ssl/ca/requests/agent.pem' 

在选择 puppet cert 的操作时,可以省略选项名前的连字符;你可以直接使用 puppet cert listpuppet cert sign

现在,代理可以按如下方式接收其证书并运行目录:

root@agent# puppet agent --test
Info: Caching certificate for agent
Info: Caching certificate_revocation_list for ca
Info: Caching certificate for agent
Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Caching catalog for agent
Info: Applying configuration version '1437065761'
Notice: Applied catalog in 0.11 seconds

代理现在已完全正常工作。它接收了一个目录并应用了其中找到的所有资源。在继续阅读了解代理的常规操作之前,尤其是对于 Puppet 3 的用户来说,有一项注意事项非常重要。

请记住,你在本章前面已经配置了主节点,使用 master.example.net 作为主节点机器的名称,并通过设置主节点 puppet.conf 文件中的 certname 选项来指定。

由于这是主节点证书中的通用名称,因此之前的命令甚至在 Puppet 3.x 版本上也无法工作。它在 puppetserver 和 Puppet 4 上工作,因为默认的 puppet 名称现在已经默认包含在证书的主题备用名称(SAN)中。

然而,最好不要依赖这个别名。在生产环境中,您可能希望确保主节点有一个完全合格的名称,至少在您的网络内部可以解析。因此,您应在每台代理机器的 puppet.conf 文件的 main 部分中添加以下内容:

[agent] 
server=master.example.net 

如果没有 DNS 来解析此名称,您的代理将需要在其 hosts 文件中有适当的条目,或采用类似的地址解析方式。

这些步骤在 Puppet 3.x 设置中是必要的。如果您正在使用 Puppet 4 代理,您可能会注意到在此更改后,它会生成一个新的证书签名请求:

root@agent# puppet agent –test
Info: Creating a new SSL key for agent.example.net
Info: csr_attributes file loading from /etc/puppetlabs/puppet/csr_attributes.yaml
Info: Creating a new SSL certificate request for agent.example.net
Info: Certificate Request fingerprint (SHA256): 85:AC:3E:D7:6E:16:62:BD:28:15:B6:18: 12:8E:5D:1C:4E:DE:DF:C3:4E:8F:3E:20:78:1B:79:47:AE:36:98:FD
Exiting; no certificate found and waitforcert is disabled

如果发生这种情况,您需要再次在主节点上使用 puppet cert sign。然后,代理将获取一个新的证书。

代理的生命周期

在以 Puppet 为中心的工作流中,通常希望所有服务器(甚至工作站)的配置更改都从 Puppet 主节点发起,并自动传播到代理。每一台新机器都会与 Puppet 基础设施集成,主节点位于中心位置,并在退役时将其移除,如下图所示:

第一步,生成密钥和证书签名请求,如果本地尚未存在 SSL 数据,通常会在代理程序启动时隐式并自动执行。如果找不到相应的文件,Puppet 会创建所需的数据。稍后会在本节中简要描述如何手动触发此行为。

下一步通常是对代理证书进行签名,这在主节点上进行。最好通过在控制台上列出待处理的请求来监控它们:

root@puppetmaster# puppet cert list
root@puppetmaster# puppet cert sign '<agent fqdn>'

从此时起,代理将定期检查主节点以加载更新的目录。默认的检查间隔为 30 分钟。每次运行时,代理会执行一个目录,并检查所有资源的同步状态。即使目录没有变化,运行仍会执行,因为同步状态可能在运行间发生变化。

在您成功签署证书之前,代理进程会在短时间内定期查询主节点。这样可以避免代理启动时如果证书还没有准备好,而导致 30 分钟的延迟。

启动此后台进程可以通过一个简单的命令手动完成:

root@agent# puppet agent

然而,最好通过 puppet 系统服务来执行此操作。

当代理机器被移出活跃服务时,应该使其证书失效。与 SSL 的惯例一样,这通常是通过吊销和清除证书来完成的。主节点将证书的序列号添加到证书吊销列表中。这个列表也会与每台代理机器共享。吊销操作在主节点上通过 puppet cert 命令发起:

root@puppetmaster# puppet cert revoke agent

更新后的 CRL 直到主服务重启后才会生效。如果安全性是一个问题,这一步骤不能被延迟。

代理将无法再使用其旧证书:

root@agent# puppet agent --test
Warning: Unable to fetch my node definition, but the agent run will continue:
Warning: SSL_connect SYSCALL returned=5 errno=0 state=unknown state
[...]
Error: Could not retrieve catalog from remote server: SSL_connect SYSCALL returned=5 errno=0 state=unknown state
[...]

更新代理的证书。

有时,在代理机器的生命周期中,需要重新生成其证书及相关数据。可能的原因包括数据丢失、人为错误或证书过期等。重新生成的步骤如下:

  1. 执行重新生成过程非常简单:所有相关文件都保存在代理机器上的 /etc/puppetlabs/puppet/ssl(对于 Puppet 3.x,这是 /var/lib/puppet/ssl)目录下。

  2. 一旦这些文件(或者整个 ssl/ 目录树)被移除,Puppet 将在下一次代理运行时重新生成所有内容。当然,新的证书必须被签名。这需要一些准备,仅仅从代理发起请求将会失败:

root@agent# puppet agent –test
Info: Creating a new SSL key for agent
Info: Caching certificate for ca
Info: Caching certificate for agent.example.net
Error: Could not request certificate: The certificate retrievedfrom the master does not match the agent's private key.
Certificate fingerprint: 6A:9F:12:C8:75:C0:B6:10:45:ED:C3:97:24:CC:98:F2:B6:1A:B5: 4C:E3:98:96:4F:DA:CD:5B:59:E0:7F:F5:E6

主服务仍然缓存着旧的证书。这是一个简单的防护措施,防止未经授权的实体伪装成你的代理。

  1. 为了解决这个问题,从主服务和代理中删除证书,然后启动一次 Puppet 运行,这将自动重新生成证书:

    • 在主服务上,使用以下命令:
 puppet cert clean agent.example.net
    • 在代理上,使用以下命令:

    • 在大多数平台上,使用以下命令:

find /etc/puppetlabs/puppet/ssl -name agent.example.net.pem –delete
    • 在 Windows 上,使用以下命令:
del "/etc/puppetlabs/puppet/ssl/agent.example.net.pem" /f
puppet agent –t
Exiting; failed to retrieve certificate and waitforcert is disabled
  1. 一旦你根据前述输出执行了主服务上的清理操作,并从代理机器上移除指示的文件,代理将能够成功地放置其新的 CSR:
root@puppetmaster# puppet cert clean agent
Notice: Revoked certificate with serial 18
Notice: Removing file Puppet::SSL::Certificate agent at '/etc/puppetlabs/ puppet/ssl/ca/signed/agent.pem'
Notice: Removing file Puppet::SSL::Certificate agent at '/etc/puppetlabs/ puppet/ssl/certs/agent.pem'

剩余的过程与原始证书创建完全相同。代理将其 CSR 上传到主服务,在主服务中通过 puppet cert sign 命令创建证书。

从 cron 运行代理。

还有一种替代方法来操作代理。我们之前提到过启动一个长时间运行的 puppet agent 进程,它按照设定的间隔工作,然后回到休眠状态。然而,也可以通过 cron 在相同的间隔启动一个独立的代理进程。这个代理将联系主服务一次,运行接收到的目录,然后终止。这有几个优点,具体如下:

  • 代理操作系统节省资源。

  • 间隔是精确的,不会受到偏差的影响(当运行后台代理时,偏差来自目录运行过程中消耗的时间),并且分布式间隔偏差可能导致“雷鸣效应”。

  • 任何代理崩溃或意外终止都不会致命。

使用 cron 启动代理的 Puppet 设置也非常简单!你可以使用如下的清单:

service { 'puppet': enable => false, }
cron { 'puppet-agent-run':
  user    => 'root',
  command => 'puppet agent --no-daemonize --onetime --logdest=syslog',
  minute  => fqdn_rand(60),
  hour    => absent,
}

fqdn_rand 函数为每个代理计算一个独特的分钟。将 hour 属性设置为 absent 意味着该任务应该每小时运行一次。

性能优化。

操作 Puppet 主机相比于在所有机器上仅使用 puppet apply 为您带来了许多好处。当然,这也有代价。主机和代理之间形成了一个服务器/客户端关系,与大多数此类结构一样,服务器可能会成为瓶颈。

好消息是,Puppet 代理是一个胖客户端。文件内容检查、与软件包管理子系统、服务子系统的接口以及其他很多工作都由代理完成。主机只需要编译清单并从中构建目录。随着您将更多控制交给 Puppet,这一过程变得越来越复杂。

还有一项任务是由主机负责的。您的许多清单将包含依赖于已准备好内容的文件资源:

file { '/usr/local/etc/my_app.ini':
  ensure => file,
  owner  => 'root',
  group  => 'root',
  source =>  
  'puppet:///modules/my_app/usr/local/etc/my_app.ini',
}

带有 URL 值的 source 参数表示该文件已经预生成并放置在 Puppet 主机上的模块中(有关模块的更多内容,请参见 第五章,将类、配置文件和扩展组合成模块)。代理将通过校验和比较本地文件与主机的副本,并在需要时下载规范版本。大多数代理运行时会频繁进行比较;您会让 Puppet 管理大量文件。主机不需要大量资源来完成此操作,但如果主机出现拥塞,将会妨碍代理的流畅操作。

这可能是由于以下任意组合原因导致的:

  • 代理的总数过多

  • 代理检查频率过高

  • 清单过于复杂

  • Puppet 服务器未得到充分调优

  • 主机的硬件资源不足

有方法可以通过负载均衡来扩展您的主机操作,但这些内容本书未涉及。

Puppet Labs 在 docs.puppetlabs.com/guides/scaling_multiple_masters.html 上提供了一些关于几种高级方法的文档。

调优 puppetserver

puppetserver 是运行主机服务的绝佳方式。它的设置和维护非常简单,而且在操作期间性能也非常好。启动过程可能需要一些时间来初始化所有必要的内容。

只有少数可自定义的设置会影响性能。由于 puppetserver 运行在 JVM 中,最重要的调优方法是调整堆内存。较小的堆内存会增加垃圾回收的开销。因此,您应该使用 -Xmx-Xms Java 选项,让 JVM 使用大量可用内存来分配上述堆内存。

在 Debian 系统中,这些设置位于 /etc/default/puppetserver。建议将相同的值传递给两者。动态堆内存几乎没有好处,因为您无法安全地使用任何节省下来的内存。

为了保证 puppetserver 的正常运行,建议您至少有 4 GB 的 RAM 可用。

完善 PuppetDB 堆栈

PuppetDB 是一个专门为与 Puppet 主节点交互而设计的数据库 REST API。它主要包括一个 PostgreSQL 后端和一个 API 包装器。后者是用 Clojure 编写的,并在另一个 JVM 中运行。

PuppetDB 协助主节点的次要任务——存储报告和其他代理数据。它对于某些特定的清单编译器功能也是必需的。相关内容将在 第六章, Puppet 初学者进阶部分 中介绍。

设置和配置 PuppetDB 的最佳方式实际上就是 Puppet 本身。由于必要的工具尚未介绍,我们将在 第六章, Puppet 初学者进阶部分 中讲解这一步骤。这样做没有问题,因为 PuppetDB 对于基础主节点操作并非必需。

然而,在完成本章内容后,你应该将 PuppetDB 集成到任何新的主节点设置中,因为它支持高级报告和自省功能。

Puppet CA

对于新用户来说,最令人沮丧的问题之一是代理的 SSL 握手问题。这些错误特别麻烦,因为 Puppet 并不总是能在日志中提供有用的分析 —— 问题发生在 SSL 库函数中,而应用程序无法检查具体情况。

Puppet Labs 的在线文档中有一个故障排除部分,其中还提供了一些关于 SSL 相关问题的建议,详见 docs.puppetlabs.com/guides/troubleshooting.html

请参考以下 --test 命令的输出:

root@agent# puppet agent --test
Warning: Unable to fetch my node definition, but the agent run will continue:
Warning: SSL_connect returned=1 errno=0 state=unknown state: certificate verify failed: [CRL is not yet valid for /CN=Puppet CA: puppet.example.net] 

代理认为它从主节点接收到的 CRL 尚未生效。当代理的时钟被重置为非常早的时间时,或者由于轻微的时钟偏差(当主节点上进行吊销操作时,CRL 最近更新),就可能发生这种错误。如果代理机器的系统时钟返回的时间远在未来,它将认为证书已过期。

这些与时钟相关的问题最好通过在所有 Puppet 代理和主节点上运行 ntp 服务来避免。

如果代理的 $ssldir 中的数据不一致,通常会导致错误。比如,当代理与一个备用主节点(例如测试实例)交互时,就可能发生这种情况。当你向社区询问如何解决这类问题时,最常得到的建议通常是从头开始创建一个新的代理证书。这在 代理的生命周期 部分中有描述:

  • 删除代理机器上的所有 SSL 数据

  • 使用 puppet cert clean 撤销并移除主节点上的证书

  • 请求并签署新的证书

在你开始恢复程序之前,请确保你登录的是受影响的代理机器,而不是主节点。丢失主节点的 SSL 数据将需要重新创建完整的 SSL 基础设施。

这种方法确实可以解决大部分问题。请小心不要在代理机的相关位置留下任何旧文件。如果问题仍然存在,则需要更复杂的解决方案。openssl命令行工具对分析证书及相关文件很有帮助。不过,关于这种分析的详细内容超出了本书的范围。

总结

现在,你可以使用先进的 puppetserver 解决方案来设置自己的 Puppet 主控。你已经成功为 Puppet 代理签署了证书,并且可以在需要时撤销证书。通过在主控清单中使用node块,你可以为每个独立的代理描述各自的清单。最后,你也学习了一些基于 SSL 的认证中可能出现的问题。

在第三章,《Puppet 的 Ruby 部分 - Facts, Types, and Providers》中,我们将深入探讨 Puppet 的内部工作原理,以便帮助你理解 Puppet 代理如何适应其环境。你还将了解代理如何向主控提供反馈,允许你创建适应不同需求的灵活清单。

第三章:一窥 Puppet 中的 Ruby 部分——Facts、Types 和 Providers

到目前为止,在本书中,你主要做了一些实际操作——编写清单、设置主机、分配代理、签署证书等等。在介绍你需要掌握的缺失语言概念之前,这些概念将帮助你在更大的项目中有效地使用 Puppet,我们需要先了解一些背景知识。别担心,这不会全是枯燥的理论——Puppet 的大部分重要部分与你的日常工作密切相关。

本章的主题在之前已有所提及;第一章,编写你的第一个清单,简要描述了类型和提供者的概念。接下来的一些相关主题将通过“使用 Facter 收集系统信息”、“理解类型系统”和“使用提供者控制命令执行”这些部分深入探讨。

将所有内容整合在一起——使用 Facter 收集系统信息

配置管理是一个相当动态的问题。换句话说,需要配置的系统大多是不断变化的目标。在某些情况下,系统管理员或操作员会很幸运,能与大量 100% 一致的硬件和软件进行工作。然而,在大多数情况下,服务器和其他计算节点的环境是相当异质的,至少在某些微妙的方面是这样。即使在统一的网络中,也可能有多代机器或操作系统,它们在各自的配置要求上会有更大或更小的差异。

例如,Puppet 的一个常见任务是处理系统监控的配置。你的业务逻辑可能会为仪表盘设置警告阈值,比如系统负载值。然而,这些阈值很少是固定的。在一台双处理器的虚拟机上,系统负载值为 10 表示系统严重过载,而同样的值对一台硬件配置最先进的大型数据库管理系统(DBMS)服务器来说是完全可以接受的。

另一个重要的因素是软件平台。你的基础设施可能跨越多个 Linux 发行版或其他操作系统,如 BSD、Solaris 或 Windows,每种操作系统在处理某些场景时都有不同的方法。例如,假设你想让 Puppet 管理来自 fstab 文件的一些内容。在你那台罕见的 Solaris 系统上,你必须确保 Puppet 目标指向的是 /etc/vfstab 文件,而不是 /etc/fstab

通常不建议直接在清单中与 fstab 文件进行交互。这个例子将在涉及提供者的章节中进一步完善。

Puppet 力求为你提供一种统一的方式来管理你的所有基础设施。因此,它需要一种方法,使你的清单能够适应代理机器上不同的情况。这包括它们的操作系统、硬件布局以及许多其他细节。请记住,通常情况下,清单需要在主机上进行编译。

有几种可行的方式可以为这个特定问题实现解决方案。一种直接的方法是使用一种语言结构,允许主机将一段 Shell 脚本(或其他代码)发送给代理并接收其输出。

以下是伪代码;然而,Puppet DSL 中并没有反引号表达式:

if `grep -c ^processor /proc/cpuinfo` > 2 {
  $load_warning = 4
}
else {
  $load_warning = 2
} 

这个解决方案会非常强大,但也很昂贵。当编译过程遇到这样的表达式时,主机需要回调代理。编写能够应对这种命令返回错误代码的清单会非常繁琐,Puppet 可能最终会变成一个奇怪的脚本引擎。

Puppet 使用不同的方法。它依赖于一个名为 Facter 的二级系统,Facter 的唯一目的是检查其运行所在的机器。它提供了一组根据运行系统生成的已知变量名称和值。例如,一个实际的 Puppet 清单,如果需要根据代理上的处理器数量形成条件,将使用以下表达式:

if $::processors['count'] > 4 { … } 

Facter 的变量被称为事实processors就是其中之一。使用 Facter 3 或更高版本时,大多数数据将以结构化事实(JSON)的形式收集和呈现。在这个示例中,我们访问了processors数据中的'count'元素。旧的键值对仍然可用。事实值由代理收集并发送到主机,主机会使用这些事实来编译目录。所有事实名称都可以作为变量在清单中使用。

事实(Facts)也可以用于与puppet apply一起使用的清单。你可以通过以下简单的方式来测试:

puppet apply -e 'notify { "I am ${::networking['fqdn']} and have ${::processors['count']} CPUs": }'

访问和使用事实值

你已经在一个示例中看到过processors事实的使用。在清单中,每个事实值都作为全局变量值可用。这就是为什么你可以在需要的地方直接使用::processors表达式的原因。

你经常会看到类似$::processors['count']$::networking['ip']的常规用法。强烈建议在事实名称前加上双冒号。官方风格指南在docs.puppetlabs.com/guides/style_guide.html#namespacing-variables中推荐了这一做法。这个前缀表明你正在引用来自 Facter 的变量。Facter 变量被放入 Puppet 主机的顶级作用域中。

一些有用的事实已经提到过了。processors事实可能在你的配置中起着作用。当配置某些服务时,你可能希望在配置文件中或作为参数值使用机器的networking['ip']值:

file { '/etc/mysql/conf.d/bind-address':
  ensure  => 'file',
  mode    => '0644',
  content => "[mysqld]\nbind-address=${::networking['ip']}\n",
}

除了主机名外,你的清单还可以利用代理机器的完全限定域名FQDN)。

代理默认使用其 fqdn 事实的值作为其证书的名称(clientcert)。主机会收到这两个值。注意,代理可以覆盖 fqdn 的值,但 clientcert 的值与代理所使用的签名证书绑定。有时,你希望主机将敏感信息传递给单独的节点。清单必须通过 clientcert 事实来标识代理,而不能使用 fqdnhostname,原因如上所述。以下代码给出了一个示例:

file { '/etc/my-secret': 
  ensure => 'file', 
  mode   => '0600', 
  owner  => 'root', 
  source => 
  "puppet:///modules/secrets/${::clientcert}/key", 
} 

有一组专门用于描述操作系统的事实。每个事实在不同的情况下都很有用。os[‘name’] 事实的值可以是 DebianCentOS 等:

if $::os['name'] != 'Ubuntu' {
  package { 'avahi-daemon':
    ensure => absent
  }
}

如果你的清单在 RHEL、CentOS 和 Fedora 上的行为完全相同(但在 Debian 和 Ubuntu 上不同),你应该使用 osfamily 事实:

if $::os['family'] == 'RedHat' {
  $kernel_package = 'kernel'
}

os[‘release’][‘full’] 事实允许你根据不同的操作系统版本来定制你的清单:

if $::so['name'] == 'Debian' {
  if versioncmp($::os['release']['full'], '7.0') >= 0 {
    $ssh_ecdsa_support = true
  }
}

事实如 MAC 地址、不同的 SSH 主机密钥、指纹等使得使用 Puppet 来管理硬件清单变得非常简单。当然,还有许多其他有用的事实。当然,这些集合并不能满足每个用户的所有需求。这就是为什么 Facter 是可扩展的原因。

扩展 Facter 以添加自定义事实

从技术上讲,除了维护你自己的 Facter 包,或者通过 Puppet 管理直接将 Ruby 代码文件部署到代理上外,什么都不会阻止你在核心事实旁边添加你自己的事实代码。不过,Puppet 提供了一种更方便的替代方案——自定义事实。

我们还没有涉及 Puppet 模块的内容。它们将在第五章《将类、配置文件和扩展结合成模块》中进行详细介绍。目前,只需在主机上创建一个 Ruby 文件 /etc/puppetlabs/code/environments/production/modules/hello_world/lib/facter/hello.rb。Puppet 会将其识别为名为 hello 的自定义事实。(对于 Puppet 3 或更早版本,路径应该是 /etc/puppet/modules/hello_world/lib/facter/hello.rb。)

Facter 的内部工作原理非常简单且目标明确。每个事实都有一块 Ruby 代码,代码的返回值即为事实值。许多事实是自足的,但也有一些会依赖于一个或多个基础事实的值。例如,确定本地机器的 IP 地址的方法在很大程度上依赖于操作系统。

hello 事实非常简单:

Facter.add(:hello) do
  setcode { "Hello, world!" }
end

setcode 块的返回值是字符串 Hello, world!,你可以在 Puppet 清单中使用这个事实作为 $::hello

在 Facter 2.0 版本之前,每个事实都有一个字符串值。如果代码块返回另一个值,例如数组或哈希,Facter 1.x 会将其转换为字符串。许多情况下,这样的结果并不有用。出于这个历史原因,存在像ipaddress_eth0ipaddress_lo这样的事实,而不是(或者说除了)带有接口名称和地址的正确哈希结构。

在代理端启用pluginsync选项非常重要。这已成为默认设置,并且不需要任何定制。代理在每次与主机连接时都会同步所有自定义事实。之后,它们将永久保留在代理机器上。然后,你可以使用以下命令行从命令行检索hello事实:

# puppet facts | grep hello 

只需调用以下命令而不带参数,你就可以请求列出所有事实名称和值。

# puppet facts

还有一个facter命令。它的功能大致与puppet facts相同,但只会显示内置事实,而不是自定义事实。

在 Puppet 3 及更早版本中,没有puppet facts子命令。你必须依赖 Facter CLI(Facter 2.x 或更早版本),并调用facter -p来包括自定义事实。一些 Facter 3.0 版本移除了这个参数;新版本又重新支持了它。

本书不会涵盖 Facter API 的所有方面,但有一个功能非常重要。你的许多自定义事实仅在类 Unix 系统上有用,另一些则只在 Windows 主机上有用。你可以使用与以下类似的结构来检索这些事实:

if Facter.value(:kernel) != "windows"
  nil
else
  # actual fact code here
end

这会相当繁琐和重复。不过,你可以在Facter.add(name) { ... }块内调用confine方法:

Facter.add(:msvs_version) do
  confine :kernel => :windows
  setcode do
    # …
  end
end

你也可以将一个事实限制为多个替代值:

confine :kernel => [ :linux, :sunos ]

最后,如果某个事实在不同情况下是合理的,但每个情况下需要完全不同的代码,你可以多次添加相同的事实,每次使用不同的confine值。像ipaddress这样的核心事实经常这样做:

Facter.add(:ipaddress) do
  confine :kernel => :linux
  …
end
Facter.add(:ipaddress) do
  confine :kernel => %w{FreeBSD OpenBSD Darwin DragonFly}
  …
end
… 

你可以基于其他事实的任何组合来限制事实,而不仅仅是kernel。不过,这种方式非常流行。在某些情况下,operatingsystemosfamily事实可能更合适。从技术上讲,你甚至可以将一些事实限制为某些processorcount值,等等。

使用外部事实简化操作

如果由于某些原因你团队中不希望编写和维护 Ruby 代码,你可能更倾向于使用其他方式,比如允许使用 Shell 脚本,或者任何编程语言,甚至完全不涉及编程的静态数据。Facter 支持这种方式,即外部事实

创建外部事实与常规自定义事实的过程类似,但有以下区别:

  • 外部事实由独立的可执行文件或带有静态数据的文件生成,代理必须在/etc/puppetlabs/facter/facts.d/目录下找到它们。

  • 数据不仅仅是一个字符串值,而是一个任意数量的 key=value 键值对

数据不必使用 ini 文件表示法;键/值对也可以采用 YAML 或 JSON 格式。以下外部事实包含相同的数据:

# site-facts.txt
workgroup='CT4Site2'
domain_psk='nm56DxLp%' 

事实可以按以下方式以 YAML 格式编写:

# site-facts.yaml
workgroup: 'CT4Site2'
domain_psk: 'nm56DxLp%'

在 JSON 格式中,事实可以如下编写:

# site-facts.json
{ 'workgroup': 'CT4Site2', 'domain_psk': 'nm56DxLp%' } 

外部事实的部署通过你的 Puppet 清单中的 file 资源简单地实现:

file { '/etc/puppetlabs/facter/facts.d/site-facts.yaml':
  ensure => 'file',
  source => 'puppet:///…',
}   

在较新的 Puppet 和 Facter 版本中,外部事实将像自定义事实一样自动同步,只要它们在任何模块的 facts.d/* 中被发现(例如,/etc/puppetlabs/code/environments/production/modules/hello_world/facts.d/hello.sh)。这不仅更加方便,而且有一个重要的好处:当 Puppet 必须通过 file 资源获取外部事实时,它的事实值在清单编译时不可用。而 pluginsync 机制确保所有同步的事实在清单编译开始之前就可用。

当事实不是静态的,无法放置在 txtYAML 文件中时,你可以将文件设为可执行并添加一个 shebang。它通常是一个 shell 脚本,但实现方式无关紧要;重要的是确保正确格式化的数据写入标准输出。你可以通过这种方式简化 /etc/puppetlabs/code/environments/production/modules/hello_world/facts.d/hello.sh 中的 hello 事实:

#!/bin/sh
echo hello=Hello, world\!

对于可执行的事实,ini 风格的 key=value 格式是唯一支持的格式。在此上下文中,YAML 或 JSON 格式不适用。

Facter 2 引入了结构化事实。结构化事实返回一个数组或哈希。在较旧版本的 Puppet(3.4 之前)中,结构化事实必须通过在 puppet.conf 中将 stringify_facts 设置为 false 来启用。这是 Puppet 4.0 及以后版本的默认设置。

Facter 的目标

Facter 的整体结构和理念服务于实现平台无关的使用和开发目标。相同的事实集合(大致)在所有支持的平台上都可以使用。这使得 Puppet 用户能够在所有不同系统的清单中保持一致的开发风格。

Facter 对硬件和软件的特征形成了一个抽象层。它是 Puppet 平台无关架构中的一个重要组成部分。之前提到的另一个特性是类型和提供者子系统。类型和提供者将在接下来的章节中详细探讨。

理解类型系统

作为 Puppet 模型的基石之一,资源在第一章 编写你的第一个清单中很早就引入了。记住,每个资源表示代理系统上的一部分状态。它有一个资源类型、一个名称(或标题)和一组属性。属性可以是propertyparameter。在这两者之间,属性代表了独立的状态,而参数仅影响 Puppet 对property值的操作。

让我们更详细地了解资源类型,并理解它们的内部工作原理。这不仅在用你自己的资源类型扩展 Puppet 时非常重要(将在第五章 将类、配置文件和扩展结合成模块中演示)。它还帮助你根据清单预测 Puppet 将采取的操作,并更好地理解主节点和代理的工作原理。

首先,我们更深入地了解 Puppet 的操作结构,了解它的组成部分和阶段。代理在独立的事务中执行所有工作。事务在以下任何情况下开始:

  • 背景代理进程激活并向主节点检查。

  • 启动代理进程时使用--onetime--test选项。

  • 使用puppet apply编译本地清单。

事务总是经过多个阶段。它们如下:

  1. 收集事实值以形成实际的目录请求。

  2. 从主节点接收已编译的目录。

  3. 预取当前资源状态。

  4. 目录内容的验证。

  5. 系统与目录中的property值进行同步。

Facter 在前一节中已有解释。资源类型在编译过程中及之后的代理事务中变得非常重要。主节点加载所有资源类型以执行一些基本检查;它基本上确保在清单中找到的资源类型确实存在,并且属性名称与相应的类型匹配。

资源类型在代理端的生命周期

一旦编译成功,主节点会发出目录,代理进入目录验证阶段。每个资源类型可以定义一些 Ruby 方法,以确保传递的值是合理的。这发生在两个层次上:每个属性可以验证其输入值,然后整个资源可以检查一致性。

一个关于属性值验证的例子可以在ssh_authorized_key资源类型中找到。如果此类型的资源的key值包含空格字符,则验证失败,因为 SSH 密钥不能包含多个字符串。

整个资源的验证是通过cron类型进行的,例如。它确保time字段能够一起合理地配合。以下资源将无法通过验证,因为特殊时间(如midnight)不能与数字字段组合:

cron { 'invalid-resource':
  command => 'apt-get update',
  special => 'midnight',
  weekday => [ '2', '5' ],
}

这个阶段的另一个任务是将输入值转换为更合适的内部表示。资源类型代码称之为munge操作。munge的典型例子包括去除字符串值的前后空格,或将数组值转换为适当的字符串格式——这可以是以逗号分隔的列表,但对于搜索路径,分隔符应该是冒号。其他类型的值会使用不同的表示方式。

接下来是预取阶段。某些资源类型允许代理创建系统中存在的资源实例的内部列表。这些类型被称为可枚举类型。例如,对于已安装的软件包,这是可能的(且有意义的)——Puppet 可以直接调用包管理器来生成该列表。对于其他类型,如file,这就不太合适了。创建整个文件系统中所有可访问路径的列表可能会非常耗费资源,具体取决于代理所在的系统。

可以通过在命令行中运行puppet resource <resource type> <title>来模拟预取,如下所示:

# puppet resource user root
   user { 'root':
     ensure           => 'present',
     comment          => 'root',
     gid              => '0',
     home             => '/root',
     password         => '$6$17[...]o.rLdk/9MZANEGt/',
     password_max_age => '99999',
     password_min_age => '0',
     shell            => '/bin/bash',
     uid              => '0',
  }

最后,代理开始遍历其内部的相互依赖资源图。如果有必要,每个资源都会同步。大多数情况下,这是分别针对每个单独的属性进行的。

对于支持的类型,ensure属性是一个显著的例外。它应该自行管理所有其他属性;当通过ensure属性将资源从absent更改为present时(换句话说,资源正在被创建),此操作还应同步所有其他属性。

整个代理过程有一些值得注意的方面。首先,属性是独立处理的。每个属性可以为不同的阶段定义自己的方法。有相当多的钩子,允许资源类型作者为模型添加大量的灵活性。

对于有志成为类型作者的人来说,浏览核心类型可以是非常有启发性的。你会对许多属性很熟悉;在你的清单中使用它们并研究它们的钩子会带来不少见解。

还需要注意的是,整个验证过程是由代理执行的,而不是主节点。这在性能上是有益的。主节点节省了大量的工作,这些工作被分配到代理网络中(并且网络会根据需求自动扩展)。

提供者的命令执行控制

在本章开始时,你了解了 Facter 以及它如何作为支持平台上的一层抽象。这一统一的信息库是 Puppet 实现操作系统独立性的最重要手段之一。另一个手段当然是 DSL。最后,Puppet 还需要一种方法,能够透明地根据每个代理运行的平台来调整其行为。

换句话说,根据计算环境的特性,代理需要在不同的资源实现之间切换。这与面向对象编程类似——类型系统提供了一个统一的接口,类似于抽象基类。程序员不必担心具体引用的是哪个类,只要它正确实现了所有必需的方法。在这个类比中,Puppet 的提供者就像是实现了抽象接口的具体类。

举个实际的例子,看看软件包管理。不同版本的类 Unix 操作系统有各自的实现。最常见的 Puppet 平台分别使用 aptyum,但也可以(有时必须)通过 dpkgrpm 来管理它们的软件包。其他平台则使用像 emergezypperfink 等工具,甚至还有很多其他工具。甚至有一些软件包,它们存在于操作系统软件库之外,由 gempip 等特定语言的软件包管理工具处理。每个管理工具都有一个对应的 package 类型提供者。

这些工具中的许多允许执行相同的一组操作——安装和卸载软件包,以及将软件包更新到特定版本。然而,后者并不是普遍可行的。例如,dpkg 只能安装命令行中指定的本地软件包,无法选择其他版本。

也有一些独特的功能,这些功能是特定工具的专有,或者只有少数工具支持。某些管理系统可以将软件包锁定在特定版本。某些系统使用不同的状态来区分未安装的和已清除的软件包。某些系统有虚拟软件包的概念。这样的例子还可以继续列举下去。

正因为这种潜在的多样性(不仅限于软件包管理系统),Puppet 提供者可以选择 功能。这些功能集是特定于资源类型的。所有同类型的提供者可以支持同一组功能中的一个或多个。对于 package 类型,功能包括 versionablepurgeableholdable 等等。你可以像下面这样为任何软件包资源设置 ensure => purged

package { 'haproxy':
  ensure => 'purged'
} 

然而,如果你通过rpm管理HAproxy包,Puppet 将无法理解它,因为rpm没有purged状态的概念,因此,purgeable功能在rpm提供者中缺失。尝试使用不受支持的功能通常会产生错误信息。有些属性,如install_options,可能会导致 Puppet 发出警告。

Puppet Labs 官方网站上的官方文档包含了所有核心资源类型及其内建提供者的完整列表,并附有各自的功能矩阵。非常容易找到合适的提供者及其能力。文档可以在docs.puppetlabs.com/references/latest/type.html找到。

使用通用提供者的资源类型

有一些资源类型不使用提供者,但它们在核心类型中是罕见的。Puppet 使得大多数有趣的管理任务变得更加容易,只是它们在操作系统之间的工作方式不同,而提供者以最优雅的方式实现了这一点。

即使是所有平台上都相同的简单任务,也可能需要一个提供者。例如,有一个host类型,用于管理/etc/hosts文件中的条目。其语法是通用的,因此代码技术上可以仅在类型中实现。然而,Puppet 代码库中确实有某些种类的提供者的抽象基类。其中一个使得构建提供者来编辑文件变得非常容易,前提是这些文件由带有有序字段的单行记录组成。因此,为host类型实现一个提供者,并以这个提供者类为基础是有意义的。

对于好奇的人来说,下面是一个主机资源的样子:

host { 'puppet':
  ip           => '10.144.12.100',
  host_aliases => [ 'puppet.example.net', 'master' ],
} 

总结类型和提供者

Puppet 的资源类型及其提供者共同构成了一个坚固的抽象层,涵盖了软件配置的细节。类型系统是 Puppet 强大 DSL 的可扩展基础。它为多态提供者层提供了一个复杂的接口。

提供者灵活地实现了 Puppet 应执行的实际管理操作。它们将必要的同步步骤映射到命令和系统交互上。许多提供者无法满足资源类型所模型化的每一个细微差别。功能系统以透明的方式处理这些差异。

将所有内容整合在一起

读到这里,你可能会觉得这一章是一个相当奇怪的主题混合。虽然类型和提供者确实密切相关,但 Facter 的整个介绍在这个上下文中似乎有些不合时宜。然而,这种感觉是具有误导性的;事实在类型/提供者结构中扮演着至关重要的角色。它们对 Puppet 在不同提供者之间做出良好选择至关重要。

我们再来看一个来自扩展 Facter 自定义事实部分的例子。内容涉及fstab条目和 Solaris 的区别,Solaris 使用/etc/vfstab而非/etc/fstab。该部分建议使用一个根据事实值自适应的清单。如你所学,Puppet 有一个资源类型来管理fstab内容:mount类型。然而,对于路径有所不同的小差异,Solaris 并没有专门的mount提供者。实际上,所有平台只有一个提供者,但在 Solaris 上,它的行为有所不同。它通过解析 Facter 的os['family']值来实现这一点。以下代码示例来自实际的提供者代码:

case Facter.value(:os['family'])
when"Solaris"
  fstab = "/etc/vfstab"
else
  fstab = "/etc/fstab"
end 

然而,在其他情况下,Puppet 应在不同的平台上使用完全不同的提供者。包管理就是一个经典的例子。在类似 Red Hat 的平台上,你几乎希望 Puppet 在所有情况下都使用yum提供者。也可以使用rpm,甚至apt可能也可用。然而,如果你告诉 Puppet 确保安装某个包,你期望它在必要时使用yum来安装该包。

这显然是一个常见主题。某些管理任务需要在不同的环境中执行,且使用的工具链完全不同。在这种情况下,显而易见哪个提供者最适合。为了实现这一点,如果条件满足,提供者可以声明自己是默认的。对于yum来说,条件如下:

defaultfor :os['name'] => [:fedora, :centos, :redhat] 

这些条件基于事实值。如果某个特定代理的operatingsystem值在列出的范围内,yum会认为自己是默认的包管理提供者。

操作系统和操作系统家族的事实是提供者中最常见的查询选项,但任何事实都是可用的。

除了标记自己为默认外,还有更多依赖事实值的提供者过滤。提供者还可以限制自己只能在某些值的组合下使用。例如,yum的替代者zypper限制自己只适用于 SUSE Linux 发行版:

confine :os['name'] => [:suse, :sles, :sled, :opensuse] 

这种提供者方法与本章前面讨论的 Facter 中的confine方法相同。如果代理机器上的相应事实没有任何白名单值,提供者甚至不会被视为有效。

如果你查看核心提供者的代码,会发现限制(甚至默认提供者的声明)基于功能值,尽管没有该名称的 Facter 事实。这些功能与提供者功能无关,它们来自与 Facter 类似的另一层反射机制,但它们是硬编码到 Puppet 代理中的。这些代理功能是一些标志,用于标识某些系统属性,这些属性无需以事实的形式向清单提供。例如,exec类型的posix提供者在存在相应功能时成为默认提供者:defaultfor :feature => :posix

您会发现某些提供者完全放弃了 confine 方法,因为它并非正确操作代理所必需的。Puppet 在查找所需的操作系统命令时,也会识别不合适的提供者。例如,某些 BSD 版本的 pw 提供者不会使用 confine 声明。它只声明其一个必需的命令:

commands :pw => "pw"  

在搜索路径中找不到 pw 二进制文件的代理将完全不尝试使用此提供者。

这就是通过 Facter 探索类型和提供者内部工作原理的简短介绍。有关构建类型提供者的完整示例,并使用您现在已学习的内部工具,您可以参考第五章,将类、配置文件和扩展结合成模块

总结

Puppet 使用 Facter 收集所有代理系统的信息。信息库由大量独立的部分组成,称为事实(facts)。清单可以查询这些事实的值,以适应触发其编译的相应代理。Puppet 还使用事实来选择提供者,这些工作马使得抽象的资源类型在广泛支持的平台上可用。

资源类型不仅完全定义了 Puppet 在 DSL 中暴露的接口,它们还负责所有输入值的验证、在将值交给提供者之前必须执行的转换,以及其他相关任务。

提供者封装了实际操作系统及其相应工具链的所有知识。它们实现了资源类型所描述的功能。Puppet 模型的配置适用于不同的平台,而平台之间有所不同,因此并非每个资源类型的每个方面都适用于所有代理。通过仅暴露受支持的特性,提供者可以表达此类限制。

在深入了解内部细节后,我们将再次关注更实际的问题。接下来的章节将涵盖构建各类规模复杂和高级清单所需的工具。

第四章:在类和定义类型中结合资源

到目前为止,您已经使用 Puppet 执行了一些生产级任务。您学习了如何编写独立的 Manifest,并使用 puppet apply 来应用它们。在设置您的第一个 Puppet 主控和代理时,您在主控上为节点 Manifest 创建了一个简单的示例。在 node '<hostname>' 块中,您创建了一个 Manifest 文件的等效物。这样,Puppet 主控就只使用特定代理节点的这个 Manifest。

尽管这些内容非常有用且基本重要,但显然不足以应对日常业务需求。通过使用包含一组资源的 node 块,您将不得不对类似节点执行大量的复制和粘贴操作,整个结构会迅速变得难以管理。这是一种不自然的方法来开发 Puppet Manifest。尽管与您熟悉的许多其他语言存在显著差异,但 Puppet DSL 是一种编程语言。仅仅通过 node 块和资源来构建 Manifest 与仅仅编写只有 main 函数的 C 程序或没有自定义类的 Ruby 代码是一样的。

您可以使用已经掌握的工具编写的 Manifest 并不是平面的。您学习了诸如 ifcase 等常见的控制结构,您的 Manifest 可以使用这些结构根据 Facter 事实的值查询并根据结果进行分支。

然而,这些结构应该由语言工具来补充,以创建可重复使用的代码单元,类似于过程化语言中的函数或方法。本章通过以下主题介绍这些概念:

  • 引入类和定义类型

  • 设计模式

  • 已定义类型的动态方面

  • 类之间的排序和事件

  • 通过参数使类更加灵活

引入类和定义类型

Puppet 的方法或函数的等效物有两种:一方面是,另一方面是定义类型(也简称为定义)。

你会发现,对于类来说,函数类比有些薄弱,但对于定义类型来说非常合适。

乍一看它们很相似,因为它们都包含一块可重用的 Puppet DSL 代码块。但它们在使用方式上有很大的区别。让我们先看看类。

定义和声明类

Puppet 类可以被认为是包含 Puppet 资源声明集合的容器。它只创建一次(类定义),并被所有需要使用准备好的功能的节点使用。每个类代表系统配置的一个众所周知的子集,例如 ntpnginxssh

例如,一个经典的用例 case 是一个安装 Apache Web 服务器并应用一些基本设置的类。这个类看起来与以下内容相同:

class apache {
  package { 'apache2':
    ensure => present,
  }
  file { '/etc/apache2/apache2.conf':
    ensure => 'file',
    source =>
    'puppet:///modules/apache/etc/apache2/apache2.conf', 
  } 
  service { 'apache2': 
    ensure    => running,
    enable    => true, 
    subscribe => File'/etc/apache2/apache2.conf', 
  } 
} 

所有 Web 服务器节点将使用这个类。为此,它们的清单需要包含一个简单的声明:

include apache 

这称为包含一个类,或者声明它。如果你的 apache 类足够强大,可以完成所有必要的任务,那么这一行可能会完全构成 node 块的内容:

node 'webserver01' {
  include apache
}

在你自己的设置中,可能不会编写自己的 Apache 类。你可以使用通过 Puppet 模块提供的开源类。[第五章,将类、配置文件和扩展组合成模块,将为你提供所有详细信息。

这就是我们对类的简要概述。虽然还有更多内容要讨论,但我们还是先来看一下已定义类型。

创建和使用已定义类型

已定义类型可以看作是一个新的资源类型,它利用了现有的资源类型。当你有多个相同的现有资源类型实例时,使用已定义类型非常有用,因为你可以将它们包装在已定义类型中。作为一个类,它主要由一个包含清单代码的主体构成。然而,已定义类型接收参数,并将这些参数的值作为局部变量在其主体中使用。

这里是另一个典型的已定义类型示例,即 Apache 虚拟主机配置:

define virtual_host(
  String $content,
  String[3,3] $priority = '050'
) {
  file { "/etc/apache2/sites-available/${name}":
    ensure  => 'file',
    owner   => 'root',
    group   => 'root',
    mode    => '0644',
    content => $content
  }
  file { "/etc/apache2/sites-enabled/${priority}-${name}": 
    ensure => 'link', 
    target => "../sites-available/${name}";
  }
} 

数据类型如 String 从 Puppet 4 开始就已可用。在 Puppet 3 及之前的版本中,你会直接跳过这些;所有变量曾经都是无类型的。

这段代码可能看起来还是很晦涩。从实际使用它的其他地方来看,它会变得更加清晰;下面的代码展示了如何使用:

virtual_host { 'example.net': 
  content => file('apache/vhosts/example.net') 
}
virtual_host{ 'fallback': 
  priority => '999', 
  content  => file('apache/vhosts/fallback') 
}   

这就是为什么这个构造被称为已定义类型;你现在可以在清单中放置看似是资源的内容,但实际上你是在调用你自己定义的清单代码构造。

当声明多个相同类型的资源时,如前面的代码所示,你可以在一个块中进行声明,并用分号分隔它们:

virtual_host {
  'example.net':
    content  => 'foo';
  'fallback':
    priority => '999',
    content  => ...,
} 

官方风格指南禁止使用这种语法,但在某些情况下,它确实可以使清单更具可读性和可维护性。

virtual_host 类型接受两个参数:content 参数是必需的,因为它没有默认值,并且在配置文件资源中按原样使用。Puppet 将同步该文件的内容为清单中指定的内容。priority 参数是可选的,它的值将作为文件名的前缀。如果省略该参数,相应的虚拟主机定义将使用默认优先级 050

本示例类型的两个参数都是 String 类型。有关 Puppet 变量类型系统的详细信息,请参见 第五章,将类、配置文件和扩展组合成模块。可以简要地说,你可以将参数限制为某些值类型。然而,这并非强制要求。你可以省略类型名,Puppet 将接受该参数的任何值。

此外,每个定义的类型可以隐式引用它被调用时的名称(或标题)。换句话说,您定义的每个实例都会有一个名称,您可以通过 $name$title 变量访问它。

在定义类型的主体中,有一些其他的 魔法 变量是可用的。如果一个定义类型的资源声明了像 require => ... 这样的元参数,它的值可以通过 $require 变量在主体中访问。如果没有使用元参数,则该变量值为空。这适用于诸如 beforenotify 等所有元参数,但您可能永远不需要使用这些。元参数会自动做出正确的处理。

理解并利用差异

Puppet 的类和定义类型各自的目的非常明确,通常不会重叠。

类声明了与系统某种方式相关的资源和属性。类是您系统中一个或多个方面的最终描述。无论类表示什么,它都只能以一种形式存在;对于 Puppet 来说,每个类本质上是一个单例,是一个固定的信息集,要么应用于您的系统(类被包含),要么不应用。

您将在类中封装并方便地包含在清单中的典型资源如下:

  • 一个或多个应该安装(或移除)的包

  • /etc 中的特定配置文件

  • 用于存储许多子系统脚本或配置文件的常用目录

  • 应该在所有适用系统中几乎相同的 Cron 作业

define 用于所有存在多个实例的事物。所有在系统中以不同数量出现的方面都可以使用这种语言结构建模。从这个角度来看,define 与其声明语法所模拟的完整资源非常相似。定义类型的典型内容包括:

  • conf.d 风格目录中的文件

  • /etc/hosts 之类的易于解析文件中的条目

  • Apache 虚拟主机

  • 数据库中的模式

  • 防火墙中的规则

该类的单例特性尤为重要,因为它防止了多次资源声明的冲突。请记住,每个资源必须对一个目录是唯一的。例如,考虑 Apache 包的第二次声明:

package { 'apache2': }

该声明可以出现在您的一个 Web 服务器的清单中的任何位置(例如,直接在 node 块中,紧挨着 include apache);这个额外的声明将防止目录的成功编译。

阻止成功编译的原因是 Puppet 目前无法确保两个声明表示相同的目标状态,或者可以合并成一个复合状态。可能多个相同资源的声明会在属性的期望值上发生冲突(例如,一个声明可能希望确保一个包是absent,而另一个声明则需要它是present)。你应该将 Puppet 视为对系统配置状态的声明性描述。

该类的优点是可以在清单中任意数量地添加相同类的include语句。Puppet 只会将该类的内容提交到目录一次。

资源的唯一性约束适用于已定义的类型。你自己定义的两个实例不能共享相同的名称。使用相同的名称两次或更多次会导致编译错误:

virtual_host { 'wordpress': 
  content  => file(...), 
  priority => '011', 
}
virtual_host { 'wordpress': 
  content  => '# Dummy vhost', 
  priority => '600', 
}

设计模式

你对类和定义类型的了解仍然偏学术化。你已经学习了它们的定义方面以及使用它们的语法,但我们还没有让你体会这些概念在不同实际场景中的应用。

以下部分将概述你可以使用这些语言工具做什么。

编写全面的类

许多类被编写来让 Puppet 在代理平台上执行重要任务。在这些类中,Apache 类可能是一个较为简单的例子。你可以设想一个类,它可以从任何机器的清单中包含,并确保满足以下条件:

  • 防火墙软件已安装并配置了默认规则集。

  • 恶意软件检测软件已安装。

  • 定时任务按照设定的时间间隔运行扫描器。

  • 邮件子系统已配置,以确保定时任务能够传递其输出。

创建此类规模的类有两种常见方法。它可以成为所谓的单体实现,一个包含所有资源的大型类,这些资源共同工作以形成期望的安全基线。这种方法的优点是能准确描述你的基础设施,但缺乏可维护性。另一方面,你可以选择组合设计,类的主体中几乎没有资源(或者根本没有),而是通过多个include语句来包含较简单的类。功能被模块化,中心类充当收集器。这是 Puppet 模块开发人员常用的最佳实践方式。

我们还没有涉及类包含其他类的能力。这是因为这非常简单。类的主体几乎可以包含任何清单,include语句也不例外。类中唯一不能出现的几个元素之一就是node块。

给描述增添一些生动的元素,大致上各个类将呈现如下样子:

class monolithic_security { 
  package { [ 'iptables', 'rkhunter', 'postfix' ]:
    ensure => 'installed';
  } 
  cron { 'run-rkhunter': 
    ... 
  } 
  file { '/etc/init.d/iptables-firewall': 
    source => ... 
    mode => 755 
  }
  file { '/etc/postfix/main.cf': 
    ensure => 'file', 
    content => ... 
  } 
  service { [ 'postfix', 'iptables-firewall' ]: 
    ensure => 'running', 
    enable => true 
  } 
}
class divided_security {
  include iptables_firewall
  include rkhunter
  include postfix
}

在开发自己的功能类时,您不应选择这两种极端中的任何一种。大多数类最终会位于这两者之间的某个位置。选择可以在很大程度上基于个人偏好。技术上的影响是微妙的,但它们各自的缺点如下:

  • 因此,追求单体类可能会导致资源冲突,因为您几乎没有利用类的单例特性。

  • 将类拆分得过于细碎会使得施加秩序和分配刷新事件变得困难,您可以参考本章稍后的“将类、配置文件和扩展组合成模块”部分。

在大多数情况下,这些方面并不是至关重要的。逐案设计选择将基于每个作者的经验和偏好。在不确定的情况下,首先倾向于选择组合设计。

编写组件类

还有一个类的常见使用案例。您不仅可以将一个类填充很多协同工作的方面来实现复杂的目标,还可以将类限制为非常特定的目的。有些类只包含一个资源。可以说,这个类将该资源包装起来。

这对于在不同上下文中需要的资源非常有用。通过将它们包装在一个类中,您可以确保这些上下文不会产生同一资源的多次声明。

例如,netcat包对防火墙服务器很有用,也可以对 Web 应用服务器有用。可能会有一个firewall类和一个appserver类。两个类都声明了netcat包:

package { 'netcat':  
  ensure => 'installed'  
}  

如果某个服务器同时具有两个角色(这可能由于预算原因或其他不可预见的情况发生),这将成为一个问题;当同时包含firewallappserver类时,结果清单会声明两次netcat包。这是禁止的。为了解决这个问题,netcat包资源可以被包装在一个netcat类中,这个类由firewallappserver类共同引用:

class netcat { 
  package { 'netcat':  
    ensure => 'installed'  
  }  
}  

让我们考虑另一个典型的组件类示例,它确保一个公共文件路径的存在。假设您的 IT 政策要求所有自定义脚本和应用程序都安装在/opt/company/bin目录下。许多类,如前面例子中的firewallappserver,都需要在这里放置一些相关内容。每个类都需要确保在部署脚本之前,相关目录已经存在。可以通过包含一个组件类来实现,该类包装了directory树中的file资源:

class scripts_directory {  
  file { [ '/opt/company/', '/opt/company/bin' ]:  
    ensure => 'directory',  
    owner  => 'root',  
    group  => 'root',  
    mode   => '0755',  
  }  
} 

组件类是一个相当精确的概念。然而,正如您在前一节关于更强大类的部分中看到的那样,所有可能的类设计形式构成了一个细致的尺度,介于所呈现的例子之间。您编写的所有清单很可能会包含多个类。获得最佳实践的最佳方式是直接使用类来构建您所需要的清单。

全面类和组件类这些术语并不是官方的 Puppet 语言,社区也不会用它们来传达设计实践。我们随意选择它们来描述我们在这些章节中阐述的思想。对定义类型的用例描述也是如此,接下来的章节中将会看到这些用例。

接下来,我们来看一下定义类型的一些用途。

使用定义类型作为资源包装器

尽管定义类型与类看似相似,但它们的使用方式有所不同。例如,组件类被描述为封装一个资源。这在一个非常特定的上下文中是准确的,封装的资源是一个单例,并且在整个清单中只能以一种形式出现。

当将资源包装在定义类型中时,你最终会得到相应资源类型的变种。清单中可以包含任意数量的定义类型实例,每个实例将封装一个不同的资源。

为了使其生效,在定义类型的主体中声明的资源名称必须是动态创建的。它几乎总是相应定义类型实例的$name变量,或者是从它派生的值。

这里是另一个来自许多清单中的典型示例:大多数使用 Puppet 文件服务功能的用户,都会希望在某个时候包装file类型,这样就无需为每个文件单独输入相应的 URL:

define module_file(String $module) {  
  file { $title:  
    source => "puppet:///modules/${module}/${title}" 
  }  
} 

这使得 Puppet 能够轻松地将文件从主服务器同步到代理。主服务器上的副本必须正确放置在主服务器上的命名模块中:

module_file { '/etc/ntpd.conf':  
  module => 'ntp': 
}  

这个资源将使 Puppet 从ntp模块中检索ntp.conf文件。前面的声明比完全编写的文件资源和 Puppet URL(尤其是你可能需要同步的大量文件)更简洁、不冗余,后者可能类似于以下内容:

file { '/etc/ntpd.conf':  
  source => 'puppet:///modules/ntp/etc/ntpd.conf': 
}  

对于像module_file这样的包装器,它可能会被广泛使用,你需要确保它支持所有被包装资源类型的属性。在这种情况下,module_file包装器应该接受所有file属性。例如,以下是如何将mode属性添加到包装器类型中的方法:

define module_file( 
  String $module, 
  Optional[String] $mode = undef 
) {  
  if $mode != undef {  
    File { mode => $mode }  
  }  
  file { $title:  
    source => "puppet:///modules/${module}/${title}"  
  }  
} 

File { ... }块声明了相同作用域内所有file资源属性的默认值。undef值类似于 Ruby 中的nil,它是一个方便的默认参数值,因为用户不太可能需要将它作为实际值传递给被包装的资源。

你也可以使用覆盖语法,而不是默认语法:

File[$name] { mode => $mode }

这使得代码的意图稍微更明显,但在只有一个file资源的情况下并不是必须的。第六章,Puppet 初学者进阶部分,包含了更多关于覆盖和默认值的信息。

使用定义类型作为资源多路复用器

使用已定义类型包装单一资源是有用的,但有时你可能希望在你包装的资源类型之外添加额外的功能。在其他时候,你可能希望已定义类型统一很多功能,就像本节开始时的综合类一样。

对于这两种情况,你需要在已定义类型的主体中有多个资源。这里也有一个经典的例子:

define user_with_key(
  String $key, 
  Optional[String] $uid = undef, 
  String $group = 'users'
) {
  user { $title: 
    ensure     => present
    gid        => $group, 
    uid        => $uid,
    managehome => true, 
  }
  ssh_authorized_key { "key for ${title}": 
    ensure => present, 
    user   => $title, 
    type   => 'rsa', 
    key    => $key, 
  } 
}

这段代码允许你在一个资源声明中创建具有授权 SSH 密钥的用户帐户。这个代码示例有一些显著的特点:

  • 由于你基本上是在包装多个资源类型,所有内部资源的标题都是从当前已定义类型实例的实例标题(或名称)派生的;实际上,这是所有已定义类型的必备实践。

  • 你可以硬编码业务逻辑的部分;在这个例子中,我们取消了对非 RSA SSH 密钥的支持,并将 users 定义为默认组。

使用已定义类型作为宏

一些源代码需要许多重复的任务。假设你的站点使用一个子系统,该子系统依赖于某个位置的符号链接来启用配置文件,就像 init 使用 rc2.d/ 及其兄弟目录中的符号链接,并指向 ../init.d/<service>

一个启用大量配置片段的清单可能会看起来像这样:

file { '/etc/example_app/conf.d.enabled/england': 
  ensure => 'link', 
  target => '../conf.d.available/england' 
}
file { '/etc/example_app/conf.d.enabled/ireland': 
  ensure => 'link', 
  target => '../conf.d.available/ireland' 
}
file { '/etc/example_app/conf.d.enabled/germany': 
  ensure => 'link', 
  target => '../conf.d.available/germany' 
  ... 
}

这段代码很难阅读且维护起来有点痛苦。在 C 程序中,人们会使用预处理器宏,仅使用链接和目标的基本名称,就能扩展为每个资源描述的三行代码。Puppet 不使用预处理器,但你可以使用已定义类型来实现类似的效果:

define example_app_config { 
  file { "/etc/example_app/conf.d.enabled/${title}": 
    ensure => 'link', 
    target => "../conf.d.available/${title}", 
  } 
} 

该已定义类型实际上更像是一个简单的函数调用,而不是一个真正的宏。

该定义不需要任何参数,它可以仅依赖于资源名称,因此前面的代码现在可以简化为以下内容:

example_app_config {'england': }
example_app_config {'ireland': }
example_app_config {'germany': }
... 

或者,以下代码更加简洁:

example_app_config { [ 'england', 'ireland', 'germany', ... ]: 
}

这种数组表示法引出了已定义类型的另一种用途。

利用已定义类型中的数组值

编程中一个比较常见的场景是需要接受某个来源的数组值,并对每个值执行任务。Puppet 清单也不例外。

假设之前示例中的符号链接实际上指向的是目录,并且每个这样的目录都包含一个子目录,用于存放指向区域的可选链接。Puppet 也应该管理这些链接。

当然,在了解了已定义类型的宏观概念后,你肯定不希望将每个区域都作为单独的资源添加到你的清单中。然而,你需要设计一种方法,将区域名称映射到国家。既然已经有一个为国家定义的资源类型,那么有一种非常直接的方法:将区域列表作为已定义类型的一个属性(或者说是一个参数):

define example_app_config (
  Array $regions = []
) {
  file { "/etc/example_app/conf.d.enabled/${name}":
    ensure => link,
    target => "../conf.d.available/${name}",
  }
  # to do: add functionality for $regions
}

使用该参数非常简单:

example_app_config { 'england':
  regions => [ 'South East', 'London' ],
}
example_app_config { 'ireland':
  regions => [ 'Connacht', 'Ulster' ],
}
example_app_config { 'germany':
  regions => [ 'Berlin', 'Bayern', 'Hamburg' ],
}
... 

实际的挑战是将这些值付诸实践。一种天真的做法是将以下内容添加到example_app_config的定义中:

file { $regions:
  path   => "/etc/example_app/conf.d.enabled/${title}/ 
    regions/${name}",
  ensure => 'link',
  target => "../../regions.available/${name}";
}

然而,这样做是行不通的。$name变量并不引用正在声明的file资源的标题。它实际上就像$title一样,引用的是外部类或定义类型的名称(在这种情况下是国家名称)。不过,实际的结构应该是你非常熟悉的。唯一缺少的部分是另一个定义类型:

define example_app_region(String $country) { 
  file { "/etc/example_app/conf.d.enabled/${country}/regions/${title}":
    ensure => 'link', 
    target => "../../regions.available/${title}",
  } 
}  

example_app_config定义类型的完整定义应该如下所示:

define example_app_config(Array $regions = []) {
  file { "/etc/example_app/conf.d.enabled/${title}": 
    ensure => 'link', 
    target => "../conf.d.available/${title}", 
  } 
  example_app_region { $regions: 
   country => $title,
  } 
}

外部定义类型通过将其自己的资源名称作为参数值传递,来调整example_app_region类型以适应其各自的需求。

使用迭代器函数

使用 Puppet 4 及更高版本时,你可能不会像上一节中那样编写代码。得益于新语言特性,使用定义类型作为迭代器已经不再必要。我们将通过以下示例概述这一替代方法,更多详细内容请参考第七章,来自 Puppet 4 和 5 的新特性

现在可以通过使用each函数从数组中声明普通的国家链接:

[ 'england', 'ireland', 'germany' ].each |$country| { 
  file { "/etc/example_app/conf.d.enabled/${country}": 
    ensure => 'link', 
    target => "../conf.d.available/${country}",
  } 
}   

区域可以通过结构化数据声明。对于这个用例,使用哈希就足够了:

$region_data = { 
  'england' => [ 'South East', 'London' ], 
  'ireland' => [ 'Connacht', 'Ulster' ], 
  'germany' => [ 'Berlin', 'Bayern', 'Hamburg' ], 
}
$region_data.each |$country, $region_array| {
  $region_array.each |$region| {
    file { "/etc/example_app/conf.d.enabled/${country}/ 
      regions/${region}":
      ensure => link,
      target => "../../regions.available/${region}",
    }
  }
} 

在新的清单文件中,你应该选择使用eachmap函数进行迭代,而不是为了此目的使用定义类型。不过,你仍然可以在旧的清单代码中找到前者的示例。有关更多信息,请参见第七章,来自 Puppet 4 和 5 的新特性

从定义类型中包含类

上一个示例中定义的example_app_config类型应该服务于一个非常特定的目的。因此,它假设基本目录/etc/example_app及其子目录在定义类型之外被独立管理。这是一个合理的设计,但许多定义类型是为了被多个独立类或其他定义类型使用的。此类定义需要自包含。

在我们的示例中,定义类型需要确保以下资源是清单的一部分:

file { [ '/etc/example_app', '/etc/example_app/config.d.enabled' ]:
  ensure => 'directory',
} 

只将此声明放入定义体内将导致重复资源错误。每个example_app_config实例都会尝试自己声明目录。然而,我们已经讨论过一种模式来避免这一问题,我们称之为组件类。

为了确保任何example_app_config类型的实例都是自包含并能够独立工作,请将前面的声明包裹在一个类中(例如,class example_app_config_directories),并确保将此类直接包含在定义体内:

define example_app_config(Array $regions = []) {
  include example_app_config_directories
...
} 

你可以参考本书附带的示例来查看类的定义。

类之间的排序和事件

Puppet 的类与 Java 或 Ruby 等面向对象编程语言中的类几乎没有相似之处。没有方法或属性。没有任何类的独立实例。你不能创建接口或抽象基类。

为数不多的共享特性之一是封装特性。就像面向对象编程(OOP)中的类一样,Puppet 的类隐藏了实现细节。要让 Puppet 开始管理某个子系统,你只需包含适当的类。

在类和定义类型之间传递事件

通过将所有资源分类到类中,你使得(你的同事或其他协作者)无需了解每个单独的资源。这是有益的。你可以将类和定义类型的集合视为你的接口。你不希望阅读项目中任何人写过的所有清单文件。

然而,封装在传递资源事件时不方便。假设你有一个守护进程,它从 Apache 日志文件中创建实时统计数据。它应该订阅 Apache 的配置文件,以便在有任何更改时重新启动(这些更改可能会影响该守护进程的操作)。在另一种情况下,你可能会让 Puppet 管理一些外部数据,用于一个自编译的 Apache 模块。如果 Puppet 更新了这些数据,你可能会希望触发 Apache 服务的重启以重新加载所有内容。

凭借已知在 apache 类的某处定义了一个服务 Service['apache2'],你可以直接让你的模块数据文件通知该资源。如果 Puppet 不对在外部类中声明的资源施加任何保护,它将有效。然而,这可能会带来一个小的可维护性问题。

资源的引用位置远离资源本身。稍后维护清单时,你或你的同事可能希望在遇到引用时查看资源。在 Apache 的情况下,找到查看的位置并不困难,但在其他情况下,引用目标的位置可能不那么明显。

通常不需要查找目标资源,但了解该资源实际做什么可能很重要。特别是在调试过程中,如果更改了清单后,引用的资源找不到时,这一点尤为重要。

此外,这种方法在另一种情况下无法使用,其中你的守护进程需要订阅配置更改。当然,你可以盲目地订阅中央的 apache2.conf 文件。然而,如果负责的类选择在 /etc/apache2/conf.d 中的片段里做大部分配置工作,这样做就无法达到预期的效果。

通过将 notifysubscribe 参数指向管理相关实体的整个类,可以优雅地解决两种情况:

file { '/var/lib/apache2/sample-module/data01.bin':
  source => '...',
  notify => Class['apache'],
}
service { 'apache-logwatch': 
  enable    => true, 
  subscribe => Class['apache'], 
}  

当然,现在信号是无差别地发送(或接收)的,文件不仅通知了 Service['apache2'],还通知了 apache 类中的每一个其他资源。这通常是可以接受的,因为大多数资源会忽略事件。

至于 logwatch 守护进程,如果 apache 类中的资源需要同步操作,它可能会不必要地刷新自己。此现象发生的概率取决于类的实现。为了获得理想的结果,可能需要将配置文件资源移到它们自己的类中,以便守护进程可以订阅那个类。

对于你定义的类型,你可以应用相同的规则:根据需要订阅和通知它们。这样做非常自然,因为它们本来就是像本地资源一样声明的。这就是你如何订阅多个定义类型实例(如 symlink)的方法:

$active_countries = [ 'England', 'Ireland', 'Germany' ]
service { 'example-app': 
  enable    => true, 
  subscribe => Symlink[$active_countries], 
} 

诚然,这个示例有些笨拙,因为它要求所有 symlink 资源标题都必须在一个数组变量中可用。在这种情况下,更自然的做法是让定义类型实例通知服务,而不是这样做:

symlink { [ 'England', 'Ireland', 'Germany' ]: 
  notify => Service['example-app'], 
} 

该表示法将一个元参数传递给已定义的类型。其结果是该参数值会应用于定义内部声明的所有资源。

如果一个已定义的类型包装或包含了 serviceexec 类型的资源,也可能需要通知该定义的实例刷新包含的资源。以下示例假设 service 类型被一个名为 protected_service 的定义类型所包装:

file { '/etc/example_app/main.conf':  
  source => '...',  
  notify => Protected_service['example-app'],  
} 

容器排序

notifysubscribe 元参数并不是唯一可以直接应用于已定义类型的类和实例的参数,beforerequire 这两个元参数也适用。这些元参数允许你为资源定义相对于类的顺序,排列已定义类型的实例,甚至对类之间的顺序进行排序。

后者是通过链式操作符来实现的:

include firewall 
include loadbalancing 
Class['firewall'] -> Class['loadbalancing'] 

这段代码的效果是,所有来自 firewall 类的资源将在任何来自 loadbalancing 类的资源之前同步,如果前者的任何资源失败,后者的所有资源都将无法同步。

链接箭头不能仅仅放置在 include 语句之间。它只在资源定义或资源引用之间有效。

由于这些排序语义,要求整个类同步实际上是相当合理的。你实际上将相关资源标记为依赖于该类。因此,只有在类所建模的整个子系统成功同步后,相关资源才会同步。

限制

不幸的是,容器的排序和刷新事件的分发存在一个相当严重的问题:它们都不会超越后续类的 include 语句。考虑以下示例:

class apache {
  include apache::service
  include apache::package
  include apache::config
}
file { '/etc/apache2/conf.d/passwords.conf':
  source  => '...', 
  require => Class['apache'], 
} 

我经常提到,apache 类如何全面地模拟 Apache 服务器子系统的一切,在上一节中,我进一步解释了,如果将 require 参数指向这样一个类,将确保只有在子系统成功配置后,Puppet 才会接触到相关的依赖资源。

这一点大多是正确的,但由于类边界的限制,它在这个场景中无法实现预期效果。依赖的配置文件实际上应该需要在 class apache::package 中声明的 Package['apache'] 包。然而,这个关系并不会跨越多个类包含,因此这个特定的依赖关系根本不会成为生成目录的一部分。

同样,发送到 apache 类的任何刷新事件都不会起作用;它们会被分发到类体内声明的资源(但其中没有任何资源),而不会传递给包含的类。订阅该类也没有意义,因为在包含类内部生成的任何资源事件都不会被 apache 类转发。

关键点是,不能完全忽视类的实现来构建类之间的关系。如果有疑问,您需要确保目标类中实际声明了您感兴趣的资源。

讨论围绕着类中的 include 语句展开,但由于在定义类型中也常常使用它们;相同的限制在这种情况下也适用。

这里也有一个亮点。根据之前解释的示例,Apache 配置文件的更正确实现应该依赖于该包,但在服务之前也会同步自身,并可能通知它(以便在必要时重启 Apache)。当所有资源都属于 apache 类,并且你只想遵循与容器交互的模式时,它将导致如下声明:

file { '/etc/apache2/conf.d/passwords.conf':  
  source  => '...',  
  require => Class['apache'],  
  notify  => Class['apache'],  
}  

这形成了一个即时的依赖循环:file 资源要求在处理之前,apache 类的所有部分都必须同步,但为了通知它们,它们必须排在 file 资源之后,这在顺序图中是行不通的。通过了解 apache 类的内部结构,用户可以选择真正有效的元参数值:

file { '/etc/apache2/conf.d/passwords.conf': 
  source  => '...',  
  require => Class['apache::package'],  
  notify  => Class['apache::service'],  
}  

对于好奇的人,前面的代码大致展示了内部类的样子。

另一个好消息是,调用定义类型不会像 include 语句那样引发问题。事件会正常传递给定义类型内部的资源,跨越任意数量的堆叠调用。排序也像预期的那样正常工作。让我们简要看看这个例子:

class apache {  
  virtual_host { 'example.net': ... } 
  ...  
} 

这个apache类还使用已定义类型virtual_host创建了一个虚拟主机。需要此类的资源将隐式地要求virtual_host实例中的所有资源。订阅该类的用户将接收到来自这些资源的事件,指向该类的事件也将到达该virtual_host的资源。

事实上,有充分的理由使include语句在这方面表现不同。由于类可以非常宽松地包含(得益于它们的单例特性),类通常会建立一个庞大的包含网络。通过在清单中添加一个include语句,你可能在不知情的情况下将数百个类拉入该清单。假设关系和事件超越了整个网络,随之而来的将是各种意外的效果。依赖循环几乎是不可避免的。整个构造将变得无法管理。此类关系的成本也将呈指数增长。请参阅下一节。

容器关系的性能影响

还有一个方面,你在引用容器类型以建立关系时应牢记。Puppet 代理需要从中构建一个依赖关系图。此图包含所有资源作为节点,所有关系作为边。类和已定义类型会扩展为它们所有的声明资源。与容器的所有关系都会扩展为与每个资源的关系。

如果关系的另一端是本地资源,那么这种情况通常是无害的。一个需要五个声明资源的类会导致五个依赖关系,这不会造成问题。如果相同的类被一个包含三个资源的已定义类型的实例所要求,那么每个资源都会与该类的资源建立关系,这样你就会在图中得到 15 条边。

当容器调用复杂的已定义类型时,成本会更高,可能还会是递归调用。

更复杂的图意味着 Puppet 代理需要更多的工作,其运行时间也会更长。当在调试或开发清单时交互式运行代理时,这尤其令人烦恼。为了避免不必要的努力,请仔细考虑你的关系声明,并仅在真正适当时使用它们。

缓解限制

Puppet 语言的架构师已经设计了两种替代方法来解决排序问题。我们将考虑这两种方法,因为你可能会在现有的清单中遇到它们。在新的设置中,你应该始终选择后一种方法。

锚点模式

anchor模式是解决递归类include语句中排序和信号传递问题的经典解决方法。可以通过以下示例类进行说明:

class example_app { 
  anchor { 'example_app::begin': 
    notify => Class['example_app_config'], 
  } 
  include example_app_config 
  anchor { 'example_app::end': 
   require => Class['example_app_config'],
  } 
} 

假设有一个资源被放置在before=> Class['example_app']之前。它会被放在链中的每个anchor之前,因此也会在example_app_config中的任何资源之前,尽管存在include的限制。这是因为Anchor['example_app::begin']伪资源会通知被包含的类,因此它会被排在所有资源之前。对于需要类的对象,example::end锚点也会起到类似的效果。

anchor资源类型是专门为此目的创建的。它不是 Puppet 核心的一部分,而是通过stdlib模块提供的(下一章会介绍模块)。由于它也转发刷新事件,因此甚至可以通知和订阅这个锚定类,事件将会在被包含的example_app_config类内外传播。

stdlib模块可以在 Puppet Forge 中找到,更多内容将在下一章介绍。关于anchor模式的描述性文档也可以在 Puppet 文档的projects.puppetlabs.com/projects/puppet/wiki/Anchor_Pattern中找到。鉴于锚点模式已经被 Puppet 的类容器功能所取代,这份文档有些过时。

contain功能

为了让复合类能够绕过include语句的限制,你可以利用在 Puppet 版本 3.4.x 及更高版本中提供的contain功能。

如果之前的apache示例写成如下形式,关于顺序和刷新事件就不会再有问题了:

class apache {
  contain apache::service
  contain apache::package
  contain apache::config
}

官方文档描述了如下行为:

"一个被包含的类在包含类开始之前不会被应用,并且会在包含类结束之前完成。"

这可能让你觉得我们现在在讨论一个可以解决类顺序问题的灵丹妙药。从现在开始,你是否只需要使用contain替代include,然后再也不用担心类的顺序问题呢?当然不是;这样做会引入许多不必要的顺序约束,并且很快让你陷入无法修复的依赖循环中。使用contain类,但要确保这样做是合理的。被包含的类应当真正构成包含类所建模内容的核心部分。

被引用的文档仅提到了类,但类同样可以包含在定义的类型中。包含的效果不仅仅局限于顺序方面。刷新事件也会被正确地传播。

通过参数使类更具灵活性

直到目前为止,类和定义在灵活性方面被视为直接对立的;定义类型本身通过不同的参数值具有适应性,而类则建模了单一的静态状态。正如本节标题所示,这并不完全正确。类也可以有参数。在这种情况下,它们的定义和声明与定义类型的定义和声明非常相似:

class apache::config(Integer $max_clients=100) { 
  file { '/etc/apache2/conf.d/max_clients.conf':
    content => "MaxClients ${max_clients}\n",
  }
}

使用如上所示的定义,类可以用参数值进行声明:

class { 'apache::config':  
  max_clients => 120, 
}  

这能够实现一些非常优雅的设计,但也带来了一些缺点。

参数化类的注意事项

允许类参数的后果几乎显而易见:你会失去单例特性。嗯,这也不完全正确,但你在声明类时的自由度会被大大限制。

定义了所有参数默认值的类仍然可以使用include语句进行声明。在同一清单中,这仍然可以任意多次进行。

然而,class { 'name': }的类似资源声明不能在同一清单中对任何给定的类出现多于一次。这与资源的规则相一致,不应感到非常惊讶——毕竟,如果在清单的不同位置试图为类的参数绑定不同的值,会显得非常尴尬。

然而,当混合使用include和替代语法时,事情变得非常混乱。在使用类似资源的语法声明类后,可以任意次数地包含该类。然而,一旦使用include声明了类,就不能再使用资源风格的声明。这是因为参数将被认为采用默认值,而class { 'name': }声明与此相冲突。

总之,以下代码是有效的:

class { 'apache::config': } 
include apache::config 

然而,以下代码无法正常工作:

include apache::config 
class { 'apache::config': } 

结果是,你实际上不能为组件类添加参数,因为include语句在大量使用时不再安全。因此,参数本质上仅对综合类有用,而这些类通常不会从清单的不同部分进行包含。

在第五章,将类、配置文件和扩展组合成模块中,我们将讨论一些替代模式,其中一些利用了类参数。还要注意,第八章,使用 Hiera 实现代码与数据的分离,提出了一种解决方案,使你在使用参数化类时更加灵活。通过这种方法,你可以更自由地使用类接口。

更倾向于使用 include 关键字

自从类参数可用以来,一些 Puppet 用户感到有必要编写(示例)代码,故意放弃include关键字,转而使用类似资源的类声明,如下所示:

class apache {
  class { 'apache::service': }
  class { 'apache::package': }
  class { 'apache::config': }
}

这么做是一个非常糟糕的主意。我们无法过度强调这一点:Puppet 类的最强大概念之一是它们的单例特性,即可以在清单中任意包含类,而无需担心与其他代码的冲突。上述声明语法会剥夺你这种能力,即使相关的类不支持参数。

最安全的做法是尽可能使用include,并尽量避免使用替代语法。事实上,第八章,通过 Hiera 分离代码和数据,介绍了在没有与类声明相同的资源情况下使用类参数的能力。这样,即使在使用参数时,你也可以完全依赖include。这些是最安全的推荐做法,可以帮助你避免与不兼容的类声明发生冲突。

总结

类和定义类型是创建可重用 Puppet 代码的基本工具。类包含清单中不能重复的资源,而定义则能够在每次调用时管理一组独特的适配资源。它通过利用接收到的参数值来实现这一点。尽管类也支持参数,但仍有一些需要注意的注意事项。

在清单中使用定义的类型时,你可以像声明本地类型的资源一样声明实例。类主要通过include语句使用,尽管也有其他替代方式,例如class { }语法或contain函数。类的顺序问题也可以通过contain函数得到一定缓解。理论上,类和定义足以构建你所需的几乎所有清单。实际中,你可能希望将代码组织成更大的结构。

第五章,将类、配置文件和扩展组合成模块,将向你展示如何做到这一点,并向你介绍一系列超出此内容的有用功能。

第五章:将类、配置文件和扩展组合成模块

在上一章中,你了解了将类和定义类型模块化和可重用的工具。我们讨论了几乎所有 Puppet 资源都应该被分离到适当的类中,除非它们在逻辑上需要作为定义类型的一部分。这几乎提供了足够的语法来构建一个完整代理节点集群的 manifests;每个节点选择适当的复合类,而复合类又包含其他所需类,所有类会递归地实例化定义类型。

直到现在为止,还没有讨论过 manifests 在文件系统中的组织方式。显然,将所有代码塞进一个大的 site.pp 文件是不理想的。解决这个问题的方法是通过模块实现的,本章将对此进行解释。

除了组织类和定义,模块还是共享公共代码的一种方式。它们是 Puppet manifests 和插件的软件库。模块还提供了一个便捷的位置,用来存放在前一章中提到的接口描述。Puppet Labs 运营着一个专门的服务,用于托管开源模块,称为 Puppet Forge。

模块的存在和一般位置在 第三章 中简要提到过,Puppet 中 Ruby 部分的初探 - Facts、Types 和 Providers。现在是时候更详细地探讨这些内容以及其他方面了。本章将涵盖以下主题:

  • Puppet 模块的内容

  • 管理环境

  • 构建一个组件模块

  • 查找有用的 Forge 模块

Puppet 模块的内容

模块可以看作是一个更高级的组织单元。它将有助于实现共同管理目标的类和定义类型捆绑在一起(例如,特定的系统方面或软件)。这些 manifests 并不是模块组织的全部内容;大多数模块还会捆绑文件和文件模板。模块中也可以包含几种类型的 Puppet 插件。本节将解释模块的不同部分,并展示它们的存放位置。你还将了解模块文档的手段以及如何获取现有模块以供使用。

模块的组成部分

对于大多数模块,manifests 是最重要的部分——核心功能。manifests 由类和定义类型组成,这些类和类型共享一个命名空间,以模块名称为根。例如,ntp 模块只包含名称以 ntp:: 前缀开头的类和定义。

许多模块包含可以同步到代理文件系统的文件。这通常用于配置文件或代码片段。你已经看过这些示例,但让我们再重复一遍。许多 manifests 中常见的资源之一是 file 资源,例如以下内容:

file { ‘/etc/ntp.conf’: 
  source => ‘puppet:///modules/ntp/ntp.conf’, 
}  

前述资源引用的是一个随虚拟ntp模块提供的文件。它已准备好提供一般适用的配置数据。然而,通常需要调整这类文件中的某些参数,以便节点清单可以为相应的代理声明定制的配置设置。用于此目的的工具是模板,将在第六章,Puppet 初学者高级部分中讨论。

模块的另一个可能组成部分是自定义事实 - 这是在请求目录之前同步到代理并运行的代码,以便将输出作为有关代理系统的事实提供。

这些事实并不是可以与模块一起提供的唯一 Puppet 插件。还有解析器函数(也称为自定义函数)。在许多情况下,它们是构建某些特定实现的最便捷方式,如果不是唯一方式。

在前文已经暗示过的最终插件类型是自定义本地类型和提供者,它们也方便地放置在模块中。

模块结构

所有提及的组件都需要位于特定的文件系统位置,以便主节点进行拾取。每个模块形成一个目录树。其根目录以模块本身命名。例如,ntp模块存储在名为ntp/的目录中。

所有清单都存储在名为manifests/的子目录中。每个类和定义类型都有各自的文件。例如,ntp::package类将在manifests/package.pp中找到,名为ntp::monitoring::nagios的定义类型将在manifests/monitoring/nagios.pp中找到。容器名称的第一个部分(ntp)始终是模块名称,其余描述了manifests/下的位置。您可以参考下文的模块树获取更多示例。

manifests/init.pp文件是特殊的。可以将其视为默认的清单位置,因为它被查找以获取来自相关模块的任何定义。

刚才提到的示例都可以放在init.pp中,仍然可以工作。不过,这样做会使得定位定义变得更加困难。

在实践中,init.pp应仅包含一个类,其名称与模块相同(例如ntp类),如果您的模块实现了这样的类。这是一种常见的做法,因为它允许清单使用简单语句来调用模块的核心功能:

include ntp 

您可以参考模块最佳实践部分获取有关此主题的更多注释。

模块为代理提供的文件和模板并没有严格分类到特定位置。重要的是它们分别被放置在 files/templates/ 子目录中。这些子树的内容可以按照模块作者的喜好进行结构化,并且清单必须正确引用它们。静态文件应始终通过 URL 进行访问,例如以下这些:

puppet:///modules/ntp/ntp.conf 
puppet:///modules/my_app/opt/scripts/find_my_app.sh 

这些文件位于 files/ 的相应子目录中:

.../modules/ntp/files/ntp.conf 
.../modules/my_app/files/opt/scripts/find_my_app.sh 

URI 中的 modules 前缀是必须的,后面必须跟着模块名称。其余路径直接对应于 files/ 目录中的内容。模板也有类似的规则。有关详细信息,请参阅第六章,《Puppet 初学者进阶部分》

最后,所有插件都位于 lib/ 子树中。自定义事实是存放在 lib/facter/ 中的 Ruby 文件。解析器函数存储在 lib/puppet/parser/functions/ 中,Puppet 4 API 函数位于 lib/puppet/functions/,而自定义资源类型和提供者分别位于 lib/puppet/type/lib/puppet/provider/ 中。这并非巧合;这些 Ruby 库由主节点和代理节点在相应的命名空间中查找。本章稍后会提供所有这些组件的示例。

简而言之,以下是树状视图中可能出现的模块内容:

/opt/puppetlabs/code/environments/production/modules/my_app
    |- templates/ # templates are covered in the next chapter
    |- files/
    | |- subdir1/ # puppet:///modules/my_app/subdir1/<filename>
    | |- subdir2/ # puppet:///modules/my_app/subdir2/<filename>
    | | \- subsubdir/ # puppet:///modules/my_app/subdir2/subsubdir/...
    |- manifests/
    | |- init.pp # class my_app is defined here
    | |- params.pp # class my_app::params is defined here
    | |- config/
    | | |- detail.pp # my_app::config::detail is defined here
    | | \- basics.pp # my_app::config::basics is defined here
    \- lib/
        |- facter/ # contains .rb files with custom facts
        \- puppet/
           |- functions # contains .rb files with Puppet 4 functions
           |- parser/
           | \- functions # contains .rb files with parser functions
           |- type/ # contains .rb files with custom types
           \- provider/ # contains .rb files with custom providers

模块中的文档

一个模块可以并且应该包含文档。Puppet 主节点不会自动处理任何模块文档。因此,模块作者在如何为其特定站点创建的模块结构化文档方面有很大的决定权。尽管如此,仍然存在一些常见的做法,遵循这些做法是一个好主意。此外,如果一个模块最终要在 Forge 上发布,适当的文档应当视为强制性的。

发布模块的过程超出了本书的范围。你可以在docs.puppetlabs.com/puppet/latest/reference/modules_publishing.html.找到相关指南。

对于许多模块,文档的主要焦点集中在 README 文件上,该文件位于模块的根目录中。它通常以 Markdown 格式书写,文件名为 README.mdREADME.markdownREADME 文件应包含解释说明,通常还包括参考文档。

Puppet DSL 接口也可以直接在清单中以 rdocYARD 格式进行文档化。这适用于类和定义的类型:

# Class: my_app::firewall
#
# @summary This class adds firewall rules to allow access to my_app.
#
# @example Declaring the class
# include my_app::firewall
#
# @param Parameters: none
class my_app::firewall {
  # class code here
}

你可以使用puppet strings子命令为所有模块生成 HTML 文档(包括导航)。安装 puppet-strings Ruby 扩展后,便可以使用此子命令:puppet resource package puppet-strings provider=puppet_gem。这个做法相对较为晦涩,因此这里不会详细讨论。然而,如果这个选项对你有吸引力,我们鼓励你阅读相关文档。

以下命令概述了可能的 puppet strings 功能:

puppet help strings 

管理环境

Puppet 并不是仅通过模块来组织内容的。还有一个更高层次的单元,叫做环境,它将模块进行分组和包含。一个环境主要由以下部分组成:

  • 一个或多个站点清单文件

  • 一个包含模块的modules目录

  • 一个可选的environment.conf配置文件

当管理员为节点编译清单时,它仅使用一个环境来执行此任务。如第二章《Puppet 服务器和代理》中所述,它始终从manifests/*.pp开始,这些文件形成环境的站点清单。在我们查看实际操作是如何进行之前,先来看看一个示例environment目录:

/opt/puppetlabs/code/environments/
    \- production/
         |- environment.conf
         |- manifests/
         | |- site.pp
         | \- nodes.pp
         \- modules/
             |- my_app/
             \- ntp/

environment.conf文件可以自定义环境。通常,Puppet 使用site.ppmanifests目录中的其他文件。若要让 Puppet 读取另一个目录中的所有pp文件,可以在environment.conf中设置manifest选项:

#/opt/puppetlabs/code/environments/production/environment.conf 
manifest = puppet_manifests

在大多数情况下,无需更改manifest选项。

site.pp文件将包含来自模块的节点分类。Puppet 会在活动环境的modules子目录中查找模块。你可以通过在environment.conf中设置modulepath选项,定义包含模块的额外子目录:

#/opt/puppetlabs/code/environments/production/environment.conf 
modulepath = modules:site-modules 

目录结构可以做得更加具有区分度:

/opt/puppetlabs/code/environments/
     \- production/
         |- manifests/
         |- modules/
         | \- ntp/
         \- site-modules/
             \- my_app/

配置环境位置

Puppet 默认使用production环境。这个环境以及其他环境预期位于/opt/puppetlabs/code/environments目录中。你可以通过在puppet.conf中设置environmentpath选项来覆盖此默认设置:

[main]
environmentpath = /etc/local/puppet/environments

获取和安装模块

下载现有模块是非常常见的。Puppet Labs 拥有一个专门的站点,用于共享和获取模块——Puppet Forge。它的工作方式与 RubyGems 或 CPAN 相同,并且使用户可以通过命令行界面轻松获取指定模块。在 Forge 中,模块的命名方式是通过将作者的名字前缀到实际模块名称上,例如puppetlabs-stdlibffrank-constraints

puppet module install命令会在活动环境中安装一个模块:

root@puppetmaster# puppet module install puppetlabs-stdlib

测试你的模块部分提供了关于如何使用不同环境的信息。

当前版本的stdlib模块(由用户puppetlabs编写)从 Forge 下载并安装到标准模块位置。这是当前环境的modulepath中的第一个位置,通常是modules子目录。具体来说,这些模块很可能会最终安装到environments/production/modules目录中。

特别是stdlib模块应被视为必需的;它为 Puppet 语言添加了大量有用的功能。比如keysvalueshas_key函数,这些对于正确处理哈希结构至关重要,举几个例子。这些函数一旦模块安装完成,就可以在你的清单文件中使用,无需包含任何类或其他显式加载。如果你编写自己的模块来添加函数,这些函数也会以相同的方式自动加载。

模块最佳实践

在所有当前版本的 Puppet 中,你应该养成将所有清单代码放入模块中的习惯,只有以下几个例外:

  • node

  • 应包含那些应该始终存在的类的include语句(最常见的设计模式是在所谓的基础角色中进行处理;不过,具体的角色和配置模式请参考第九章,Puppet 角色与配置文件

  • 声明有用的变量,这些变量应该在你的清单文件中具有与 Facter 事实相同的可用性

本节提供了如何相应地组织你的清单文件的详细信息。它还建议了一些设计实践和策略,用以测试模块的更改。

将所有内容放入模块中

你可能会在一些非常老的安装中找到一些清单文件,它们将大量的清单文件聚集在一个或多个目录中,并在site.pp文件中使用import语句,例如:

import '/etc/puppet/manifests/custom/*.pp' 

这些文件中的所有类和定义的类型都将全局可用。

这种方法存在可扩展性问题,早已被弃用。import关键字在 Puppet 4 及以后的版本中已不再支持。

给类和定义类型起有意义的名称会更加高效,这样 Puppet 可以在模块集合中查找它们。这个方案在前面已经讨论过了,现在我们来看一个示例,Puppet 编译器遇到一个类名时,比如:

include ntp::server::component::watchdog 

Puppet 将继续在当前环境的所有已配置模块位置中查找ntp模块(即modulepath设置中的路径)。然后,它会尝试读取ntp/manifests/server/component/watchdog.pp文件,以查找类定义。如果失败,它将尝试ntp/manifests/init.pp

这样会使得编译变得非常高效。Puppet 会动态识别所需的清单文件,并仅包括那些文件进行解析。它还帮助代码检查和开发,因为非常清楚在哪里可以找到特定的定义。

从技术上讲,确实可以将一个模块的所有清单放入其init.pp文件中,但这样做会失去结构化模块清单树所提供的优势。

避免泛化

每个模块理想情况下应当服务于一个特定的目的。在依赖 Puppet 来管理多样化服务器基础设施的网站上,可能会有针对每个服务的模块,比如apachesshnagiosnginx等等。这些模块中的大多数将来自上游开发,并被称为“技术组件模块”。也可以有网站特定的模块,例如usersshell_settings,如果操作需要这种细粒度的控制。这些自定义模块有时只是以拥有它们的团队或公司命名。

理想的粒度取决于你设置的具体需求。你通常希望避免使用诸如utilitieshelpers这样的模块名称,这些模块作为无法归入任何现有模块的想法的“集大成者”。这种缺乏组织的情况会对规范性产生不利影响,可能导致混乱的模块,包含本应成为各自独立模块的定义。

添加更多模块是很便宜的。一个模块通常不会对 Puppet 主控操作产生额外成本,而且随着模块数量的增加,用户体验通常会变得更加高效,而不是降低效率。当然,如果你的网站对每个模块有特殊的文档或其他处理要求,这种平衡可能会被打破。这些规则必须在模块组织决策时进行权衡。

测试你的模块

根据你的代理网络的规模,你的一些或许多个模块可以被各种各样的节点使用。尽管这些节点有很多共同之处,它们之间可能仍然存在显著差异。对一个中央模块的更改,例如sshntp,可能会对大量代理产生广泛的后果。

测试工作最重要的工具是 Puppet 的--noop选项。它适用于puppet agent以及puppet apply。如果在命令行中给出该选项,Puppet 将不会执行任何必要的同步操作,而仅仅将相应的输出行呈现给你。在第一章中有这个的示例,编写你的第一个清单

但是,当使用主控而不是本地使用puppet apply时,会出现一个新问题。所有代理都会查询主控。除非在你测试新清单时禁用所有代理,否则很可能会有代理检查并不小心运行未经测试的代码。

事实上,即使在你登录时,你的测试代理也可以在后台透明地触发其常规运行。

防范此类无法控制的清单应用非常重要。一个小错误可能会在短时间内损坏大量代理机器。最好的做法是,在主节点上定义多个环境并逐步进行代码更改。第九章,Puppet 角色与配置文件将进一步介绍相关内容。

使用环境进行安全测试

除了production环境之外,你应该至少创建一个测试环境。你可以称其为testing或其他任何你喜欢的名称。使用目录环境时,只需在environmentpath中创建其目录即可。

这样的附加环境在测试更改时非常有用。测试环境或多个环境应为生产数据的副本。首先在testing中准备所有清单更改。你可以让代理在将其复制到生产环境之前测试此更改:

root@agent# puppet agent --test --noop --environment testing

你甚至可以省略某些或所有代理上的noop标志,以便实际部署更改。某些在清单中出现的细微错误无法通过检查noop输出来检测出来,因此在发布之前,通常最好至少运行一次代码。

当环境与源代码控制结合使用时,它们的效果更为显著,特别是像gitmercurial这样的分布式系统。无论是否使用环境和测试,为你的 Puppet 代码进行版本控制都是一个好主意;这正是 Puppet 通过其基础设施即代码(Infrastructure as Code)理念为你提供的最大优势之一。

使用环境和noop模式形成了一个务实的测试方法,适用于大多数场景。当然,针对错误的 Puppet 行为的安全性是有限的。还有更正式的模块测试方式:

详细解释这些工具超出了本书的范围。

构建组件模块

本章已讨论了模块的许多理论和操作方面,但你尚未了解编写模块的过程。为此,本章的其余部分将一步一步带领你创建一个示例模块。

再次强调,大多数情况下,你会想从 Forge 上寻找通用模块。可用模块的数量不断增加,因此很有可能已经有某个模块可以帮助你完成所需的工作。

假设你想在你的网络中添加 Cacti:一个基于 RRD 工具的趋势监控和图形服务器,包括一个 web 界面。如果你首先检查 Forge,你确实会找到一些模块。但是,假设这些模块都没有符合你的需求,因为它们的功能集或实现方式不合适。如果连这些模块的接口都不符合要求,那么基于现有模块(如在 GitHub 上分叉)来开发自己的模块也没有多大意义。那你就需要从头开始编写自己的模块。

为你的模块命名

模块名称应该简洁且直观。如果你管理的是特定的软件,可以根据它来命名模块——apachejavamysql 等等。避免使用动词,如 install_cactimanage_cacti。如果模块名称需要由多个单词组成(因为目标子系统名称较长),它们应由下划线字符分隔。禁止使用空格、连字符和其他非字母数字字符。

在我们的示例中,模块应该被命名为 cacti

通常,你不会编写像 apache、mysql、java 这样的模块名称,因为这些名称已经被上游开发使用了。当学习 Puppet 时,想要从一个简单的模块实现开始,可能上游模块太复杂,难以理解。在这种情况下,你应该用公司或团队的名称作为前缀来命名模块。记住不要使用连字符,而应使用下划线分隔公司/团队名称,例如 packt_apacheinfra_mysql。这个命名模式可以保留原始的命名空间,并且在以后迁移到上游模块时更加容易。

使你的模块对 Puppet 可用

要使用你自己的模块,你不需要通过 puppet module 使其可安装。为此,你需要先将模块上传到 Forge,这将需要额外的工作。不过幸运的是,如果你只是将源代码放在主节点的适当位置,模块就能正常工作,而不需要进行所有这些准备工作。

要创建你自己的 cacti 模块,请创建基本目录:

root@puppetmaster# mkdir -p /opt/puppetlabs/code/environments/testing/packt_cacti/{manifests,files}  

一旦代理使用了它们,别忘了将所有的更改同步到 production

实现基础的模块功能

大多数模块通过其清单文件完成所有工作。

有一些显著的例外,比如 stdlib 模块。它主要增加了解析器函数和一些通用资源类型。

在规划模块的类时,最直接的思考方式是考虑你希望如何使用完成的模块。接口设计的方式有很多种。事实上的标准规定,管理的子系统通过在代理系统中包含模块的主类进行初始化;该类与模块同名,并且实现于模块的 init.pp 文件中。

对于我们的 Cacti 模块,用户应该使用以下内容:

include packt_cacti 

结果,Puppet 将采取所有必要的步骤来安装软件,并在需要时执行任何额外的初始化。

首先创建cacti类,并以命令行方式实现设置,将命令替换为适当的 Puppet 资源。在 Debian 系统中,安装cacti包就足够了。其他所需的软件通过依赖项引入(完成 LAMP 堆栈),并且在包安装后,接口通过服务器机器上的 Web URI/cacti/可用:

# .../modules/packt_cacti/manifests/init.pp 
class packt_cacti { 
  package { 'cacti': 
    ensure => installed, 
  } 
} 

你的模块现在可以进行测试了。通过在site.ppnodes.pptesting环境中从代理的清单中调用它:

node 'agent' { 
  include packt_cacti 
} 

直接在代理上应用它:

root@agent# puppet agent --test --environment testing  

这在 Debian 上可以工作,Cacti 可以通过http://<address>/cacti/访问。

一些站点使用外部节点分类器ENC),例如 Foreman。除了其他有用的功能外,它还可以集中分配环境到节点。在这种情况下,--environment开关将不起作用。

遗憾的是,当通过/ URI 请求主页时,Cacti 的 Web 界面不会显示。为了启用此功能,请为模块提供配置适当重定向的能力。在模块中的/opt/puppetlabs/code/environments/testing/packt_cacti/files/etc/apache2/conf.d/cacti-redirect.conf准备一个 Apache 配置片段:

# Do not edit this file - it is managed by Puppet! 
RedirectMatch permanent ^/$ /cacti/ 

警告通知很有帮助,特别是当多个管理员可以访问 Cacti 服务器时。

添加一个专门的类来将此文件同步到代理机器是有意义的:

# .../modules/packt_cacti/manifests/redirect.pp
class packt_cacti::redirect {
  file { '/etc/apache2/conf.d/cacti-redirect.conf':
    ensure => file,
    source => 'puppet:///modules/packt_cacti/etc/apache2/conf.d/cacti-redirect.conf',
    require => Package['cacti'];
  }
}

这样简短的文件也可以通过file类型的content属性而不是source来管理:

$puppet_warning = '# Do not edit - managed by Puppet!'
$line = 'RedirectMatch permanent ^/$ /cacti/'
file { '/etc/apache2/conf.d/cacti-redirect.conf':
  ensure  => file,
  content => "${puppet_warning}\n${line}\n",
}

这样做更高效,因为内容是目录的一部分,因此代理无需通过另一个请求从主服务器获取校验和。

该模块现在允许用户include packt_cacti::redirect以获取此功能。这本身并不是一个糟糕的接口,但这种修改实际上非常适合成为cacti类的一个参数:

class packt_cacti( 
  $redirect = true,) 
{ 
  if $redirect { 
    contain packt_cacti::redirect 
  } 
  package { 'cacti': 
    ensure => installed, 
  } 
} 

当清单使用include cacti时,重定向现在默认安装。

如果 Web 服务器有其他虚拟主机提供非 Cacti 的内容,这可能是不希望的。在这种情况下,清单将声明带有以下参数的类:

class { 'packt_cacti': 
  redirect => false,
} 

说到最佳实践,大多数模块也会将安装过程拆分为独立的类。在我们的例子中,这几乎没有帮助,因为安装状态通过一个单一资源得到保证,但我们还是这样做:

class packt_cacti( 
  $redirect = true, 
) { 
  contain packt_cacti::install 
  if $redirect { 
    contain packt_cacti::redirect 
  } 
} 

在这里使用contain是明智的,以便使 Cacti 管理成为一个独立的单元。cacti::install类被放入一个单独的install.pp清单文件中:

# .../modules/packt_cacti/manifests/install.pp 
class packt_cacti::install { 
  package { 'cacti': 
    ensure => 'installed', 
  } 
} 

在 Debian 上,cacti包的安装过程会将另一个 Apache 配置文件复制到/etc/apache2/conf.d。由于 Puppet 执行的是常规的apt安装,因此会达到这一结果。然而,Puppet 并不会确保配置保持在期望的状态。

配置可能会被破坏,这确实存在实际风险。如果给定节点使用puppetlabs-apache模块,它通常会清除/etc/apache2/目录下的任何未管理的配置文件。在为现有服务器启用此模块时要非常小心。在noop模式下进行测试。如果需要,修改清单以包含现有的配置。

明智的做法是向清单中添加一个file资源,用来保存安装后状态下的配置片段。通常在 Puppet 中,这需要你将配置文件内容复制到模块中,就像重定向配置文件一样位于主服务器上的某个文件中。然而,由于 Cacti 的 Debian 包包含了位于/usr/share/doc/cacti/cacti.apache.conf的配置片段,你可以指示代理与此同步实际配置。在另一个事实标准模块config类中执行此操作:

# .../modules/packt_cacti/manifests/config.pp 
class packt_cacti::config {  
  file { '/etc/apache2/conf.d/cacti.conf':  
    mode   => '0644',  
    source => '/usr/share/doc/cacti/cacti.apache.conf',  
  }  
}  

这个类应该也包含在packt_cacti类中。重新运行代理现在不会产生任何效果,因为配置已经到位。

为派生清单创建实用工具

现在,你已经创建了几个类,这些类将模块的基本安装和配置工作进行了模块化。类非常适合实现与管理软件整体相关的全局设置。

然而,仅仅安装 Cacti 并使其网页界面可用,毕竟并不是一个特别强大的功能,模块做的事情不过是用户通过包管理器安装 Cacti 时能够完成的工作。Cacti 的一个更大的痛点是,它通常需要通过网页界面进行配置;添加服务器以及为每个服务器选择和配置图表可能是一个繁琐的任务,并且根据你的图表需求的复杂程度,可能需要每个服务器数十次点击。

这就是 Puppet 最有帮助的地方。期望状态的文本表示允许通过正则表达式快速地进行复制粘贴和名称替换。更好的是,一旦有了 Puppet 接口,用户可以自己设计定义类型,从而避免重复的复制粘贴工作。

说到定义类型,它们是你模块所需的,用于支持这种配置方式。Cacti 配置中的每台机器应该是一个定义类型的实例。图表也可以有自己的类型。

与类的实现一样,你首先需要问自己的是,如何通过命令行来完成这项任务。

事实上,更好的问题可能是应该使用哪个 API,最好是从 Ruby 出发。然而,这只有在你打算编写 Puppet 插件(资源类型和提供者)时才重要。我们将在本章稍后讨论这个问题。

Cacti 附带了一组 CLI 脚本。Debian 包将这些脚本放在 /usr/share/cacti/cli 目录下。让我们在实现 Puppet 接口的过程中探索这些脚本。目标是定义类型,这些类型将有效地封装命令行工具,以便 Puppet 始终能够通过适当的查询和更新命令保持定义的配置状态。

添加配置项

在为 Cacti 模块设计更多功能时,首先是能够注册一个用于监控的机器——或者更确切地说,是一个 设备,正如 Cacti 自身所称(网络基础设施,如交换机和路由器也经常被监控,而不仅仅是计算机)。因此,第一个定义类型的名称应该是 cacti::device

来自 命名你的模块 小节的相同警告适用——除非你有非常充分的理由(如移除是不可能的),否则不要被诱惑给你的类型命名为 create_devicedefine_domain 等。即便如此,最好还是跳过动词。

用于注册设备的 CLI 脚本名为 add_device.php。它的帮助输出清楚地指示需要两个参数,即 descriptionip。自定义实体的描述通常是相应 Puppet 资源标题的良好用途。现在类型几乎已经可以自然而然地写出来了:

# .../modules/packt_cacti/manifests/device.pp 
define packt_cacti::device ( 
  $ip, 
) { 
  $cli = '/usr/share/cacti/cli' 
  $options = "--description='${title}' --ip='${ip}'" 
  exec { "add-cacti-device-${title}": 
    command => "${cli}/add_device.php ${options}", 
    require => Class['cacti'], 
} 

实际上,通常不需要使用这么多变量,但它有助于在页面有限的水平空间中提高可读性。

这个 exec 资源赋予 Puppet 使用 CLI 在 Cacti 配置中创建新设备的能力。由于 PHP 是 Cacti 包的要求之一,因此只需要让 exec 资源 require cacti 类即可。注意 $title 的使用,不仅用于 --description 参数,还用于 exec 资源的资源名称。这确保每个 packt_cacti::device 实例都声明一个唯一的 exec 资源来创建自身。

exec 资源类型允许以 root 权限和空的 shell 环境运行任意命令。这使得能够灵活地在 Puppet DSL 中封装配置命令。但 exec 资源类型也有其缺点:exec 资源类型本身并不是幂等的,并且存在所有操作都通过运行命令完成的风险,这与 Puppet 作为声明式配置管理系统的性质相悖。最好的做法是将 exec 资源类型视为应急出口:只有在没有其他方法可以实现目标时才使用它。

通常,自定义资源类型更为合适,特别是在运行复杂的命令时需要处理复杂的检查选项。自定义资源类型将在本章后面讲解。

然而,这仍然缺少一个重要方面。如前面的示例所写,这个exec资源会使 Puppet 代理在任何情况下都运行 CLI 脚本。虽然这是不正确的——它只应在设备尚未添加时才运行。

每个exec资源应当包含createsonlyifunless中的一个参数。它定义了一个查询,供 Puppet 用来判断当前的同步状态。除非设备已经存在,否则必须执行add_device调用。现有设备的查询必须通过add_graphs.php脚本(这可能不太直观)进行。当使用--list-hosts选项时,它会打印一行头信息和一个设备列表,设备描述位于第四列。以下unless查询将找到相关资源:

$search = "sed 1d | cut -f4- | grep -q '^${title}\$'" 
exec { "add-cacti-device-${title}": 
  command => "${cli}/add_device.php ${options}", 
  path    => '/bin:/usr/bin', 
  unless  => "${cli}/add_graphs.php --list-hosts |  
              ${search}", 
  require => Class[cacti], 
} 

path参数非常有用,因为它允许调用核心实用程序时无需提供完整路径。

一般设置标准的搜索路径列表是个好主意,因为某些工具在PATH环境变量为空时无法正常工作。

如果在设备列表中找到了完全相同的资源标题,unless命令将返回0。最后的$符号被转义,以便 Puppet 在$search命令字符串中按字面意义包括它。

你现在可以通过将以下资源添加到代理机器的清单中来测试你新的定义:

# in manifests/nodes.pp 
node 'agent' { 
  include packt_cacti 
  packt_cacti::device { 'Puppet test agent (Debian 7)':  
    ip => $::ipaddress, 
  }  
} 

在下一次执行puppet agent --test时,你会收到通知,表示添加设备的命令已经执行。再次执行,Puppet 会判断所有内容已经与目录同步。

允许自定义

add_device.php脚本有一系列可选参数,允许用户自定义设备。Puppet 模块应当暴露这些参数。我们来选择其中一个并在packt_cacti::device类型中实现。每个 Cacti 设备都有一个默认为tcpping_method。使用这个模块时,我们甚至可以覆盖软件的默认值:

define packt_cacti::device(
  $ip,
  $ping_method='icmp'
){
  $cli = '/usr/share/cacti/cli'
  $base_opt = "--description='${title}' --ip='${ip}'"
  $ping_opt = "--ping_method=${ping_method}"
  $options = "${base_opt} ${ping_opt}"
  $search = "sed 1d | cut -f4- | grep -q '^${title}\$'"
  exec { "add-cacti-device-${title}":
    command => "${cli}/add_device.php ${options}",
    path    => '/bin:/usr/bin',
    unless  => "${cli}/add_graphs.php --list-hosts | ${search}",
    require => Class[cacti],
  }
}

该模块默认使用icmp而不是tcp。无论是否将其传递给packt_cacti::device实例,值都会始终传递给 CLI 脚本。在后一种情况下,使用的是参数默认值。

如果你计划发布你的模块,尽可能使用与托管软件相同的默认值会更加明智。

一旦你加入了所有可用的 CLI 开关,你将成功创建一个 Puppet API,用于将设备添加到你的 Cacti 配置中,用户将因此受益于便捷的复现、共享、隐式文档、简单的版本控制等功能。

移除不需要的配置项

仍然有一个剩下的问题。对于 Puppet 类型来说,无法删除它们创建的实体是不典型的。当前,这实际上是你模块所依赖的 CLI 的技术限制,因为它尚未实现remove_device功能。此类脚本已经在互联网上发布,但在撰写本文时,它们尚未正式成为 Cacti 的一部分。

为了使模块更具功能性,合理的做法是将额外的 CLI 脚本加入到模块的文件中。将适当的文件放入modules/cacti/files/下的正确目录,并向cacti::install类中添加另一个file资源:

file { '/usr/share/cacti/cli/remove_device.php': 
  ensure  => file, 
  mode    => '0755', 
  source  => 
       'puppet:///modules/packt_cacti/usr/share/cacti/cli/
     remove_device.php', 
  require => Package['cacti'], 
} 

然后,你可以向cacti::device类型添加一个ensure属性:

define packt_cacti::device( 
  $ensure='present', 
  $ip, 
  $ping_method='icmp', 
{ 
  $cli = '/usr/share/cacti/cli' 
  $search = "sed 1d | cut -f4- | grep -q '^${title}\$'" 
  case $ensure { 
  'present': { 
    # existing cacti::device code goes here 
  } 
  'absent': { 
    $remove = "${cli}/remove_device.php" 
    $get_id = "${remove} --list-devices | awk -F'\\t' 
       '\$4==\"${title}\" { print \$1 }'" 
    exec { "remove-cacti-device-${name}": 
        command => "${remove} --device-id=\$( ${get_id} 
      )", 
        path    => '/bin:/usr/bin', 
        onlyif  => "${cli}/add_graphs.php --list-hosts | 
           ${search}", 
        require => Class[cacti], 
      } 
    } 
  } 
} 

请注意,我们在这里对缩进做了一些处理,以避免断行过多。这个新的exec资源很复杂,因为remove_device.php脚本需要删除设备的数字 ID。这是通过执行--list-devices调用并将其传递给awk来获取的。为了进一步影响可读性,像双引号、$符号和反斜杠等内容必须进行转义,以便 Puppet 在清单中包含有效的awk脚本。

还要注意,查询这个exec资源的同步状态与add资源的查询是相同的,唯一的区别是现在它与onlyif参数一起使用:仅在配置中仍然找到相关设备时才执行操作。

处理复杂性

我们为packt_cacti::device定义实现的命令相当复杂。在这种复杂度的层面,Shell 单行命令变得不再适用于驱动 Puppet 的资源。当处理 Cacti 图表时情况变得更糟;add_graphs.php CLI 脚本不仅需要设备的数字 ID,还需要图表的数字 ID。在这种情况下,将复杂性移出清单并为实际的 CLI 编写包装脚本是合理的。我将简要描述这个实现。包装脚本将遵循这个大致模式。

#!/bin/bash 
DEVICE_DESCR=$1 
GRAPH_DESCR=$2 
DEVICE_ID=` #scriptlet to retrieve numeric device ID` 
GRAPH_ID=`  #scriptlet to retrieve numeric graph ID` 
GRAPH_TYPE=`#scriptlet to determine the graph type` 
/usr/share/cacti/cli/add_graphs.php \ 
  --graph-type=$GRAPH_TYPE \ 
  --graph-template-id=$GRAPH_ID \ 
  --host-id=$DEVICE_ID

使用这个,你可以添加一个简单的graph类型:

define packt_cacti::graph( 
  $device, 
  $graph=$title 
) { 
  $add = '/usr/local/bin/cacti-add-graph' 
  $find = '/usr/local/bin/cacti-find-graph' 
  exec { "add-graph-${title}-to-${device}": 
    command => "${add} '${device}' '${graph}'", 
    path    => '/bin:/usr/bin', 
    unless  => "${find} '${device}' '${graph}'", 
  } 
} 

这还需要一个额外的cacti-find-graph脚本。添加这个脚本带来了额外的挑战,因为当前的 CLI 没有列出已配置图表的功能。可以添加到cacti模块的功能还包括管理 Cacti 的数据源、改变设备选项,甚至可能是更改配置中已存在的其他对象的选项。

这样的商品超出了基本内容的范畴,暂时不做详细说明。让我们看看cacti模块的其他部分作为示例。

通过插件增强代理

可重用的类和定义使得使用你模块的清单更加具有表现力。现在安装和配置 Cacti 变得简洁,而执行这一操作的清单变得非常易读和可维护。

现在是时候探索模块的另一个更强大的方面:Puppet 插件了。插件的不同类型包括自定义事实(在第三章中讨论过,Puppet Ruby 部分概览 - 事实、类型和提供者)、解析器函数、资源类型和提供者。所有这些插件都存储在主机上的模块中,并会同步到所有代理上。代理不会使用解析器函数(虽然在同步后,puppet apply 的用户可以在代理机器上使用它们);相反,事实和资源类型在代理上执行大部分工作。现在让我们集中讨论类型和提供者;其他插件将在后续的专门章节中讨论。

本节可以视为可选内容。许多用户永远不会接触任何资源类型或提供者的代码,因为清单本身已经提供了所需的所有灵活性。如果你不关心插件,可以跳过本节,直接阅读关于如何查找 Forge 模块的最后部分。另一方面,如果你对 Ruby 技能有信心,并希望在 Puppet 安装中利用它们,继续阅读,了解自定义类型和提供者如何为你提供帮助。

虽然自定义资源类型在主机和代理上都可以使用,但提供者将主要在代理端完成所有工作。虽然资源类型也主要通过代理执行,但它们对主机有一个影响;它们使清单能够声明该类型的资源。代码不仅描述了哪些属性和参数存在,还可以包含对各自值的验证和转换代码。该部分由代理调用。有些资源类型甚至会自行处理同步和查询,尽管通常至少会有一个提供者来负责这些操作。

在上一节中,你通过封装一些 exec 资源实现了一个定义类型,并完成了所有的同步工作。通过 Puppet 安装二进制文件和脚本,你可以通过这种方式实现几乎任何功能,且无需编写任何插件。然而,这种做法也有一些缺点:

  • 理想情况下,输出是晦涩的,而在出现错误时则会让人不知所措。

  • Puppet 每个资源至少会调用一个外部进程;在许多情况下,需要多个子进程。

简而言之,你需要付出代价,无论是可用性还是性能方面。考虑 packt_cacti::device 类型。对于每个声明的资源,Puppet 在每次运行时都会运行 exec 资源的 unless 查询(或者在指定 ensure => absent 时,运行 onlyif)。这包括一次调用 PHP 脚本(可能会比较昂贵),以及几个必须解析输出的核心工具。在拥有数十或数百个受管设备的 Cacti 服务器上,这些调用会累积起来,使代理花费大量时间来生成和等待这些子进程。

另一方面,考虑一个提供者。它可以实现一个instances钩子,这将在初始化时创建一个已配置的 Cacti 设备的内部列表。总共只需要一个 PHP 调用,所有输出的处理可以直接在代理进程中的 Ruby 代码内完成。仅这些节省的操作就能使每次运行成本大大降低:已经同步的资源不会受到任何惩罚,因为不需要运行额外的外部命令。

在我们继续实现一个简单的类型/提供者配对之前,先快速看一下代理输出。以下是cacti::device类型创建设备时的输出:

Notice: /Stage[main]/Main/Node[agent]/Packt_cacti::Device[Agent_VM_Debian_7]/Exec[add-cacti-device-Agent_VM_Debian_7]/returns: executed successfully  

本地类型以更简洁的方式表达这些操作,例如来自file资源的输出:

Notice: /Stage[main]/Main/File[/usr/local/bin/cacti-search-graph]/ensure: created  

使用本地类型替换定义类型

创建一个与之匹配的提供者的自定义资源类型的过程

(或者多个提供者)并不容易。让我们一步步了解其中的步骤:

  • 命名你的类型

  • 创建资源类型的接口

  • 设计合理的参数钩子

  • 使用资源名称

  • 添加提供者

  • 声明管理命令

  • 实现基本功能

  • 允许提供者预取现有资源

  • 在配置过程中使类型更强健

命名你的类型

本地类型和定义类型之间的第一个重要区别是命名。自定义类型没有像定义类型那样的模块命名空间,定义类型是基于清单的。本地类型来自所有已安装的模块,可以自由地混合使用。它们使用普通名称。因此,简单地将packt_cacti::device的本地实现命名为device是不明智的——这很容易与其他模块可能有的设备概念发生冲突。命名你第一个资源类型的明显选择是cacti_device

该类型必须完全实现于packt_cacti/lib/puppet/type/cacti_device.rb。所有钩子和调用都将封装在Type.newtype块中:

Puppet::Type.newtype(:cacti_device) do 
  @doc = <<-EOD 
    Manages Cacti devices. 
    EOD 
end 

@doc中的文档字符串应视为强制性的,并且它应该比这个例子更有实质性。考虑包含一个或多个示例资源声明。将所有后续的代码片段放在EOD终止符和最终的end之间。

创建资源类型的接口

首先,类型应该具有ensure属性。Puppet 的资源类型有一个方便的助手方法,通过简单调用生成所有必要的类型代码:

ensurable 

在类型的主体中通过这个方法调用,你可以添加典型的ensure属性,包括所有必要的钩子。这个行在类型代码中是唯一需要的(实际实现将在提供者中跟进)。大多数属性和参数需要更多的代码,就像ip参数一样:

require 'ipaddr' 
newparam(:ip) do 
  desc "The IP address of the device." 
  isrequired 
  validate do |value| 
    begin 
      IPAddr.new(value) 
    rescue ArgumentError 
      fail "'#{value}' is not a valid IP address" 
    end 
  end 
  munge do |value| 
    value.downcase 
  end 
end

通常这应该是一个ip属性,但提供者将依赖于 Cacti CLI,而 Cacti CLI 无法更改已配置的设备。如果 IP 地址是一个属性,那么为了执行属性值同步,需要进行此类更改。

如你所见,IP 地址参数的代码主要由验证组成。

在文件顶部附近添加require 'ipaddr'行,而不是放在Type.newtype块内。

该参数现在可用于cacti_device资源,代理甚至会拒绝添加 IP 地址无效的设备。这对用户很有帮助,因为明显的地址错误将被早期检测到。在我们更仔细地查看munge钩子之前,先实现下一个参数。

设计合理的参数钩子

接下来是ping_method参数,它只接受来自有限集合的值,因此验证很容易:

newparam(:ping_method) do 
  desc "How the device's reachability is determined. 
    One of `tcp` (default), `udp` or `icmp`." 
  validate do |value| 
    [ :tcp, :udp, :icmp ].include?(value.downcase.to_sym) 
  end 
  munge do |value| 
    value.downcase.to_sym 
  end 
  defaultto :tcp 
end 

仔细查看munge块,你会注意到它们旨在统一输入值。对于参数来说,这比属性要不那么关键,但如果将来某个 Cacti 模块版本中这些参数被改为属性,它将不会尝试将ping_methodtcp同步为TCP。如果用户在清单中更喜欢使用大写字母,后者可能会出现。通过 munging,两个值都会变成:tcp。对于 IP 地址,调用downcase仅对 IPv6 有影响。

超出 Puppet 本身的范围,参数值的 munging 也很重要。它允许 Puppet 接受比被管理的子系统更方便的值。例如,Cacti 可能不接受TCP作为值,但 Puppet 会接受,并且会正确处理它。

使用资源名称

你需要处理最后一个要求:每个 Puppet 资源类型必须声明一个name 变量或简称namevar。如果资源本身未指定该参数,该参数将使用清单中的资源标题作为其值。例如,exec类型具有command参数作为其namevar。你可以将可执行命令放入资源标题中,或显式声明该参数:

exec { '/bin/true': } 
# same effect: 
exec { 'some custom name': command => '/bin/true' } 

若要将现有参数标记为名称变量,请在该参数的主体中调用isnamevar方法。如果类型有一个名为:name的参数,它会自动成为名称变量。这是一个安全的默认值。

newparam(:name) do 
  desc "The name of the device." 
  #isnamevar # → commented because automatically assumed 
end 

资源类型现在已经可以在清单中使用:

cacti_device { 'eth0': 
  ensure      => present, 
  ip          => $::ipaddress, 
  ping_method => 'icmp', 
} 

这段代码将被编译到一个目录中,但代理会抛出错误,因为没有提供者可用。

添加一个提供者

资源类型本身已经准备好执行操作,但缺少执行系统检查和执行同步的提供者。让我们一步步构建它,就像构建资源类型一样。提供者的名称不需要反映它所针对的资源类型,而应包含对其实现的管理方法的引用。由于你的提供者将依赖于 Cacti CLI,命名为cli即可。如果多个提供者为不同类型提供功能,共享相同的名称也是可以的。

packt_cacti/lib/puppet/provider/cacti_device/cli.rb中创建框架结构:

Puppet::Type.type(:cacti_device).provide( 
  :cli, 
  :parent => Puppet::Provider 
  ) do 
end 

实际上,指定:parent => Puppet::Provider并不是必要的。Puppet::Provider是提供者的默认基类。如果你为一个子系统编写了几个类似的提供者(每个提供者针对不同的资源类型),而这些提供者都依赖相同的工具链,你可能希望实现一个基类提供者,作为所有兄弟提供者的父类。

现在,让我们集中精力构建一个自给自足的cli提供者,针对cacti_device类型。首先,声明你将需要的命令。

声明管理命令

提供者使用commands方法来方便地绑定可执行文件

Ruby 标识符:

commands :php => ‘php’
commands :add_device => ‘/usr/share/cacti/cli/add_device.php’
commands :add_graphs => ‘/usr/share/cacti/cli/add_graphs.php’
commands :rm_device => ‘/usr/share/cacti/cli/remove_device.php’

你不会直接调用php。它在这里被包含是因为声明命令有两个作用:

  • 你可以通过生成的方法方便地调用命令

  • 只有在找到所有命令后,提供者才会标记自己为valid

因此,如果在 Puppet 的搜索路径中找不到php CLI 命令,Puppet 会认为该提供者是无效的。用户可以通过 Puppet 的调试输出快速判断这一错误情况。

实现基本功能

提供者的基本功能现在可以通过三个实例方法来实现。这些方法的名称本身并没有什么特别的含义,但这些是默认ensure属性期望可用的方法(记住你在类型代码中使用了ensurable快捷方式)。

第一个方法是创建一个资源(如果它尚不存在)。它必须收集所有资源参数的值,并构建一个合适的add_device.php调用:

def create 
  args = [] 
  args << "--description=#{resource[:name]}" 
  args << "--ip=#{resource[:ip]}" 
  args << "--ping_method=#{resource[:ping_method]}" 
  add_device(*args) 
end 

不要像在命令行中那样引用参数值。Puppet 会为你处理这个问题。它还会转义参数中的任何引号,因此在这种情况下,Cacti 会接收到这些引号并将其包含在配置中。例如,这将导致标题不正确。

args << "--description='#{resource[:name]}'" 

提供者还必须能够删除或销毁实体:

def destroy 
  rm_device("--device-id=#{@property_hash[:id]}") 
end 

property_hash 变量是提供者的实例成员。每个资源都有其特定的提供者实例。继续阅读,了解它如何被初始化以包含设备的 ID 号。

在我们进入这部分之前,让我们先添加最后一个提供者方法,以实现ensure属性。这是一个查询方法,代理会用来判断资源是否已经存在:

def exists? 
  self.class.instances.find do |provider| 
    provider.name == resource[:name] 
  end 
end 

ensure属性依赖于提供者类方法instances,以获取系统上所有实体的providers列表。它将每个实体与resource属性进行比较,resource属性是当前提供者实例正在执行操作的资源类型实例。如果这让你感到困惑,请参考下一节的图示。

允许提供者预先获取现有资源

instances方法非常特殊——它在提供者初始化期间实现了系统资源的预取。你必须自己将其添加到提供者中。一些子系统不适合大规模获取所有现有资源(例如file类型)。这些提供者没有instances方法。而枚举 Cacti 设备则是完全可行的:

def self.instances
  return @instances ||= add_graphs(“--list-hosts”).
    split(“\n”).
    drop(1).
    collect do |line|
      fields = line.split(/\t/, 4)
      Puppet.debug “prefetching cacti_device #{fields[3]} 
      “ +
                   “with ID #{fields[0]}”
      new(:ensure => :present,
            :name => fields[3],
              :id => fields[0])
    end
end

提供者实例的ensure值反映了当前状态。该方法为系统中找到的资源创建实例,因此对于这些资源,值始终是present。还要注意,方法的结果会缓存在@instances类成员变量中。这一点非常重要,因为exists?方法会调用instances,这可能会频繁发生。

Puppet 需要另一种方法来执行正确的预取操作。你通过instances实现的大规模获取,向代理提供了一份代表系统上实体的提供者实例列表。代理从主节点接收资源类型实例的列表。然而,Puppet 还没有在资源(类型实例)和提供者之间建立关系。你需要在提供者类中添加一个prefetch方法来实现这一点:

def self.prefetch(resources)
  instances.each do |provider|
    if res = resources[provider.name]
      res.provider = provider
    end
  end
end 

代理将cacti_device资源作为哈希传递,资源标题作为相应的键。这使得查找非常简单(且快速)。

这完成了cli提供者对cacti_device类型的支持。你现在可以用cacti_device实例替换你的cacti::device资源,以享受更好的性能和更清晰的代理输出:

node "agent" {
  include cacti
  cacti_device { ‘Puppet test agent (Debian 7)":
    ensure => present,
    ip     => $::ipaddress,
  }
}

请注意,与您定义的类型cacti::device不同,本地类型不会为其ensure属性假设默认值present。因此,您必须为任何cacti_device资源指定此值。否则,Puppet 只会管理已存在资源的属性,而不关心实体是否存在。在cacti_device的特殊情况下,这样做永远不会有任何作用,因为没有其他属性(仅有参数)。

你可以参考第六章,Puppet 初学者进阶部分,了解如何使用资源默认值来避免重复的ensure => present规范。

在配置过程中增强类型的健壮性

packt_cacti模块还有一个小问题。它是自给自足的,并处理 Cacti 的安装和配置。然而,这意味着在 Puppet 的第一次运行中,cacti包及其 CLI 将不可用,代理会正确判断cli提供者尚不适用。由于它是cacti_device类型的唯一提供者,在cacti包之前同步的任何此类型的资源都会失败。

packt_cacti::device定义类型的例子中,你只是将require元参数添加到了内部资源。为了在原生类型实例中实现相同的效果,你可以使用autorequire特性。就像文件自动依赖于它们所在的目录一样,Cacti 资源应该依赖于cacti包的成功同步。将以下代码块添加到cacti_device类型中:

autorequire :package do 
  catalog.resource(:package, 'cacti') 
end 

通过事实增强 Puppet 的系统知识

当在第三章中介绍事实时,《Puppet 中 Ruby 部分概述 - 事实、类型和提供者》,你对创建自定义事实的过程进行了简要了解。

我们在那时提到过模块,现在,我们可以更详细地了解事实代码如何部署,以下是Cacti模块的例子。我们将重点关注原生 Ruby 事实——它们比外部事实更具可移植性。由于后者容易创建,这里无需深入讨论它们。

关于外部事实的详细信息,你可以参考 Puppet Labs 网站上的自定义事实在线文档:docs.puppetlabs.com/facter/latest/custom_facts.html#external-facts

事实是 Puppet 插件的一部分,模块可以包含它们,就像前面章节中的类型和提供者一样。它们属于lib/facter/子树。对于cacti模块的用户,了解给定 Cacti 服务器上有哪些图表模板可能会很有帮助(当然前提是图表管理功能已经实现)。完整的列表可以通过一个事实传递。以下代码位于packt_cacti/lib/facter/cacti_graph_templates.rb,将执行此操作:

Facter.add(:cacti_graph_templates) do
  setcode do
    cmd = ‘/usr/share/cacti/cli/add_graphs.php’
    Facter::Core::Execution.exec(“#{cmd} --list-graph-
    templates”).
      split(“\n”).
      drop(1).
      collect do |line|
        line.split(/\t/)[1]
      end
  end
end

该代码将调用 CLI 脚本,跳过其输出的第一行,并将每一行其余部分的第二列的值合并成一个列表。清单可以通过全局变量$cacti_graph_templates访问此列表,正如访问任何其他事实一样。

通过自定义函数优化模块的接口

函数在保持你的清单干净且易于维护方面非常有帮助,有些任务甚至无法在没有 Ruby 函数的情况下实现。

自定义函数的一个常见用法(特别是在 Puppet 3 中)是输入验证。您可以在清单本身中执行此操作,但由于语言的限制,这可能会让人感到沮丧。生成的 Puppet DSL 代码可能很难阅读和维护。stdlib 模块提供了许多基本数据类型的 validate_X 函数,例如 validate_bool。Puppet 4 及更高版本中的类型化参数使这一过程更加方便和自然,因为对于支持的变量类型,已经不再需要验证函数。

与所有插件一样,这些函数不需要特定于模块的领域,它们会立即对所有清单可用。举个例子,packt_cacti 模块可以使用 packt_cacti::device 参数的验证函数。检查一个字符串是否包含有效的 IP 地址与 Cacti 完全没有关系。另一方面,检查 ping_method 是否是 Cacti 识别的那些方法,则不那么通用。

为了查看它是如何工作的,让我们实现一个函数,完成 packt_cacti::device 中 IP 地址参数的 validatemunge 钩子的功能。如果地址无效,编译应该失败;否则,它应该返回统一的地址值:

module Puppet::Parser::Functions
  require ‘ipaddr’
  newfunction(:cacti_canonical_ip, :type => :rvalue) do |args|
    ip = args[0]
    begin
      IPAddr.new(ip)
    rescue ArgumentError
      raise “#{@resource.ref}: invalid IP address ‘#{ip}’”
    end
    ip.downcase
  end
end

在异常消息中,@resource.ref 会扩展为出现问题的资源类型实例的文本引用,例如 Packt_cacti::Device[Edge Switch 03]

以下示例说明了在没有 ensure 参数的简单版本 cacti::device 中使用该函数:

define packt_cacti::device($ip) {
  $cli = ‘/usr/share/cacti/cli’
  $c_ip = cacti_canonical_ip(${ip})
  $options = “--description=‘${name}’ --ip=‘${c_ip}’”
  exec { “add-cacti-device-${name}”:
    command => “${cli}/add_device.php ${options}”,
    require => Class[cacti],
  }
}

如果 IP 地址(巧妙地)存在,清单将无法编译:

转置数字:

ip => '912.168.12.13' 

IPv6 地址将被转换为全小写字母。

Puppet 4 引入了更强大的 API 用于定义自定义函数。请参考 第六章,Puppet 初学者高级部分,了解其优势。

使您的模块能够在多个平台上移植

不幸的是,我们的 Cacti 模块非常依赖 Debian 包。它期望在特定位置找到 CLI,在另一个位置找到 Apache 配置片段。这些位置很可能是特定于 Debian 包的。如果模块能够在 Red Hat 衍生系统上也能正常工作,那就更好了。

第一步是通过执行手动安装来了解差异。我选择使用运行 Fedora 18 的虚拟机进行测试。基本的安装与 Debian 相同,当然,使用的是 yum 而不是 apt-get。Puppet 会自动在这里执行正确的操作。puppet::install 类也包含一个 CLI 文件。Red Hat 包将 CLI 安装在 /var/lib/cacti/cli,而不是 /usr/share/cacti/cli

如果模块应该支持两个平台,那么remove_device.php脚本的目标位置不再固定。因此,最好从模块的中心位置部署脚本,而在代理系统上的目标位置则成为一个模块参数,如果您愿意的话。这些值通常会在params类中收集:

# …/packt_cacti/manifests/params.pp
class packt_cacti::params {
  case $osfamily {
    ‘Debian’: {
      $cli_path = ‘/usr/share/cacti/cli’
    }
    ‘RedHat’: {
      $cli_path = ‘/var/lib/cacti/cli’
    }
    default: {
      fail “the cacti module does not yet support the 
      ${osfamily} 
        platform”
    }
  }
}

最好是针对不支持的代理平台失败编译。用户必须从其模块中删除cacti类的声明,而不是让 Puppet 尝试未经测试的安装步骤,这很可能不起作用(这可能涉及 Gentoo 或 BSD 变体)。

需要访问变量值的类必须包含params类:

class packt_cacti::install {
  include pack_cacti::params
  file { ‘remove_device.php’:
    ensure => file,
    path   => 
     “${packt_cacti::params::cli_path}/remove_device.php’,
    source => 
    ‘puppet:///modules/packt_cacti/cli/remove_device.php’,
    mode   => ‘0755’,
  }
}

对于cacti::redirect类和cacti::config类,可能需要类似的转换。只需向params类添加更多变量。这不仅限于清单;事实和提供者也必须按照代理平台的行为。

您经常会看到params类是继承而不是包含:

class packt_cacti(
  $redirect = ${packt_cacti::params::redirect}
)inherits packt_cacti::params{
  # ...
}

这是因为类主体中的include语句不允许使用params类中的变量值作为类参数的默认值,比如此示例中的$redirect参数。

您自己的自定义模块通常不需要可移植性实践。在理想情况下,您不会在多个平台上使用它们。但如果打算在 Forge 上共享它们,则应该考虑这种实践是强制性的。对于大多数 Puppet 需求,您不希望编写模块,而是从 Forge 下载现有的解决方案。

在 Puppet 4.9 及更高版本中,params 类模式将不再需要用于传递默认参数值。取而代之的是一种新的数据绑定机制。该机制在《第八章》中有详细解释,使用 Hiera 分离代码与数据

查找有用的 Forge 模块。

使用forge.puppetlabs.com的 Web 界面非常直观。通过填写软件、系统或服务的名称来搜索,通常会得到一列非常合适的模块,而它们的名称往往就是你搜索的关键词。事实上,对于常见术语,可用模块的数量可能会让人眼花缭乱。

您可以立即了解每个模块的成熟度和流行度。如果模块正在积极使用和维护:

  • 它的分数接近 5。

  • 它有一个版本号,表示超过 1.0.0(甚至 0.1.0)的发布。

  • 最近的版本发布并不久,也许不到半年的时间。

  • 它有大量的下载量。

然而,后面三个数字可能会有很大差异,这取决于模块实现的功能数量以及其主题的普及程度。更重要的是,仅仅因为某个模块获得了大量关注和定期贡献,并不意味着它是最适合你情况的选择。

你被鼓励评估那些访问量较少的模块——这样你可能会发现一些隐藏的宝藏。下一节详细介绍了一些质量的深层指标,供你参考。

如果你不能,或者不想花太多时间去挖掘最合适的模块,也可以参考 Puppet 支持和 Puppet 批准的模块侧边栏。所有在这些类别中展示的模块都得到了 Puppet Labs 的质量认证。

识别模块特性

在 Forge 中导航到模块的详细信息时,会显示其README文件。一个空的或非常简略的文档表明模块作者并未花费多少心思。README文件中的示例清单通常是快速启动模块的一个好起点。

如果你正在寻找能够通过额外的资源类型和提供者增强代理的模块,可以查看模块详细信息页面上的类型标签。点击模块描述顶部附近的项目 URL 链接也是一个有启发性的做法。这个链接通常指向 GitHub。在这里,你不仅可以方便地浏览lib/子树中的插件,还可以大致了解模块清单的结构。

另一个精心维护模块的标志是单元测试。这些测试可以在spec/子树中找到。大多数 Forge 模块都有这个子树。不过,这个子树通常没有实际的测试。可能会有所有类和定义类型的测试代码文件,这些文件通常位于spec/classes/spec/defines/子目录中。对于插件,理想情况下会在spec/unit/spec/functions/中有单元测试。

一些模块的README文件中包含一个绿色的标签,写着构建通过。这个标签有时会变红,显示构建失败。这些模块通过 GitHub 使用 Travis CI,因此它们很可能至少有一些单元测试。

摘要

所有的 Puppet 开发都应该在模块中进行,每个模块应尽可能服务于一个特定的目的。大多数模块只包含清单文件。这足以提供非常有效且易读的节点清单,通过包含恰当命名的类和实例化定义的类型,清晰简洁地表达其意图。

模块还可以包含 Puppet 插件,形式包括资源类型和提供者、解析函数或事实。这些通常都是 Ruby 代码。然而,外部事实可以用任何语言编写。虽然编写自己的类型和提供者不是必须的,但它可以提高你的性能和管理灵活性。

并不需要自己编写所有模块。相反,尽可能依赖于来自 Puppet Forge 的开源模块是一个明智的选择。Puppet Forge 是一个不断增长的有用代码集合,几乎涵盖了 Puppet 能够管理的所有系统和软件。特别是,由 Puppet Labs 策划的模块通常质量非常高。像所有开源软件一样,您非常欢迎自行添加任何缺失的需求到这些模块中。

在对 Puppet 的整体构建模块有了一个广泛的了解后,第六章,Puppet 初学者进阶部分,将进一步缩小范围。现在,您已经具备了构建和组织清单代码库的工具,接下来您将学习一些精细的技巧,以优雅地解决一些 Puppet 中独特的问题。

第六章:Puppet 初学者的进阶部分

在我们深入讨论了清单结构元素(class 和 define)以及整体结构(modules)之后,你已经处于一个很好的位置,可以为所有代理编写清单。确保你获得了可以帮助你工作的 Forge 模块。然后,继续添加根据你需求实现 Forge 模块的特定站点模块。最后,你将拥有用于 node 块的组合类,可以被使用或包含。

这些概念有些复杂。现在是时候稍微放慢一下节奏,靠后坐,处理一些更简单的代码结构和思路了。你即将学习一些不一定每天都用得上的技术,尽管如此,它们能让复杂的场景变得更简单。因此,在你实际工作一段时间后,再回过头来看这一章,可能会有帮助。你可能会发现,一些设计可以用这些工具简化。

具体来说,以下是将要介绍的技术:

  • 构建动态配置文件

  • 管理文件片段

  • 使用虚拟资源

  • 跨节点配置与导出资源

  • 为资源参数设置默认值

  • 避免反模式

构建动态配置文件

在介绍部分,我提到过,你现在正在学习的技术并不常常需要。这是对的,除了一个话题。模板实际上是 Puppet 配置管理的基石。

模板是管理配置文件或任何文件的另一种方式。你可以将主节点的文件同步到处理某些 Apache 配置设置的代理节点。严格来说,这些文件不是模板,它们只是已经准备好的静态文件,准备好进行复制粘贴。

这些静态文件在很多情况下已经足够了,但有时候你会希望主节点为每个代理管理非常具体的配置值。这些值可能非常个性化。例如,Apache 服务器通常需要一个 MaxClients 设置。适当的值取决于许多方面,包括硬件规格和运行的 Web 应用的特性。如果在模块中准备所有可能的选项作为独立文件是不可行的。

学习模板语法

模板可以轻松处理这种场景。在 Puppet 4 中,EPP(嵌入式 Puppet)模板被引入。旧版的 ERB(嵌入式 Ruby)模板仍然可用且功能完备。如果你熟悉 PHP 或 JSP,你很快就能掌握 EPP 或 ERB Puppet 模板。以下 EPP 模板将输出 Hello,world! 三次:

<% [1,2,3].each  |$p| { %> 
Hello, world! 
<% } %> 

以下 ERB 模板完成了相同的功能:

<% (1 .. 3).each  do %> 
Hello, world! 
<% end %> 

这个模板也会产生很多空行,因为 <%%> 标签之间的文本会从输出中移除,但最终的换行符不会被移除。为了让 EPP 引擎做到这一点,只需要将结束标签改为 -%>

<% [1,2,3].each |$p| { -%> 
Hello, world! 
<% } -%> 

这个示例当然对于配置文件不是很有帮助。要将动态值包含到输出中,请将 Ruby 表达式括在<%=tag对中:

<% [1,2,3].each  |$index|  { -%> 
Hello, world #<%= $index %> ! 
<% } -%> 

现在,迭代器的值是输出每一行的一部分。你还可以使用以$符号为前缀的成员变量,并使用变量的完整命名空间。

这些变量被填充了来自 Puppet 清单变量的值:

<IfModule mpm_worker_module> 
ServerLimit         <%= $apache::apache_server_limit %> 
StartServers        <%= $apache::apache_start_servers %> 
MaxClients          <%= $apache::apache_max_clients %> 
</IfModule> 
<% $apache::apache_ports.each  |$port| { -%> 
Listen <%= $port %> 
NameVirtualHost *:<%= $port %> 
<% } -%> 

在模板中使用的变量必须在使用模板的相同作用域或作用域中定义。下一部分将解释这是如何实现的。

在 Puppet 3.x 中,变量值大多是字符串、数组或哈希。为了编写高效的模板,偶尔浏览相应 Ruby 类的方法是很有帮助的。在 Puppet 4 中,变量的值更加多样化。

有几种方式可以在 ERB 模板中使用 Puppet 变量:

  • 通过@符号前缀变量:这意味着该变量是全局的,或者它是在使用模板的同一类中定义的。这在 Puppet 2.7、Puppet 3 和 Puppet 4 中都适用。

  • 使用scope.lookupvar('variablewithscopename')函数:这允许你引用模块中任何类中的任何变量。请不要在其他模块中查找变量;这样会在其他模块上建立一个隐性依赖关系。该语法适用于 Puppet 2、Puppet 3 和 Puppet 4。

  • 使用scope['variablewithscope']:在 Puppet 3 中,可以直接使用作用域哈希。其行为类似于scope.lookupvar。这在 Puppet 3 和 Puppet 4 中都适用。

在实践中使用模板

模板在模块中有其自己的位置。你可以自由地将它们放置在模块的templates/子树中。epp函数通过一个简单的描述符来定位它们:epp('cacti/apache/cacti.conf.epp')

该表达式计算位于modules/cacti/templates/apache/cacti.conf.epp中的模板内容。第一个路径元素(没有前导斜杠)是模块名称。其余的路径将被转换为模块中的templates/树。此函数通常用于生成file资源的content属性值:

file { '/etc/apache2/conf.d/cacti.conf':  
  content => epp('cacti/apache/cacti.conf.epp'),  
}  

许多模板期望在它们的作用域中定义某些变量。确保这一点的最简单方法是将相应的file资源包装在一个参数化的容器中。那些具有已知名称的单例文件,如/etc/ssh/sshd_config,应该通过一个参数化类进行管理。可以包含多个文件的配置项,如/etc/logrotate.d/*/etc/apache2/conf.d/*,非常适合通过定义的类型进行包装:

define logrotate::conf(
  String $pattern,
  Integer $max_days=7,
  Array $options=[]
) {
  file { "/etc/logrotate.d/$name": 
    ensure  => file,
    mode    => '0644', 
    content => epp('logrotate/config-snippet.epp',
      {
        'pattern'  => $pattern,
        'max_days' => $max_days,
        'options'  => $options,
      },) 
  }
}

类中的 EPP 模板与定义中的 EPP 模板之间有一个主要的区别。类中的 EPP 模板可以直接使用带命名空间的类变量。而定义的资源类型没有固定的命名空间。因此,需要向epp函数添加一个映射哈希,其中我们指定模板中的变量和定义中对应的变量。

之后,可以直接在模板中使用变量($pattern, $max_days, $options)。

对于快速且简便的数据字符串转换,你还可以在清单中使用inline_epp函数。这个函数通常出现在变量赋值的右侧:

$comma_seperated_list = inline_epp('<%= $my_class::my_array * "," %>') 

这个示例假设my_class类中的$my_array Puppet 变量包含一个数组值。

避免模板中的性能瓶颈

在使用模板时,无论是通过epp还是inline_epp函数,都需要注意每次调用都会对 Puppet 主服务器带来性能损耗。在编译目录时,Puppet 必须为它遇到的每个模板初始化 EPP 引擎。EPP 评估发生在一个独立的环境中,该环境来自于epp函数调用的相应作用域。

因此,模板的复杂度并不重要。如果你的清单需要频繁扩展一个非常简短的模板,那么每次初始化都会产生巨大的开销。特别是在使用简单的inline_epp函数时,如前所述,值得投入更多的精力来创建一个解析函数,正如在第五章中所看到的,将类、配置文件和扩展合并为模块。函数可以在不增加累积性能损失的情况下执行变量值转换。

从积极的一面看,使用模板对于代理来说非常经济,因为代理可以直接在目录中接收到完整的文本文件内容。无需额外调用主服务器并检索文件元数据。在高延迟的网络环境下,这能带来明显的节省。

这里没有万能的解决方案。不要让性能影响阻止你将特定的配置文件转换为模板。基于模板的解决方案通常会使你的模块更具可维护性,通常可以抵消性能上的影响;毕竟,硬件价格一直在下降。只要避免频繁(且简单)的扩展,就不会浪费资源。

管理文件片段

我们接下来要讨论的技术帮助你解决清单中的冲突,并在特殊情况下构建一些优雅的解决方案。这主要指的是配置文件,其中一个文件可能无法完全管理,或者文件是由不同子类构建的。

Puppet 提供了几种方法来实现这一点:

  • 单行

  • 单个条目在一个部分中

  • 从多个片段构建

  • 其他资源类型

如何处理一个配置文件,用户可能会添加额外的内容?到目前为止,我们看到的是管理完整的配置文件,在这些文件中,所做的更改将被重置。但考虑一下用户的.bashrc文件,系统管理员希望确保用户使用特定的代理。

通常,可以在/etc/profile.d/的片段中全局指定这一点。这更多的是作为展示。

Puppet 有一个特定的资源类型,可以管理配置文件中的单行条目:file_line资源类型。这个资源类型不是核心资源类型,但由stdlib模块提供。

file_line { 'user admin proxy': 
  ensure   => present, 
  path     => '/home/admin/.bashrc', 
  line     => 'export 
  http_proxy=http://proxy.domain.com:3127', 
} 

在前面的示例中,如果该行缺失,它将被添加到文件的底部。如果该行已经存在,Puppet 将不会更改文件。

请记住,file_line资源类型要求文件已经存在于系统中。如果不确定这一点,最佳实践是管理文件的存在性,而不指定内容或源:

file { '/home/admin/.bashrc': 
  ensure => file, 
  owner  => 'admin', 
  group  => 'admin', 
  mode   => '0644', 
} 

部分中的单个条目

但是如果需要在包含多个部分的配置文件中配置一行怎么办?在这种情况下,最好不要将该行添加到文件的底部。

这就是ini_setting资源类型将发挥作用的地方。这个资源类型不是 Puppet 核心的一部分,但随puppetlabs-inifile模块一起提供。

ini_setting { 'puppet agent report': 
  ensure  => present, 
  path    => '/etc/puppetlabs/puppet/puppet.conf', 
  section => 'agent', 
  setting => 'report', 
  value   => 'true', 
} 

上述示例将检查代理部分是否有report = true的条目,如果缺少,则会添加该条目。如果整个部分还不存在,它也会添加该部分。如果整个文件缺失,ini_setting资源类型也会创建该文件。

通常,ini_setting假定节名称放在括号中,并且使用=符号作为设置值分隔符。

该资源类型允许采用section_prefixsection_suffix以及key_value_separator

ini_setting { 'ssh config host default':
  ensure              => present,
  path                => '/etc/ssh/ssh_config',
  section             => 'Host *',
  section_prefix      => '',
  section_suffix      => '',
  key_value_separator => ' ',
  setting             => 'HashKnownHosts',
  value               => 'true',
}

这将产生以下输出:

Host * 
HashKnownHosts true 

但是管理ssh_config文件中的所有单个条目非常低效,因为必须在每个资源类型声明中提供所有单个条目。在这种特定情况下,从片段构建配置文件是可行的。

从多个片段构建

与通过file_lineini_setting资源类型构建文件相比,构建文件时有一个主要区别。后两者仅管理文件中的单个条目,而通过文件片段管理文件则是管理整个配置文件。

如果事先不知道需要多少条目,这非常有用。例如,动态扩展 haproxy 后端,或为数据库服务器添加备份条目,其中数据库的数量尚不确定。

文件片段的最常见解决方案是puppetlabs-concat模块。concat模块需要至少一次no-noop运行,因为它必须在节点上管理concat脚本。此脚本用于构建最终的配置文件。

首先,需要向 concat 指明它即将管理的文件:

concat { 'ssh config':
  ensure => present,
  path   => '/etc/ssh/ssh_config',
}

这将使 concat 能够从 concat_fragments 构建文件。所有片段应按特定顺序排列:

concat::fragment { 'ssh_config header':
  target  => 'ssh config',
  content => "# Managed by Pupept\n",
  order   => '01',
}
concat::fragment { 'default host':
  target => 'ssh config',
   source => 
'puppet:///modules/<modulename>/ssh_config_default host',
   order => '10',
}

使用虚拟资源

接下来我们将讨论的技术可以帮助你解决清单中的冲突,并在特殊情况下构建一些优雅的解决方案。

记住在第一章中介绍的唯一性约束,编写你的第一个清单,任何资源在清单中最多只能声明一次。不能有两个类或定义类型实例声明相同的 filepackage 或任何其他类型的资源。每个资源必须具有唯一的类型/名称组合。这适用于定义类型的实例以及原生资源。

当多个模块需要共享资源时,可能会出现问题,例如已安装的包,或者甚至是同一配置文件中的独立设置。像在第四章中介绍的那样,为此类资源创建组件类,将资源组合到类和定义类型中,将解决此类基本冲突。它可以在同一清单中任意次数地包含。

当共享资源的数量非常大时,这种方式可能不太实际。假设你遇到了一种情况,许多不同的 Puppet 节点需要来自大量yum仓库的软件。Puppet 会通过其 yumrepo 类型乐于在代理上管理仓库配置。然而,你并不希望所有这些仓库都在每一台机器上配置,因为这样会带来维护开销。相反,更理想的做法是,每个节点自动接收其所需仓库的配置,但不包含多余的仓库。

当使用组件类解决这个问题时,你需要将每个仓库封装在一个独立的类中。类名应该与各自仓库的名称紧密相关(并且很可能包含这些名称):

class yumrepos::team_ninja_stable {
  yumrepo { 'team_ninja_stable': 
    ensure => present, 
    ... 
  }
}

依赖于一个或多个此类仓库的包资源需要附带适当的 include 语句:

include yumrepos::team_ninja_stable
include yumrepos::team_wizard_experimental
package { 'doombunnies': 
  ensure  => installed, 
  require => [
    Class['yumrepos::team_ninja_stable'], 
    Class['yumrepos::team_wizard_experimental']
  ],
}

这是可行的,但效果不理想。Puppet 提供了一种避免重复资源声明的替代方法,形式为虚拟资源。它允许你在清单中添加资源声明,而无需将该资源添加到实际目录中。虚拟资源必须被实现收集才能达到此目的。与类包含类似,虚拟资源的实现可以在同一清单中任意发生。

因此,我们之前的示例可以使用一个更简单的结构,通过一个类声明所有 yum 仓库作为虚拟资源,并设置标签参数:

class yumrepos::all { 
  @yumrepo { 'tem_ninja_stable':
    ensure => present,
    tag    => 'stable',
  }
  @yumrepo { 'team_wizard_experimantel':
    ensure => present,
    tag    => 'experimental',
  }
} 

@ 前缀将 yumrepo 资源标记为虚拟资源。这个类可以安全地包含在所有节点中,直到资源被实际化之前,不会影响目录:

realize(Yumrepo['team_ninja_stable'])
realize(Yumrepo['team_wizard_experimental'])
package { 'doombunnies': 
  ensure  => installed, 
  require => [ 
    Yumrepo['team_ninja_stable'], 
    Yumrepo['team_wizard_experimental'], 
  ], 
}  

realize 函数将引用的虚拟资源转换为真实资源,并将其添加到目录中。诚然,这比之前依赖组件类的代码并没有什么优势。虚拟资源至少使意图更加明确,实际化它们比一些 include 语句要更清晰;一个类可以包含许多资源,甚至更多的 include 语句。

这个 define 结构实际上也可以通过组件类来实现。类名可以作为参数传递,或者通过中央数据结构传递。include 函数将接受类名的变量值。

使用收集器更灵活地实现资源

你可以不调用 realize 函数,而是依赖一个不同的语法结构,即 collector

Yumrepo<| title == 'team_ninja_stable' |>

这比函数调用更灵活,代价是稍微有一些性能损失。它可以在某些上下文中作为已实现资源的引用。例如,你可以使用链式操作符添加顺序约束:

Yumrepo<| title == 'team_ninja_stable' |> -> Class['...']

甚至可以在收集过程中更改资源属性的值。关于这种覆盖的内容将在本章后面的一个专门章节中讨论。

由于收集器基于表达式,你可以方便地实现一系列资源。有时这会非常动态,你会创建一些虚拟资源,这些资源已经被一个相当不加区分的收集器实现了。我们来看一个常见的例子:

User<| |>

如果没有表达式,集合将包括所有给定类型的虚拟资源。这使得你可以收集它们,而无需担心它们的显式标题或属性。这看起来可能是多余的,因为这会使得最初声明资源为虚拟资源没有意义。然而,请记住,收集器可能只出现在某些选定的清单中,而虚拟资源则可以安全地添加到所有节点中。

为了更具选择性,将虚拟资源根据其标签进行分组可能会很有用。我们还没有讨论标签。每个资源都被打上几个标识符标签。每个标签只是一个简单的字符串。你可以通过定义 tag 元参数手动为资源打标签:

file { '/etc/sysctl.conf': 
  ensure => file, 
  tag    => 'security', 
} 

然后,命名标签会被添加到资源中。Puppet 会隐式地为所有资源打上声明类的名称、包含模块的名称以及其他一系列有用的元信息标签。例如,如果你的用户模块将 user 资源划分为 administratorsdevelopersqa 和其他角色,你可以通过基于类名标签的集合,让特定的节点或类选择给定角色的所有用户:

User<| tag == 'developers' |>

注意,这些标签实际上形成了一个数组。== 比较将查找此上下文中 tag 数组中是否存在 developers 元素。看一下另一个例子,让这个概念更加清晰:

@user { 'felix':  
  ensure => present,  
  groups => [ 'power', 'sys' ],  
} 
User<| groups == 'sys' |> 

通过这种方式,你可以收集所有属于 sys 组的用户。

如果你更喜欢函数调用而非较为晦涩的收集器语法,你可以继续使用 realize 函数和收集器一起使用。这样不会有问题。记住,每个资源可以多次实现,甚至可以同时以两种方式实现。

如果你想知道,给定代理的清单只能实现该代理清单中声明的虚拟资源。虚拟资源不会泄漏到其他清单中。因此,资源不能从一个清单故意转移到另一个清单中。不过,还有另外一个概念可以实现这样的交换;它将在下一节中描述。

使用导出资源进行跨节点配置

Puppet 常用于配置整个服务器集群或 HPC 工作节点。与手动管理相比,任何配置管理系统都能使这项任务变得更加高效。相似节点之间可以共享清单。需要为每个节点单独定制的配置项会被单独建模。整个过程非常自然且直接。

另一方面,有一些配置任务不太适合所有状态的中央定义范式。例如,集群设置可能包括共享生成的密钥,或在对等节点变得可用时注册其 IP 地址。自动化设置应包括此类共享信息的交换。Puppet 也可以帮助处理这个问题。

这是一个非常合适的匹配。它节省了一个元层,因为你不需要在 Puppet 中实现信息交换系统的设置。共享是安全的,依赖于 Puppet 的身份验证和加密基础设施。还有日志记录和集中控制共享配置的部署。Puppet 保持其作为所有系统详细信息的中央源的角色;它充当了安全信息交换的中心。

导出和收集资源

Puppet 通过导出资源的方法来解决多个代理节点之间共享配置信息的问题。这个概念很简单。节点 A 的清单可以包含一个或多个纯虚拟资源,这些资源不在 节点 A 的清单中实现。其他节点,例如 BC,可以导入其中的一部分或全部资源。然后,这些资源成为这些远程节点的目录的一部分。

导入和导出资源的语法与虚拟资源的语法非常相似。

资源。导出资源通过在资源类型前添加前缀来声明。

带有两个 @ 字符的名称:

@@file { 'my-app-psk':  
  ensure  => file, 
  path    => '/etc/my-app/psk',  
  content => 'nwNFgzsn9n3sDfnFANfoinaAEF',  
  tag     => 'cluster02',  
}

导入清单使用表达式收集这些资源,这与收集虚拟资源类似,但使用了双角括号

括号,<>

File <<| tag == 'cluster02' |>>

标签是控制这种导出资源分发的非常常见的方式。

配置主节点以存储导出的资源

唯一推荐的启用导出资源支持的方式是 PuppetDB。它是一个 REST API,用于将 Puppet 主机在常规操作过程中处理的各种数据存储在 PostgreSQL 数据库中。这包括来自代理的清单请求(包括其重要的事实数据)、清单应用程序的报告和导出资源。

第二章,Puppet 服务器和代理,详细描述了手动安装主机的过程。现在通过 Puppet 以更优雅的方式添加 PuppetDB!在 Forge 上,你将找到一个便捷的模块来轻松实现这一点:

puppet module install puppetlabs-puppetdb  

在主节点上,设置现在变成了一个简单的命令行调用:

puppet apply -e 'include puppetdb, puppetdb::master::config'

由于我们的测试主机使用的是一个非标准的 SSL 证书,名为 master.example.net(而不是其完全限定域名 FQDN),因此也必须为 puppetdb 进行配置:

include puppetdb 
class { ‘puppetdb::master::config’: 
         puppetdb_server => 'master.example.net', 
      } 

随后的清单运行相当令人印象深刻。Puppet 安装了 PostgreSQL 后端、Jetty 服务器和实际的 PuppetDB 包,并一次性完成了所有配置和启动服务。在应用了这个简短的清单之后,你已经将一个复杂的基础设施组件添加到你的 Puppet 设置中。现在,你可以利用导出的资源来执行各种有用的任务。

导出 SSH 主机密钥

对于集群机器之间的自定义交互,SSH 可以是一个宝贵的工具。通过无处不在的 sshd 服务,文件传输和远程执行任意命令变得轻松可行。出于安全原因,每个主机都会生成一个唯一的密钥来识别自己。当然,这种公钥认证系统只有在信任网络中,或者预先共享公钥的情况下才能真正工作。

Puppet 可以很好地完成后者的工作:

@@sshkey { $::facts['networking']['fqdn']: 
  host_aliases => $::facts['networking']['hostname'], 
  key          => $::facts['sshecdsakey'], 
  tag          => 'san-nyc' 
}  

感兴趣的节点会收集带有已知模式的密钥:

Sshkey<<| tag == 'san-nyc' |>>

现在,SSH 服务器可以通过 Puppet 安全存储在其数据库中的相应密钥进行身份验证。像往常一样,Puppet 主机是安全的支点。

事实上,一些来自 Puppet Forge 的 ssh 模块会使用这种结构来为你完成这项工作。

本地管理主机文件

许多站点可以依赖本地的 DNS 基础设施。通过这种设置,解析名称为本地 IP 地址是很容易的。然而,小型网络,或者由许多独立集群组成且基础设施共享较少的站点,必须依赖 /etc/hosts 中的名称。

你可以为每个网络单元维护一个中心化的主机文件,或者你可以

Puppet 会分别维护每个主机文件中的每一项条目。后一种方法有一些优势:

  • 更改会通过 Puppet 代理网络自动分发

  • Puppet 处理主机文件中未管理的行

手动维护的注册表容易过时,并且会覆盖代理机器上任何本地添加的 hosts 文件内容。

使用导出资源的优越方法的清单实现与前一节的sshkey示例非常相似:

@@host { $::facts['networking']['fqdn']:
  ip           => $::facts['networking']['ipaddress'],
  host_aliases => [ $::facts['networking']['hostname'] ],
  tag          => 'nyc-site',
}

这是同样的原理,只不过现在每个节点导出的是它的$ipaddress事实值,而不是公钥,导入也按同样的方式进行:

Host<<| tag == 'nyc-site' |>> 

自动化自定义配置项

你还记得在上一章创建的 Cacti 模块吗?它让你可以非常简单地在 Cacti 服务器的清单中配置所有被监控的设备。然而,既然已经可以这样做,难道不会更好,如果你网络中的每个节点都能自动注册到 Cacti 吗?这很简单:让设备导出它们各自的cacti_device资源供服务器收集:

@@cacti_device { $::facts['networking']['fqdn']: 
  ensure => present, 
  ip     => $::facts['networking']['ipaddress'], 
  tag    => 'nyc-site', 
} 

Cacti 服务器,除了包含cacti类外,现在只需要收集这些设备即可:

Cacti_device<<| tag == 'nyc-site' |>>

如果一个 Cacti 服务器处理所有的机器,你可以直接省略tag的比较:

Cacti_device<<| |>>

一旦模块支持其他 Cacti 资源,你可以用相同的方式来处理它们。我们来看一个来自另一个流行监控解决方案的例子。

简化 Nagios 的配置

Puppet 支持管理Nagios(以及兼容版本的Icinga)的完整配置。每个配置部分都可以通过不同的 Puppet 资源来表示,类型如nagios_hostnagios_service

有人正在努力从核心 Puppet 中移除对这个功能的支持。然而,这并不意味着支持会被完全取消,它只是会转移到另一个优秀的 Puppet 模块中。

你的每一台机器都可以在导出它们各自的nagios_host资源的同时,也导出hostcacti_device资源。然而,得益于 Nagios 的广泛支持,你还能做得更好。

假设你有一个模块或类来封装 SSH 处理(当然,你是在使用 Forge 模块来进行实际管理),你可以从自己的 SSH 服务器类内部处理监控。通过在这个类中添加导出,你可以确保包括这个类的节点(只有这些节点)也会获得监控:

class site::ssh { 
  # ...actual SSH management... 
  @@nagios_service { "${::facts['networking']['fqdn']}-
  ssh": 
  use       => 'ssh_template', 
  host_name => $::facts['networking']['fqdn'], 
  } 
}  

你可能已经了解了这个流程,但让我们再重复一遍口号:

Nagios_service<<| |>> 

使用这个集合,Nagios 主机会根据所有代理清单创建的服务自动配置自己。

对于大型的 Nagios 配置,你可能希望自己重新实现 Nagios 类型,使用简单的定义,从模板构建配置。在这种情况下,原生类型可能比file资源更慢,因为它们需要在每次运行时解析整个 Nagios 配置。而file资源则会更便宜,因为它们依赖于内容无关的校验和。

维护你的中央防火墙

说到那些不属于 Puppet 核心功能但很有用的特性,你当然可以管理 iptables 防火墙的规则。你需要 puppetlabs-firewall 模块来提供适当的类型。然后,每台机器都可以(除其他有用功能外)将其所需的端口转发导出到防火墙机器上:

@@firewall { "150 forward port 443 to ${::facts['networking']['hostname']}": 
  proto       => 'tcp', 
  dport       => '443', 
  destination => $public_ip_address, 
  jump        => 'DNAT', 
  todest      => $::facts['networking']['ipaddress'], 
  tag         => 'segment03', 
}

$public_ip_address 的值当然不是一个 Facter facts。你的节点必须配置适当的信息。你可以参考 第七章,来自 Puppet 4 和 5 的新功能,了解一种不错的方法来实现这一点。

防火墙规则资源的标题通常以三位数字索引开头,以便进行排序。防火墙机器会自然地收集所有这些规则:

Firewall<<| tag == 'segment03' |>>

如你所见,通过导出的 Puppet 资源建模分布式系统的可能性是多种多样的。我们已经为几种资源类型总结的简单模式,足以应对广泛的使用场景。结合已定义的资源类型,它使得你能够灵活地启用清单,以便协同工作,从而形成复杂的集群配置,且所需的努力相对较少。集群越大,Puppet 通过导出和收集,从你身上分担的工作就越多。

移除过时的导出

当某个节点清单停止导出任何资源时,该资源的记录会在该节点的清单编译完成后从 PuppetDB 中移除。这通常发生在代理与主服务器连接时。

但是,如果你永久停用某个代理,这种情况就不会发生。因此,你需要手动从数据库中移除这些导出。否则,其他节点将继续导入旧资源。

要从 PuppetDB 中清除此类记录,请在主服务器上使用 puppet node deactivate 命令:

puppet node deactivate vax793.example.net

为资源参数设置默认值

导出资源和虚拟资源都只声明一次,然后在不同的上下文中收集。语法非常相似,概念也类似。

然而,有时,某些资源的中央定义无法在所有节点上安全实现;例如,考虑所有 user 资源的集合。你很可能希望管理分配给每个帐户的用户 ID,以便在你的网络中保持一致。

这通常通过 LDAP 或类似的目录解决,但有些站点无法实现这一点。

即使几乎所有机器上的所有账户都能够使用其指定的 ID,仍然可能会有一些例外。在一些较旧的机器上,某些 ID 可能已经被用作其他用途,且无法轻易更改。在这些机器上,使用这些 ID 创建用户将失败。

如果允许重复的 ID,可以创建账户,但这并不是解决问题的方法,因为通常不希望出现重复 ID。

幸运的是,Puppet 提供了一种方便的方式来表达这种例外。要给用户felix分配非标准的 UID 2066,只需通过指定属性值来实现资源:

User<| title == 'felix' |> { 
  uid => '2066' 
} 

你可以传递任何适用的属性、参数或元参数给

相关资源类型。你以这种方式指定的值是最终的,不能再被重写。

这个语言特性比前面的示例所显示的要强大得多。这是因为重写不仅限于虚拟和导出的资源。你可以重写清单中的任何资源。这为一些了不起的构造和快捷方式提供了可能。

以你在上一章创建的 Cacti 模块为例。它声明了一个package资源,以确保软件被安装。为此,它指定了ensure => installed。然而,如果你的模块的任何用户需要 Puppet 保持其软件包最新,这就不够了。对此的解决方案是给模块的类添加一些参数,允许用户选择软件包和其他资源的ensure属性值。然而,这实际上并不实用。复杂的模块可能管理数百个属性,而通过参数暴露所有这些属性将形成一个令人困惑的界面。

重写语法在这里提供了一个简单而优雅的解决方法。实现所需结果的清单非常直接:

include cacti 
Package<| title == 'cacti' |> { ensure => 'latest' } 

尽管这个清单简单,但对于那些不熟悉收集器/重写语法的协作者来说,理解起来会很困难。这并不是重写的唯一问题。你不能多次重写同一个属性。这其实是件好事,因为任何解决此类冲突重写规则的方式都让人很难预测包含多个此类重写的清单的实际语义。

过度依赖这种重写语法会使你的清单容易产生冲突。组合错误的类会导致编译器停止创建目录。即使你设法避免了所有冲突,清单也会变得相当混乱。定位给定节点的所有活动重写可能会很困难。任何类或定义的最终行为变得难以预测。

总的来说,最安全的做法是非常谨慎地使用重写。

收集器在没有选择器表达式的情况下使用时特别危险:

Package<| |> { before => Exec['send-software-list'] }

它不仅会实现给定类型的所有虚拟资源。它还会强制给同一类型的虚拟和常规资源应用意外的属性值。

通过使用资源默认值来节省冗余

本章介绍的最终语言构造可以为你节省很多时间。

一些打字工作,或者更准确地说,它可以让你避免复制和粘贴。写一个冗长的、重复的清单当然不会花费你大量时间。然而,简洁的清单通常更具可读性,因此也更易于维护。你可以通过定义资源默认值来实现这一点;这些默认值是为那些没有自定义值的资源所使用的:

Mysql_grant { 
  options    => ['GRANT'], 
  privileges => ['ALL'], 
  tables     => '*.*', 
} 
mysql_grant { 'root': 
  ensure => 'present', 
  user   => 'root@localhost', 
}
mysql_grant { 'apache': 
  ensure => 'present', 
  user   => 'apache@10.0.1.%', 
  tables => 'application.*', 
}
mysql_grant { 'wordpress': 
  ensure => 'present', 
  user   => 'wordpress@10.0.5.1', 
  tables => 'wordpress.*', 
}
mysql_grant { 'backup':
  ensure     => 'present',
  user       => 'backup@localhost',
  privileges => [ 'SELECT', 'LOCK TABLE' ],
} 

默认情况下,每个授权应适用于所有数据库,并包括所有权限。这允许你相当简洁地定义每个实际的 mysql_grant 资源。否则,你将不得不为所有资源指定 privileges 属性。在这个例子中,options 属性尤其重复,因为它们对于所有授权都是相同的。

请注意,ensure 属性也具有重复性,但没有被包含在内。通常认为将这个属性从资源默认值中排除是一个好做法。

mysql_grant 资源类型在核心 Puppet 中不可用。它是 puppetlabs-mysql 模块的一部分,位于 Forge 上。

尽管这种方法带来了便利,但不应在每个看似适用的机会中使用。它也有一些缺点,值得你记住:

  • 如果默认值适用于在与默认定义有词法距离的资源(例如在清单文件几屏之远的地方声明的资源),它可能会让人感到意外。

  • 默认值超越了类的包含和定义的实例化。

这两个方面形成了一个危险的组合。来自复合类的默认值可能会影响到清单中非常遥远的部分:

class webserver {  
  File { owner => 'www-data' }  
  include apache, nginx, firewall, logging_client  
  file {  
    ...  
  } 
} 

webserver 类中声明的文件应该属于一个默认用户。然而,这个默认值在包含的类中也会递归生效。owner 属性是一个属性:一个没有定义值的资源,因为它会忽略当前的状态。清单中指定的值将会被代理执行强制执行。通常情况下,你不关心托管文件的所有者:

file { '/etc/motd': content => '...' } 

然而,由于默认的 owner 属性,Puppet 现在会强制要求该文件属于 www-data。为了避免这种情况,你必须通过用 undef 来覆盖默认值,从而取消设置默认值,undef 是 Puppet 中相当于 nil 的值:

File { owner => undef } 

这也可以在单独的资源中完成:

file { '/etc/motd': content => '...', owner => undef } 

然而,频繁地这样做几乎不可行。后者选项尤其不吸引人,因为它会导致清单代码的复杂度增加,而不是简化它。毕竟,在这里不定义默认的 owner 属性才是更简洁的方式。

使默认值在多个清单区域生效的语义被称为动态作用域。它曾经也适用于变量值,并且通常被认为是有害的。事实上,Puppet 3.0 中最决定性的变化之一就是移除了动态变量作用域。资源默认值仍然使用它,但预计这将在未来的版本中有所改变。

应谨慎使用资源默认值。对于某些属性,如file modeownergroup,通常应避免使用。

避免反模式

说到需要避免的东西,有一个语言特性我们仅在此提及,以便提醒大家要格外小心。Puppet 附带一个叫做defined的函数,它允许你查询清单中已声明的资源:

if defined(File['/etc/motd']) { 
  notify { 'This machine has a MotD': } 
} 

这个概念的问题在于它永远无法可靠。即使资源出现在清单中,编译器也可能会在if条件之后才遇到它。这可能会引发严重问题,因为某些模块会尝试通过这种构造使自己变得可移植:

if ! defined(Package['apache2']) {  
  package { 'apache2':  
    ensure => 'installed'  
  }  
}  

模块作者假设如果

清单在其他地方声明了Package['apache2']。如前所述,这种方法仅在块在编译器运行时足够晚的时候才有效。如果编译器在此块之后遇到其他声明,冲突仍然可能发生。

如果清单中包含多个相同查询的出现,清单的行为会变得完全不可预测:

class cacti { 
  if !defined(Package['apache2']) { 
    package { 'apache2': ensure => 'present' } 
  }
}
class postfixadmin { 
  if !defined(Package['apache2'] { 
    package { 'apache2': ensure => 'latest' } 
  }
}

第一个看到的块会优先执行。如果清单的无关部分被重组,这种优先顺序可能会发生变化。你无法预测给定的清单是会对apache2包使用ensure=>latest,还是使用installed。如果某个块希望通过ensure=>absent移除资源,而另一个块没有如此要求,结果将变得更加不可预测。

defined函数长期以来被认为是有害的,但目前尚无合适的替代方法。stdlib模块中的ensure_resource函数试图减少这种场景的危害:

ensure_resource('package', 'apache2', { ensure => 'installed' }) 

通过依赖这个函数,而不是依赖基于defined函数的前述反模式,你可以避免冲突声明导致的不可预测行为。相反,这会导致编译器在将声明传递给ensure_resource时失败。不过,这仍然不是一种干净的做法。编译失败也并不是一个理想的替代方案。

应避免使用这两个函数,建议采用清晰的模块结构,并且避免模糊的资源声明。有关如何确保可重用、可组合和可堆叠的类的更多细节,请参见第九章,Puppet 角色和配置文件

摘要

模板是 Puppet 中常见的现象,它是管理动态文件内容的最佳方式之一。每次评估模板都需要编译器付出额外的努力,但通常灵活性上的提升是值得的。模板中的变量必须使用 Puppet 变量语法,并带有完整的命名空间,或者在模板和类中通过操作哈希传递变量。

虚拟资源的概念并不如常见。虚拟资源允许你灵活地将某些实体添加到节点的目录中。用于此的收集器语法也可以用来覆盖属性值,这同样适用于非虚拟资源。

一旦安装并配置了 PuppetDB,你还可以导出资源,以便其他节点清单能够接收它们的配置信息。这使得你可以优雅地建模分布式系统。

资源默认值只是一个语法快捷方式,有助于保持清单的简洁。然而,它们需要小心使用。一些语言特性,如 defined 函数(以及它的模块化继任者,即 ensure_resource 函数),如果可能的话,应避免使用。

第七章,Puppet 4 和 5 的新特性,为你概述并介绍了角色和配置文件模式,这是将上游模块与平台实现结合的最佳实践。

第七章:Puppet 4 和 5 的新特性

现在我们已经对 Puppet DSL 及其概念有了全面了解,接下来是时候看看 Puppet 4 版本引入的新特性了。解析器(编译目录的工具)为了更好的性能,几乎是从零开始重写的。这个里程碑版本还增加了一些缺失的功能和编码原则。

Puppet 4 及更高版本不仅提供了新功能,还打破了旧的做法,移除了一些不再被认为是最佳实践的功能。这要求现有的 manifest 代码需要进行适当的测试,并且可能需要进行大量修改以兼容 Puppet 4。

本章将涵盖以下主题:

  • 升级到 Puppet 4

  • 使用 Puppet 类型系统

  • 学习 Lambda 函数和常规函数

  • 创建 Puppet 4 函数

  • 利用新的模板引擎

  • 使用 HEREDOC 处理多行文本

  • Puppet 5 服务器指标

  • 打破旧有的做法

升级到 Puppet 4

首先让我们看看 Puppet 3 版本的用户如何进行更新。

与其升级 Puppet 主机,不如并行设置一台新服务器,并仔细迁移服务。这有一些优点,例如,如果遇到问题,可以轻松回滚。

新的 Puppet 4 及更高版本可以通过多种方式进行安装:

  • 使用 Puppet Labs 的软件仓库,它们将移除旧的 Puppet 包。

  • 这种方法意味着没有提前进行测试的强制切换,不推荐使用。在对 Puppet manifest 代码进行深入测试后,才应升级到 Puppet 4 及更高版本。

  • 作为 Ruby gem 扩展或从 tarball 安装

  • 这种方法需要单独安装 Ruby,但大多数现代 Linux 发行版并没有提供 Ruby。对于 Puppet 4,要求 Ruby 2.1;对于 Puppet 5,需要 Ruby 2.4。

  • 更新到 Puppet 3.8,启用并迁移到环境路径设置,并且仅在特定的测试环境中启用未来解析器。

  • 后者的解决方案是最智能的,也是最具向后兼容性的。

使用 Puppet 4 及更高版本,以及 Puppet Labs 提供的 All-in-One (AIO) 打包方式,Puppet 配置、模块、环境和 SSL 证书的路径将会发生变化。

  • Puppet 4 和 5 将其配置文件(puppet.conf)存储在 /etc/puppetlabs/puppet 目录下。

  • Hiera 配置文件位于 /etc/puppetlabs/hiera/hiera.yaml

  • Puppet CA 和证书可以在 /etc/puppetlabs/puppet/ssl 中找到。

  • 查找 Puppet 代码(环境和模块)

    安装到 /etc/puppetlabs/code/environments/

使用 Puppet 3.8 和环境目录

新的解析器在 Puppet 3.5 中与旧的解析器一同引入。为了使用新的语言特性,需要显式设置一个特殊的配置项。这使得早期用户和对新技术感兴趣的人可以在早期阶段测试解析器并检查代码的不兼容问题。

在 Puppet 3.x 上,新的解析器可能会在没有进一步通知的情况下发生变化。因此,建议升级到最新的 3.x 版本。

使用目录环境,可以在 environment.conf 配置文件中指定特定环境的设置:

# /etc/puppet/environments/puppet_update/environment.conf
# environment config file for special puppet_update environment
parser = future

接下来,你的所有 Puppet 代码需要放入新创建的环境路径中,包括节点分类。

现在,在每种不同的节点类型上,可以手动运行:

puppet agent --test --environment=puppet_update --noop 

检查主机和代理的输出及日志文件,查看是否有错误或不需要的更改,并根据需要调整你的 Puppet 代码。

Puppet 4 和 5 主机

确保你的代理准备好与 Puppet 4 主机一起操作。有关代理的说明,请参见以下部分。

启动一个新的 Puppet 主机是另一种方法。以下过程假设已经使用 puppet.conf 文件中的 DNS 替代名称设置创建了 Puppet CA。如果已配置了 DNS 替代名称,则需要完全重新创建 Puppet CA。

Puppet CA 需要了解 Puppet 主机 fqdn通用名称CN)。可以提供 DNS 替代名称,CA 也将对其有效。

通常,Puppet 使用主机的 fqdn 作为通用名称。但如果你在生成 CA 之前提供了配置属性 dns_alt_names,此配置选项将被添加到 CA 中。

强烈建议配置 dns_alt_names。启用此选项后,你可以扩展到多个编译主机,并为迁移过程添加额外的 Puppet 主机。

若要查找是否已添加 DNS 替代名称,可以使用 puppet cert 命令:

puppet cert list -all  

此命令将打印所有证书。检查你 Puppet 主机的证书。例如,考虑以下内容:

puppet cert list --all
+ "puppetmaster.example.net" (SHA256) 7D:11:33:00:94:B3:C4:48:D2:10:B3:C7:B0:38:71:28:C5:75:2C:61:3B:3E:63:C6:95:7C:C9:DF:59:F7:C5:BE (alt names: "DNS:puppet", "DNS:puppet.example.net", "DNS:puppetmaster.example.net")

以下步骤将指导你完成 Puppet 4 的设置。在基于 Debian 7 的系统上,添加 PC1 Puppet Labs 仓库:

curl -O http://apt.puppetlabs.com/puppetlabs-release-pc1-wheezy.deb
dpkg -i puppetlabs-release-pc1-wheezy.deb
apt-get update
apt-get install puppetserver puppet-agent

还不要启动 Puppet 服务器进程!需要将新的 Puppet 4 或 5 主机作为 CA 主机运行,这需要将 Puppet 3 主机的 CA 和证书复制到新的 Puppet 4 主机。

截至目前,基于 Java 的 Puppet 5 主机包需要来自 Debian 8 的 Java 8 回溯包。除此之外,PuppetDB 5 现在需要 PostgreSQL 9.6,而 Puppet 4 的 PuppetDB 必须与 PostgreSQL 9.4 一起使用。

在下一步中,所有 Puppet 代理都需要修改 puppet.conf 文件。你需要为 ca_serverserver 提供不同的设置:

ini_setting { 'puppet_ca_server':
  path    => '/etc/puppet/puppet.conf',
  section => 'agent',
  setting => 'ca_server',
  value   => 'puppet4.example.net'
}

ini_setting 资源类型可通过 Forge 上的 puppetlabs-inifile 模块使用。

现在,将你的所有 Puppet 代码放到新的 Puppet 4 主机上的一个环境中(/etc/puppetlabs/code/environments/development/{manifests,modules})。

通过在每个节点上运行以下命令来测试你的 Puppet 4 错误:

puppet agent --test --noop --server puppet4.example.net --environment development

更改你的 Puppet 代码以修复潜在的错误。一旦在 Puppet 4 主节点和代理上没有错误,也没有不希望的配置更改,你的代码就是与 Puppet 4 兼容的。

另一种验证你的 Puppet 代码在 Puppet 4 和 5 上完全正常工作的方式是比较目录。现在有几种解决方案可用。最常见的有 puppetlabs-catalog_diffzack-catalogoctocatalog-diff

更新 Puppet 代理

确保现有的代理已准备好与已经是版本 4 的主节点一起操作非常重要。请检查以下方面:

  • 所有代理应该使用最新版本的 Puppet 3

  • 代理配置应指定 stringify_facts = false

后续步骤为代理更新做好准备,因为 Puppet 4 始终会表现得像这样,并且避免将所有事实值转换为字符串类型。

请确保你更新到 Puppet Server 2.1 或更高版本。基于 Passenger 的主节点和 Puppet Server 2.0 与 Puppet 3 代理不兼容。

Puppet 在线文档包含了许多关于此更新路径的有用细节:docs.puppetlabs.com/puppetserver/latest/compatibility_with_puppet_agent.html

测试 Puppet DSL 代码

另一种验证现有 Puppet 代码是否能在 Puppet 4 上运行的方法是使用 rspec-puppetbeaker 进行单元和集成测试。此过程不在本书的范围内。

无论你是全新开始使用 Puppet 4,还是使用上述程序之一迁移 Puppet 3 基础设施,现在是时候发现新版本的好处了。

使用类型系统

旧版本的 Puppet 只支持一小部分数据类型:BoolStringArrayHash。Puppet DSL 几乎没有检查一致变量类型的功能。请考虑以下场景。

带参数的类使代码库中的其他用户能够更改类的行为和输出:

class ssh (
  $server = true,
){
  if $server {
    include ssh::server
  }
}

这个类定义检查是否已将 server 参数设置为 true。然而,在这个示例中,类并未防止错误数据的使用:

class { 'ssh': 
  server => 'false', 
} 

在这个类声明中,server 参数被赋予了一个字符串而不是布尔值。由于 false 字符串不为空,因此 if $server 条件实际上会通过。这不是用户预期的结果。

在 Puppet 3 中,推荐使用 stdlib 模块中的多个函数来添加参数验证:

class ssh (
  $server = true,
){
  validate_bool($server)
  if $server {
    include ssh::server
  }
}

仅使用一个参数时,这似乎是一个不错的方法。但如果你有多个参数呢?我们如何处理像哈希表这样的复杂数据类型?

这就是类型系统发挥作用的地方。类型系统了解许多通用数据类型,并遵循许多现代编程语言中也使用的模式。

Puppet 区分核心数据类型和抽象数据类型。核心数据类型是真正的数据类型,是 Puppet 代码中最常用的类型:

  • 字符串

  • 整型、浮动型和数字型

  • 布尔值

  • 数组

  • 哈希

  • 正则表达式

  • 未定义

  • 默认

在给定的示例中,server参数应始终检查是否包含布尔值。代码可以简化为以下模式:

class ssh ( 
  Boolean $server = true, 
){ 
  if $server { 
    include ssh::server 
  } 
} 

如果未给定布尔值参数,Puppet 将抛出错误,并说明哪个参数的数据类型不匹配:

class { 'ssh': 
  server = 'false', 
} 

显示的错误如下:

root@puppetmaster# puppet apply ssh.pp
Error: Expected parameter 'server' of 'Class[Ssh]' to have type Boolean, got String at ssh.pp:2 on node puppetmaster.example.net

NumericFloatInteger数据类型在变量及其类型方面有一些更有趣的方面。

Puppet 会自动识别由数字(无论是否带有负号)组成且没有小数点的Integers

Floats通过小数点来识别。当对IntegerFloat的组合进行算术运算时,结果将始终是Float

Floats-11之间的数值必须在小数点前写上0数字,否则,Puppet 会抛出错误。

此外,Puppet 还支持十进制、八进制和十六进制表示法,类似于 C 语言等语言的表示法:

  • 非零十进制数不能以0开头

  • 八进制值必须以前导0开始

  • 十六进制值的前缀是0x

Puppet 会在插值过程中自动将数字转换为字符串:("Value of number: ${number}")

Puppet 不会将字符串转换为数字。要实现这一点,你可以简单地在字符串上加上 0 来转换:

$ssh_port = '22' 
$ssh_port_integer = 0 + $ssh_port 

Default数据类型有些特殊。它并不直接指代一个数据类型,但可以在选择器和案例语句中使用:

$enable_real = $enable ? { 
  Boolean => $enable, 
  String  => str2bool($enable), 
  default => fail('Unsupported value for ensure. Expected either 
   bool or string.'), 
} 

抽象数据类型是有助于更复杂或宽松的类型检查的构造:

  • 标量

  • 集合

  • 变体

  • 数据

  • 模式

  • 枚举

  • 元组

  • 结构体

  • Optional

  • 目录条目

  • 类型

  • 任意

  • 可调用

假设一个参数只接受来自有限集合的字符串。仅检查是否为String类型是不足够的。在这种情况下,Enum类型很有用;可以为其指定一系列有效值:

class ssh (
  Boolean $server = true,
  Enum['des','3des','blowfish'] $cipher = 'des',
){
  if $server {
    include ssh::server
  }
}

如果listen参数未设置为列出元素之一,Puppet 将抛出错误:

class { 'ssh': 
  ciper => 'foo', 
} 

显示以下错误:

puppet apply ssh.pp
Error: Expected parameter 'ciper' of 'Class[Ssh]' to have type Enum['des','3des','blowfish'] got String at ssh.pp:2 on node puppetmaster.example.net 

有时,使用特定数据类型比较困难,因为参数可能被设置为undef值。考虑一个可能为空(undef)或设置为任意字符串数组的userlist参数。

这就是Optional类型的作用:

class ssh ( 
  Boolean $server = true, 
  Enum['des','3des','blowfish'] $cipher = 'des', 
  Optional[Array[String]] $allowed_users = undef, 
){ 
  if $server { 
    include ssh::server 
  } 
} 

再次提醒,使用错误的数据类型会导致 Puppet 错误:

class { 'ssh': 
  allowed_users => 'foo', 
} 

显示的错误如下:

puppet apply ssh.pp
Error: Expected parameter 'userlist' of 'Class[Ssh]' to have type Optional[Array[String]], got String at ssh.pp:2 on node puppetmaster.example.net

在前面的示例中,我们使用了数据类型组合。这意味着数据类型可以拥有更多的类型检查信息。

假设我们想在类中设置 ssh 服务端口。通常,ssh 应该运行在 1 到 1023 之间的特权端口上。在这种情况下,我们可以通过传递额外信息,将整数数据类型限制为只允许 1 到 1023 之间的数字:

class ssh ( 
  Boolean $server = true, 
  Optional[Array[String]] $allowed_users = undef, 
  Integer[1,1023] $sshd_port, 
){ 
  if $server { 
    include ssh::server 
  } 
} 

一如既往,提供错误的参数会导致错误:

class { 'ssh': 
  sshd_port => 'ssh', 
} 

上述代码行给出了以下错误:

puppet apply ssh.pp
Error: Expected parameter 'sshd_port' of 'Class[Ssh]' to have type Integer[1, 1023], got String at ssh.pp:2 on node puppetmaster.example.net  

使用多种数据类型的复杂哈希,在新类型系统下非常难以描述。

使用 Hash 类型时,只能检查一般的哈希,或检查具有特定类型键的哈希。你可以选择性地验证哈希中的最小和最大元素数量。

以下示例提供了一个有效的哈希类型检查:

$hash_map = { 
  'ben'     => { 
    'uid'   => 2203, 
    'home'  => '/home/ben', 
  }, 
  'jones'   => { 
    'uid'   => 2204, 
    'home'  => 'home/jones', 
  } 
} 

特别地,用户 joneshome 条目缺少前导斜杠:

class users ( 
  Hash $users 
){ 
  notify { 'Valid Hash': } 
} 
class { 'users': 
  users => $hash_map, 
} 

运行上述代码,将会得到以下输出:

puppet apply hash.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.32 seconds
Notice: Valid hash
Notice: /Stage[main]/Users/Notify[Valid hash]/message: defined 'message' as 'Valid hash'
Notice: Applied catalog in 0.03 seconds 

使用上述符号,数据类型是有效的,但哈希图内部存在错误。

检查 ArraysHashes 的内容需要使用另一种抽象数据类型:Tuple(用于 Arrays)或 Struct(用于 Hashes)。

然而,Struct 数据类型只会在键名来自已知有限集时工作,这在给定的示例中并不适用。

在这个特殊情况下,我们有两个可能的选择:

  • 扩展 hash 数据类型以了解哈希的内部结构

  • type 数据包装成一个定义,使用所有键并利用键函数(来自 stdlib

第一个解决方案如下:

class users ( 
  Hash[ 
    String, 
    Struct[ { 'uid' => Integer, 
              'home' => Pattern[ /^\/.*/ ] } ] 
  ] $users 
){ 
  notify { 'Valid hash': } 
} 

然而,当数据类型不匹配时,错误信息很难理解:

puppet apply hash.pp
Error: Expected parameter 'users' of 'Class[Users]' to have type Hash[String, Struct[{'uid'=>Integer, 'home'=>Pattern[/^\/.*/]}]], got Struct[{'ben'=>Struct[{'uid'=>Integer, 'home'=>String}], 'jones'=>Struct[{'uid'=>Integer, 'home'=>String}]}] at hash.pp:32 on node puppetmaster.example.net

第二个解决方案给出了一个更聪明的提示,指示哪些数据可能是错误的:

define users::user ( 
  Integer        $uid, 
  Pattern[/^\/.*/] $home, 
){ 
  notify { "User: ${title}, UID: ${uid}, HOME: ${home}": } 
} 

该定义类型随后会在用户类中使用:

class users ( 
  Hash[String, Hash] $users 
){ 
  $keys = keys($users) 
  each($keys) |String $username| { 
    users::user{ $username: 
      uid  => $users[$username]['uid'], 
      home => $users[$username]['home'], 
    } 
  } 
} 

如果在哈希中提交了错误的数据,你将收到以下信息:

错误消息:

puppet apply hash.pp
Error: Expected parameter 'home' of 'Users::User[jones]' to have type Pattern[/^\/.*/], got String at hash.pp:23 on node puppetmaster.example.net  

错误消息指向用户 jones 的 home 参数,该参数在哈希中给出。正确的哈希如下:

$hash_map = { 
  'ben'    => { 
    'uid'  => 2203, 
    'home' => '/home/ben', 
  }, 
  'jones'  => { 
    'uid'  => 2204, 
    'home' => '/home/jones', 
  } 
} 

上述代码产生了预期的结果,如下所示:

puppet apply hash.pp
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.33 seconds
Notice: User: ben, UID: 2203, HOME: /home/ben
Notice: /Stage[main]/Users/Users::User[ben]/Notify[User: ben, UID: 2203, HOME: /home/ben]/message: defined 'message' as 'User: ben, UID: 2203, HOME: /home/ben'
Notice: User: jones, UID: 2204, HOME: /home/jones
Notice: /Stage[main]/Users/Users::User[jones]/Notify[User: jones, UID: 2204, HOME: /home/jones]/message: defined 'message' as 'User: jones, UID: 2204, HOME: /home/jones'
Notice: Applied catalog in 0.03 seconds

除了现有的数据类型,Puppet 还提供了基于现有数据类型构建新数据类型的可能性。

在最后一个示例中,我们使用正则表达式来匹配绝对路径,但有时正则表达式可能变得非常复杂且难以理解。这时,类型声明就派上了用场。

类型声明必须是模块的一部分,使用模块命名空间作为前缀,并且必须放置在类型目录中。

# stlib/types/absolutepath.pp 
type Stdlib::Absolutepath = Variant[Stdlib::Windowspath, Stdlib::Unixpath] 

Windows 和 Unix 路径类型具有适当的正则表达式。

当你想要验证一个非常特定的数据类型时,类型系统非常有用。想想防火墙模块,你可能需要检查 IPv4 或 IPv6 地址。

我们可以使用与 absolutepath 数据类型相同的模式:

#firewall/types/ipaddress.pp 
type Firewall::Ipaddress = Variant[Firewall::Ipv4, Firewall::Ipv6] 

上述清单使用了 each 函数,这是 Puppet 4 语言的另一个部分。下一节将更详细地探讨它。

学习 lambda 表达式和函数

函数长期以来一直是 Puppet 的一个重要组成部分。由于新的类型系统,基于参数数据类型的不同行为,现已可以实现全新的函数集。

要理解函数,首先我们必须了解 Lambdas,它们是在 Puppet 4 中引入的。Lambdas 代表一段 Puppet 代码片段,可以在函数中使用。从语法上讲,Lambdas 由一个可选类型和至少一个变量组成,可选的默认值,变量被管道符号(|)括起来,后跟一段大括号内的 Puppet 代码:

$packages = ['htop', 'less', 'vim']
each($packages) |String $package| 
{
      package { $package:
        ensure => latest,
  }
}

Lambda 通常用于函数。前面的示例在 $packages 变量上使用了 each 函数,遍历其内容,在每次迭代中,将 lambda 变量 $package 设置为 htoplessvim,分别对应。Puppet 代码块随后在资源类型声明中使用 lambda 变量。

括号中的 Puppet 代码必须确保不会发生重复的资源声明。

由于 Puppet 现在了解数据类型,你可以以更优雅的方式与变量及其内部数据进行交互和操作。

用于数组和哈希的函数:

  • Puppet 4 提供了一整套内建的数组和哈希函数:each

  • slice

  • filter

  • map

  • reduce

我们已经看到 each 函数的实际应用。在 Puppet 4 之前,必须将所需的 Puppet 资源类型包装在 define 中,并使用数组声明 define 类型:

class puppet_symlinks { 
  $symlinks = [ puppet', 'facter', 'hiera' ] 
  puppet_symlinks::symlinks { $symlinks: } 
} 

define puppet_symlinks::symlinks { 
  file { "/usr/local/bin/${title}": 
    ensure => link, 
    target => "/opt/puppetlabs/bin/${title}", 
  } 
} 

通过这个概念,动作(创建 symlink)被放入一个定义类型中,不再直接出现在清单中。新的迭代方法保持了动作的位置:

class puppet_symlinks { 
  $symlinks = [ 'puppet', 'facter', 'hiera' ] 
  $symlinks.each | String $symlink | { 
    file { "/usr/local/bin/${symlink}": 
      ensure => link, 
      target => "/opt/puppetlabs/bin/${symlink}", 
    } 
  } 
} 

你是否注意到,这次我们使用了另一种通过函数的方式?在第一个示例中,我们使用了 Puppet 3 风格的函数调用:

function($variable) 

Puppet 4 还支持后缀表示法,其中函数通过点(.)附加到其参数上:

$variable.function 

Puppet 4 支持使用函数的两种方式。这使你可以继续遵循自己的编码风格,并利用新功能。

让我们回顾一下其他用于数组和哈希的函数:

  • slice 函数允许你拆分和分组一个数组或哈希。它需要一个额外的参数(整数),用于定义要分组的对象数量:
$array = [ '1', '2', '3', '4'] 
$array.slice(2) |$slice| { 
  notify { "Slice: ${slice}": } 
} 
  • 这段代码将产生以下输出:
    Notice: Slice: [1, 2]
    Notice: Slice: [3, 4]
  • 在对哈希使用 slice 函数时,会得到键

    (根据分组的键数量)并相应地,

    子哈希:

$hash = { 
  'key 1' => {'value11' => '11', 'value12' => '12',}, 
  'key 2' => {'value21' => '21', 'value22' => '22',}, 
  'key 3' => {'value31' => '31', 'value32' => '32',}, 
  'key 4' => {'value41' => '41', 'value42' => '42',}, 
} 

$hash.slice(2) |$hslice| { 
  notify { "HSlice: ${hslice}": } 
} 
  • 这将返回以下输出:
 Notice: HSlice: [[key1, {value11 => 11, value12 => 
  12}], 
  [key2, {value21 => 21, value22 => 22}]]
 Notice: HSlice: [[key3, {value31 => 31, value32 => 
  32}], 
  [key4, {value41 => 41, value42 => 42}]]
  • filter 函数可以用来过滤数组或哈希中的特定条目。

  • 在对数组使用时,所有元素都会传递到代码块中,代码块会判断该项是否匹配。如果你想筛选出数组中的某些项(例如,应该安装的包),这非常有用:

$pkg_array = [ 'libjson', 'libjson-devel', 'libfoo', 'libfoo-devel' ] 
$dev_packages = $pkg_array.filter |$element| { 
  $element =~ /devel/ 
} 
notify { "Packages to install: ${dev_packages}": } 
  • 这将返回以下输出:
 Notice: Packages to install: [libjson-devel, libfoo-
 devel]
  • 哈希的行为有所不同。在使用哈希时,必须提供两个 lambda 变量:keyvalue。你可能只想添加具有特定gid设置的用户:
$hash = { 
  'jones' => { 
    'gid' => 'admin', 
  }, 
  'james' => { 
    'gid' => 'devel', 
  }, 
  'john'  => { 
    'gid' => 'admin', 
  }, 
} 

$user_hash = $hash.filter |$key, $value| { 
  $value['gid'] =~ /admin/ 
} 
$user_list = keys($user_hash) 
notify { "Users to create: ${user_list}": } 
  • 这将只返回admin gid 下的用户:
    Notice: Users to create: [jones, john]

创建 Puppet 4 函数

Puppet 3 函数 API 存在一些限制,并且缺少一些功能。Puppet 4 中的新函数 API 在这一点上有了显著改进。

旧版函数的部分限制如下:

  • 这些函数没有自动类型检查。

  • 由于平坦的命名空间,这些函数必须具有唯一的名称。

  • 这些函数不是私有的,因此可以在任何地方使用。

  • 如果不运行 Ruby 代码,无法获取文档。

在 Puppet 3 中,函数必须位于lib/puppet/parser/functions目录中的模块中。因此,人们称这些函数为解析器函数,但这个名称是误导性的。函数与 Puppet 解析器无关。

在 Puppet 4 中,函数必须放在路径lib/puppet/functions中的模块中。

这是创建一个返回 Puppet 主机名的函数的方式:

# modules/utils/lib/puppet/functions/resolver.rb 
require 'socket' 
Puppet::Functions.create_function(:resolver) do 
  def resolver() 
    Socket.gethostname 
  end 
end 

使用dispatch可以对属性进行类型检查。根据所需的功能,可能会有多个dispatch块(检查不同的数据类型)。每个dispatch可以引用函数内部的另一个已定义的 Ruby 方法。通过为dispatch和 Ruby 方法使用相同的名称,可以实现这种引用。

以下示例代码应增加附加功能;根据参数类型,函数应该返回本地系统的主机名,或使用 DNS 根据 IPv4 地址或给定主机名的ipaddress获取主机名:

require 'resolv' 
require 'socket' 
Puppet::Functions.create_function(:resolver) do 
  dispatch :ip_param do 
     param 'Pattern[/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/]', :ip 
  end 
  dispatch :fqdn_param do 
     param 'Pattern[/^([a-z0-9\.].*$/]', :fdqn 
  end 
  dispatch :no_param do 
  end 

  def no_param 
    Socket.gethostname 
  end 
  def ip_param(ip) 
    Resolv.getname(ip) 
  end 
  def fqdn_param(fqdn) 
    Resolv.getaddress(fqdn) 
  end 
end 

在文件的开头,我们需要加载一些 Ruby 模块,以允许 DNS 名称解析并找到本地主机名。

前两个dispatch部分检查参数值的数据类型并设置唯一的符号。最后一个dispatch部分不检查数据类型,这与没有提供参数时相符。

每个已定义的 Ruby 方法使用相应的dispatch名称,并根据参数类型执行 Ruby 代码。

现在,解析器函数可以通过三种不同的方式在 Puppet 清单代码中使用:

class resolver { 
  $localname = resolver() 
  notify { "Without argument resolver returns local 
  hostname: 
  ${localname}": } 

  $remotename = resolver('puppetlabs.com') 
  notify { "With argument puppetlabs.com: ${remotename}": 
  } 

  $remoteip = resolver('8.8.8.8') 
  notify { "With argument 8.8.8.8: ${remoteip}": } 
  } 

声明此类时,以下输出将会显示:

puppet apply -e 'include resolver'
Notice: Compiled catalog for puppetmaster.example.net in environment production in 0.35 seconds
...
Notice: Without argument resolver returns local hostname: puppetmaster
Notice: With argument puppetlabs.com: 52.10.10.141
Notice: With argument 8.8.8.8: google-public-dns-a.google.com
Notice: Applied catalog in 0.04 seconds

在 Puppet 3 的函数中,无法有两个相同名称的函数。在使用新模块时,必须检查是否出现了重复的函数。

Puppet 4 函数现在提供了与类相同的命名空间功能。

让我们将函数迁移到类命名空间中:

# modules/utils/lib/puppet/functions/resolver/resolve.rb 
require 'resolv' 
require 'socket' 
Puppet::Functions.create_function(:'resolver::resolve') do 
  # the rest of the function is identical to the example given 
   # above 
end 

在示例中,代码需要位于resolver/lib/puppet/functions/resolver/resolve.rb,对应于function name: 'resolver::resolve'

带命名空间的函数按常规方式调用:

class resolver { 
  $localname = resolver::resolve() 
  $remotename = resolver::resolve('puppetlabs.com') 
  $remoteip = resolver::resolve('8.8.8.8') 
} 

利用新的模板引擎

在第六章,《Puppet 初学者进阶部分》中,我们介绍了模板和 ERB 模板引擎。在 Puppet 4 中,增加了一个替代方案:EPP 模板引擎。模板引擎之间的主要区别如下:

  • 在 ERB 模板中,不能使用 Puppet 语法指定变量($variable_name

  • ERB 模板不接受参数

  • 在 EPP 模板中,你将使用 Puppet DSL 语法,而不是 Ruby 语法

EPP 模板引擎需要模块中的作用域变量:

# motd file - managed by Puppet 
This system is running on <%= $::operatingsystem %> 

清单定义了以下局部变量:<%= $motd::local_variable %>。EPP 模板还具有一个独特的扩展:它们可以接受类型化参数。

为了使用此功能,模板必须以参数声明块开始:

<%- | String $local_variable, 
      Array  $local_array 
| -%> 

这些参数与 Puppet 清单中的变量不同。相反,必须使用 epp 函数传递参数:

epp('template/test.epp', {'local_variable' => 'value', 'local_array' => ['value1', 'value2'] }) 

没有参数的模板应该仅在模板仅由一个模块使用时使用,这样可以安全地依赖 Puppet 变量来定制内容。

当模板从多个地方使用时,推荐使用带参数的 EPP 模板函数。通过在开始时声明参数,特别清楚模板需要哪些数据。

在遍历数组和哈希时,模板引擎之间有一个具体的区别。ERB 语法使用 Ruby 代码和不受限的局部变量,而 EPP 语法则需要指定 Puppet DSL 代码:

# ERB syntax 
<% @array.each do |element| -%> 
<%= element %> 
<% end -%> 

# EPP syntax 
<% $array.each |$element| { -%> 
<%= $element %> 
<% } -%> 

内联 ERB 函数也通过内联 EPP 进行了补充。使用内联 EPP,可以指定一小段 EPP 代码进行求值:

file {'/etc/motd': 
  ensure  => file, 
  content => inline_epp("Welcome to <%= $::fqdn %>\n") 
} 

在 Puppet 4 之前,传递多于一个小代码片段比较不方便。随着 Puppet 4 和 HEREDOC 支持的发布,结合 inline_epp,复杂模板变得更加容易且可读性更好。

使用 HEREDOC 处理多行

在 Puppet 中编写多行文件片段大多导致代码难以阅读,主要是因为缩进问题。随着 Puppet 4 的发布,增加了 heredoc 风格。现在可以指定一个 heredoc 标签和标记:

$motd_content = @(EOF) 
  This system is managed by Puppet 
  local changes will be overwritten by next Puppet run. 
EOF

heredoc 标签以 @ 符号开始,后跟括号中包含的任意字符串。heredoc 标记即为标签中给定的字符串。

如果在 heredoc 文档中需要变量,可以通过将标签字符串放入双引号来启用变量插值。heredoc 中的变量与 Puppet DSL 变量书写方式相同:一个美元符号,后跟作用域和变量名:

$motd_content = @("EOF") 
  Welcome to ${::fqdn}. 
  This system is managed by Puppet version ${::puppetversion}. 
  Local changes will be overwritten by the next Puppet run 
EOF

通常,heredoc 不处理转义序列。转义序列需要显式启用。从 Puppet 4.2 开始,heredoc 提供了以下转义序列:

  • * \n 换行符

  • * \r 回车符

  • * \t 制表符

  • * \s 空格

  • * \$ 字面量美元符号(防止插值)

  • * \u Unicode 字符

  • \L 什么都不做(忽略源代码中的换行符)

启用的转义序列必须放在heredoc标签字符串的后面:

$modt_content = @("EOF"/tn) 
Welcome to ${::fqdn}.\n\tThis system is managed by Puppet version ${::puppetversion}.\n\tLocal changes will be overwritten on next Puppet run. 
EOF

在这个例子中,文本总是从第一列开始,这使得它很难阅读,并且与周围的代码(通常会有一些空白缩进)显得格格不入。

可以通过在heredoc标记前放置空格和管道符号来去除缩进。管道符号将指示每行的第一个字符:

$motd_content = @("EOF")
    Welcome to ${::fqdn}.
    This system is managed by Puppet version ${::puppetversion}.
    Local changes will be overwritten on next Puppet run.
    | EOF

现在可以轻松地将heredocinline_epp结合使用:

class my_motd (
  Optional[String] $additional_content = undef
){
  $motd_content = @(EOF)
    Welcome to <%= $::fqdn %>.
    This system is managed by Puppet version 
    <%= $::puppetversion %>.
    Local changes will be overwritten on next Puppet run.
    <% if $additional_content != undef { -%>
    <%= $additional_content %>
    <% } -%>
    | EOF
  file { '/etc/motd':
    ensure  => file,
    content => inline_epp($motd_content, {  
    additional_content => $additional_content } ),
  }
} 

声明此类会在motd文件中生成以下结果:

puppet apply -e 'include my_motd'
Welcome to puppetmaster.example.net.
This system is managed by Puppet version 4.2.1.
Local changes will be overwritten on next Puppet run.  

在使用heredocinline_epp组合时,你需要小心不要引用heredoc起始标签。否则,变量替换会发生在inline_epp函数调用之前。

使用 Puppet 5 服务器度量

在 Puppetserver 5 版本中,之前 Puppet Enterprise 的度量系统已被移植到 Puppet Open Source 中。

度量系统允许你从 JMX 控制台读取内部信息,如编译时间、文件服务状态和函数运行时,或者将数据推送到 graphite 系统。

启用度量系统非常简单,只需编辑位于/etc/puppetlabs/puppetserver/conf.d/metrics.conf的 puppet 服务器metrics.conf文件。

有三个重要的设置。在metrics.server-id中,可以指定一个 ID,该 ID 稍后会在 Grafana 仪表板中使用。metrics.registry.puppetserver.reporters.graphite.enabled值必须设置为 true,同时metrics.reporters.graphite哈希值必须包含 graphite 的主机名和端口,以及更新间隔设置:

# settings related to metrics
metrics: {
  # a server id that will be used as part of the namespace 
  for metrics produced
  # by this server
  server-id: "puppet.bi.example42.com"
  registries: {
    puppetserver: {
      # specify metrics to allow in addition to those in 
      the default list
      #metrics-allowed: ["compiler.compile.production"]
      # enable or disable JMX metrics reporter
      jmx: {
        enabled: true
      }
      # enable or disable Graphite metrics reporter
      graphite: {
        enabled: true
      }
    }
  }
  # this section is used to configure settings for 
  reporters that will send
  # the metrics to various destinations for external 
  viewing
  reporters: {
    graphite: {
      host: "10.0.4.2"
      port: "2003"
      update-interval-seconds: 5
    }
  }
  metrics-webservice: {
    jolokia: {
      # Enable or disable the Jolokia-based metrics/v2 
      endpoint.
      # Default is true.
      # enabled: false
      # Configure any of the settings listed at:
      # 
      https://jolokia.org/reference/html/agents.html#war-
      agent-installation
      servlet-init-params: {
        # Specify a custom security policy:
        # https://jolokia.org/reference/html/security.html
        # policyLocation: 
        "file:///etc/puppetlabs/puppetserver/jolokia-
        access.xml"
      }
    }
  }
}

在做完更改后,别忘了重新启动puppetserver进程。

对于自动化设置,应该使用puppetlabs-hocon模块,它能够设置所有所需的值:

Hocon_setting {
  path   => 
  '/etc/puppetlabs/puppetserver/conf.d/metrics.conf',
  notify => Service['puppetserver'],
  }
  hocon_setting {'server metrics server-id':
    ensure  => present,
    setting => 'metrics.server-id',
    value   => 'localhost',
  }
  hocon_setting {'server metrics reporters graphite':
    ensure  => present,
    setting => 
 'metrics.registries.puppetserver.reporters.graphite.enabled',
    value   => true,
  }
  hocon_setting {'server metrics graphite host':
    ensure  => present,
    setting => 'metrics.reporters.graphite.host',
    value   => $graphite_server,
  }
  hocon_setting {'server metrics graphite port':
    ensure  => present,
    setting => 'metrics.reporters.graphite.port',
    value   => 2003,
  }
  hocon_setting {'server metrics graphite update 
  interval':
    ensure  => present,
    setting => 'metrics.reporters.graphite.update-
    interval-seconds',
    value   => 5,
  } 

本书不涉及 graphite 的设置。可以使用一个 Puppet 模块进行评估目的(github.com/tuxmea/puppet-grafanadash)。

该模块会在 CentOS 7 系统上安装并配置 elasticsearch、graphite 和 grafana。

你只需要在节点上包含grfanadash::dev类:

node 'graphite.example.com' {   include grafanadash::dev }

之后,你可以通过http://graphite.example.com访问 graphite,通过http://graphite.example-com:10000访问 grafana。在 grafana 仪表板中,可以从模块示例文件夹加载.json文件。

请注意,可能需要最多 10 分钟,之前的值才能出现在 grafana 中。

打破旧有的做法

当 Puppet Labs 决定对解析器和新特性进行改进时,他们也决定移除一些已经在多个版本中弃用的功能。

转换节点继承

在旧版本的 Puppet 中,节点继承被视为一种良好的实践。为了避免在节点级别上写太多代码,创建了一个通用的、虚拟的主机(basenode),而真实的节点继承自basenode

node basenode {
  include security
  include ldap
  include base
}
node 'www01.example.net' inherits 'basenode' {
  class { 'apache': }
  include apache::mod::php
  include webapplication
}

Puppet 4 不再支持此节点分类。

从 2012 年开始,角色和配置模式变得越来越流行,带来了新的方法来实现智能节点分类。从技术角度看,角色和配置是 Puppet 类。配置模块封装了技术模块,并通过提供如 ntp 服务器和 ssh 配置选项等数据,来将它们的使用适应现有的基础设施。角色模块描述了系统的业务用例,并使用已声明的配置:

class profile::base { 
  include security 
  include ldap 
  include base 
} 
class profile::webserver { 
  class { 'apache': } 
  include apache_mod_php 
} 

class role::webapplication { 
  include profile::base 
  include profile::webserver 
  include profile::webapplication 
} 

node 'www01.example.net' { 
  include role::webapplication 
} 

最后一章将更详细地描述角色和配置模式。

处理字符串上的布尔代数

一个影响巨大的小变动是空字符串比较的变化。在 Puppet 4 之前,可以通过检查变量来测试未设置的变量或包含空字符串的变量:

class ssh ( 
  $server = true, 
){
  if $server {...} 
} 

在以下不同的声明中,ssh 类的行为类似($server 评估为 true):

include ssh 
class { 'ssh': server => 'yes', } 

禁用 ssh 类中的服务器部分可以通过以下类声明实现:

class { 'ssh': server => false, } 
class { 'ssh': server => '', } 

最后一个示例(空字符串)的行为在 Puppet 4 中发生了变化。空字符串现在在布尔上下文中等于 true,就像在 Ruby 中一样。如果你的代码使用了这种变量检查方式,你需要添加空字符串检查,以保持与 Puppet 4 一致的行为:

class ssh ( 
  $server = true, 
){ 
  if $server and $server != '' {...} 
} 

使用严格的变量命名

变量有时看起来与常量相同,并展示以下特性:

  • 变量不能再次声明

  • 在一个节点的作用域内,大多数变量是静态的(hostnamefqdn 等等)

有时,开发者更喜欢使用大写字母书写变量,这是出于之前提到的原因,使它们看起来与 Ruby 常量相同。

在 Puppet 4 中,变量名不能以大写字母开头:

class variables 
{ 
  $Local_var = 'capital variable' 
  notify { "Local capital var: ${Local_var}": } 
} 

声明此类将产生以下错误信息:

root@puppetmaster:/etc/puppetlabs/code/environments/production/modules# puppet apply -e 'include variables'
Error: Illegal variable name, The given name 'Local_var' does not conform to the naming rule /^((::)?[a-z]\w*)*((::)?[a-z_]\w*)$/ at /etc/puppetlabs/code/environments/production/modules/variables/manifests/init.pp:3:3 on node puppetmaster.example.net 

学习新的引用语法

由于类型系统以及 Puppet 4 现在将一切视为表达式,因此必须正确命名对其他声明资源的引用。引用现在有一些严格的规定:

  • 引用类型和开括号之间不能有空格

  • 引用标题(不带引号使用)不得以大写字母拼写

以下内容将在 Puppet 4 中产生错误:

User [Root] 
User[Root]

从 Puppet 4 开始,引用必须按照以下模式编写:

Type['title']

我们的示例需要更改为:

User['root'] 

清理名称中的连字符

许多模块(即使是在模块 Forge 上)都使用了连字符

模块名称。连字符现在不再是字符串字符,而是数学运算符(减法)。因此,现在严格禁止在以下描述符中使用连字符:

  • 模块名称

  • 任何类名

  • 已定义类型的名称

使用带有连字符的模块时,需要删除连字符或将其替换为字符串字符(例如下划线)。

这在旧版本中是可行的,如下所示:

class syslog-ng {...} 

include syslog-ng 

现在,新风格如下:

class syslog_ng { 
  ... 
} 

include syslog_ng 

不再使用 Ruby DSL

一些人利用了将 .rb 文件作为模块中的清单文件的可能性。这些 .rb 文件包含 Ruby 代码,主要用于处理数据。Puppet 4 现在有了数据类型,使得这一做法变得过时。

Puppet 4 移除了对这些 Ruby 清单的支持。

相对类名解析

在 Puppet 3 及更早版本中,如果本地类名与另一个模块相同,必须指定绝对类名:

# in module "mysql" 
class mysql { 
  ... 
} 
# in module "application" 
class application::mysql { 
  include mysql 
} 

application:: 命名空间的范围内,Puppet 3 会在该命名空间中搜索 mysql 类进行包含。实际上,application::mysql 类会将自身包含进去。然而,这并不是我们想要的效果。我们其实是想找 mysql 模块。作为变通方法,大家都被鼓励指定 mysql 模块类的绝对路径:

class application::mysql { 
  include ::mysql 
} 

这种相对名称解析在 Puppet 4 中不再适用。原始示例现在可以正常工作。

处理不同数据类型

因为 Puppet 3 并不知道不同的数据类型

(大多数情况下,一切都被视为字符串类型),所以可以将多个不同的数据类型组合在一起。

Puppet 4 现在对组合不同数据类型非常严格。最简单的例子是处理浮动类型和整数类型;当将浮动数与整数相加时,结果将是浮动数类型。

对不同数据类型(如字符串和布尔值)进行操作将导致错误。以下代码将正常工作:

case $::operatingsystemmajrelease { 
  '8': { 
    include base::debian::jessie 
  } 
} 

另一方面,以下代码将无法工作:

if $::operatingsystemmajrelease > 7 { 
  include base::debian::jessie 
} 

您将收到以下错误消息:

Error: Evaluation Error: Comparison of: String > Integer, is not possible. Caused by 'A String is not comparable to a non String' 

仔细检查不同 Facter 变量的比较。某些 Facter 变量,如 operatingsystemmajrelease,返回字符串类型的数据;而 processorcount 返回整数值。

总结

升级到 Puppet 3 应按逐步程序进行,在此过程中,您的现有代码将使用 Puppet 3.8 和新的解析器进行评估。

多亏了类型系统,现在可以在 Puppet DSL 代码中以更加优雅的方式处理数据。新的函数 API 允许您通过使用命名空间,立即识别一个函数属于哪个模块。通过使用 dispatch 方法和数据类型,类似的函数现在可以在一个文件内结合使用,从而实现函数重载。

新的 EPP 模板通过使用 Puppet 语法进行变量引用,提供了更好的变量来源理解。将参数传递给模板将允许您以更灵活的方式使用模块。

结合 EPP 模板和 HEREDOC 语法,将允许您将模板代码和数据直接显示在类内。

在接下来的章节中,您将了解 Hiera 以及它如何帮助您为可扩展的 Puppet 代码库带来秩序。

第八章:使用 Hiera 实现代码与数据的分离

在前七章的学习中,你已经在多个示例和场景中使用了 Puppet 的基本结构元素。你也对更高级的语言特性有了快速的了解,并应该对 Puppet 4 中的清单编写过程与早期版本之间的区别有了清晰的认识。

尽管清单文件具有强大的表达能力,但它们确实有一些局限性。一个按照目前所学原则设计的清单文件将逻辑与数据混合。逻辑不仅体现在控制结构中(如ifelse),它还出现在类和定义的网络中,这些类和定义相互包含和实例化。

然而,仅仅包含一些通用类是无法配置一台机器的。给定系统的许多属性都是个别的,必须作为参数传递。这对于必须容纳大量节点的清单文件可能会带来维护上的问题。本章将教你如何在这种复杂的代码基础上恢复秩序。在本章中,我们将涵盖以下主题:

  • 理解需要独立的数据存储

  • 构建层次化数据结构

  • 从类中获取数据

  • 调试数据查找

  • 从数据管理资源

  • 模块和环境中的数据

理解需要独立的数据存储

回顾你在本书中迄今为止的实现,你已经创建了一些非常通用的代码,能够以自动化的方式完成非常有用的任务。你的节点可以在它们之间分发 /etc/hosts 的条目。它们为彼此注册公钥以进行身份验证。一个节点可以自动注册到中央 Cacti 服务器。

多亏了 Facter,Puppet 拥有能够轻松处理这些使用案例的信息。许多配置项对于每个节点都是唯一的,因为它们引用的是已经定义的细节(如 IP 地址或生成的密钥)。有时,所需的配置数据只能在远程机器上找到,而 Puppet 通过导出资源来处理此问题。这种可以依赖事实的清单设计非常经济。信息已经收集完成,一个类很可能能够正确地处理你的多个或所有节点,并且能够优雅地管理一个共同的任务。

然而,一些配置任务必须为每个节点单独执行,这些任务可能包含一些相当任意的设置,并非直接来自节点的现有属性:

  • 在一个跨多个服务器的复杂 MySQL 复制设置中,每个参与者都需要一个唯一的服务器 ID。必须在任何情况下避免重复,因此随机生成 ID 号是不安全的

  • 你的一些网络可能需要定期从cron运行维护任务。Puppet 应该为每台机器定义一个启动时间,以防止两个机器的运行时间重叠

  • 在服务器操作中,你必须监控所有系统的磁盘空间使用情况。大多数磁盘应当生成早期警告,以便有时间作出反应。然而,其他磁盘大多数时间应该接近满载,并且应该有一个更高的警告阈值。

当通过 Puppet 管理定制的系统和软件时,它们也可能需要对每个实例进行这种微观管理。这里的示例仅代表 Puppet 必须显式和独立管理的事务中的一小部分。

在清单中定义数据的后果

Puppet 清单有多种方法来解决这种微观管理问题。最直接的方法是为每个单独的节点定义一整套类:

class site::mysql_server01 { 
  class { 'mysql': server_id => '1', ... } 
} 
class site::mysql_server02 { 
  class { 'mysql': server_id => '2', ... } 
} 
...  
class site::mysql_aux01 { 
  class { 'mysql': server_id => '101', ... } 
} 
# and so forth ... 

这是一个需要大量维护的解决方案,原因如下:

  • 各个类可能会变得相当复杂,因为每个类中必须使用所有必需的 mysql 类参数。

  • 参数之间存在大量冗余,实际上它们在所有节点中是相同的。

  • 单独不同的值可能很难识别,必须小心保持它们在整个类集合中的唯一性。

  • 这只有通过将这些类保持在一起才真正可行,这可能与代码库的其他组织原则相冲突。

简而言之,这是一种暴力解决方案,它带来了自身的成本。一个更经济的方法是将节点之间不同的值(仅限这些!)传递给一个包装类:

node 'xndp12-sql09.example.net' { 
  class { 'site::mysql_server': 
    mysql_server_id => '103', 
  } 
} 

这个包装类可以以通用方式声明 mysql 类,因为每个节点的参数值是独立的:

class site::mysql_server( 
  String $mysql_server_id 
) { 
  class { 'mysql':  
    server_id => $mysql_server_id,  
    ... 
  } 
} 

这样做要好得多,因为它消除了冗余及其对可维护性的影响。问题在于,node 块可能会变得相当杂乱,因为涉及到许多不同子系统的参数赋值。解释性注释进一步增加了每个 node 块可能成为的文字墙。

你可以进一步扩展,通过在哈希变量中定义查找表。

在任何 nodeclass 之外,在全局范围内:

$mysql_config_table = { 
  'xndp12-sql01.example.net' => { 
    server_id   => '1', 
    buffer_pool => '12G', 
  }, 
  ... 
} 

这减少了在 node 块中声明任何变量的需求。类直接从哈希中查找这些值:

class site::mysql_server( 
  $config = $mysql_config_table[$::certname] 
) { 
  class { 'mysql': 
    server_id => $config['server_id'],  
    ... 
  } 
} 

这相当复杂,实际上接近本章稍后你将学习的更好方法。请注意,这种方法仍然保留了冗余的可能性。某些配置值在属于同一组的所有节点之间可能是相同的,但对于每个组来说是唯一的(例如,各种类型的预共享密钥)。

这要求假设的 xndp12 集群中的所有服务器包含一些对于所有成员都是相同的键值对:

$crypt_key_xndp12 = 'xneFGl%23ndfAWLN34a0t9w30.zges4'
$config = {
'xndp12-stor01.example.net' => { $crypt_key =>
  $crypt_key_xndp12, … },
'xndp12-stor02.example.net' => { $crypt_key =>
  $crypt_key_xndp12, … },
 'xndp12-sql01.example.net' => { $crypt_key =>
  $crypt_key_xndp12, … },
...
}

这并不理想,但我们先停在这里。没有必要再担心更复杂的方式来将配置数据排序到递归的哈希结构中。这样的解决方案会迅速变得难以理解和维护。有效的解决方案是一个外部数据库,保存所有单独的和共享的值。在我详细介绍如何使用 Hiera 来实现这一目的之前,让我们先讨论一下层次数据存储的一般概念。

构建层次结构数据结构

在前一节中,我们将数据问题简化为对每个 Puppet 管理节点特定的键值对的需求。Puppet 及其清单作为引擎,根据这些简化的信息生成实际的配置。

解决这个问题的一种简化方法是使用ini风格的配置文件,它为每个节点提供一个设置所有可配置键值的部分。共享值将在一个或多个通用部分中声明:

[mysql]
buffer_pool=15G
log_file_size=500M
...
[xndp12-sql01.example.net]
psk=xneFGl%23ndfAWLN34a0t9w30.zges4
server_id=1

Rails 应用程序通常会做类似的事情,并将它们的配置存储在 YAML 格式中。用户可以定义不同的环境,如生产预发布测试。每个环境中定义的值会覆盖全局设置值。

这与 Puppet 通过 Hiera 绑定允许的层次结构配置非常相似。上述 Rails 应用程序和ini文件通过配置环境实现的层次结构相当简单,只有一个全局层和一个用于专业配置的覆盖层。而使用 Hiera 和 Puppet 时,一个配置数据库通常会处理整台机器群集和整个机器群集的网络。这就意味着需要一个更复杂的层次结构。

Hiera 允许你定义自己的层次结构层。有一些典型的、经过验证的示例,这些示例可以在许多配置中找到:

  • 公共层保存所有代理的默认值

  • 位置层可以根据托管每个节点的数据中心覆盖某些值

  • 每台代理机器通常在你的基础设施中扮演一个不同的角色,例如wordpress_appserverpuppetdb_server

  • 一些配置是特定于每台机器

例如,考虑一个假设的报告客户端的配置。你的公共层将保存许多预设值,如默认的详细程度设置、传输压缩选项以及其他适用于大多数机器的选择。在位置层,你确保每台机器都检查到相应的本地服务器,报告时不应使用 WAN 资源。

每个角色的设置可能是最有趣的部分。它们允许针对特定服务器类别的精细设置。也许您的应用服务器应当以非常短的时间间隔监控其内存消耗。对于数据库服务器,您可能希望更密切地观察硬盘操作和性能。对于 Puppet 服务器,可能需要特殊插件来收集特定数据。

machine 层在声明规则的任何例外时非常有用。总有一些机器因某种原因需要特殊处理。通过一个顶层层次结构,它为每个代理存储数据,从而使您完全控制代理使用的所有数据。

这些概念仍然相当抽象,因此我们最后来看看 Hiera 的实际应用。

配置 Hiera

自 Puppet 3 版本起,已内置支持从 Hiera 中检索数据值。您需要做的就是在配置目录中放置一个 hiera.yaml 文件。

当然,配置的位置和名称是可定制的,几乎与配置相关的所有内容都可以定制。请查看 hiera_config 设置。

正如文件名扩展名所示,配置采用 YAML 格式,并包含一个哈希值,哈希中的键对应后端、层次结构和特定后端的设置。键使用 Ruby 符号表示,并以冒号开头:

# /etc/puppetlabs/puppet/hiera.yaml
:backends:
  - yaml
:hierarchy: 
  - node/%{::clientcert}
  - role/%{::role}
  - location/%{::datacenter}
  - common
:yaml: 
  :datadir: /etc/puppetlabs/code/environments/%{::environment}/hieradata

请注意,:backends 的值实际上是一个单元素数组。您可以选择多个后端。稍后会解释其意义。:hierarchy 的值包含先前描述的实际层次结构的列表。每个条目都是数据源的名称。当 Hiera 检索一个值时,它会依次搜索每个数据源。%{} 表达式允许您访问 Puppet 变量的值。这里只能使用事实或全局作用域变量;其他变量将使 Hiera 的行为变得非常混乱。

最后,您需要为每个后端包含配置。上述配置只使用了 YAML 后端,因此只有一个 :yaml 的哈希,里面包含一个支持的 :datadir 键。这是 Hiera 期望找到包含数据的 YAML 文件的位置。对于每个数据源,datadir 可以包含一个 .yaml 文件。由于源的名称是动态的,通常会创建四个或五个以上的数据源文件。在我们简短讨论多个后端的组合之前,让我们先创建一些示例。

Hiera 5 在 Puppet 4.9 中发布。Hiera 的新版本使用了另一种配置文件的布局,并提供了更多的灵活性。在解释 Hiera 5 的设置、迁移和额外功能之前,我们将首先介绍 Hiera 3,因为大多数基本概念是相同的。

存储 Hiera 数据

你的 Hiera 配置的后端决定了你如何存储配置值。对于 YAML 后端,你需要将 datadir 填充为包含每个值哈希的文件。让我们将一些报告引擎配置元素放入示例层次结构中:

# /etc/puppetlabs/code/environments/production/hieradata/common.yaml
reporting::server: stats01.example.net
reporting::server_port: 9033

common.yaml 中的值是适用于所有代理的默认值。它们位于层次结构的广泛基础处。特定于某个 locationrole 的值适用于你的代理的小组。例如,postgres 角色的数据库服务器应该运行一些特殊的报告插件:

# /etc/puppetlabs/code/environments/production/hieradata/role/postgres.yaml 
reporting::plugins: 
  - iops 
  - cpuload 

在如此高的层次上,你还可以覆盖低层次的值。例如,特定于角色的数据源,如 role/postgres.yaml,也可以为 reporting::server_port 设置一个值。层次结构会从最具体到最不具体进行搜索,并使用第一个值。这就是为什么在层次结构的最上层有一个特定于节点的数据源是个好主意。在这一层,你可以为每个代理覆盖任何值。在这个例子中,报告节点可以使用回环接口访问自己:

#/etc/puppetlabs/.../hieradata/node/stats01.example.net.yaml 
reporting::server: localhost 

每个代理根据构成其特定层次结构的具体 YAML 文件接收一个配置值的拼贴。

如果这一切让你感到有些不知所措,不用担心;本章还有更多的示例。Hiera 也有一个迷人的特点,表面看起来似乎很复杂,但一旦你自己尝试使用,它会感觉非常自然且直观。

选择你的后端

有两个内置的后端:YAML 和 JSON。本章将重点介绍 YAML,因为它是一种非常方便且高效的数据表示形式。JSON 后端与 YAML 非常相似。它查找 .json 文件中的数据,而不是每个数据源的 .yaml 文件;这些文件使用不同的数据表示格式。

使用多个后端通常不是真正必要的。在大多数情况下,精心设计的层次结构足以满足你的需求。使用第二个后端时,数据查找将针对每个后端遍历一次你的层次结构。这意味着主后端的最低层将高于任何附加后端的层次。

在某些情况下,添加另一个后端可能是值得的,这样可以在不同的位置(比如分布式文件系统或具有不同提交权限的源代码控制库)定义更基础的默认值。

此外,请注意,你可以为 Hiera 添加自定义后端,因此这些也可能是二级甚至三级后端的合理选择。Hiera 后端是用 Ruby 编写的,就像 Puppet 插件一样。创建这种后端的详细过程超出了本书的范围。

一个特别受欢迎的后端插件是 eyaml,可以通过 hiera-eyaml Ruby gem 获得。这个后端允许你在 YAML 数据中加入加密字符串。Puppet 在检索时解密数据。

在 Puppet 5 中,eyaml 插件已经成为 Puppet 构建操作系统包的一部分。

你已经深入学习了在 Hiera 中存储数据的理论,现在终于到了看看如何在 Puppet 中使用它的时机。

从类中获取数据

在 Hiera 中查找键值是很简单的。Puppet 提供了一个非常直接的函数来实现这一点:

$plugins = hiera('reporting::plugins') 

每当编译器在当前代理节点的清单中遇到这样的调用时,它会触发层级结构中的搜索。具体的数据源由你的 hiera.yaml 文件中的层级结构决定。它几乎总是依赖于代理提供的事实值来进行灵活的数据源选择。

如果在代理的层级结构中找不到指定的键,主节点会中止目录编译并报告错误。为了防止这种情况,通常建议为查找提供一个默认值:

$plugins = hiera('reporting::plugins', []) 

在这种情况下,如果层级结构没有提到插件,Puppet 会使用一个空数组。

另一方面,你也可以故意省略默认值。就像 classdefine 参数一样,这表示 Hiera 值是必需的。如果用户未提供该值,Puppet 将中止清单编译。

使用简单值

你已经看到如何调用 hiera 函数来检索值。实际上,除了一个可选参数外,除了你在前一节中看到的内容,没有更多的复杂性。这个可选参数允许你在层级结构的顶部增加一个额外的层次。如果在指定的数据源中找到该键,它将覆盖常规层级的结果:

$plugins = hiera('reporting::plugins', [], 'global-overrides') 

如果在 global-overrides 数据源中找到 reporting::plugins 键,则从该位置获取值。否则,会搜索常规层级结构。

通常,将检索到的值分配给清单变量是非常常见的。然而,你也可以在其他有用的上下文中调用 hiera 函数,如下所示:

@@cacti_device { $::fqdn: 
  ip => hiera('snmp_address', $::ipaddress), 
} 

查找结果可以直接作为参数值传递给资源。这是一个示例,展示了如何允许 Hiera 为每台机器定义一个特定的 IP 地址,用于某个特定服务。它作为一种简单的方式来手动覆盖 Facter 的假设。

通常,将 Hiera 查找结果先存储在一个变量中更为安全。这可以让你检查它们的数据类型。在 Puppet 3 中,你需要使用来自 stdlib 模块的 assert 函数。Puppet 4 有一个为此目的设计的操作符:

$max_threads = hiera('max_threads') 
if $max_threads !~ Integer { 
    fail "The max_threads value must be an integer number" 
}

另一个常见的情况是通过 Hiera 查找使参数默认值变得动态:

define logrotate::config( 
  Integer $rotations = hiera('logrotate::rotations', 7) 
) { 
  # regular define code here 
} 

对于声明了显式参数值的 logrotate::config 资源,Hiera 值会被忽略:

logrotate::config { '/var/log/cacti.log': rotations => 12 } 

这可能有点让人困惑。不过,这种模式带来了一些便利。大多数代理可以依赖于默认设置。层级结构允许你在多个粒度级别上调优这个默认值。

自动绑定类参数值

从目前为止的介绍来看,参数化类的概念可能已经有些不太好的声誉。它被认为使得在清单中包含来自多个地方的类变得困难,或者在变化的情况下默默地允许这种做法。虽然这是正确的,但你可以通过依赖 Hiera 来满足你的类参数化需求,从而避免这些问题。

从 Puppet 3.2 版本开始,用户可以选择任何值

类的参数可以直接在 Hiera 数据中定义。每当你包含一个有参数的类时,Puppet 会查询 Hiera 为每个参数找到一个值。键必须以类名和参数名命名,并用双冒号连接。记住我们在第五章中提到的 cacti 类,将类、配置文件和扩展结合成模块。它有一个 $redirect 参数。要在 Hiera 中定义其值,请添加 cacti::redirect 键:

# node/cacti01.example.net.yaml 
cacti::redirect: false 

有些类有非常复杂的接口,例如 Puppet Labs 的 Apache 模块中的 apache 类,在写本文时,它接受 70 个参数。如果你需要其中很多参数,可以将它们作为一个统一的键值块放入目标机器的专用 YAML 文件中。这会很容易阅读,因为 apache:: 前缀对齐了。

相比于直接在清单中指定参数,你并不会节省任何行数,但至少在编写清单时,选项墙不会妨碍你,你将数据与代码分离。

或许类参数化最值得称赞的一点是,在你的层级结构中,每个键都是独立的。许多参数很可能可以为你的大部分或所有机器定义。应用服务器的集群可以共享一些设置(如果你的层级结构中包含了它们被分组在一起的层级),你也可以根据需要覆盖单台机器的参数:

# common.yaml 
apache::default_ssl_cert: /etc/puppetlabs/puppet/ssl/certs/%{::clientcert}.pem 
apache::default_ssl_key: /etc/puppetlabs/puppet/ssl/private_keys/%{::clientcert}.pem 
apache::purge_configs: false 

上述示例为你的网站准备了使用 Puppet 证书来支持 HTTPS。这对于内部服务是一个不错的选择,因为可以轻松建立对 Puppet CA 的信任,而且证书在所有代理机器上都可用。第三个参数purge_configs可以防止该模块摧毁任何不在 Puppet 管理下的现有 Apache 配置。

让我们来看一个更具体的层级示例,它会覆盖这个设置:

# role/httpsec.yaml 
apache::purge_configs: true 
apache::server_tokens: Minimal 
apache::server_signature: off 
apache::trace_enable: off 

在拥有 httpsec 角色的机器上,应该清除 Apache 配置,以确保它完全匹配受管理的配置。这些机器的层级结构还定义了一些在 common 层级中未定义的附加值。common 中的 SSL 设置保持不变。

如果需要,特定机器的 YAML 文件可以覆盖任意层级的键:

# node/sec02-sxf12.yaml 
apache::default_ssl_cert: /opt/ssl/custom.pem 
apache::default_ssl_key: /opt/ssl/custom.key 
apache::trace_enable: extended 

所有这些设置不需要额外的工作。只要包含了 puppetlabs-apache 模块中的 apache 类,它们就会自动生效。

对于某些用户而言,这可能是他们的主服务器上唯一使用 Hiera 的方式,这是完全有效的。你甚至可以专门设计清单,将所有可配置项作为类参数暴露出来。然而,记住 Hiera 的另一个优势是,可以从清单中的许多不同位置检索任何值。

例如,如果你的防火墙服务器可以通过专用的 NAT 端口访问,你将希望将这些端口添加到每台机器的 Hiera 数据中。清单不仅可以将该值导出到防火墙服务器本身,还可以导出到外部服务器,以便它们在脚本和配置中使用该值来访问导出机器:

$nat_port = hiera('site::net::nat_port') 
@@firewall { "650 forward port ${nat_port} to ${::fqdn}": 
  proto       => 'tcp', 
  dport       => $nat_port,  
  destination => hiera('site::net::nat_ip'), 
  jump        => 'DNAT', 
  todest      => $::ipaddress, 
  tag         => hiera('site::net::firewall_segment'), 
}

这些值很可能会在不同的层次结构中定义。nat_port 是特定于代理的,只能在 %{::fqdn}(或为了更好的安全性,可以使用 %{::clientcert})派生的数据源中定义。nat_ip 可能对同一集群中的所有服务器都是相同的。它们可能共享相同的服务器角色。firewall_segment 可能对所有共享相同位置的服务器都是相同的:

# stor03.example.net.yaml 
site::net::nat_port: 12020 
... 
# role/storage.yaml 
site::net::nat_ip: 198.58.119.126 
... 
# location/portland.yaml 
site::net::firewall_segment: segment04 
... 

如前所述,部分数据在其他上下文中也会有所帮助。假设你通过定义的类型部署了一个脚本。该脚本会向远程机器发送消息。目标地址和端口作为参数传递给定义的类型。每个需要作为目标的节点可以导出该脚本资源:

@@site::maintenance_script {"/usr/local/bin/maint-${::fqdn}": 
  address => hiera('site::net::nat_ip'), 
  port    => hiera('site::net::nat_port'), 
}

将所有这些功能放在一个接收端口和地址作为参数的类中是不切实际的。你可能希望从不同的类甚至模块中检索相同的值,每个类或模块负责各自的导出。

处理哈希和数组

本章中的一些示例在 Hiera 中定义了数组值。好消息是,从 Hiera 中检索数组和哈希值与检索简单的字符串、数字或布尔值没有任何不同。hiera 函数会返回所有这些值,这些值已准备好在清单中使用。

还有两个函数提供了对这些值的特殊处理:hiera_arrayhiera_hash 函数。

这些函数的存在可能会让人感到困惑。新用户可能会误以为在从层次结构中检索哈希或数组时,必须使用这些函数。在继承 Puppet 代码时,检查这些派生函数是否在特定上下文中正确使用是个好主意。

当调用 hiera_array 函数时,它会从整个层次结构中收集所有命名的值,并将它们合并成一个长数组,包含所有找到的元素。再以分布式防火墙配置为例。每个节点应该能够导出一个开放端口以供公共访问的规则列表。该清单完全由 Hiera 驱动:

if hiera('site::net::nat_ip', false) { 
  @@firewall { "200 NAT ports for ${::fqdn}": 
    port        => hiera_array('site::net::nat_ports'), 
    proto       => 'tcp', 
    destination => hiera('site::net::nat_ip'), 
    jump        => 'DNAT', 
    todest      => $::ipaddress, 
  } 
} 

请注意,标题200 NAT 端口并不是指端口的数量,而只是遵循了firewall资源的命名约定。数字前缀使得维护顺序变得更容易。此外,请注意if子句中site::net::nat_ip键的默认值false看似不合逻辑。虽然该资源只有在为相应节点定义了public_ip时才会导出,但这形成了一个有用的模式。

如果false或空字符串是该键可能的值,则必须小心。在这种情况下,if子句将忽略该值。在这种情况下,您应该使用明确的比较:

if hiera('feature_flag_A', undef) != undef { ... }

层次结构可以在多个层次上持有端口:

# common.yaml 
nat_ports: 22 

SSH 端口应该对所有获得公共地址的节点可用。请注意,这个值本身不是一个数组。没关系,Hiera 会将标量值包含在结果列表中而不发出任何警告:

# role-webserver.yaml 
nat_ports: [ 80, 443 ] 

独立的 Web 应用服务器会暴露它们的 HTTP 和 HTTPS 端口

公共:

# tbt-backend-test.example.net.yaml 
nat_ports:  
  - 5973 
  - 5974 
  - 5975 
  - 6630 

新的云服务的测试实例应该暴露一系列自定义服务的端口。如果它具有webserver角色(以某种方式),它将导致端口2280443的导出,以及它单独选择的端口列表。

在设计这种结构时,请记住,数组合并永远只是累积性的。无法从最终结果中排除在较低层次添加的值。在这个例子中,你将无法为任何给定的机器禁用 SSH 端口22。添加常见值时需要特别小心。

哈希值也有类似的查找函数。hiera_hash函数也会遍历整个层次结构,并通过合并它在所有层次上找到的所有哈希来构建一个哈希。较高层次的哈希键会覆盖较低层次的哈希键。在这种情况下,所有值必须是哈希。字符串、数组或其他数据类型在此情况下不允许:

# common.yaml 
haproxy_settings: 
  log_socket: /dev/log 
  log_level: info 
  user: haproxy 
  group: haproxy 
  daemon: true 

这些是haproxy在最低层次上的默认设置。在 Web 服务器上,守护进程应该作为通用的 Web 服务用户运行:

# role/webserver.yaml 
haproxy_settings: 
  user: www-data 
  group: www-data 

使用hiera('haproxy_settings')时,这将只评估为哈希值{'user'=>'www-data','group'=>'www-data'}。角色特定层次上的哈希完全覆盖了默认设置。

要获取所有值,请改用hiera_hash('haproxy_settings')创建一个合并器。结果可能更有用:

{ 'log_socket' =>'/dev/log', 'log_level' => 'info', 
'user' => 'www-data', 'group' => 'www-data', 'daemon' => true } 

限制与hiera_array类似。任何层次结构级别的键都无法删除;只能用不同的值覆盖它们。最终结果与将哈希替换为一组键非常相似:

# role/webserver.yaml 
haproxy::user: www-data 
haproxy::group: www-data 

如果你选择这样做,数据也可以很容易地适配到一个类中,该类可以自动将这些值绑定到参数上。因此,优先使用平面结构可能是有益的。正如下一节所解释的那样,在 Hiera 中定义哈希仍然是值得的。docs.puppetlabs.com/references/latest/function.html#createresources 最初是为此设计的。Hiera 可以作为一个基础的 ENC。

在清单和 Hiera 设计之间进行选择

现在你可以将大多数具体配置转移到数据存储中。

类可以从清单中或通过 Hiera 包含。Puppet 在层级中查找参数值,你可以灵活地将配置值分配到那里,以便为每个节点实现所需的结果,最大限度地减少工作量和冗余。

这并不意味着你不再编写实际的清单代码。清单仍然是你设计的核心支柱。你将经常需要使用配置数据作为输入的逻辑。例如,可能会有一些类,只有从 Hiera 中检索到某个特定值时才应该包含:

if hiera('use_caching_proxy', false) { 
    include nginx 
} 

如果你仅仅依赖 Hiera,你将需要在所有设置 use_caching_proxy 标志为 true 的层级中,将 nginx 添加到 classes 数组。这很容易出错。更糟糕的是,这个标志可以在更具体的层级上被覆盖为 false,但是 nginx 元素不能从通过 hiera_include 检索的数组中移除。

重要的是要记住,清单和数据应当相辅相成。你应该主要构建清单,并在合适的位置添加查找函数调用。在 Hiera 中定义标志和值应当允许你(或模块的用户)更改清单的行为。数据不应当是目录编排的驱动因素,除非是在你用大量数据结构替换大量静态资源的地方。

调试数据查找

如你从前面的例子中看到的那样,贡献到任何模块的完整配置的数据可能会分散在你的数据源集合中。这使得确定每个代理节点从哪个位置检索相应的值变得具有挑战性。追踪数据源找出某些层级的更改为什么不会对某些代理生效,可能会让人感到沮丧。

为了帮助使这一过程更加透明,Hiera 附带了一个名为 hiera 的命令行工具。调用它很简单:

root@puppetmaster # hiera -c /etc/puppetlabs/code/hiera.yaml demo::atoms  

它使用 hiera.yaml 中指定的配置检索给定的键。确保你使用与 Puppet 相同的 Hiera 配置。

当然,只有当 Hiera 选择与编译器相同的数据源时,这才是合理的,编译器使用事实值来形成具体的层级。这些必需的事实可以作为最终参数直接在命令行上提供:

root@puppetmaster # hiera -c /etc/puppetlabs/code/hiera.yaml demo::atoms 
::clientcert=int01-web01.example.net ::role=webserver ::location=ny  

这会将指定服务器的 demo::atoms 值打印到控制台。事实值也可以从 YAML 文件或其他替代来源中获取。使用 hiera --help 以获取可用场景的信息。

确保添加 -d(或 --debug)标志,以便获取有关遍历层级结构的有用信息:

root@puppetmaster # hiera -d -c ...

Hiera 5 提供了另一种调试数据查找的方法。我们将在本章稍后介绍 Hiera 5。

从数据管理资源

现在,你可以将配置设置移到 Hiera 并将你的清单专注于逻辑部分。就类及其参数而言,这一过程非常顺畅,因为类参数会自动从 Hiera 获取其值。对于需要实例化资源的配置,你仍然需要编写完整的清单并添加手动查找函数调用。

例如,Apache 网络服务器需要一些全局设置,但其配置的有趣部分通常在虚拟主机配置文件中完成。Puppet 通过定义的资源类型来建模它们。如果你想配置 iptables 防火墙,你必须声明大量的 firewall 类型资源(通过 puppetlabs-firewall 模块提供)。

这类复杂的资源可能会使你的清单变得杂乱无章,但它们大多只是数据而已。许多防火墙规则没有内在的逻辑(尽管有时一组规则是从一个或多个关键值推导出来的)。虚拟主机通常也代表自己,与其他部分的配置细节关系较少或没有关系。

Puppet 还提供了另一个函数,允许你将整组此类资源移到 Hiera 数据中。模式很简单:同类型的资源通过哈希表示。键是资源标题,值是另一个包含属性键值对的哈希层:

services: 
  apache2: 
    enable: true 
    ensure: running 
  syslog-ng: 
    enable: false 

这些 YAML 数据表示两个 service 资源。要使 Puppet 将它们作为实际资源添加到目录中,请使用 Puppet 4 中的迭代器函数:

$resource_hash.each |$res_title,$attributes| { 
  service { $res_title: 
    ensure => $attributes['ensure'], 
    enable => $attributes['enable'], 
  } 
} 

在旧版 Puppet 代码中,你很可能会找到使用 create_resources 函数的情况:

$resource_hash = hiera('services', {}) 
create_resources('service', $resource_hash) 

第一个参数是资源类型的名称,第二个参数必须是实际资源的哈希。这个技术还有一些其他方面,但请注意,使用 Puppet 4 后,不再需要依赖 create_resources 函数。

无论如何,了解它的基本原理还是很有用的。它仍然在现有的清单中广泛使用,并且是将数据转换为资源的最简洁方式。欲了解更多信息,请参考在线文档 docs.puppetlabs.com/references/latest/function.html#createresources

Puppet 4 的迭代器相比 create_resources 方法有一些优势:

  • 您可以执行数据转换,例如为字符串值添加前缀,或推导额外的属性值。

  • 每次迭代不仅仅是创建一个资源,例如,还可以包括所需的类。

  • 您可以设计一个偏离 create_resources 严格预期的数据结构。

  • 清单更加清晰和直观,特别是对于没有经验的读者。

对于创建许多简单资源(例如前面示例中的服务),您可能希望避免在 Puppet 4 清单中使用 create_resource。请记住,如果不利用这一点,您仍然可以通过坚持使用 create_resources 来保持清单简洁。

Puppet 4 提供了一个有用的工具来生成适用于 create_resources 的 YAML 数据。使用以下命令,您可以让 Puppet 输出代表本地系统中可用服务及其当前属性值的服务类型资源:

puppet resource -y service 

-y 选项选择 YAML 输出,而不是 Puppet DSL。

从理论上讲,这些技术允许您将几乎所有代码移动到 Hiera 数据中(下一节将讨论这到底有多可取)。还有一个功能进一步推动了这一方向:

hiera_include('classes') 

这个调用从整个层级结构中收集值;就像hiera_array一样。结果数组被解释为类名的列表。所有这些命名的类都会被包含。这允许您在清单中进行一些额外的合并:

# common.yaml 
classes: 
  - ssh 
  - syslog 
... 
# role-webserver.yaml 
classes: 
  - apache 
  - logrotate 
  - syslog 

您甚至可以使用 hiera_include 在任何 node 块之外声明这些类。数据将影响所有节点。此外,您还可以通过 hiera_include 声明一些不同的类,其名称存储在不同的 Hiera 键下。

为每个节点列举要包含的类,正是 Puppet 的外部节点分类器ENCs)最初的设计目的。得益于hiera_include函数,Hiera 可以作为一个基本的 ENC。与编写自定义 ENC 相比,这种方法通常更受欢迎。然而,需要注意的是,一些开源 ENC,如 Foreman,功能强大,并能带来更多的便利;因此,Hiera 并没有完全取代这一概念。

这些工具的组合为您提供了一些方法,可以将清单缩小到其核心部分,并通过 Hiera 优雅地配置您的机器。

Hiera 版本 5

Hiera 5 是与 Puppet 4.9 一同发布的。早期的 Puppet 4 版本捆绑了 Hiera 4。旧版 Hiera 与 Hiera 5 之间的主要区别是多层次 Hiera 层级结构的概念:

  • 第一层是模块层。Hiera 5 允许您通过在模块根目录中指定一个 Hiera 5 配置版本的 hiera.yaml 文件,在模块中使用 Hiera 数据。

  • 第二层是环境层,您可以将 hiera.yaml 文件放置在环境根目录中。

最后一层是主层,其中 hiera.yaml 位于 /etc/puppetlabs/puppet/hiera.yaml。这是旧版 Hiera 中唯一的层。

主层不再被认为是最佳实践,存在的原因是为了兼容性。大家都被鼓励将数据从主层迁移到环境层。

在 Hiera 5 中,配置文件的内容完全不同。它仍然是 YAML 风格的文件,但后端不再全局配置,而是放入层级结构中。这允许你为不同的 Hiera 层级指定不同的后端。

让我们将 hiera.yaml 文件从 Hiera 3 转换为 Hiera 5:

# /etc/puppetlabs/puppet/hiera.yaml 
:backends: 
  - yaml 
:hierarchy:  
  - node/%{::clientcert} 
  - role/%{::role} 
  - location/%{::datacenter} 
  - common 
:yaml:  
  :datadir: /etc/puppetlabs/code/environments/%{::environment
     }/hieradata 

将此文件转换为 Hiera 5 .yaml 文件:

# /etc/puppetlabs/code/environments/production/hiera.yaml 
--- 
version: 5 
# specify the default datadir and yaml backend 
defaults: 
  datadir: hieradata 
  data_hash: yaml_data 
# build hierarchy. Note that paths need the file ending! 
hierarchy: 
  - name: "Per-node data" 
    path: "node/%{::clientcert}.yaml" 
  - name: "Per-role data" 
    path: "role/%{::role}.yaml" 
  - name: "Per-location data" 
    path: "location/%{::datacenter}.yaml" 
  - name: "Common data" 
    path: 'common.yaml' 

除此之外,还可以通过向 Hiera 数据中添加 lookup_options 键来配置 Hiera 查找行为。在每次数据查找时,Hiera 5 会首先检查 lookup_options 键,并使用此信息来查找所需的数据。但是,Hiera 应该如何知道使用哪一层呢?

在 Hiera 3 中,我们使用 hierahiera_arrayhiera_hash 函数来检索数据。而在 Hiera 5 中,这些函数被单一的 lookup 函数替代。

  • hiera(键)转换为 lookup(键)

  • hiera_array(键)转换为 lookup(键,数组,唯一)

  • hiera_hash(键)转换为 lookup(键,哈希,哈希)

从类中自动查找数据无需进一步的更改。Hiera 5 的另一个变化是我们调试 Hiera 数据查找的方式。Puppet 现在具有 Puppet lookup 接口。记住 Hiera 的命令行工具:

root@puppetmaster # hiera -c /etc/puppetlabs/code/hiera.yaml demo::atoms 
::clientcert=int01-web01.example.net ::role=webserver ::location=ny  

现在我们可以使用 Puppet lookup 命令行工具:

root@puppetmaster
**# puppet lookup demo::atoms -node int01-web01.example.net**  

主要区别在于 Puppet lookup 使用存储在 Puppet 主机上的事实,而不是在命令行中添加每个使用的事实。除此之外,还有一个额外的选项 --explain,它取代了 Hiera 命令行工具中的调试选项。

使用 Puppet lookup 时,带 --explain 选项,输出还会显示合并行为的查找。

描述所有新特性,尤其是路径和通配符的可能性,以及如何在 Hiera 内部配置 Hiera 查找行为,超出了本书的范围:

www.craigdunn.org/2012/05/239/,该设计后来被许多用户采用。

总结

Hiera 是一个以层次结构方式存储和检索数据的工具。每次检索都会使用来自每个层级的不同数据源,并从最具体的层级遍历到最不具体的层级。层级由用户在 YAML 文件中作为数组定义。

Puppet 内置了 Hiera 支持,你可以用它来将数据与代码分离。在清单中,你主要通过 hiera 函数执行查找。在大多数情况下,相应的条目将依赖于事实值。

通过 Puppet 使用 Hiera 的另一种常见方式是以<class-name>::<parameter-name>格式命名 Hiera 键。当包含一个带参数的类时,Puppet 会在 Hiera 中查找这些键。如果清单未提供参数值,Puppet 会自动将 Hiera 中的值绑定到相应的参数。

拥有大量静态资源的清单可以通过将声明转换为哈希,并使用create_resourceseach函数从数据中声明资源来进行清理。

Hiera 5 提供了一整套新的功能,包括模块中的数据和环境中的数据。本章向你提供了如何将数据迁移到 Hiera 5 的指导。

在第九章,《Puppet 角色和配置文件》中,我们将讨论模块和节点分类如何协同工作,以及如何自动构建和部署 Puppet 代码环境。

第九章:Puppet 角色与配置文件

现在我们已经对 Puppet DSL 及其概念有了完整的了解,是时候看看如何基于 Puppet 构建反映你基础设施设置和需求的实现了。

在 Puppet 的早期,通常的做法是将资源和变量添加到节点分类中。这通常导致代码重复,使重构几乎不可能。这种模式大多反映了通常的管理员工作,即配置单个系统。

为了避免难以管理和维护的代码,Puppet 模块周围出现了一个社区。该社区致力于将系统的技术部分实现为 Puppet 模块。模块的好处在于可以通过参数重用,并且由于共享的努力,可以更快地修复错误和实现新功能。

由于我们现在有大量可用的模块,我们必须重新思考结合模块的节点分类模式。在这里,角色和配置文件模式发挥了作用。

本章将涵盖以下主题:

  • 技术组件模块

  • 在配置文件中实现组件

  • 从配置文件构建角色

  • 业务用例和节点分类

  • 将代码放置在 Puppet 服务器上

技术组件模块

直到现在,我们一直将模块称为严格的目录结构,包含类、静态文件、模板和扩展。现在我们必须区分上游或通用模块与我们的平台实现模块。

现在负责特定技术组件的模块被称为技术组件模块。技术组件本身是为在系统上运行的某些软件(如 Nginx、Postgres 或 LDAP)配置的一组配置。

一直存在一个问题,即模块是否是技术组件模块。有一些模式可以帮助你识别技术组件模块:

  • 在上游开发,拥有活跃的社区

  • 开源,包含READMELICENSE文件

  • 仅管理所需内容

  • 清晰描述的入口类,包含采纳和可重用的参数

  • 允许与其他技术组件类堆叠在一起

  • 使用与配置技术相关的模块名称

  • 通常支持多个操作系统

  • 拥有公共信息,如包和配置文件名

  • 不包含私人数据,例如内部 DNS 服务器 IP

在配置文件中实现组件

Puppet 代码如果不是从上游获取,而是内部开发的,用于描述你的基础设施,通常是资源和上游类的实现。这种实现被称为配置文件类。

从技术上讲,配置文件是一个包含类的模块,通常还会有参数、定义、文件和模板。在极少数情况下,将自定义事实或自定义函数也包含在配置文件中可能是有价值的。

在一个配置文件内部,指定数据和资源。数据可以是静态数据,适用于整个平台,或者放入 Hiera 中。资源可以是任何东西,比如类、文件、包和服务。

将这些内容组合成一个配置文件,构建出另一个抽象层:

  • 数据通过 Hiera 进行抽象。

  • CLI 命令通过资源类型进行抽象。

  • 资源类型通过技术组件模块进行抽象。

  • 技术组件模块通过配置文件进行抽象。

当你在互联网上搜索配置文件时,你大多数会找到关于 Apache、MySQL 和 WordPress 安装的简短介绍。

配置文件不适合公开发布,因为它们通常包含关于你基础设施的私人信息。相反,你需要自己开发配置文件。

让我们从一个例子开始:一个基于 phpmyadmin 的数据库管理系统。

系统由多个基础技术组件组成:远程登录、备份、防火墙、Web 服务器、数据库、phpphpmyadmin。这些组件中的每一个都由上游开发的技术组件模块进行管理。我们希望的实现方式是通过配置文件完成的:

# Class profile::login
#
# manages ssh access
# company policy requires the following settings:
# - forbid root login
# - forbid x11 forwarding
# - allow login based on group (admins)
#
# We have several other settings which are put into our own
# sshd_config template file
#
class profile::login {
  class { 'ssh':
    sshd_x11_forwarding     => false,
    sshd_config_template    => 
    epp('profile/login/sshd_config.epp'),
    sshd_config_allowgroups => ['admins'],
    permit_root_login       => 'no',
  }
}

不必为每个不同的设置写一个单独的配置文件,你可以选择为配置文件添加参数并利用 Hiera 查找,或者将组件堆叠在一起:

# Class profile::login::secure
#
# Reuses profile::login classes
# adds known_host_file based on template
#
class profile::login::secure {
  include profile::ssh
  file { '/etc/ssh/ssh_known_hosts':
    ensure  => file,
    content => epp('profile/login/ssh_known_hosts.epp'),
  }
}

对 MySQL 使用相同的模式。主mysql配置文件仅使用puppetlabs-mysql模块安装一个 MySQL 实例:

# Class profile::database::mysql
#
# Needs data in hiera:
# - mysql_root_password (String), defaults to 123456
# - mysql_database (Hash)
#
class profile::database::mysql {
  $mysql_root_password = lookup('mysql_root_password', String, 'first', '123456')
  $mysql_database = lookup('mysql_database', Hash, 'deep', '')
  class { 'mysql':
    root_password           => $mysql_root_password,
    remove_default_accounts => true,
  }
  class { 'mysql::bindings':
    php_enable => true,
  }
  $mysql_database.each |$db, $options| {
    mysql::db { $db:
      * => $options,
    }
  }
}

相同的模式适用于使用上游模块安装 phpphpmyadmin

# Class profile::scripting::php
#
# uses puppet/php mdoule
# basic installation only
#
class profile::scripting::php {
  include ::php
} 
# Class profile::apps::phpmyadmin
#
# uses jlondon/phpmyadmin
# configures the application and application vhost
#
class profile::apps::phpmyadmin {
  class { 'phpmyadmin': }
  phpmyadmin::server{ 'default': }
  phpmyadmin::vhost { 'internal.domain.net':
    vhost_enabled => true,
    priority      => '20',
    docroot       => $phpmyadmin::params::doc_path,
    ssl           => true,
   }
}
# Class profile::apps::phpmyadmin::db
#
# uses jlondon/phpmyadmin module
# exports the setting for phpmyadmin
# 
class profile::apps::phpmyadmin::db {
  @@phpmyadmin::servernode { "${::ipaddress}":
    server_group => 'default',
  }
}

在目录结构中对配置文件进行分组没有技术上的必要。想象一下一个基础设施,包含大量配置文件,甚至是大量相似的配置文件,如 PostgreSQL、MySQL、MariaDB、Galera Cluster、Oracle DB 和 MSSQL。在这种情况下,分组比使用平面文件结构更合适,因为许多平面文件会导致难以阅读的目录结构:

profile/
|- manifests/
|   |- apps/
|   |   |─ phpmyadmin/
|   |   |   \- db.pp
|   |   \- phpmyadmin.pp
|   |- database/
|   |   \- mysql.pp
|   |- login/
|   |   \- secure.pp
|   |- login.pp
|   \- scripting/
|   \─ php.pp
\- templates/
    \- login/
        |- sshd_config.epp
        \- ssh_known_hosts.epp 

业务使用场景和节点分类。

随着所有实现的就位,我们开始考虑如何进行节点分类。

有多种可用的选项,哪个是最佳解决方案,主要取决于你的平台。

当你有一个非常多样化的平台时,角色作为另一层抽象的概念并不是非常有用,因为它通常会导致代码重复。在这种情况下,大多数人选择使用配置文件进行节点分类。

当你有大量相同配置的系统时,最好采用角色模式并按业务使用场景对系统进行分类。

业务使用场景允许你描述系统时,不仅仅是按它们的功能来描述,更是按它们的使用目的来描述。

想一想phpmyadmin的安装。根据使用场景和业务方的不同,可能会有不同的分类名称:

技术人员会使用“数据库控制面板”这个术语。如果phpmyadmin安装是由销售团队使用的,可能他们会将这个系统称为crm 数据管理系统

最好的解决方案是确定应用程序的相关方,并询问该应用程序的用途。这样可以带来一个积极的副作用,即获得所有业务使用案例的概览。如果你发现一个系统有多个业务使用案例,那么在系统发生故障时,就能更容易理解其业务影响。

过去,人们将许多应用程序堆叠到一个系统上,以便最大化硬件使用率。这些是单个节点上有多个业务使用案例的基础设施。随着虚拟机概念的出现,这种做法已经过时。如今,一个虚拟机应该只服务于一个业务使用案例。

从配置文件构建角色

让我们继续使用phpmyadmin的例子,它是为销售部门构建的,目的是让他们管理他们的 CRM 数据库。

在这种情况下,我们根据实现配置文件为该系统构建一个角色。角色的名称反映了业务使用案例:

class role::crm_db_control_panel {
  contain profile::login::secure
  contain profile::database::mysql
  contain profile::scripting::php
  contain profile::apps::phpmyadmin::db
  contain profile::apps::phpmyadmin
}

在角色中,只需要声明配置文件。不需要编写代码逻辑、资源或数据查找。这使得角色的使用更加灵活。不要尝试构建几乎相同的角色,因为这样会导致重复代码。相反,更好的做法是创建带有数据查找的配置文件,以便反映个人使用情况。

前面提到的角色可以用于节点分类:

node 'dbcrmmgmt.domain.com' {
  contain role::crm_db_control_panel
}

如你所见,我们有一个单一实例和一个单一角色。这在从头开始构建系统时非常有用。在现有的基础设施中,人们可以使用这一概念来识别当一个系统不可用时,哪些业务单元会受到影响。除此之外,它还帮助我们了解在需要时应该在哪里分离服务。

有些环境中,角色和配置文件的概念并不适用。通常,这些是现有平台,在这些平台上,多个服务(角色)运行在同一系统上,并且同一配置文件有许多不同的实现。在这些情况下,应该验证仅依靠实现层(配置文件)是否足够。

将代码放在 Puppet 服务器上

从技术角度看,角色和配置文件是模块中的类。通常,模块会放入环境的modules目录中。但是角色和配置文件不同于模块,它们是模块的实现和实现的集合。

为了反映这种不同的行为,通常的做法是向环境中添加另一个module目录。这个配置可以在环境中的environment.conf文件中完成:

#/etc/puppetlabs/code/environments/production/environment.conf
modulepath = site:modules:$basemodulepath

在我们的例子中,我们已经向模块路径设置中添加了一个新路径:site。该目录位于我们的环境中(/etc/puppetlabs/code/environments/production/site)。该目录将包含我们所有的角色和配置文件:

/etc/puppetlabs/code/environment/production/site/
  |- profile/
  | |- manifests/
  | | |- apps/
  | | | |- phpmyadmin/
  | | | | \- db.pp
  | | | \- phpmyadmin.pp
  | | |- database/
  | | | \- mysql.pp
  | | |- login/
  | | | \- secure.pp
  | | |- login.pp
  | | \ - scripting/
  | | \- php.pp
  | \- templates/
  | \- login/
  | |- sshd_config.epp
  | \- ssh_known_hosts.epp
\- role/
       \- manifests/
            \- db_control_panel.pp

这样,我们就能将角色和配置文件保存在一个单独的目录结构中,并且模块可以单独存放。

Puppet 控制仓库

通常,Puppet 模块与上游开发的库相同。我们希望确保在 Puppet 代码中使用的模块以一种可以升级的方式进行存储。因此,我们不能将模块直接放入我们的环境 Git 仓库中。除此之外,我们还希望在将 Puppet 代码更新投入生产之前进行测试。

最佳实践是拥有一个控制代码库,其中包含我们的角色和配置文件、清单的节点分类、环境配置文件和 Hiera v5 配置。现在我们再添加另一个文件:Puppetfile

Puppetfile 引用模块,并可选地指定它们的源位置和版本:

# Third Party modules
mod "puppetlabs/concat", '3.0.0' # postgresql requires concat < 3.0.0
mod "puppetlabs/stdlib", :latest
mod "puppetlabs/aws", :latest
mod "jdowning/rbenv", :latest
mod "puppet/archive", :latest
mod "puppetlabs/inifile", :latest
# Used by profile::puppet::server
mod 'puppetlabs/postgresql', :latest
mod 'puppetlabs/puppetdb', :latest
mod 'puppet/puppetserver',
  :git => "https://github.com/voxpupuli/puppet-puppetserver.git",
  :tag => '2.1.0'
mod 'puppetlabs/puppetserver_gem', :latest
mod 'puppet/r10k', :latest
# mod 'puppet/puppetboard', :latest

当没有给定源时,模块将从 Puppet Forge 安装(forge.puppet.com)。由于大多数生产系统无法连接到互联网,因此在私有 Git 服务器上拥有上游模块开发仓库的克隆是很有用的。

Puppet 控制代码库可以具有以下文件和目录结构:

 control-repo/
  |- environment.conf
  |- hieradata/
  |- hiera.yaml
  |- manifests/
  | |- dmz.pp
  | |- internal.pp
  | \- site.pp
  |- Puppetfile
  |- README.md
  \- site/
       |- profile/
       | |- manifests/
       | | |- apps/
       | | | |- phpmyadmin/
       | | | | \- db.pp
       | | | \- phpmyadmin.pp
       | | |- database/
       | | | \- mysql.pp
       | | |- login/
       | | | \- secure.pp
       | | |- login.pp
       | | \- scripting/
       | | \- php.pp
       | \- templates/
       | \- login/
       | |- sshd_config.epp
       | \- ssh_known_hosts.epp
       \- role/
            \- manifests/
                 \- db_control_panel.pp  

同步上游模块

通常,可以使用工作站进行同步。首先,在本地 Git 服务器上创建一个空的代码库并克隆到工作站。在这个本地代码库中,添加一个新的远程位置:

git remote add github https://github.com/puppetlabs/puppetlabs-concat.git

现在获取上游代码:

$ git pull github master
remote: Counting objects: 2871, done.
remote: Compressing objects: 100% (11/11), done.
remote: Total 2871 (delta 1), reused 7 (delta 1), pack-reused 2859
Receiving objects: 100% (2871/2871), 634.98 KiB | 648.00 KiB/s, done.
Resolving deltas: 100% (1394/1394), done.
From https://github.com/puppetlabs/puppetlabs-concat
* branch master -> FETCH_HEAD

Git 将代码与对象分离。通常,上游使用标签来标识其模块的版本发布。标签是非代码对象的一部分。接下来,获取非代码对象:

git fetch --all
Fetching origin
Fetching github
remote: Counting objects: 33, done.
remote: Compressing objects: 100% (29/29), done.
remote: Total 33 (delta 8), reused 28 (delta 3), pack-reused 0
Unpacking objects: 100% (33/33), done.
From https://github.com/puppetlabs/puppetlabs-concat
* [new branch] 1.0.x -> github/1.0.x
[...]
   * [new tag] 3.0.0 -> 3.0.0
   * [new tag] 4.0.0 -> 4.0.0
   * [new tag] 4.0.1 -> 4.0.1 

现在,代码被推送到本地代码库服务器:

$ git push origin master
   Counting objects: 2871, done.
Compressing objects: 100% (1368/1368), done.
    Writing objects: 100% (2871/2871), 634.76 KiB | 0 bytes/s, done.
Total 2871 (delta 1394), reused 2871 (delta 1394)
To /var/repositories/puppetlabs-concat.git
* [new branch] master -> master 

别忘了也要 push 标签:

$ git push origin master --tags
Counting objects: 19, done.
Compressing objects: 100% (19/19), done.
Writing objects: 100% (19/19), 11.01 KiB | 0 bytes/s, done.
Total 19 (delta 0), reused 0 (delta 0)
To /var/repositories/puppetlabs-concat.git
 * [new tag] 0.1.0 -> 0.1.0
[...]
 * [new tag] 3.0.0 -> 3.0.0
 * [new tag] 4.0.0 -> 4.0.0
 * [new tag] 4.0.1 -> 4.0.1

R10K 代码部署

通常,公司会有一个暂存系统,其中新的开发会在投入生产前进行测试。开发团队有一个开发阶段。从这个阶段,变更将被部署到质量门阶段,并在成功测试后放入生产阶段。

许多人使用这些名称来命名 Puppet 代码环境。但是,如果你的 Puppet 更改会破坏整个开发阶段会发生什么呢?在这种情况下,开发团队将无法继续进行紧急的改进或修复。这可能导致耗时且成本高昂的工作,需要从头开始重新构建开发阶段。

但我们如何开发和测试 Puppet 代码更改呢?通常,这需要为基础设施开发人员设置另一个阶段。所有其他基础设施阶段(开发、QA、生产)随后会使用稳定的生产 Puppet 环境代码进行部署。

这就导致了两个 Puppet 环境:开发生产

但我们不希望这些是两个独立的 Git 仓库,因为这会使更改的分阶段变得非常困难。这就是 R10K 的作用所在。R10K 使用 Puppet 控制代码库中的分支,并将这些分支部署到 Puppet Master 上作为环境。现在,代码更改可以通过从一个分支合并到另一个分支来完成。

分支名称可以自由选择,但有一些特殊名称不应使用:master、agent、main。特别是对于 Git 仓库,其中默认分支为 master,需要重新配置默认分支名称。

必须在 Puppet Master 上安装并配置 R10K。我们可以使用 Puppet 来进行安装:

$ puppet resource package r10k ensure=present provider=puppet_gem
package { 'r10k':
  ensure => ['2.5.5'],
}  

r10k 配置文件必须放置在 /etc/puppetlabs/r10k/r10k.yaml 目录下。在该文件中,我们激活所需的 cache 目录,r10k 会在其中存储所有仓库的本地副本。记住保持这些缓存的干净状态,并在与本地 Git 仓库缓存相关的行为异常时,删除整个缓存。

R10K 允许使用多个控制仓库。这些仓库可以轻松地在一个环境中共存,因为它们会被加上提供的源名称作为前缀。控制仓库被放置在 sources 部分,并获得一个唯一的名称。然后,我们指定 R10K 获取代码的远程 URL。代码被部署到 basedir 设置中提到的路径。

最后的两个设置与获取代码相关。第一个设置涉及 Git 访问。在 Git 设置中,可以设置提供程序。提供程序有两个选项:shellgit 和 rugged。shellgit 提供程序使用 git 二进制文件,必须在 Puppet 主机上可用。运行 R10K 命令的用户必须配置好 git Shell 环境,并指定用于授权的 ssh 密钥。Rugged 是一个 Ruby 实现,可以直接在 r10k.yaml 文件中指定 Git ssh 设置。通常,使用 shellgit 就足够了。最后一个设置指定了 R10K 如何从 Puppet Forge 获取模块。在这里,我们只能指定一个代理服务器:

# /etc/puppetlabs/r10k/r10k.yaml
---
 cachedir: '/var/cache/r10k'
  sources:
  infrastructure:
   remote: 'https://gitserver/infra/r10k-control-repo.git'
  basedir: '/etc/puppetlabs/code/environments'
  qa:
   remote: 'https://gitserver/security/r10k-control-repo.git'
  basedir: '/etc/puppetlabs/code/environments'
   prefix: true
git:
 provider: shellgit
    forge:
  # proxy: 'http://proxyserver:port'

从控制仓库部署分支和安装模块通过运行 r10k 命令来完成。请确保仅以需要获取代码的用户身份运行此命令。以 root 身份运行此命令时,可能是 ssh 凭据错误,或者环境之后归属于 root 用户。参数 -v 启用详细模式:

# r10k deploy environment -v# r10k deploy environment -vINFO -> Deploying environment /etc/puppetlabs/code/r10k/developmentINFO -> Environment development is now at db43d907d5b39d6197e42fc5c8edb4c0a4db27d6[...]INFO -> Deploying environment /etc/puppetlabs/code/r10k/productionINFO -> Environment production is now at 149a903d027d69d88a50ef3a563cb432c1e087c4[…]INFO -> Deploying environment /etc/puppetlabs/code/r10k/qa_developmentINFO -> Environment qa_development is now at db43d907d5b39d6197e42fc5c8edb4c0a4db27d6[…]INFO -> Deploying environment /etc/puppetlabs/code/r10k/qa_productionINFO -> Environment production is now at 149a903d027d69d88a50ef3a563cb432c1e087c4

可以通过安装来自 voxpupulipuppet-r10k 模块来替代手动运行 r10k 命令行工具 (github.com/voxpupili/puppet-r10k):

class profile::puppet::master::r10k_webhook {
  class {'r10k::webhook::config':
    enable_ssl      => false,
    use_mcollective => false,
  }
  class {'r10k::webhook':
    use_mcollective => false,
    user            => 'root',
    group           => '0',
  }
}

此 webhook 可以由 Git 服务器或 CI/CD 测试/部署工具链触发,例如 Jenkins 或 GoCD。通常,两者的组合是一种有效的设置,您可以尽快部署功能分支,并且仅在测试成功后才在开发或生产环境中运行更新。

总结

在构建和维护 Puppet 代码库时,最好实施角色和配置文件模式。它要求您定义角色以覆盖所有机器使用场景。角色通过混合和匹配配置文件类,这些类基本上是从自定义和开源模块中收集的类,用来管理实际资源。

在设置您的第一个大型 Puppet 安装时,最好从第一天起就遵循这种模式,因为这样可以让您在不陷入复杂结构的情况下扩展清单。

Puppet 代码的部署通过r10k管理,其中 Git 分支名称反映了您的 Puppet 代码质量。使用Puppetfile可以将您自己的 Puppet 代码开发与上游模块开发分离开来。

这就是我们对Puppet Essentials的介绍。我们已经覆盖了相当多的内容,但正如您可以想象的那样,我们仅仅触及了一些话题的皮毛,比如 Puppet 代码测试、提供者开发,或是利用 PuppetDB。您所学的内容最有可能满足您的即时需求。如需了解更多内容,请随时查阅docs.puppet.com/上的精彩在线文档,或者加入社区,在聊天、Slack 或邮件列表中提出您的问题。

感谢阅读,希望您在使用 Puppet 及其管理工具家族时能充满乐趣。

posted @ 2025-07-08 12:23  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报