Puppet5-初学者指南第三版-全-

Puppet5 初学者指南第三版(全)

原文:annas-archive.org/md5/d9e5b9495adc5799164a508075d35174

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

写一本技术书有很多错误的方式。一种方式是简单地重复官方文档。另一种是通过一个庞大复杂的例子来引导读者,而这个例子不一定有实际用途,只是展示作者有多聪明。还有一种方式是详尽无遗地列出技术的所有可用功能,以及你可能使用它们的所有方法,却没有足够的指导告诉你哪些功能是真正会用到的,或者哪些功能最好避免。

和你一样,我在工作中读了很多技术书籍。我不需要文档的转述:我可以在线阅读。我也不想看到大量的代码示例,尤其是一些我不需要做的事情。当然,我也不希望看到对每一个功能的无批判的叙述。

我希望的是,作者能够给我一个简明易懂的工具工作原理的解释,详细到让我能够立即开始使用它,但又不至于信息过载。我希望按我可能会使用它们的顺序来学习功能,并且希望能从第一章就开始构建一个能够运行并带来业务价值的东西。

这就是你从本书中可以期待的内容。无论你是开发人员、系统管理员,还是只是对 Puppet 感兴趣,你都会学习到可以立刻应用的 Puppet 技能。没有过多的理论或背景细节,我将向你展示如何安装软件包和配置文件、创建用户、设置定时任务、配置云实例、构建容器等等。每个例子都涉及到你工作中可能需要的真实、实际的内容,你将看到实现它的完整 Puppet 代码,连同每一步的操作说明和你会看到的输出。所有的例子都可以在 GitHub 仓库中下载并进行调整。

每个练习后,我都会详细解释每一行代码的作用和工作原理,帮助你将其调整到你自己的需求中,并让你确信自己理解了每一个细节。到本书结束时,你将具备使用 Puppet 进行实际、有效、日常工作的所有技能,并且你将获得一个完整的示范 Puppet 仓库,可以用最少的努力将你的基础设施搭建起来。

那么,让我们开始吧。

本书内容概述

第一章,开始使用 Puppet,介绍了 Puppet 并让你快速启动本书附带的 Vagrant 虚拟机。

第二章,创建你的第一个 manifest,展示了 Puppet 是如何工作的,以及如何编写代码来管理软件包、文件和服务。

第三章,使用 Git 管理 Puppet 代码,介绍了 Git 版本控制工具,展示了如何创建一个代码仓库以存储你的代码,并将其分发到 Puppet 管理的节点。

第四章,理解 Puppet 资源,详细讲解了packagefileservice资源,并介绍了如何管理用户、SSH 密钥、计划任务和命令的资源。

第五章,变量、表达式和事实,介绍了 Puppet 的变量、数据类型、表达式和条件语句,展示了如何使用 Facter 获取节点的数据,以及如何创建你自己的自定义事实。

第六章,使用 Hiera 管理数据,解释了 Puppet 的键值数据库,如何使用它来存储和检索数据,包括密钥,并展示了如何根据 Hiera 数据创建 Puppet 资源。

第七章,掌握模块,教你如何使用r10k工具从 Puppet Forge 安装现成的模块,介绍了四个关键模块,包括标准库,并展示了如何构建你自己的模块。

第八章,类、角色和配置文件,介绍了类和定义的资源类型,并展示了使用角色和配置文件来组织 Puppet 代码的最佳方式。

第九章,使用模板管理文件,展示了如何利用 Puppet 的 EPP 模板机制构建带有动态数据的复杂配置文件。

第十章,控制容器,介绍了 Puppet 对 Docker 容器的新强大支持,并展示了如何使用 Puppet 资源下载、构建和运行容器。

第十一章,编排云资源,解释了如何使用 Puppet 在 Amazon AWS 上配置云服务器,并介绍了一个基于 Hiera 数据的完全自动化云基础设施。

第十二章,综合应用,带你完成一个完整的 Puppet 基础设施示例,你可以下载并修改以适应你自己的项目,运用前面章节中的所有概念。

本书所需内容

你需要一台相对现代的计算机系统和互联网连接。你不需要成为 Unix 专家或经验丰富的系统管理员;我假设你能够安装软件、运行命令和编辑文件,但其他内容我会在接下来的部分逐步解释。

本书适合的人群

本书的主要读者群体是那些刚接触 Puppet 的人,包括系统管理员和开发人员,他们希望管理计算机服务器系统以进行配置管理。不要求具有编程或系统管理经验。然而,如果您以前使用过 Puppet,本书将为您提供最新特性和模块的全面介绍,希望您仍能学到许多新知识。

规范

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

文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名将以如下方式显示:“Puppet 可以使用 file 资源在节点上管理文件。”

代码块如下所示:

file { '/tmp/hello.txt':
  ensure  => file,
  content => "hello, world\n",
}

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

file { '/tmp/hello.txt':
 ensure  => file,
  content => "hello, world\n",
}

任何命令行输入或输出均按如下方式书写:

sudo puppet apply /vagrant/examples/file_hello.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.07 seconds

新术语重要词汇以粗体显示。您在屏幕上看到的词汇,比如菜单或对话框中的词汇,将以这种方式显示:“在 AWS 控制台中,从服务菜单中选择VPC。”

注意

警告或重要提示会以类似这样的框框形式显示。

提示

提示和技巧如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——喜欢或不喜欢的地方。读者的反馈对我们非常重要,因为它帮助我们开发出您真正能够从中受益的书籍。

要向我们发送一般反馈,请通过电子邮件 <feedback@packtpub.com>,并在邮件主题中注明书名。

如果您在某个领域有专业知识,并且有兴趣为本书撰写或贡献内容,请参见我们的作者指南:www.packtpub.com/authors

客户支持

既然您已成为 Packt 书籍的骄傲拥有者,我们有许多方法帮助您充分利用您的购买。

下载示例代码

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

您可以按照以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的官方网站。

  2. 将鼠标悬停在顶部的支持选项卡上。

  3. 点击代码下载与勘误表

  4. 搜索框中输入书名。

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

  6. 从下拉菜单中选择您购买此书的渠道。

  7. 点击代码下载

您还可以通过点击书籍网页上的代码文件按钮下载代码文件,该网页可以通过在搜索框中输入书籍名称访问。请注意,您需要登录您的 Packt 账户。

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

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书的代码包也托管在 GitHub 上,网址如下:

您还可以使用 GitHub 上 Packt Publishing 仓库中的代码包:

github.com/PacktPublishing/Puppet-5-Beginners-Guide-Third-Edition

我们还在 github.com/PacktPublishing/ 提供了来自我们丰富图书和视频目录的其他代码包。快去看看吧!

勘误表

尽管我们已尽力确保内容的准确性,但错误仍然可能发生。如果您在我们的书籍中发现错误——无论是文本还是代码中的错误——我们非常感激您能向我们报告。通过这样做,您可以帮助其他读者避免困扰,并帮助我们改进后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata 来提交,选择您的书籍,点击勘误提交表单链接,并输入错误详情。一旦您的勘误得到验证,您的提交将被接受,错误将上传到我们的网站或添加到该书的勘误部分的现有错误列表中。

要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support 并在搜索框中输入书名。所需信息将出现在勘误部分。

盗版

互联网上的版权材料盗版问题在各类媒体中普遍存在。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供该位置地址或网站名称,以便我们采取措施。

如有疑问,请通过<copyright@packtpub.com>联系我们,并提供涉嫌盗版内容的链接。

感谢您帮助我们保护作者和我们提供宝贵内容的能力。

问题

如果你对本书的任何部分有问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 使用 Puppet 入门

欲了解科技未能提升生活质量的所有方式,请按三号键。
--爱丽丝·卡恩

在本章中,你将了解在服务器上管理配置的一些挑战,常见的解决方案,以及像 Puppet 这样的自动化工具如何提供帮助。你还将学习如何下载包含本书所有源代码和示例的 GitHub 仓库,如何设置你自己的 Vagrant 虚拟机来运行代码,以及如何下载和安装 Puppet。

无论你是系统管理员,还是偶尔需要操作服务器的开发人员,或者只是对部署新应用所需的时间感到烦恼的人,你都可能遇到 Puppet 旨在解决的那类问题。

Puppet 入门

为什么我们需要 Puppet?

在生产环境中管理应用程序和服务是一项艰苦的工作,涉及许多步骤。首先,你需要一些服务器来提供服务。幸运的是,这些服务器可以从本地云服务商那里轻松获得,价格非常低廉。所以你已经拥有一台服务器,里面安装了基础操作系统,你可以登录了。那么接下来呢?在你进行部署之前,你还需要做一些事情:

  • 添加用户帐户和密码

  • 配置安全设置和权限

  • 安装运行应用所需的所有软件包

  • 自定义这些软件包的配置文件

  • 创建数据库和数据库用户帐户;加载一些初始数据

  • 配置应该运行的服务

  • 部署应用代码和静态资源

  • 重启任何受影响的服务

  • 配置机器以进行监控

这需要做很多事情——而且下一个你构建的服务器,你还得完全重复一遍相同的操作。这样似乎不太对劲。难道没有更简便的解决方案吗?

如果你能写一个可执行的规范,定义服务器应该如何设置,并且能够将其应用到任意数量的机器上,岂不是很好吗?

保持配置的同步

手动设置服务器非常繁琐。即使你是那种喜欢做繁琐事情的人,仍然有另一个问题需要考虑。下次你设置服务器时,几周或几个月后会发生什么呢?

你的细心笔记将不再与现实同步。在你度假期间,开发人员安装了几个应用现在依赖的新库——我猜你忘了告诉你!当然,他们面临着很大的进度压力。你可以发一封严厉的邮件,要求每个人在更改任何内容时都更新构建文档,大家也许会遵守。但即使他们更新了文档,实际上没有人会重新测试新构建过程,所以当你去做时,你会发现它不再有效。事实证明,如果只是升级现有数据库,一切正常,但如果在一台空白服务器上安装新版本,问题就出现了。

此外,由于构建文档已更新,上游发布了一个关键库的新版本。因为你总是在构建时安装最新版本,现在你新服务器的配置与旧服务器略有不同。这将导致一些微妙的问题,可能需要你三天时间,或者三瓶威士忌,才能调试出来。

当你拥有四五台服务器时,它们之间总会有一些差异。哪一台才是权威的?还是它们都稍微出错了?它们存在的时间越久,偏差就越大。你不会同时运行四五个不同版本的应用代码,那么为什么服务器配置可以这么混乱呢?

如果能够定期检查并将所有机器的配置状态与中央标准版本进行同步,岂不是很好吗?

在多台服务器上重复更改

人类并不擅长准确地反复执行复杂任务;这就是我们发明机器人的原因。我们容易出错、遗漏某些内容,或者被打断,导致忘记自己做过的事情。

变化随时发生,随着基础设施的增长,保持最新和同步变得越来越困难。同样地,当你更改应用代码时,你不会在每台服务器上使用文本编辑器手动修改这些更改。你只需修改一次,然后将其推广到所有地方。难道你的防火墙设置不像用户模型一样是你代码的一部分吗?

如果你只需要在一个地方做出更改,并且这些更改能够自动地推广到整个网络,是不是很好呢?

自我更新文档

在现实生活中,我们太忙,无法每五分钟停下来记录我们刚刚做的事情。正如我们所看到的,即使文档保持着极其更新,它的实用性也是有限的。

事实上,唯一可靠的文档就是服务器本身的状态。你可以查看一台服务器来了解它的配置,但这只适用于你仍然能访问那台机器的情况下。如果出现故障,你无法访问机器或其中的数据,你唯一的选择就是从头开始重建丢失的配置。

如果你有一个清晰、可读的人类构建过程,独立于你的服务器,并且保证是最新的,因为服务器实际上是根据它构建的,岂不是很好吗?

版本控制和历史

当你手动对系统进行临时更改时,你无法将它们恢复到某个时间点。很难撤销一系列的更改;你没有办法记录你做了什么以及事情是如何变化的。

当只有一个人时,这种情况已经很糟糕了。若是在团队中工作,情况会更糟,大家都在独立地做更改,互相干扰。

当你遇到问题时,你需要一种方式来知道是什么时候发生了变化,谁做的变化。你还需要能够将配置恢复到任何以前的稳定状态。

如果你能回到过去,岂不是很好吗?

为什么不直接写 shell 脚本?

许多人使用 shell 脚本来管理配置,这比手动做要好一些,但也没有好到哪里去。shell 脚本的一些问题包括:

  • 脆弱且不可移植

  • 难以维护

  • 作为文档阅读起来不容易

  • 非常依赖于特定环境

  • 不是一种好的编程语言

  • 很难对现有的服务器应用更改

为什么不直接使用容器?

容器!还有什么词能比这个更让人激动吗?许多人觉得容器能够让配置管理的问题迎刃而解。但这种感觉通常在试图容器化一个应用程序的最初几个小时之后就消失了。是的,容器让软件的部署和管理变得更容易,但容器到底是从哪里来的呢?事实证明,容器必须由人来构建和维护,这意味着需要管理 Dockerfile、数据卷、网络、集群、镜像仓库、依赖项等。换句话说,就是配置。计算机科学有一个我刚刚发明的公理,叫做痛苦守恒定律。如果你在一个地方避免了痛苦,它会在另一个地方重新出现。无论什么新技术诞生,都无法解决我们所有的问题;顶多,它会将这些问题替换成一些不同的、令人耳目一新的问题。

是的,容器很棒,但事实上,基于容器的系统需要更多的配置管理。你需要配置运行容器的节点,基于中央策略构建和更新容器镜像,创建和维护容器网络与集群等等。

为什么不直接使用无服务器架构(serverless)?

如果容器是由魔法小精灵驱动的,那么无服务器架构就是纯粹的仙尘。承诺是你只需将应用推送到云端,云端就会负责部署、扩展、负载均衡、监控等等。像大多数事情一样,现实并没有完全符合市场宣传。不幸的是,无服务器并不是真正的“无服务器”:它只是意味着你的业务运行在你无法直接控制的服务器上,并且你有更高的固定成本,因为你在为别人管理这些服务器付费。无服务器可以作为一个不错的起点,但它不是长期解决方案,因为最终你需要拥有自己的配置。

配置管理工具

配置 管理CM)工具是以代码形式管理基础设施的现代且合理的方式。有许多此类工具,它们的工作方式或多或少相同:你通过可编辑的文本文件和系统资源的模型指定所需的配置状态,工具会将每个节点(我们用来表示配置管理服务器的术语)的当前状态与所需状态进行比较,并进行必要的更改以使其保持一致。

正如大多数无关紧要的事情一样,互联网上有大量关于哪个 CM 工具最好讨论和争论。虽然不同工具在方法和功能上有显著差异,但不要被这些因素掩盖了一个事实:使用任何工具来管理配置,远比手动操作要好得多。

话虽如此,虽然有许多 CM 工具可用,但 Puppet 是一个出色的选择。没有任何工具比它更强大、更具可移植性或更广泛被采用。在本书中,我将向你展示是什么使 Puppet 如此优秀,以及只有 Puppet 才能做的事情。

什么是 Puppet?

Puppet 是两样东西:一种表达所需状态的语言(即如何配置你的节点),以及一个引擎,能够解释用 Puppet 语言编写的代码并将其应用到节点上,从而实现所需状态。

这个语言看起来像什么?它不像一系列指令,像是 shell 脚本或 Ruby 程序。更像是一组关于事物应该如何的声明。看一下下面的例子:

package { 'curl':
  ensure => installed,
}

用英文,这段代码表示:“应安装curl包。”当你应用这个清单(Puppet 程序称为清单)时,工具会执行以下操作:

  1. 检查节点上已安装的包列表,查看是否已安装curl

  2. 如果是,什么也不做。

  3. 如果没有,安装它。

这是另一个 Puppet 代码的例子:

user { 'bridget':
  ensure => present,
}

这是 Puppet 语言的声明,表示“bridget 用户应该存在。”(关键字 ensure 意味着“资源的期望状态是...”)。再次强调,这会导致 Puppet 检查 bridget 用户是否存在于节点上,并在必要时创建它。这也是一种文档,正式地以人类可读的方式表达系统的声明。代码表达了作者希望 bridget 用户始终存在的愿望。

因此,您可以看到 Puppet 程序——Puppet 清单——是关于应该存在什么内容以及它们应该如何配置的一组声明。

您不会发出“做这个,然后做那个”这样的命令。相反,您描述事物应该如何,剩下的交给 Puppet 去实现。这是两种完全不同的编程方式。一种(所谓的过程式风格)是传统的模型,像 C、Python、shell 等语言都使用这种模型。Puppet 的风格被称为声明式风格,因为您声明最终结果应该是什么,而不是指定实现步骤。

这意味着您可以将相同的 Puppet 清单重复应用到一个节点上,无论应用多少次,最终的结果都会相同。更好地理解 Puppet 清单,它更像是一种规范或声明,而不是传统意义上的程序。

资源和属性

Puppet 允许您通过 资源(可以存在的事物类型,如用户、文件或软件包)及其 属性(资源类型的适当属性,如用户的主目录,或文件的所有者和权限)来描述配置。您不必深入了解资源在不同平台上是如何创建和配置的。Puppet 会为您处理这些事情。

这种方法的优势在于,给定的清单可以应用于不同的节点,这些节点可能运行不同的操作系统,结果在各处都会相同。

Puppet 架构

值得注意的是,Puppet 有两种不同的使用方式。第一种方式被称为 代理/主节点架构,它使用一个专门的节点来运行 Puppet,所有其他节点都与这个节点联系以获取其配置。

另一种方式被称为 独立 Puppet无主模式,不需要专门的 Puppet 主节点。Puppet 在每个独立的节点上运行,并不需要联系一个中央位置来获取其配置。相反,您可以使用 Git 或其他任何复制文件到节点的方式,如 SFTP 或 rsync,来更新每个节点上的 Puppet 清单。

无论是独立架构还是代理/主节点架构,都得到 Puppet 官方的支持。您可以根据自己的喜好选择使用哪种方式。在本书中,我将仅讨论独立架构,这对于大多数组织来说更简单、更易于操作,但无论您使用代理/主节点架构还是独立架构,本书中的几乎所有内容都将一样适用。

提示

若要设置带有代理/主控架构的 Puppet,请参考官方 Puppet 文档。

为 Puppet 做准备

虽然 Puppet 本身是跨平台的,且支持多种操作系统,但本书将重点讲解一个操作系统,即 Ubuntu 16.04 LTS 版的 Linux,以及 Puppet 的最新版本 Puppet 5。不过,本书中的所有示例应该在任何最新操作系统或 Puppet 版本上都能正常运行,只需做一些微小的调整。

你可能会发现,阅读本书的最佳方式是使用你自己的 Linux 机器,按照示例进行操作。无论这是一台物理服务器、桌面或笔记本电脑、云实例,还是虚拟机,都没关系。我将使用流行的 Vagrant 软件在自己的计算机上运行虚拟机,你也可以这样做。本书的 GitHub 公共仓库包含一个 Vagrantfile,你可以使用它仅需几步就能启动并运行 Puppet。

安装 Git 并下载仓库

若要获取本书随附的仓库副本,请按照以下步骤操作:

  1. 浏览到 git-scm.com/downloads

  2. 下载并安装适合你操作系统的 Git 版本。

  3. 运行以下命令:

    git clone https://github.com/bitfield/puppet-beginners-guide-3.git
    
    

安装 VirtualBox 和 Vagrant

如果你已经有一台 Linux 机器或云服务器,可以用来执行示例,请跳过本节并进入下一章。如果你希望使用 VirtualBox 和 Vagrant 在你的计算机上运行本地 虚拟机VM)来配合示例使用,请按照以下说明操作:

  1. 浏览到 www.virtualbox.org/

  2. 下载并安装适用于你操作系统的 VirtualBox 版本

  3. 浏览到 www.vagrantup.com/downloads.html

  4. 选择适合你操作系统的 Vagrant 版本:OS X、Windows 等

  5. 按照提示安装软件

运行你的 Vagrant 虚拟机

安装完 Vagrant 后,你可以启动 Puppet 初学者指南虚拟机:

  1. 运行以下命令:

    cd puppet-beginners-guide-3
    scripts/start_vagrant.sh
    
    

    Vagrant 将开始下载基础框。下载完成并启动后,它会安装 Puppet。这可能需要一些时间,但安装完成后,虚拟机就可以使用了。

  2. 使用以下命令连接到虚拟机:

    vagrant ssh
    
    
  3. 你现在已经在虚拟机上获得了命令行 Shell。通过运行以下命令检查 Puppet 是否已安装并正常工作(你可能会得到不同的版本号,这没问题):

    puppet --version
    5.2.0
    
    

    小贴士

    如果你使用的是 Windows,可能需要安装 PuTTY 软件才能连接到你的虚拟机。关于在 Windows 上使用 Vagrant 的一些有用建议可以在这里找到:

    tech.osteel.me/posts/2015/01/25/how-to-use-vagrant-on-windows.html

故障排除 Vagrant

如果在运行虚拟机时遇到任何问题,可以访问 VirtualBox 或 Vagrant 的网站寻求帮助。特别是,如果你使用的是较旧的机器,可能会看到如下信息:

VT-x/AMD-V hardware acceleration is not available on your system. Your 64-bit guest will fail to detect a 64-bit CPU and will not be able to boot.

你的计算机可能有一个 BIOS 设置,用于启用 64 位硬件虚拟化(根据制造商的不同,这个功能的名称可能是VT-xAMD-V)。启用此功能可能会解决问题。如果没有效果,你可以尝试使用 Vagrant 盒子的 32 位版本。编辑 Git 仓库中名为Vagrantfile的文件,并通过在前面加上#字符来注释掉以下行:

config.vm.box = "ubuntu/xenial64"

通过删除前导的#字符来取消注释以下行:

# config.vm.box = "ubuntu/xenial32"

现在重新运行scripts/start_vagrant.sh命令。

总结

在本章中,我们探讨了配置管理工具可以帮助解决的各种问题,特别是 Puppet 如何建模系统配置的各个方面。我们查看了本书示例代码的 Git 仓库,安装了 VirtualBox 和 Vagrant,启动了 Vagrant 虚拟机,并首次运行了 Puppet。

在下一章中,我们将编写我们的第一个 Puppet 清单,了解 Puppet 资源的结构以及如何应用它们,并学习packagefileservice资源。

第二章:创建你的第一个清单

开始是如此脆弱的时刻。
--弗兰克·赫伯特,《沙丘》

在本章中,你将学习如何用 Puppet 编写第一个清单,并如何利用 Puppet 配置服务器。你还将了解 Puppet 如何编译和应用清单。你将看到如何使用 Puppet 管理文件内容,如何安装软件包,以及如何控制服务。

创建你的第一个清单

你好,Puppet——你的第一个 Puppet 清单

任何编程语言中的第一个示例程序,按照传统,都会打印hello, world。虽然我们在 Puppet 中可以轻松做到这一点,但让我们做一些更有挑战性的事情,让 Puppet 在服务器上创建一个包含该文本的文件。

在你的 Vagrant 盒子中,运行以下命令:

sudo puppet apply /examples/file_hello.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.07 seconds
Notice: /Stage[main]/Main/File[/tmp/hello.txt]/ensure: defined content as '{md5}22c3683b094136c3398391ae71b20f04'
Notice: Applied catalog in 0.01 seconds

我们可以暂时忽略 Puppet 的输出,但如果一切顺利,我们应该能够运行以下命令:

cat /tmp/hello.txt
hello, world

理解代码

让我们看一下示例代码,看看发生了什么(运行cat /example/file_hello.pp,或者在文本编辑器中打开文件):

file { '/tmp/hello.txt':
  ensure  => file,
  content => "hello, world\n",
}

代码术语file开始了一个资源声明,声明了一个file资源。资源是你希望 Puppet 管理的一部分配置:例如,文件、用户账户或软件包。资源声明遵循以下模式:

RESOURCE_TYPE { TITLE:
  ATTRIBUTE => VALUE,
  ...
}

资源声明将构成你几乎所有的 Puppet 清单,因此理解它们是如何工作的非常重要:

  • RESOURCE_TYPE表示你声明的资源类型;在本例中,它是file

  • TITLE是 Puppet 用来在内部标识资源的名称。每个资源必须具有唯一的标题。对于file资源,通常这个标题是文件的完整路径:在这个例子中,是/tmp/hello

这段代码的其余部分是描述如何配置资源的属性列表。可用的属性取决于资源的类型。对于文件,你可以设置contentownergroupmode等属性,但每个资源都支持的一个属性是ensure

再次强调,ensure的可能值是特定于资源类型的。在这种情况下,我们使用file来表示我们想要一个常规文件,而不是目录或符号链接:

ensure  => file,

接下来,为了在文件中添加一些文本,我们指定content属性:

content => "hello, world\n",

content属性将文件的内容设置为你提供的字符串值。在这里,文件的内容被声明为hello, world,后跟一个换行符(在 Puppet 字符串中,我们用\n表示换行符)。

请注意,content指定了文件的全部内容;你提供的字符串将替换文件中已有的内容,而不是将其附加到文件中。

修改现有文件

如果 Puppet 运行时文件已经存在且包含其他内容,会发生什么?Puppet 会修改它吗?

sudo sh -c 'echo "goodbye, world" >/tmp/hello.txt'
cat /tmp/hello.txt
goodbye, world
sudo puppet apply /examples/file_hello.pp
cat /tmp/hello.txt
hello, world

答案是肯定的。如果文件的任何属性,包括其内容,与清单不匹配,Puppet 将更改它,以使其一致。

如果你手动编辑 Puppet 管理的文件,这可能会导致一些意外的结果。如果你在不更改 Puppet 清单的情况下修改文件,Puppet 在下次运行时会覆盖该文件,你的更改将丢失。

因此,最好在 Puppet 管理的文件中添加注释,内容可以类似于以下示例:

# This file is managed by Puppet - any manual edits will be lost

在你首次部署文件时,将此内容添加到 Puppet 的文件副本中,它将提醒你和其他人不要手动更改文件。

模拟运行 Puppet

因为你不能事先确定应用 Puppet 清单会对系统做出哪些更改,所以最好先进行模拟运行。将 --noop 标志添加到 puppet apply 中,可以在不做任何实际更改的情况下,展示 Puppet 会执行的操作:

sudo sh -c 'echo "goodbye, world" >/tmp/hello.txt'
sudo puppet apply --noop /examples/file_hello.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.04 seconds
Notice: /Stage[main]/Main/File[/tmp/hello.txt]/content: current_value {md5}7678..., should be {md5}22c3... (noop)

Puppet 会根据文件的 MD5 哈希值决定是否需要更新 file 资源。在上一个例子中,Puppet 报告 /tmp/hello.txt 当前的哈希值是 7678...,而根据清单,它应该是 22c3...。因此,下一次 Puppet 运行时,该文件将被更改。

如果你想查看 Puppet 实际上会对文件做出什么更改,可以使用 --show_diff 选项:

sudo puppet apply --noop --show_diff /examples/file_hello.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.04 seconds
Notice: /Stage[main]/Main/File[/tmp/hello.txt]/content:
--- /tmp/hello.txt      2017-02-13 02:27:13.186261355 -0800
+++ /tmp/puppet-file20170213-3671-2yynjt        2017-02-13 02:30:26.561834755 -0800
@@ -1 +1 @@
-goodbye, world
+hello, world

当你想确保你的 Puppet 清单只会影响你预期的内容时,这些选项非常有用——有时,它们也能帮助你检查某些内容是否在 Puppet 外部发生了变化,而无需实际撤销更改。

Puppet 如何应用清单

这是清单的处理过程。首先,Puppet 读取清单及其中包含的资源列表(在这个例子中,只有一个资源),并将其编译成目录(节点所需状态的内部表示)。

接下来,Puppet 按顺序应用目录中的每个资源:

  1. 首先,它检查资源是否存在于服务器上。如果不存在,Puppet 将创建它。在这个例子中,我们声明了文件 /tmp/hello.txt 应该存在。第一次运行 sudo puppet apply 时,文件不存在,因此 Puppet 将为你创建该文件。

  2. 然后,对于每个资源,它检查目录中每个属性的值与服务器上实际存在的内容是否匹配。在我们的例子中,只有一个属性:content。我们指定了文件的内容应该是 hello, world\n。如果文件为空或包含其他内容,Puppet 将用目录中指定的内容覆盖该文件。

在这种情况下,第一次应用目录时,文件将为空,因此 Puppet 会将字符串 hello, world\n 写入文件中。

在后面的章节中,我们将更详细地探讨 file 资源。

创建你自己的文件

创建你自己的清单文件(你可以随意命名,只要文件扩展名是.pp)。使用file资源在服务器上创建一个文件,并设置你喜欢的内容。应用清单并使用 Puppet 检查文件是否已创建并包含你指定的文本。

直接编辑文件并更改内容,然后重新应用 Puppet,检查它是否将文件更改回清单中指定的内容。

管理包

Puppet 中的另一个关键资源类型是package。手动配置服务器的一个重要部分就是安装包,因此我们在 Puppet 清单中也会经常使用包。尽管每个操作系统都有自己的包格式,而且不同的格式在功能上差异较大,但 Puppet 通过一个单一的package类型来表示所有这些可能性。如果你在 Puppet 清单中指定一个给定的包应该被安装,Puppet 会使用适当的包管理器命令,在其运行的任何平台上安装它。

如你所见,Puppet 中所有资源声明都遵循这种格式:

RESOURCE_TYPE { TITLE:
  ATTRIBUTE => VALUE,
  ...
}

package资源没有什么不同。RESOURCE_TYPEpackage,你通常需要指定的唯一属性是ensure,而它通常需要的唯一值是installed

package { 'cowsay':
  ensure => installed,
}

尝试这个示例:

sudo puppet apply /examples/package.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.52 seconds
Notice: /Stage[main]/Main/Package[cowsay]/ensure: created
Notice: Applied catalog in 29.53 seconds

让我们来看看cowsay是否已安装:

cowsay Puppet rules!
 _______________
< Puppet rules! >
 ---------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

这可真是个有用的包!

Puppet 如何应用清单

package资源的标题是cowsay,因此 Puppet 知道我们在谈论一个名为cowsay的包。

ensure属性决定了包的安装状态:不出所料,installed告诉 Puppet 该包应该被安装。

正如我们在前面的示例中看到的,Puppet 通过逐个检查每个资源并检查它在服务器上的属性与清单中指定的属性是否匹配来处理这个清单。在这种情况下,Puppet 会查找cowsay包,看看它是否已安装。它没有,但清单中说应该安装它,因此 Puppet 执行所有必要的操作,以使现实与清单一致,这里意味着安装该包。

提示

本书仍处于早期阶段,但你已经可以使用 Puppet 做很多事情了!如果你能安装包并管理文件内容,那么你就能在很大程度上完成你可能需要的任何服务器配置。如果你现在就停止阅读(这会有点可惜,但我们都很忙),你仍然可以使用 Puppet 来自动化你将遇到的很多配置工作。但 Puppet 的功能远不止如此。

练习

创建一个清单,使用package资源安装你认为在服务器管理中有用的任何软件。以下是一些建议:tmuxsysdigatophtopdstat

使用 puppet 资源查询资源

如果你想查看 Puppet 认为你已安装的包的版本,可以使用puppet resource工具:

puppet resource package openssl
package { 'openssl':
  ensure => '1.0.2g-1ubuntu4.8',
}

puppet resource TYPE TITLE 将输出一个表示系统中指定资源当前状态的 Puppet 清单。如果你省略 TITLE,则会得到 TYPE 类型的所有资源的清单。例如,如果你运行 puppet resource package,你将看到系统上所有已安装包的 Puppet 代码。

提示

puppet resource 甚至有一个交互式配置功能。要使用它,请运行以下命令:

puppet resource -e package openssl

如果你运行此命令,Puppet 将生成资源的当前状态清单,并在编辑器中打开它。如果你现在进行更改并保存,Puppet 将应用该清单并对系统进行更改。这是一个有趣的小功能,但如果用这种方式进行整个配置,会相当耗时。

服务

第三种最重要的 Puppet 资源类型是服务:一个长期运行的进程,或者做一些持续性的工作,或者等待请求并对其做出响应。例如,在大多数系统上,sshd 进程会一直运行,并监听 SSH 登录请求。

Puppet 用 service 资源类型来建模服务。service 资源看起来像下面这个例子(你可以在 /examples/ 目录下的 service.pp 中找到这个例子。从现在起,我将只提供每个例子的文件名,因为它们都在同一个目录下):

service { 'sshd':
  ensure => running,
  enable => true,
}

ensure 参数控制服务是否应运行。如果它的值是 running,那么如你所料,Puppet 将启动服务,如果它没有运行的话。如果你将 ensure 设置为 stopped,Puppet 将停止服务,如果它正在运行的话。

服务也可以设置为在系统启动时启动,使用 enable 参数。如果 enable 设置为 true,服务将在启动时启动。如果 enable 设置为 false,则不会启动。一般来说,除非有充分的理由,否则所有服务都应该设置为在启动时启动。

使用 puppet describe 获取资源帮助

如果你记不住所有不同资源的各种属性,Puppet 有一个内置的帮助功能可以提醒你。例如,运行以下命令:

puppet describe service

这将提供 service 资源的描述,并列出所有属性和允许的值。这适用于所有内置资源类型以及许多第三方模块提供的资源类型。要查看所有可用资源类型的列表,可以运行以下命令:

puppet describe --list

包文件服务模式

给定的软件通常需要这三种 Puppet 资源类型:package 资源安装软件,file 资源部署软件所需的一个或多个配置文件,service 资源运行软件本身。

这是一个使用 MySQL 数据库服务器的例子(package_file_service.pp):

package { 'mysql-server':
  ensure => installed,
  notify => Service['mysql'],
}

file { '/etc/mysql/mysql.cnf':
  source => '/examples/files/mysql.cnf',
  notify => Service['mysql'],
}

service { 'mysql':
  ensure => running,
  enable => true,
}

package 资源确保 mysql-server 包被安装。

MySQL 的配置文件是/etc/mysql/mysql.cnf,我们使用file资源从 Puppet 仓库复制该文件,以便我们可以控制 MySQL 设置。

最后,service资源确保mysql服务正在运行。

通知链接资源

你可能注意到在前面的示例中,file资源中新增了一个属性,叫做notify

file { '/etc/mysql/mysql.cnf':
  source => '/examples/files/mysql.cnf',
  notify => Service['mysql'],
}

这是什么意思呢?假设你对mysql.cnf文件进行了修改,并使用 Puppet 应用了这个修改。更新后的文件将被写入磁盘,但因为mysql服务已经在运行,它无法知道配置文件已经更改。因此,直到重新启动服务,修改才会生效。不过,如果你在file资源上指定了notify属性,Puppet 会为你处理这个问题。notify的值是通知的资源,具体操作取决于被通知资源的类型。当它是一个服务时,默认操作是重新启动该服务。(我们将在第四章,理解 Puppet 资源中了解其他选项。)

通常,在包-文件-服务模式中,文件会通知服务,因此每当 Puppet 更改文件内容时,它会重新启动已通知的服务以应用新配置。如果有多个文件影响该服务,它们应该都通知服务,而 Puppet 足够智能,只会在有多个依赖资源更改时重新启动一次服务。

要通知的资源名称以资源类型(大写)为前缀,后跟资源标题,标题被引号括起来并放在方括号内:Service['mysql']

使用require进行资源顺序排列

在包-文件-服务示例中,我们声明了三个资源:mysql-server包、/etc/mysql/mysql.cnf文件和mysql服务。如果你考虑一下,它们需要按这个顺序应用。没有安装mysql-server包,就不会有/etc/mysql/目录来放置mysql.cnf文件。没有包或配置文件,mysql服务将无法运行。

一个完全合理的问题是,“Puppet 是否按照清单中声明的顺序应用资源?”答案通常是“是”,除非你明确使用require属性指定了不同的顺序。

所有资源都支持require属性,且其值是清单中声明的另一个资源的名称,指定方式与使用notify时相同。下面是再次给出的包-文件-服务示例,这次使用require明确指定了资源顺序(package_file_service_require.pp):

package { 'mysql-server':
  ensure => installed,
}

file { '/etc/mysql/mysql.cnf':
  source  => '/examples/files/mysql.cnf',
  notify  => Service['mysql'],
  require => Package['mysql-server'],
}

service { 'mysql':
  ensure  => running,
  enable  => true,
  require => [Package['mysql-server'], File['/etc/mysql/mysql.cnf']],
}

你可以看到,mysql.cnf资源需要mysql-server包。mysql服务则依赖于其他资源,这些资源以方括号中的数组形式列出。

当资源已经按正确的顺序排列时,您无需使用require,因为 Puppet 会按您声明的顺序应用资源。然而,显式指定顺序仍然可能是有用的,尤其是在清单文件中有大量资源时,这样做有助于代码阅读者理解。

在 Puppet 的早期版本中,资源是按较为任意的顺序应用的,因此使用require来表达依赖关系非常重要。而现在,您不需要经常使用它,通常只会在旧版代码中遇到。

总结

在本章中,我们已经看到了清单是如何由 Puppet 资源构成的。您学习了如何使用 Puppet 的file资源来创建和修改文件,如何使用package资源安装软件包,以及如何用service资源管理服务。我们还了解了常见的包-文件-服务模式,并学习了如何使用资源的notify属性向另一个资源发送消息,指示其配置已被更新。我们还讨论了在必要时使用require属性来明确资源之间的依赖关系。

您还学会了如何使用puppet resource检查系统的当前状态,并使用puppet describe获得有关所有 Puppet 资源的命令行帮助。为了检查 Puppet 会对系统进行哪些更改,而不实际进行更改,我们介绍了--noop--show_diff选项,用于puppet apply命令。

在下一章,我们将学习如何使用版本控制工具 Git 来跟踪您的清单文件,我们将介绍 Git 的基本概念,例如仓库(repo)和提交(commit),并学习如何将代码分发到您将用 Puppet 管理的每一台服务器。

第三章:使用 Git 管理你的 Puppet 代码

我们通过行动来定义自己。每一个决定都在告诉自己和世界我们是谁。
--比尔·沃特森

本章你将学习如何使用 Git 版本控制系统来管理你的 Puppet manifests。我还会向你展示如何使用 Git 将 manifests 分发到多个节点,以便你能使用 Puppet 开始管理整个网络。

使用 Git 管理你的 Puppet 代码

什么是版本控制?

如果你已经熟悉 Git,你可以跳过本章,直接阅读 创建 Git 仓库 部分。否则,这里有一个温和的入门介绍。

即使你是唯一一个在源代码(例如 Puppet manifests)上工作的人,能够查看自己做过的更改以及更改的时间也是很有用的。例如,你可能会意识到在过去某个时候引入了一个 bug,你需要精确地检查某个文件在何时被修改,以及修改了什么。版本控制系统让你能够做到这一点,它通过保持你对一组文件所做更改的完整历史记录,帮助你追踪所有更改。

追踪更改

当你与他人共同工作时,你还需要一种方式与团队其他成员沟通你的更改。像 Git 这样的版本控制工具不仅能追踪每个人的更改,还能让你记录 提交信息,解释你做了什么以及为什么这么做。以下示例展示了一个良好提交信息的一些要素:

Summarize changes in around 50 characters or less

More detailed explanatory text, if necessary. Wrap it to about 72
characters or so. In some contexts, the first line is treated as
the subject of the commit and the rest of the text as the body.
The blank line separating the summary from the body is critical
(unless you omit the body entirely); various tools like `log`,
`shortlog`, and `rebase` can get confused if you run the two together.

Explain the problem that this commit is solving. Focus on why you
are making this change as opposed to how (the code explains that).
Are there side effects or other unintuitive consequences of this
change? Here's the place to explain them.

Further paragraphs come after blank lines.

 - Bullet points are okay, too

 - Typically a hyphen or asterisk is used for the bullet, preceded
   by a single space, with blank lines in between, but conventions
   vary here

If you use an issue tracker, put references to them at the bottom,
like this:

Resolves: #123
See also: #456, #789

注意

这个示例来自 Chris Beams 的一篇精彩博文 如何编写 Git 提交信息

chris.beams.io/posts/git-commit/

当然,你不需要经常写这么长且详细的信息;大多数时候,一行就足够了。然而,提供更多的信息总比少提供好。

Git 还记录了更改发生的时间、是谁做的更改、哪些文件被更改、添加或删除了,以及哪些行被添加、修改或删除。正如你所想象的那样,如果你正在追踪一个 bug,而你能够查看代码的完整历史记录,那会大有帮助。这也意味着,如果需要,你可以将代码的状态回滚到历史中的任何一个时间点,并加以检查。

你可能会认为这增加了很多额外的复杂性。事实上,它非常简单。Git 会在你需要时才介入,你所需要做的只是当你决定记录代码更改时,写一条提交信息。

共享代码

一组受 Git 版本控制的文件叫做 仓库,通常等同于一个项目。一个 Git 仓库(从现在开始简称为 仓库)也是一个很好的方式来分发你的代码,无论是私有的还是公开的,这样别人可以使用、修改、向你提交更改,或者根据他们自己的需求朝着不同的方向发展它。本书在第一章中提到的公开 GitHub 仓库,Puppet 入门 就是一个很好的例子。你将能够使用这个仓库来完成书中的示例,但你也可以在为自己的基础设施构建 Puppet 清单时,使用它来获得帮助和灵感。

因为 Git 对于管理 Puppet 代码非常重要,所以最好熟悉它,而唯一的办法就是实际使用它。那么我们就从创建一个新的 Git 仓库开始,用来做实验。

创建一个 Git 仓库

创建一个 Git 仓库非常容易。按照以下步骤操作:

  1. 使用以下命令创建一个目录来保存你的版本化文件:

    cd
    mkdir puppet
    
    
  2. 现在运行以下命令将该目录转换为 Git 仓库:

    cd puppet
    git init
    Initialized empty Git repository in /home/ubuntu/puppet/.git/
    

做你的第一次提交

你可以随意更改仓库中的文件,但 Git 直到你进行所谓的 提交 后,才会知道这些更改。你可以将提交看作是仓库在某个特定时刻的快照,它还存储了自上次提交以来仓库发生的变化。提交会永久存储,所以你总是可以将仓库回滚到某个提交时的状态,或者查看过去提交中更改了哪些文件,并与任何其他提交时仓库的状态进行比较。

让我们对这个新仓库做第一次提交:

  1. 因为 Git 不仅记录代码的更改,还记录是谁做的更改,所以它需要知道你的身份。通过以下命令设置你的 Git 个人信息(除非你特别喜欢我的设置,使用你自己的姓名和邮箱地址):

    git config --global user.name "John Arundel"
    git config --global user.email john@bitfieldconsulting.com
    
    
  2. Git 仓库通常会有一个 README 文件,用来说明仓库的内容及如何使用它。目前,让我们先创建这个文件,并放置一个占位符消息:

    echo "Watch this space... coming soon!" >README.md
    
    
  3. 运行以下命令:

    git status
    On branch master
    Initial commit
    Untracked files:
      (use "git add <file>..." to include in what will be committed)
            README.md
    nothing added to commit but untracked files present (use "git add" to track)
    
  4. 因为我们向仓库添加了一个新文件,在我们明确告诉 Git 之前,它不会追踪对这个文件的更改。我们通过使用 git add 命令来做到这一点,如下所示:

    git add README.md
    
    
  5. Git 现在知道了这个文件,并且对它的更改将包含在下一次提交中。我们可以通过再次运行 git status 来检查:

    git status
    On branch master
    Initial commit
    Changes to be committed:
      (use "git rm --cached <file>..." to unstage)
            new file:   README.md
    
  6. 该文件列在 Changes to be committed 下,所以我们现在可以实际进行提交:

    git commit -m 'Add README file'
    [master (root-commit) ee21595] Add README file
     1 file changed, 1 insertion(+)
     create mode 100644 README.md
    
  7. 你可以随时使用 git log 命令查看仓库的完整提交历史。现在试试看,查看你刚才做的提交:

    git log
    commit ee215951199158ef28dd78197d8fa9ff078b3579
    Author: John Arundel <john@bitfieldconsulting.com>
    Date:   Tue Aug 30 05:59:42 2016 -0700
        Add README file
    

我应该多频繁提交一次?

一种常见的做法是当代码处于一致且可工作状态时进行提交,并确保提交包含一组为某个特定目的所做的相关更改。例如,如果你正在修复问题追踪系统中的第 75 号错误,你可能会更改多个不同的文件,然后,在你确认工作完成后,做一个提交,提交信息可能是:

Make nginx restart more reliable (fixes issue #75)

另一方面,如果你正在进行大量复杂的更改,并且不确定什么时候完成,最好沿途做几个单独的提交,这样如果需要的话,你可以将代码回滚到先前的状态。提交是免费的,所以当你觉得需要提交时,尽管去做。

分支

Git 有一个强大的功能叫做分支,它允许你创建代码的并行副本(一个分支),并独立进行更改。你可以随时选择将这些更改合并回主分支。或者,如果主分支在此期间有更改,你可以将这些更改并入你的工作分支并继续工作。

在使用 Puppet 时,这一点非常有用,因为它意味着你可以在测试和修改时将单个节点切换到你的分支。你所做的更改不会对不在你分支上的其他节点可见,因此不会在你准备好之前意外地推出更改。

完成后,你可以将更改合并回主分支,并将它们推广到所有节点。

类似地,两个或更多的人可以独立地在自己的分支上工作,按照需要互相交换单个提交或与主分支交换。这是一种非常灵活且有用的工作方式。

注意

有关 Git 分支的更多信息,实际上是有关 Git 的更多信息,我推荐由Scott ChaconBen Straub编写、Apress出版的优秀书籍《Pro Git》。整本书可以在 git-scm.com/book/en/v2 免费获取。

分发 Puppet 清单

到目前为止,本书中我们仅将 Puppet 清单应用于一个节点,使用 puppet apply 和清单的本地副本。要同时管理多个节点,我们需要将 Puppet 清单分发到每个节点,以便它们可以应用。

有几种方法可以做到这一点,正如我们在第一章《Puppet 入门》中看到的那样,Puppet 入门,一种方法是使用代理/主服务器架构,其中一个中央 Puppet 主服务器编译你的清单并将清单(期望的节点状态)分发到所有节点。

另一种使用 Puppet 的方式是完全不依赖主服务器,而是使用 Git 将清单分发到客户端节点,客户端节点随后运行 puppet apply 来更新其配置。这种独立式 Puppet 架构不需要专用的 Puppet 主服务器,并且没有单点故障。

Puppet 官方支持代理/主服务器架构和独立架构,并且如果你决定需要更改架构,可以从一种架构切换到另一种架构。本书中的示例是使用独立架构开发的,但如果你更喜欢代理/主服务器架构,它同样可以正常工作。Puppet 清单、语言或结构没有区别;唯一的不同是应用清单的方式。

对于一个独立的 Puppet 架构,你只需要一个 Git 服务器,所有节点都可以连接并克隆该仓库。如果你愿意,可以自己搭建 Git 服务器,或者使用像 GitHub 这样的公共 Git 托管服务。为了方便解释,我将在这个示例中使用 GitHub。

在接下来的章节中,我们将创建一个 GitHub 账户,推送我们的新 Puppet 仓库到 GitHub,然后设置虚拟机以自动拉取 GitHub 仓库中的任何更改并使用 Puppet 应用它们。

创建 GitHub 账户和项目

如果你已经有了 GitHub 账户,或者你正在使用其他 Git 服务器,可以跳过这一部分。

  1. 浏览到 github.com/

  2. 输入你想使用的用户名、电子邮件地址和密码。

  3. 选择 无限制的公共仓库免费计划

  4. GitHub 会发送一封电子邮件以验证你的电子邮件地址。当你收到邮件时,点击验证链接。

  5. 选择 开始一个项目

  6. 输入仓库名称(我建议使用 puppet,但其实没有关系)。

  7. 免费的 GitHub 账户只能创建公共仓库,所以选择 Public

    提示

    小心不要将敏感信息放入公共 Git 仓库,因为任何人都可以读取它。除非加密,否则永远不要将密码、登录凭证、私钥或其他机密信息放入这样的仓库中。我们将在第六章,使用 Hiera 管理数据中看到如何加密 Puppet 仓库中的机密信息。

  8. 点击 创建仓库

  9. GitHub 会显示一页关于如何初始化或导入代码到你的新仓库的说明。寻找那个 https URL,它标识了你的仓库;它应该像这样(https://github.com/pbgtest/puppet.git):创建 GitHub 账户和项目

将你的仓库推送到 GitHub

现在你已经准备好将之前在本章本地创建的 Git 仓库推送到 GitHub,这样你就可以与其他节点共享它。

  1. 在你的仓库目录中,运行以下命令。在 git remote add origin 后,指定你的 GitHub 仓库的 URL:

    git remote add origin YOUR_REPO_URL
    git push -u origin master
    
    
  2. GitHub 会提示你输入用户名和密码:

    Username for 'https://github.com': pbgtest
    Password for 'https://pbgtest@github.com':
    Counting objects: 3, done.
    Writing objects: 100% (3/3), 262 bytes | 0 bytes/s, done.
    Total 3 (delta 0), reused 0 (delta 0)
    To https://github.com/pbgtest/puppet.git
     * [new branch]      master -> master
    Branch master set up to track remote branch master from origin.
    
  3. 你可以通过在浏览器中访问仓库的 URL 来检查是否一切正常。它应该看起来像这样:将仓库推送到 GitHub

克隆仓库

为了使用 Puppet 管理多个节点,您需要在每个节点上复制存储库。如果您想要使用 Puppet 管理的节点,您可以在此示例中使用它。否则,请使用我们之前章节中使用的 Vagrant 虚拟机。

运行以下命令(用您自己 GitHub 存储库的 URL 替换git clone的参数,但不要丢失末尾的production):

cd /etc/puppetlabs/code/environments
sudo mv production production.sample
sudo git clone YOUR_REPO_URL production
Cloning into 'production'...
remote: Counting objects: 3, done.
remote: Total 3 (delta 0), reused 3 (delta 0), pack-reused 0
Unpacking objects: 100% (3/3), done.
Checking connectivity... done.

这是如何工作的?在生产环境中,Puppet 清单的标准位置是/etc/puppetlabs/code/environments/production/目录,因此我们的克隆存储库需要结束在那里。然而,Puppet 软件包在该目录中安装了一些示例清单,并且 Git 将拒绝克隆到已经存在的目录,因此我们使用mv production production.sample命令将该目录移出路径。然后,git clone命令重新创建该目录,但这次包含来自存储库的我们的清单。

自动提取并应用更改

在独立的 Puppet 架构中,每个节点需要定期从 Git 存储库中自动获取任何更改,并使用 Puppet 应用这些更改。我们可以使用一个简单的 shell 脚本来实现这一点,在示例存储库中有一个(/examples/files/run-puppet.sh):

#!/bin/bash
cd /etc/puppetlabs/code/environments/production && git pull
/opt/puppetlabs/bin/puppet apply manifests/

我们需要在由 Puppet 管理的节点上安装此脚本,并创建一个 cron 作业定期运行它(我建议每 15 分钟)。当然,我们可以手动完成这项工作,但这本书部分内容不正是关于自动化的优势吗?好的,那么让我们实践我们所说的话。

编写一个清单以设置定期运行 Puppet

在本节中,我们将创建必要的 Puppet 清单,以在节点上安装run-puppet脚本,并定期从 cron 运行它:

  1. 运行以下命令以在您的 Puppet 存储库中创建所需的目录:

    cd /home/ubuntu/puppet
    mkdir manifests files
    
    
  2. 运行以下命令从examples/目录复制run-puppet脚本:

    cp /examples/files/run-puppet.sh files/
    
    
  3. 运行以下命令从examples/目录复制run-puppet清单:

    cp /ubuntu/examples/run-puppet.pp manifests/
    
    
  4. 使用以下命令将文件添加并提交到 Git 中:

    git add manifests files
    git commit -m 'Add run-puppet script and cron job'
    git push origin master
    
    

您的 Git 存储库现在包含了自动拉取和应用管理节点上的更改所需的一切。在下一节中,我们将看到如何在节点上设置此过程。

注意

您可能已经注意到,每次将文件推送到 GitHub 存储库时,Git 会提示您输入用户名和密码。如果您想避免这种情况,您可以将 SSH 密钥关联到您的 GitHub 帐户。完成此操作后,您就可以在无需每次重新输入凭据的情况下进行推送。有关在 GitHub 帐户中使用 SSH 密钥的更多信息,请参阅此文章:

help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/

应用 run-puppet 清单

已创建并推送了设置自动运行 Puppet 所需的清单,现在我们需要在目标节点上拉取并应用它。

/etc/puppetlabs/code/environments/production 中克隆您的仓库的副本后,运行以下命令:

sudo git pull
sudo puppet apply manifests/
Notice: Compiled catalog for localhost in environment production in 0.08 seconds
Notice: /Stage[main]/Main/File[/usr/local/bin/run-puppet]/ensure: defined content as '{md5}83a6903e69564bcecc8fd1a83b1a7beb'
Notice: /Stage[main]/Main/Cron[run-puppet]/ensure: created
Notice: Applied catalog in 0.07 seconds

从 Puppet 的输出中可以看到,它创建了 /usr/local/bin/run-puppet 脚本和 run-puppet 定时任务。这将每 15 分钟自动运行一次,拉取 Git 仓库中的任何新变更,并应用更新后的清单文件。

run-puppet 脚本

run-puppet 脚本按顺序执行以下两个操作,以自动更新目标节点:

  1. 从 Git 服务器拉取任何更改 (git pull)。

  2. 应用清单文件 (puppet apply)。

我们的 Puppet 清单文件 run-puppet.pp 使用 file 资源将此脚本部署到目标节点,然后使用 cron 资源设置一个每 15 分钟运行一次的定时任务。我们之前还没有接触过 cron 资源,但我们将在 第四章 中更详细地介绍它。

目前,只需注意 cron 资源有一个名称 (run-puppet),这仅仅是为了让我们人类记得它的作用,它还有一个 command 来运行和 hourminute 属性来控制运行时间。值 */15 告诉 cron 每 15 分钟运行一次任务。

测试自动 Puppet 运行

为了证明自动 Puppet 运行的有效性,请更改您的清单文件以创建一个文件(例如 /tmp/hello.txt)。提交并推送此更改到 Git。等待 15 分钟,然后检查您的目标节点。该文件应该存在。如果不存在,则说明出现了问题。要排除问题,请尝试手动运行 sudo run-puppet。如果这样可以工作,请检查 sudo crontab -l 是否正确安装了定时任务。它应该看起来像以下内容:

# HEADER: This file was autogenerated at 2017-04-05 01:46:03 -0700 by puppet.
# HEADER: While it can still be managed manually, it is definitely not recommended.
# HEADER: Note particularly that the comments starting with 'Puppet Name' should
# HEADER: not be deleted, as doing so could cause duplicate cron jobs.
# Puppet Name: run-puppet
*/15 * * * * /usr/local/bin/run-puppet

管理多个节点

您现在拥有一个完全自动化的独立 Puppet 基础设施。您在 Git 仓库中提交的任何更改都将自动应用到 Puppet 管理的所有节点上。要添加更多节点到您的基础设施,请按照以下步骤为每个新节点执行:

  1. 安装 Puppet(如果使用 Vagrant 箱,则不需要)。

  2. 克隆您的 Git 仓库(如 克隆仓库 部分所述)。

  3. 应用清单文件(如 应用 run-puppet 清单 部分所述)。

您可能想知道如何告诉 Puppet 如何将不同的清单文件应用到不同的节点上。例如,您可能正在管理两个节点,一个是 Web 服务器,另一个是数据库服务器。显然,它们将需要不同的资源。

我们将在 第八章 中详细了解节点及如何控制资源应用到不同节点上。但首先,我们需要学习 Puppet 的资源及如何使用它们。我们将在下一章介绍这些内容。

概要

在本章中,我们介绍了版本控制的概念,特别是 Git 的基本内容。我们创建了一个新的 Git 仓库,注册了一个 GitHub 账户,将代码推送到 GitHub,并在一个节点上克隆了它。我们编写了一个 shell 脚本,用于自动从 GitHub 仓库拉取并应用更改,并编写了一个 Puppet 清单,安装这个脚本并通过 cron 定期运行它。

在下一章中,我们将探索 Puppet 资源的强大功能,深入了解我们已经接触过的 Puppet filepackageservice 资源,并介绍另外三种重要的资源类型:usercronexec

第四章:理解 Puppet 资源

困惑是知识的开始。
--哈里勒·吉布兰

我们已经遇到过三种重要的 Puppet 资源类型:packagefileservice。在本章中,我们将进一步了解这些资源类型,以及用于管理用户、组、SSH 密钥、定时任务和任意命令的其他重要资源类型。

理解 Puppet 资源

文件

我们在第二章 创建你的第一个清单 中看到,Puppet 可以使用 file 资源管理节点上的文件,并且我们查看了一个示例,该示例使用 content 属性将文件内容设置为特定字符串。这里是该示例的再次展示(file_hello.pp):

file { '/tmp/hello.txt':
  content => "hello, world\n",
}

path 属性

我们已经看到,每个 Puppet 资源都有一个标题(一个带引号的字符串后跟一个冒号)。在 file_hello 示例中,file 资源的标题是 '/tmp/hello.txt'。很容易猜测,Puppet 将使用此值作为创建文件的路径。事实上,path 是你可以为 file 指定的属性之一,但如果你没有指定,Puppet 将使用资源的标题作为 path 的值。

管理整个文件

虽然能够将文件的内容设置为简短的文本字符串很有用,但我们可能希望管理的大多数文件都太大,无法直接包含在 Puppet 清单中。理想情况下,我们会将文件的副本放在 Puppet 仓库中,并让 Puppet 将其复制到文件系统中的指定位置。source 属性正是实现这一功能的(file_source.pp):

file { '/etc/motd':
  source => '/examples/files/motd.txt',
}

要在你的 Vagrant box 上尝试这个示例,请运行以下命令:

sudo puppet apply /examples/file_source.pp
cat /etc/motd
The best software in the world only sucks. The worst software is significantly worse than that.
-Luke Kanies

(从现在开始,我不会再给出如何运行示例的明确说明;只需按照这里所示的方式使用 sudo puppet apply 即可。书中的所有示例都位于 GitHub 仓库的 examples/ 目录中,我会为每个示例提供相应的文件名,例如 file_source.pp。)

提示

为什么我们必须运行 sudo puppet apply 而不是直接运行 puppet apply?Puppet 拥有运行它的用户的权限,因此如果 Puppet 需要修改一个由 root 拥有的文件,它必须以 root 的权限运行(这正是 sudo 的作用)。你通常会以 root 用户身份运行 Puppet,因为它需要这些权限来执行诸如安装软件包、修改由 root 拥有的配置文件等任务。

source 属性的值可以是节点上某个文件的路径,如这里所示,或是一个 HTTP URL,如以下示例所示(file_http.pp):

file { '/tmp/README.md':
  source => 'https://raw.githubusercontent.com/puppetlabs/puppet/master/README.md',
}

尽管这是一个非常方便的功能,但请记住,每次你将这样的外部依赖添加到 Puppet 清单时,实际上是在添加一个潜在的故障点。

提示

在可能的情况下,使用文件的本地副本,而不是每次都让 Puppet 从远程获取文件。这尤其适用于需要从网站下载 tarball 构建的软件。如果可能,下载 tarball 并通过本地 Web 服务器或文件服务器提供它。如果这不可行,使用缓存代理服务器可以在构建大量节点时节省时间和带宽。

所有权

在类 Unix 系统中,文件与一个 所有者、一个 和一组 权限(用于读取、写入或执行文件)相关联。由于我们通常以 root 用户的权限(通过 sudo)运行 Puppet,Puppet 管理的文件将由该用户拥有:

ls -l /etc/motd
-rw-r--r-- 1 root root 109 Aug 31 04:03 /etc/motd

通常,这已经足够了,但如果我们需要文件属于其他用户(例如,如果该用户需要能够写入该文件),我们可以通过设置 owner 属性来表达这一点(file_owner.pp):

file { '/etc/owned_by_ubuntu':
  ensure => present,
  owner  => 'ubuntu',
}
ls -l /etc/owned_by_ubuntu
-rw-r--r-- 1 ubuntu root 0 Aug 31 04:48 /etc/owned_by_ubuntu

您可以看到,Puppet 已创建文件,并且其所有者已设置为 ubuntu。您还可以使用 group 属性设置文件的组所有权(file_group.pp):

file { '/etc/owned_by_ubuntu':
  ensure => present,
  owner  => 'ubuntu',
  group  => 'ubuntu',
}
ls -l /etc/owned_by_ubuntu
-rw-r--r-- 1 ubuntu ubuntu 0 Aug 31 04:48 /etc/owned_by_ubuntu

请注意,这次我们没有为文件指定 contentsource 属性,而是简单地设置 ensure => present。在这种情况下,Puppet 将创建一个大小为零的文件。

权限

在类 Unix 系统中,文件具有一个关联的 模式,该模式决定了对文件的访问权限。它控制文件所有者、文件所在组的用户以及其他用户的读取、写入和执行权限。Puppet 支持使用 mode 属性设置文件的权限。该属性接受一个八进制值(以 0 开头的数字表示),每个数字代表 3 个二进制位字段:分别表示所有者、组和其他用户的权限。在以下示例中,我们使用 mode 属性将文件的模式设置为 0644("所有者可读写,组和其他用户只能读取")(file_mode.pp):

file { '/etc/owned_by_ubuntu':
  ensure => present,
  owner  => 'ubuntu',
  mode   => '0644',
}

这对于经验丰富的系统管理员来说是非常熟悉的,因为文件权限的八进制值与 Unix 的 chmod 命令所理解的完全相同。欲了解更多信息,请运行命令 man chmod

目录

创建或管理 目录 的权限是一个常见任务,Puppet 也使用 file 资源来完成此操作。如果 ensure 属性的值为 directory,则文件将是一个目录(file_directory.pp):

file { '/etc/config_dir':
  ensure => directory,
}

与常规文件一样,您可以使用 ownergroupmode 属性来控制对目录的访问。

文件树

我们已经看到 Puppet 可以将单个文件复制到节点,但如果是一个包含子目录的整个文件夹(称为 文件树)呢?recurse 属性将处理这个问题(file_tree.pp):

file { '/etc/config_dir':
  source  => '/examples/files/config_dir',
  recurse => true,
}
ls /etc/config_dir/
1  2  3

recursetrue时,Puppet 会将源目录(在本例中为/examples/files/config_dir/)中的所有文件和目录(及其子目录)复制到目标目录(/etc/config_dir/)。

提示

如果目标目录已经存在且其中包含文件,Puppet 将不会干涉它们,但你可以使用purge属性来改变此行为。如果purgetrue,Puppet 将删除目标目录中任何未出现在源目录中的文件和目录。使用此属性时请谨慎。

符号链接

另一个常见的文件管理需求是创建或修改符号链接(简称symlink)。你可以通过在file资源上设置ensure => link并指定target属性(file_symlink.pp)来让 Puppet 完成此操作:

file { '/etc/this_is_a_link':
  ensure => link,
  target => '/etc/motd',
}
ls -l /etc/this_is_a_link
lrwxrwxrwx 1 root root 9 Aug 31 05:05 /etc/this_is_a_link -> /etc/motd

软件包

我们已经看到如何使用package资源安装软件包,这对于大多数软件包来说足够了。然而,package资源还有一些额外的功能,可能会很有用。

卸载软件包

ensure属性通常会取值installed来安装软件包,但如果你指定absent,Puppet 将移除已安装的软件包,否则将不采取任何行动。以下示例将在apparmor软件包已安装的情况下将其移除(package_remove.pp):

package { 'apparmor':
  ensure => absent,
}

默认情况下,当 Puppet 移除软件包时,会保留软件包管理的任何文件。要删除与软件包相关的所有文件,请使用purged而不是absent

安装特定版本

如果系统的软件包管理器提供了多个版本的包,指定ensure => installed将使 Puppet 安装默认版本(通常是最新版本)。但是,如果你需要特定版本,你可以将该版本字符串指定为ensure的值,Puppet 将安装该版本(package_version.pp):

package { 'openssl':
  ensure => '1.0.2g-1ubuntu4.8',
}

提示

在使用 Puppet 管理软件包时,最好指定确切的版本,以确保所有节点都安装相同版本的软件包。否则,如果你使用ensure => installed,它们将只安装构建时当前的版本,从而导致不同的节点安装了不同版本的软件包。

当软件包发布新版本时,如果你决定升级,你可以更新 Puppet 清单中指定的版本字符串,Puppet 会在所有地方升级该软件包。

安装最新版本

另一方面,如果你为软件包指定ensure => latest,Puppet 会确保每次应用清单时都安装最新的版本。当软件包有新版本发布时,下一次 Puppet 运行时会自动安装。

提示

当使用不在你控制下的包仓库(例如 Ubuntu 主仓库)时,这通常不是你想要的行为。这意味着包会在意外的时间进行升级,可能会导致应用崩溃(或者至少导致计划外的停机)。更好的策略是告诉 Puppet 安装你知道可以正常工作的特定版本,并在受控环境中测试升级,之后再将其应用到生产环境中。

如果你维护自己的包仓库并控制新包的发布,ensure => latest 是一个有用的功能:Puppet 会在你向仓库推送新版本时立即更新包。如果你依赖上游仓库,比如 Ubuntu 仓库,最好通过直接指定版本号来管理版本,而不是使用 ensure

安装 Ruby gems

虽然 package 资源通常用于通过普通的系统包管理器(在 Ubuntu 中是 APT)安装包,它也可以安装其他类型的包。Ruby 编程语言的库包被称为 gems。Puppet 可以通过 provider => gem 属性为你安装 Ruby gems(package_gem.pp):

package { 'ruby':
  ensure => installed,
}

package { 'puppet-lint':
  ensure   => installed,
  provider => gem,
}

puppet-lint 是一个 Ruby gem,因此我们必须为这个包指定 provider => gem,以防 Puppet 把它当作标准的系统包并试图通过 APT 安装。由于 gem 提供者只有在安装 Ruby 后才可用,我们首先安装 ruby 包,然后安装 puppet-lint gem。

顺便说一下,puppet-lint 工具是一个很有用的工具。它会检查你的 Puppet 清单文件是否存在常见的样式错误,并确保它们符合官方 Puppet 风格指南。现在就试试吧:

puppet-lint /examples/lint_test.pp
WARNING: indentation of => is not properly aligned (expected in column 11, but found it in column 10) on line 2

在这个示例中,puppet-lint 警告你 => 箭头没有垂直对齐,风格指南规定它们应该是垂直对齐的:

file { '/tmp/lint.txt':
  ensure => file,
  content => "puppet-lint is your friend\n",
}

puppet-lint 不产生任何输出时,说明文件没有 lint 错误。

在 Puppet 环境中安装 gems

Puppet 本身至少部分是用 Ruby 编写的,并且使用了多个 Ruby gem。为了避免与节点可能需要的 Ruby 版本及其他应用所需 gem 的冲突,Puppet 将自己的 Ruby 版本和相关 gem 打包到 /opt/puppetlabs/ 目录下。这意味着你可以安装(或移除)任意系统版本的 Ruby,而 Puppet 不会受到影响。

然而,如果你需要安装一个 gem 来扩展 Puppet 的某些功能,使用 package 资源和 provider => gem 是无法工作的。也就是说,gem 会被安装,但仅在系统的 Ruby 环境中,并且 Puppet 无法看到它。

幸运的是,puppet_gem 提供者正是为这个目的而存在的。当你使用这个提供者时,gem 会安装到 Puppet 的环境中(自然,它不会在系统环境中显示)。以下示例展示了如何使用这个提供者(package_puppet_gem.pp):

package { 'r10k':
  ensure   => installed,
  provider => puppet_gem,
}

提示

要查看 Puppet 上下文中安装的 gem,可以使用 Puppet 自己版本的gem命令,路径如下:

/opt/puppetlabs/puppet/bin/gem list

使用ensure_packages

为了避免 Puppet 代码的不同部分之间,或者你的代码与第三方模块之间可能发生的包冲突,Puppet 标准库提供了一个有用的package资源的包装器,叫做ensure_packages()。我们将在第七章中详细介绍,掌握模块

服务

尽管操作系统级别的服务实现方式多种多样且复杂,Puppet 通过service资源很好地抽象化了大部分内容,并只暴露了你最常用的两个服务属性:服务是否正在运行(ensure)以及是否在启动时启动(enable)。我们在第二章中介绍了这些内容,创建你的第一个清单,而且大多数情况下,你不需要知道更多关于service资源的内容。

然而,你偶尔会遇到一些服务由于各种原因与 Puppet 不兼容。有时,Puppet 无法检测到服务已经在运行,并且一直尝试启动它。其他时候,当一个依赖资源发生变化时,Puppet 可能无法正确重启服务。对于service资源,有一些有用的属性可以帮助解决这些问题。

hasstatus属性

service资源具有ensure => running属性时,Puppet 需要能够检查该服务是否实际上正在运行。它的实现方式取决于底层操作系统。例如,在 Ubuntu 16 及更高版本中,它会运行systemctl is-active SERVICE。如果该服务是以systemd工作方式打包的,那么这应该没问题,但在许多情况下,尤其是对于旧的软件,它可能无法正常响应。

如果你发现 Puppet 在每次运行时都试图启动服务,即使该服务已经在运行,可能是 Puppet 的默认服务状态检测无法正常工作。在这种情况下,你可以为该服务指定hasstatus => false属性(service_hasstatus.pp):

service { 'ntp':
  ensure    => running,
  enable    => true,
  hasstatus => false,
}

hasstatus为 false 时,Puppet 知道不需要使用默认的系统服务管理命令来检查服务状态,而是会在进程表中查找与服务名称匹配的正在运行的进程。如果找到了,Puppet 将推断该服务正在运行,并且不会采取进一步的行动。

pattern属性

有时,当使用hasstatus => false时,Puppet 中定义的服务名称实际上并不会出现在进程表中,因为提供该服务的命令有一个不同的名称。如果是这种情况,你可以通过pattern属性告诉 Puppet 具体应该查找什么。

如果hasstatusfalse并且指定了pattern,Puppet 将会在进程表中搜索pattern的值,以判断服务是否正在运行。为了找到你需要的模式,你可以使用ps命令查看正在运行的进程列表:

ps ax

查找你感兴趣的进程,并选择一个仅与该进程名称匹配的字符串。例如,如果是ntpd,你可以将pattern属性指定为ntpdservice_pattern.pp):

service { 'ntp':
  ensure    => running,
  enable    => true,
  hasstatus => false,
  pattern   => 'ntpd',
}

hasrestart 和 restart 属性

当服务被通知时(例如,如果一个file资源使用notify属性告知服务其配置文件已更改,这是我们在第二章,创建你的第一个清单中看到的常见模式),Puppet 的默认行为是先停止服务,然后重新启动它。通常这样做有效,但许多服务在其管理脚本中实现了restart命令。如果该命令可用,通常使用它是个好主意:它可能比停止并启动服务更快或更安全。例如,一些服务在停止时需要花费一定时间才能正确关闭,而 Puppet 可能没有等足够的时间就尝试重启它们,这样就可能导致服务根本没有运行。

如果你为一个服务指定了hasrestart => true,那么 Puppet 会尝试发送一个restart命令,使用当前平台适当的服务管理命令(例如,在 Ubuntu 上是systemctl)。以下示例展示了hasrestart的使用(service_hasrestart.pp):

service { 'ntp':
  ensure     => running,
  enable     => true,
  hasrestart => true,
}

更复杂的是,默认的系统服务restart命令可能无法工作,或者在重启服务时可能需要执行某些特殊操作(例如禁用监控通知)。你可以使用restart属性(service_custom_restart.pp)为服务指定任何你喜欢的restart命令:

service { 'ntp':
  ensure  => running,
  enable  => true,
  restart => '/bin/echo Restarting >>/tmp/debug.log && systemctl restart ntp',
}

在这个例子中,restart命令会在重新启动服务之前,向日志文件写入一条消息,但它当然可以做你需要的任何事情。注意,restart命令仅在 Puppet 重启服务时使用(通常是因为服务收到了某个配置文件更改的通知)。如果 Puppet 发现服务已经停止并需要启动它,它将使用正常的系统服务启动命令,而不是使用restart命令。

在极为罕见的情况下,如果无法通过默认的服务管理命令停止或启动服务,Puppet 还提供了stopstart属性,允许你指定自定义命令来停止和启动服务,和restart属性的使用方式一样。不过,如果你需要使用这些命令,可能可以安全地说你今天运气不好。

用户

类 Unix 系统中的用户不一定对应于登录并输入命令的具体人,尽管有时确实是。用户只是一个命名实体,可以拥有文件并以特定权限运行命令,可能具有或不具有读取或修改其他用户文件的权限。出于良好的安全考虑,为每个系统服务创建一个独立的用户账户是很常见的做法。这意味着该服务以该用户的身份和权限运行。

例如,web 服务器通常以www-data用户身份运行,该用户仅用于拥有 web 服务器需要读取和写入的文件。这限制了通过 web 服务器发生安全漏洞的风险,因为攻击者只能拥有www-data用户的权限,该权限非常有限,而不是root用户的权限,后者可以修改系统的任何方面。通常,作为root用户运行暴露于公共互联网的服务是一个不好的主意。服务用户应该只拥有操作该服务所需的最小权限。

基于此,系统配置的一个重要部分是创建和管理用户,而 Puppet 的user资源为此提供了一个模型。正如我们在包和服务中看到的,实施细节和管理用户的命令在不同操作系统之间有很大的差异,但 Puppet 提供了一个抽象,隐藏了这些细节,通过一组通用的用户属性来管理。

创建用户

以下示例展示了 Puppet 中典型的usergroup声明(user.pp):

group { 'devs':
  ensure => present,
  gid    => 3000,
}

user { 'hsing-hui':
  ensure => present,
  uid    => '3001',
  home   => '/home/hsing-hui',
  shell  => '/bin/bash',
  groups => ['devs'],
}

用户资源

资源的标题是用户的用户名(登录名);在这个例子中是hsing-huiensure => present属性表示该用户应当存在于系统中。

uid属性需要更多的解释。在类 Unix 系统中,每个用户都有一个唯一的数字标识符,称为uid。与用户关联的文本名称仅仅是为了方便那些(比如人类)更喜欢字符串而非数字的情况。访问权限实际上是基于 uid 而不是用户名的。

提示

为什么要设置uid属性?通常,当手动创建用户时,我们没有指定 uid,系统会自动分配一个。这样做的问题是,如果在三个不同的节点上创建相同的用户(例如hsing-hui),可能会得到三个不同的 uid。只要从未在节点之间共享文件或从一个地方复制数据到另一个地方,这不会成为问题。但实际上,这种情况经常发生,因此确保每个用户的 uid 在基础设施中的所有节点上都相同是很重要的。这就是为什么我们在 Puppet 清单中指定uid属性的原因。

home属性设置用户的主目录(如果用户登录,该目录将是当前工作目录,且为以该用户身份运行的 cron 任务的默认工作目录)。

shell 属性指定了用户交互式登录时要运行的命令行 shell。对于人类用户,通常是用户 shell,如 /bin/bash/bin/sh。对于服务用户,如 www-data,shell 应该设置为 /usr/sbin/nologin(在 Ubuntu 系统上),这会阻止交互式访问,并显示消息 此账户当前不可用。所有不需要交互式登录的用户应该使用 nologin shell。

如果用户需要成为某些组的成员,你可以通过 groups 属性传递一个包含组名的数组(在此示例中仅为 devs)。

尽管 Puppet 为 user 资源支持 password 属性,我不建议你使用它。服务用户不需要密码,交互式用户应使用 SSH 密钥登录。实际上,你应该配置 SSH 完全禁用密码登录(在 sshd_config 中设置 PasswordAuthentication no)。

组资源

资源的标题是组的名称(devs)。你不需要指定 gid 属性,但与 uid 属性一样,最好指定它。

管理 SSH 密钥

我喜欢在生产节点上尽量减少交互式登录,因为这能减少攻击面。幸运的是,使用配置管理工具,通常不需要实际登录到节点。需要交互式登录的最常见原因是系统维护和故障排除,以及部署。在这两种情况下,应该有一个单独的账户,专门用于此目的(例如 admindeploy),并配置为具有需要登录该账户的任何用户或系统的 SSH 密钥。

Puppet 提供了 ssh_authorized_key 资源来控制与用户账户关联的 SSH 密钥。以下示例展示了如何使用 ssh_authorized_key 将一个 SSH 密钥(在此示例中是我的密钥)添加到我们 Vagrant 虚拟机上的 ubuntu 用户 (ssh_authorized_key.pp):

ssh_authorized_key { 'john@bitfieldconsulting.com':
  user => 'ubuntu',
  type => 'ssh-rsa',
  key  => 'AAAAB3NzaC1yc2EAAAABIwAAAIEA3ATqENg+GWACa2BzeqTdGnJhNoBer8x6pfWkzNzeM8Zx7/2Tf2pl7kHdbsiTXEUawqzXZQtZzt/j3Oya+PZjcRpWNRzprSmd2UxEEPTqDw9LqY5S2B8og/NyzWaIYPsKoatcgC7VgYHplcTbzEhGu8BsoEVBGYu3IRy5RkAcZik=',
}

资源的标题是 SSH 密钥的注释,提醒我们这个密钥属于谁。user 属性指定了此密钥应被授权的用户账户。type 属性标识 SSH 密钥的类型,通常是 ssh-rsassh-dss。最后,key 属性设置了密钥本身。当应用此清单时,它会将以下内容添加到 ubuntu 用户的 authorized_keys 文件中:

ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAIEA3ATqENg+GWACa2BzeqTdGnJhNoBer8x6pfWkzNzeM8Zx7/2Tf2pl7kHdbsiTXEUawqzXZQtZzt/j3Oya+PZjcRpWNRzprSmd2UxEEPTqDw9LqY5S2B8og/NyzWaIYPsKoatcgC7VgYHplcTbzEhGu8BsoEVBGYu3IRy5RkAcZik= john@bitfieldconsulting.com

一个用户账户可以关联多个 SSH 密钥,持有其中一个对应私钥及其密码短语的人将能够以该用户身份登录。

删除用户

如果你需要 Puppet 移除用户账户(例如,作为员工离职的流程一部分),仅仅从 Puppet 清单中删除 user 资源是不够的。Puppet 会忽略系统中它不了解的用户,而且它绝对不会删除任何未在 Puppet 清单中提到的系统内容;那样做会非常不理想(几乎所有内容都会被删除)。因此,我们需要保留 user 声明一段时间,但将 ensure 属性设置为 absentuser_remove.pp):

user { 'godot':
  ensure => absent,
}

一旦 Puppet 在所有地方运行完成,你可以选择移除 user 资源,但完全没有必要,实际上,除非你能手动确认该用户已从所有受影响的系统中删除,否则最好保留该资源。

提示

如果你需要防止某个用户登录,但又想保留该用户的账户和所有文件(用于归档或合规性目的),你可以将其 shell 设置为 /usr/sbin/nologin。你还可以删除与该账户关联的任何 ssh_authorized_key 资源,并将 user 资源上的 purge_ssh_keys 属性设置为 true。这将移除任何非 Puppet 管理的授权密钥。

Cron 资源

Cron 是类 Unix 系统中用于运行定时任务的机制,有时也称为批处理任务,这些任务会在指定的时间或间隔执行。例如,系统维护任务,如日志轮换或检查安全更新,都是通过 cron 来执行的。关于执行内容和时间的详细信息会保存在一个特殊格式的文件中,叫做 crontab(即 cron 表 的缩写)。

Puppet 提供了 cron 资源来管理定时任务,我们在 第三章 用 Git 管理 Puppet 代码 (run-puppet.pp) 的清单中看到过这个例子:

cron { 'run-puppet':
  command => '/usr/local/bin/run-puppet',
  hour    => '*',
  minute  => '*/15',
}

run-puppet 标题标识了该 cron 任务(Puppet 会在 crontab 文件中写入一个注释,包含该名称,以区分其他手动配置的 cron 任务)。command 属性指定了 cron 执行的命令,hourminute 指定了时间(*/15 是 cron 语法,意思是“每 15 分钟”)。

注意

如果需要了解更多关于 cron 及指定定时任务时间的不同方法,可以运行命令 man 5 crontab

cron 资源的属性

cron 资源还有一些其他有用的属性,这些在以下示例(cron.pp)中展示:

cron { 'cron example':
  command     => '/bin/date +%F',
  user        => 'ubuntu',
  environment => ['MAILTO=admin@example.com', 'PATH=/bin'],
  hour        => '0',
  minute      => '0',
  weekday     => ['Saturday', 'Sunday'],
}

user 属性指定了谁来运行该 cron 任务(如果没有指定,任务将作为 root 用户运行)。如果给定了 environment 属性,它会设置 cron 任务可能需要的任何环境变量。一个常见的用途是通过 MAILTO 变量将 cron 任务的任何输出邮件发送到指定的邮箱地址。

如前所述,hourminute 属性设置作业运行的时间,而 weekday 属性可用于指定一周的特定日或多个日。(monthday 属性也是这样工作的,并且可以取 1-31 之间的任何范围或数组值来指定月份的日期。)

提示

关于 cron 调度的一个重要点是,任何调度属性的默认值都是 *,这意味着 所有允许的值。例如,如果未指定 hour 属性,那么 cron 作业将被调度为 hour*,这意味着它将每小时运行一次。这通常不是您想要的。如果确实希望每小时运行一次,请在您的清单中指定 hour => '*',但否则,请指定应在其运行的特定小时。对于 minute 也是如此。意外地省略 minute 属性,并导致作业每小时运行六十次,可能会产生有趣的后果,至少可以这么说。

随机化 cron 作业

如果在许多节点上运行一个 cron 作业,最好确保作业不会在同一时间同时运行。Puppet 提供了一个内置函数 fqdn_rand() 来帮助实现这一点;它提供一个随机数,最大值为指定值,因为随机数生成器是用节点的主机名种子化的,所以在每个节点上都会不同。

如果您有几个这样的作业要运行,还可以向 fqdn_rand() 函数提供一个进一步的种子值,这可以是任何字符串,将确保该值对于每个作业都是不同的(fqdn_rand.pp):

cron { 'run daily backup':
  command => '/usr/local/bin/backup',
  minute  => '0',
  hour    => fqdn_rand(24, 'run daily backup'),
}

cron { 'run daily backup sync':
  command => '/usr/local/bin/backup_sync',
  minute  => '0',
  hour    => fqdn_rand(24, 'run daily backup sync'),
}

因为我们为每个 cron 作业的 fqdn_rand 的第二个参数给出了不同的字符串,它将为每个 hour 属性返回不同的随机值。

fqdn_rand() 返回的值范围包括 0,但不包括您指定的最大值。因此,在前面的示例中,hour 的值将在 0 到 23 之间,包括这两个值。

删除 cron 作业

就像对待 user 资源或任何类型的资源一样,从 Puppet 清单中删除资源声明并不会从节点中删除相应的配置。为了做到这一点,您需要在资源上指定 ensure => absent

Exec 资源

尽管我们迄今为止看到的其他资源类型(filepackageserviceuserssh_authorized_keycron)都在节点上建模了一些具体的状态,例如文件,但 exec 资源有些不同。exec 允许您在节点上运行任意命令。这可能会创建或修改状态,也可能不会;您可以从命令行运行的任何内容,也可以通过 exec 资源运行。

自动化手动交互

exec资源最常见的用途是模拟命令行上的手动交互。一些较旧的软件并未为现代操作系统打包,需要从源代码编译和安装,这要求你运行特定的命令。有些软件的作者也没有意识到,或者根本不关心,用户可能正在尝试自动安装他们的产品,而他们的安装脚本会提示用户输入。这可能需要使用exec资源来解决这个问题。

exec资源的属性

以下示例展示了一个用于构建和安装虚拟软件的exec资源(exec.pp):

exec { 'install-cat-picture-generator':
  cwd     => '/tmp/cat-picture-generator',
  command => '/tmp/cat-picture/generator/configure && /usr/bin/make install',
  creates => '/usr/local/bin/cat-picture-generator',
}

资源的标题可以是你喜欢的任何内容,不过,像往常一样,Puppet 资源必须是唯一的。我倾向于根据它们尝试解决的问题来命名exec资源,就像这个示例中一样。

cwd属性设置了执行命令的工作目录(当前工作目录)。在安装软件时,这通常是软件源目录。

command属性指定要执行的命令。这个必须是命令的完整路径,但你可以使用 shell && 运算符将多个命令链接在一起。只有前一个命令成功执行时,才会执行下一个命令。因此,在这个示例中,如果configure命令成功完成,Puppet 将继续执行make install,否则它会因错误而停止。

注意

如果你应用这个示例,Puppet 会给出类似以下的错误:

Error: /Stage[main]/Main/Exec[install-cat-picture-generator]/returns: change from notrun to 0 failed: Could not find command '/tmp/cat-picture/generator/configure'

这是预期的,因为指定的命令实际上并不存在。在你自己的清单中,如果你给命令的路径错误,或者提供命令的软件包尚未安装,你可能会看到这个错误。

creates属性指定在命令执行后应存在的文件。如果该文件存在,Puppet 将不会再次运行该命令。这非常有用,因为如果没有creates属性,exec资源每次 Puppet 运行时都会执行,这通常不是你想要的。creates属性告诉 Puppet,实际上是“只有在该文件不存在时才运行exec”。

让我们来看一下它是如何工作的,假设这个exec是第一次运行。我们假设/tmp/cat-picture/目录存在,并且包含cat-picture-generator应用的源代码。

  1. Puppet 检查creates属性,发现/usr/local/bin/cat-picture-generator文件不存在;因此,必须运行exec资源。

  2. Puppet 执行/tmp/cat-picture-generator/configure && /usr/bin/make install命令。这些命令的副作用是创建了/usr/local/bin/cat-picture-generator文件。

  3. 下次 Puppet 运行时,它再次检查creates属性。这次/usr/local/bin/cat-picture-generator文件已经存在,因此 Puppet 不做任何操作。

只要 creates 属性中指定的文件存在,该 exec 资源就永远不会再次应用。你可以通过删除该文件并再次应用 Puppet 来测试此行为。exec 资源将被触发并重新创建文件。

提示

确保你的 exec 资源始终包含 creates 属性(或类似的控制属性,如 onlyifunless,我们将在本章稍后查看)。没有这个属性,exec 命令将每次 Puppet 运行时都执行,这几乎肯定不是你想要的。

请注意,从源代码构建和安装软件并不是生产系统中的推荐做法。最好在专用构建服务器上构建软件(可能使用类似本示例的 Puppet 代码),为其创建系统包,然后使用 Puppet 在生产节点上安装该包。

user 属性

如果你没有为 exec 资源指定 user 属性,Puppet 会以 root 用户身份运行该命令。这通常适用于安装系统软件或更改系统配置,但如果你希望命令以特定用户身份运行,可以指定 user 属性,如以下示例所示(exec_user.pp):

exec { 'say-hello':
  command => '/bin/echo Hello, this is `whoami` >/tmp/hello-ubuntu.txt',
  user    => 'ubuntu',
  creates => '/tmp/hello-ubuntu.txt',
}

这将以 ubuntu 用户身份运行指定的命令。whoami 命令返回运行该命令的用户名,因此当你应用此清单时,文件 /tmp/hello-ubuntu.txt 将被创建,内容如下:

Hello, this is ubuntu

与前面的示例一样,creates 属性可以防止 Puppet 多次运行该命令。

onlyifunless 属性

假设你只希望在某些条件下应用 exec 资源。例如,处理传入数据文件的命令只在有数据文件等待处理时才需要运行。在这种情况下,添加 creates 属性没有用;我们希望某个特定文件的存在触发 exec,而不是阻止它。

onlyif 属性是解决此问题的好方法。它指定了一个 Puppet 要执行的命令,且该命令的退出状态决定是否应用 exec。在类 Unix 系统中,命令通常返回零的退出状态表示成功,非零值表示失败。以下示例展示了如何以这种方式使用 onlyifexec_onlyif.pp):

exec { 'process-incoming-cat-pictures':
  command => '/usr/local/bin/cat-picture-generator --import /tmp/incoming/*',
  onlyif  => '/bin/ls /tmp/incoming/*',
}

精确的命令在这里并不重要,但假设它是我们只希望在 /tmp/incoming/ 目录中有文件时运行的命令。

onlyif 属性指定了 Puppet 首先应运行的检查命令,以确定是否需要应用 exec 资源。如果 /tmp/incoming/ 目录为空,ls /tmp/incoming/* 将返回非零退出状态。Puppet 将此视为失败,因此不会应用 exec 资源。

另一方面,如果/tmp/incoming/目录中有文件,ls命令将返回成功。这告诉 Puppet 该exec资源必须被应用,所以它继续执行/usr/local/bin/cat-picture-generator命令(我们可以假设这个命令在处理后会删除传入的文件)。

你可以将onlyif属性看作是告诉 Puppet,“只有在这个命令成功的情况下,才运行exec资源”。

unless属性与onlyif属性完全相同,只是语义相反。如果你为unless属性指定一个命令,那么除非该命令返回零退出状态,否则exec将始终运行。你可以将unless看作是告诉 Puppet,“除非这个命令成功,否则运行exec资源”。

当你应用清单时,如果你看到每次都在运行一个exec资源,而这个资源不应该这样运行,请检查它是否指定了createsunlessonlyif属性。如果它指定了creates属性,可能是它在寻找错误的文件;如果指定了unlessonlyif命令,可能它返回的结果与你的预期不符。你可以通过运行带有-d(调试)标志的sudo puppet apply命令来查看正在执行的命令以及它生成的输出:

sudo puppet apply -d exec_onlyif.pp
Debug: Execprocess-incoming-cat-pictures: Executing check '/bin/ls /tmp/incoming/*'
Debug: Executing: '/bin/ls /tmp/incoming/*'
Debug: /Stage[main]/Main/Exec[process-incoming-cat-pictures]/onlyif: /tmp/incoming/foo

refreshonly属性

使用exec资源执行一次性命令是很常见的,例如重建数据库或设置系统可调节的参数。这些通常只需要在安装软件包时触发一次,或者偶尔在更新配置文件时触发。如果一个exec资源只需要在某个其他 Puppet 资源发生变化时运行,我们可以使用refreshonly属性来实现这一点。

如果refreshonlytrue,则除非另一个资源通过notify触发它,否则exec将永远不会被应用。在以下示例中,Puppet 管理/etc/aliases文件(该文件将本地用户名映射到电子邮件地址),对该文件的更改会触发执行命令newaliases,该命令重建系统别名数据库(exec_refreshonly.pp):

file { '/etc/aliases':
  content => 'root: john@bitfieldconsulting.com',
  notify  => Exec['newaliases'],
}

exec { 'newaliases':
  command     => '/usr/bin/newaliases',
  refreshonly => true,
}

当第一次应用这个清单时,/etc/aliases资源会导致文件内容发生变化,因此 Puppet 会向exec资源发送一个notify消息。这导致执行newaliases命令。如果你再次应用该清单,你会看到aliases文件没有变化,因此exec没有被执行。

提示

虽然refreshonly属性偶尔非常有用,但过度使用它会使你的 Puppet 清单难以理解和调试,而且也可能变得相当脆弱。Felix Frank 在一篇博客文章中提出了这个观点,朋友不要让朋友使用 Refreshonly

“考虑到exec资源类型是最后的手段,它的refreshonly参数应该被视为尤其离谱。为了使exec资源更好地融入 Puppet 的模型,你应该使用[createsonlyifunless]参数。”请参阅:

ffrank.github.io/misc/2015/05/26/friends-don't-let-friends-use-refreshonly/

请注意,你不需要使用refreshonly属性来让exec资源被其他资源通知。任何资源都可以通知exec资源以使其运行;然而,如果你希望它仅在被通知时运行,可以使用refreshonly

提示

顺便提一下,如果你确实想在节点上管理电子邮件别名,请使用 Puppet 内置的mailalias资源。之前的示例只是为了演示如何使用refreshonly

logoutput 属性

当 Puppet 通过exec资源运行 shell 命令时,输出通常对我们是隐藏的。然而,如果命令似乎没有正确工作,查看它产生的输出通常非常有用,因为这通常能告诉我们为什么它没有工作。

logoutput属性决定 Puppet 是否会记录exec命令的输出,并与通常的信息性 Puppet 输出一起显示。它可以有三个值:truefalseon_failure

如果将logoutput设置为on_failure(这是默认值),Puppet 仅在命令失败时记录命令输出(即返回非零退出状态时)。如果你不希望看到任何命令输出,可以将其设置为false

然而,有时命令会返回成功的退出状态,但似乎没有做任何事情。将logoutput设置为true将强制 Puppet 记录命令输出,无论退出状态如何,这应该能帮助你找出问题所在。

超时属性

有时,命令可能需要很长时间才能运行,或者根本不会终止。默认情况下,Puppet 允许exec命令运行 300 秒,在此期间如果命令尚未完成,Puppet 会终止它。如果你需要允许命令稍长时间完成,可以使用timeout属性来设置。该值是命令最大执行时间(以秒为单位)。

timeout值设置为0会完全禁用自动超时,并允许命令无限期运行。这应当是最后的手段,因为如果没有设置超时,一个阻塞或挂起的命令可能会完全停止 Puppet 的自动运行。为了找到合适的timeout值,尝试运行命令几次,并选择一个大约是典型运行时间两倍的值。这应该能避免因网络慢导致的失败,但不会完全阻止 Puppet 运行。

如何避免错误使用 exec 资源

exec 资源可以对系统做任何你从命令行可以做的事情。正如你可以想象的那样,像这样的强大工具可能会被滥用。理论上,Puppet 是一种声明性语言:清单指定了事物应该是什么样子,Puppet 负责采取必要的措施使其实现。因此,清单是计算机科学家所说的幂等的:在应用了 catalog 后,系统始终保持在相同的状态,无论你应用多少次,它都会始终保持在这个状态。

exec 资源在一定程度上破坏了这个理论模型,因为它允许 Puppet 清单具有副作用。由于你的 exec 命令可以做任何事情,举个例子,它可能会在磁盘上创建一个新的 1 GB 大小的文件,文件名是随机的,而由于每次 Puppet 运行时都会发生这种情况,你可能会很快耗尽磁盘空间。最好避免像这样的具有副作用的命令。通常情况下,无法从 Puppet 内部精确知道 exec 资源对系统造成了哪些变化。

通过 exec 运行的命令有时也被用来绕过 Puppet 的现有资源。例如,如果 user 资源因某些原因没有完全满足你的需求,你可以通过 exec 直接运行 adduser 命令来创建用户。这也是一个不好的做法,因为这样你就失去了 Puppet 内建资源的声明性和跨平台特性。exec 资源可能会以一种 Puppet catalog 看不见的方式改变节点的状态。

提示

通常来说,如果你需要管理 Puppet 内建资源类型不支持的系统状态的某个具体方面,你应该考虑创建自定义资源类型和提供程序来完成你想做的事情。这将扩展 Puppet,添加一个新的资源类型,之后你可以在清单中使用它来建模该资源的状态。创建自定义类型和提供程序是一个高级主题,本书不涉及,但如果你想了解更多内容,可以查阅 Puppet 文档:

docs.puppet.com/guides/custom_types.html

在通过 exec 运行复杂命令之前,你也应该三思而后行,尤其是使用了循环或条件语句的命令。一个更好的做法是将任何复杂的逻辑放在 shell 脚本中(或者更好的是放在真正的编程语言中),然后用 Puppet 部署并运行它(避免我们之前提到的,不必要的副作用)。

提示

根据良好的 Puppet 风格,每个 exec 资源应该至少指定 createsonlyifunlessrefreshonly 中的一个,以防止它在每次 Puppet 运行时都被应用。如果你发现自己每次 Puppet 运行时都只是为了执行一个命令而使用 exec,不如将其设置为一个 cron 任务。

总结

我们详细探索了 Puppet 的file资源,涵盖了文件源、所有权、权限、目录、符号链接和文件树。我们学会了如何通过安装特定版本或最新版本来管理软件包,以及如何卸载软件包。我们还了解了 Ruby gems,既包括系统上下文中的,也包括 Puppet 内部上下文中的。在这个过程中,我们接触到了非常有用的puppet-lint工具。

我们已经研究了service资源,包括hasstatuspatternhasrestartrestartstopstart属性。我们学会了如何创建用户和组,管理主目录、shell、UID 和 SSH 授权密钥。我们还了解了如何调度、管理和删除 cron 作业。

最后,我们已经了解了强大的exec资源,包括如何运行任意命令,以及如何在特定条件下或仅在某个文件不存在时运行命令。我们还了解了如何使用refreshonly属性,在其他资源更新时触发exec资源,并探索了exec资源中有用的logoutputtimeout属性。

在下一章,我们将了解如何在 Puppet 清单中表示数据和变量,包括字符串、数字、布尔值、数组和哈希。我们将学会如何使用变量和条件表达式来确定应用哪些资源,还将学习 Puppet 的facts哈希以及如何使用它获取有关系统的信息。

第五章:变量、表达式和事实

无法开始学习自己认为已经掌握的知识。
--埃皮克泰图斯

在本章中,你将学习 Puppet 变量和数据类型、表达式以及条件语句。你还将学习如何使用 Facter 获取有关节点的数据,了解最重要的标准事实,并查看如何创建你自己的外部事实。最后,你将使用 Puppet 的 each 函数迭代数组和哈希,包括 Facter 数据。

变量、表达式和事实

引入变量

在 Puppet 中,变量仅仅是给特定值命名的一种方式,这样我们就可以在需要使用字面值的地方使用该变量(variable_string.pp):

$php_package = 'php7.0-cli'

package { $php_package:
  ensure => installed,
}

美元符号($)告诉 Puppet 后面跟的是变量名。变量名必须以小写字母或下划线开头,尽管变量名的其余部分也可以包含大写字母或数字。

变量可以包含不同类型的数据;其中一种类型是字符串(如 php7.0-cli),但 Puppet 变量也可以包含数字布尔值(truefalse)。以下是一些示例(variable_simple.pp):

$my_name = 'Zaphod Beeblebrox'
$answer = 42
$scheduled_for_demolition = true

使用布尔值

字符串和数字是直观的,但 Puppet 还有一种特殊的数据类型来表示真假值,我们称之为布尔值,取名自逻辑学家乔治·布尔。我们在 Puppet 资源属性中已经遇到了一些布尔值(service.pp):

service { 'sshd':
  ensure => running,
  enable => true,
}

布尔变量唯一允许的值是字面值 truefalse,但是布尔变量也可以持有条件表达式的值(值为 truefalse 的表达式),我们将在本章稍后探讨。

提示

你可能会想知道在之前的示例中,running 的值是什么类型。实际上,它是一个字符串,但它是一个特殊的、未加引号的字符串,称为裸字。虽然如果在这里使用正常的加引号字符串 'running' 对 Puppet 来说是完全一样的,但对于只能是少数几个词之一的属性值(例如,服务的 ensure 属性只能取值 runningstopped),使用裸字被认为是良好的风格。相比之下,true 不是裸字,而是布尔值,并且不能与字符串 'true' 互换。布尔值应始终使用未加引号的字面量值 truefalse

在字符串中插值变量

如果你无法再将变量中的内容取出来,那么将某些内容存储在变量中也没什么用。最常见的使用变量值的方式之一就是将其插值到字符串中。当你这样做时,Puppet 会将变量的当前值插入到字符串内容中,替换掉变量的名称。字符串插值看起来像这样(string_interpolation.pp):

$my_name = 'John'
notice("Hello, ${my_name}! It's great to meet you!")

当你应用此清单时,以下输出将被打印:

Notice: Scope(Class[main]): Hello, John! It's great to meet you!

要在字符串中插入(即插值)变量的值,可以在变量名之前加上 $ 符号,并用大括号({})将其括起来。这告诉 Puppet 用变量的值替换字符串中的变量名。

提示

我们在之前的示例中悄悄添加了一个新的 Puppet 函数notice()。它对系统没有影响,但会打印出其参数的值。这在故障排除或查看在清单中的某个特定时刻变量的值时非常有用。

创建数组

变量也可以包含多个值。数组是值的有序序列,每个值可以是任何类型。以下示例创建了一个 整数 类型的数组(variable_array.pp):

$heights = [193, 120, 181, 164, 172]

$first_height = $heights[0]

你可以通过在方括号中给出索引号来引用数组的任何单个元素,其中第一个元素的索引是[0],第二个是[1],依此类推。(如果你觉得这很混乱,你并不孤单,但可以试着将索引看作是数组起始位置的偏移量。自然地,第一项的偏移量是 0。)

声明资源数组

你已经知道,在 Puppet 资源声明中,资源的标题通常是一个字符串,比如文件的路径或软件包的名称。你可能会问:“如果你为资源提供一个字符串数组作为标题,而不是单个字符串,会发生什么呢?Puppet 会为数组中的每个元素创建多个资源吗?”让我们尝试一个实验,正好用一个包含软件包名称的数组来做这个实验,看看会发生什么(resource_array.pp):

$dependencies = [
  'php7.0-cgi',
  'php7.0-cli',
  'php7.0-common',
  'php7.0-gd',
  'php7.0-json',
  'php7.0-mcrypt',
  'php7.0-mysql',
  'php7.0-soap',
]

package { $dependencies:
  ensure => installed,
}

如果我们的直觉是对的,应用之前的清单应该会为 $dependencies 数组中列出的每个软件包创建一个软件包资源,并且每个软件包都会被安装。以下是应用清单时发生的情况:

sudo apt-get update
sudo puppet apply /examples/resource_array.pp
Notice: Compiled catalog for ubuntu-xenial in environment production in 0.68 seconds
Notice: /Stage[main]/Main/Package[php7.0-cgi]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-cli]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-common]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-gd]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-json]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-mcrypt]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-mysql]/ensure: created
Notice: /Stage[main]/Main/Package[php7.0-soap]/ensure: created
Notice: Applied catalog in 56.98 seconds

将一个字符串数组作为资源的标题会导致 Puppet 创建多个资源,除了标题不同之外,其他都相同。你不仅可以对软件包这么做,还可以对文件、用户,或者任何类型的资源这么做。在第六章,使用 Hiera 管理数据中,我们将看到一些更复杂的从数据创建资源的方式。

提示

为什么我们在应用清单之前运行了sudo apt-get update?这是 Ubuntu 命令,用于从上游服务器更新系统的本地软件包目录。在安装任何软件包之前运行此命令是一个好主意,以确保你安装的是最新版本。当然,在你的生产 Puppet 代码中,你可以通过 exec 资源来运行它。

理解哈希

哈希,在某些编程语言中也被称为字典,类似于数组,但不同于仅仅是值的序列,每个值都有一个名称(variable_hash.pp):

$heights = {
  'john'    => 193,
  'rabiah'  => 120,
  'abigail' => 181,
  'melina'  => 164,
  'sumiko'  => 172,
}

notice("John's height is ${heights['john']}cm.")

每个值的名称被称为。在前面的示例中,这个哈希的键是johnrabiahabigailmelinasumiko。要查找给定键的值,你可以在哈希名称后面用方括号括住键:$heights['john']

提示

Puppet 风格说明

你是否注意到前面示例中最后一个哈希键值对和数组最后一个元素后的逗号?尽管逗号不是严格要求的,但添加逗号是一种良好的编码风格。原因是当你需要向数组或哈希中添加新项时,如果最后一个项已经有逗号,就不用记得在扩展列表时再添加一个逗号。

从哈希中设置资源属性

你可能已经注意到,哈希看起来非常像资源的属性:它是名称和值之间的一对一映射。如果在声明资源时,我们可以直接指定一个包含所有属性及其值的哈希,那该有多方便?事实证明,你可以这样做(hash_attributes.pp):

$attributes = {
  'owner' => 'ubuntu',
  'group' => 'ubuntu',
  'mode'  => '0644',
}

file { '/tmp/test':
  ensure => present,
  *      => $attributes,
}

*字符,欢快地被称为属性拆分运算符,告诉 Puppet 将指定的哈希视为要应用于资源的属性值对列表。这完全等同于直接指定相同的属性,如以下示例所示:

file { '/tmp/test':
  ensure => present,
  owner  => 'vagrant',
  group  => 'vagrant',
  mode   => '0644',
}

引入表达式

变量不是 Puppet 中唯一具有值的事物。表达式也有值。最简单的表达式只是字面值:

42
true
'Oh no, not again.'

你可以将数字值与算术运算符(如+-*/)结合起来,创建算术 表达式,它们有一个数值,并且可以用来让 Puppet 进行计算(expression_numeric.pp):

$value = (17 * 8) + (12 / 4) - 1
notice($value)

最有用的表达式是那些计算结果为truefalse的表达式,这些被称为布尔表达式。以下是一些布尔表达式的例子,它们的结果都为trueexpression_boolean.pp):

notice(9 < 10)
notice(11 > 10)
notice(10 >= 10)
notice(10 <= 10)
notice('foo' == 'foo')
notice('foo' in 'foobar')
notice('foo' in ['foo', 'bar'])
notice('foo' in { 'foo' => 'bar' })
notice('foo' =~ /oo/)
notice('foo' =~ String)
notice(1 != 2)

认识 Puppet 的比较运算符

在前面的示例中,所有布尔表达式中的运算符被称为比较运算符,因为它们用来比较两个值。结果是truefalse。以下是 Puppet 提供的比较运算符:

  • ==!=(等于,不等于)

  • >>=<<=(大于、大于或等于、小于、小于或等于)

  • A in BAB的子字符串,A是数组B的元素,或A是哈希B的键)

  • A =~ BA被正则表达式B匹配,或A是数据类型B的值。例如,表达式'hello' =~ Stringtrue,因为值'hello'的类型是 String。)

引入正则表达式

=~操作符尝试将给定值与正则表达式进行匹配。正则表达式(在这里是指构成模式或规则的“常规”表达式)是一种特殊类型的表达式,用于指定一组字符串。例如,正则表达式/a+/描述了包含一个或多个连续a字符的所有字符串:aaaaaa,依此类推,以及包含这样的字符序列的其他字符字符串。正则表达式在 Puppet 中由斜杠字符//限定。

当我们说正则表达式匹配某个值时,我们指的是该值是正则表达式指定的一组字符串中的一个。例如,正则表达式/a+/将匹配字符串aaa或字符串Aaaaargh!

以下示例展示了一些正则表达式,它们匹配字符串fooregex.pp):

$candidate = 'foo'
notice($candidate =~ /foo/) # literal
notice($candidate =~ /f/)   # substring
notice($candidate =~ /f.*/) # f followed by zero or more characters
notice($candidate =~ /f.o/) # f, any character, o
notice($candidate =~ /fo+/) # f followed by one or more 'o's
notice($candidate =~ /[fgh]oo/) # f, g, or h followed by 'oo'

提示

正则表达式或多或少是用于表达字符串模式的标准语言。它是一种复杂而强大的语言,确实值得一本书来深入探讨(也确实有几本书),但暂时可以简单了解,Puppet 的正则表达式语法与 Ruby 语言使用的语法相同。你可以在 Ruby 文档中了解更多信息:

ruby-doc.org/core/Regexp.html

使用条件表达式

布尔表达式,如前面示例中的那些,十分有用,因为我们可以利用它们在 Puppet 清单中做出选择。只有在满足某个条件时,我们才会应用特定的资源,或者我们可以根据某个表达式是否为真来给某个属性赋予不同的值。以这种方式使用的表达式称为条件表达式

使用if语句做决策

条件表达式最常见的用法是在if语句中。以下示例演示了如何使用if来决定是否应用资源(if.pp):

$install_perl = true
if $install_perl {
  package { 'perl':
    ensure => installed,
  }
} else {
  package { 'perl':
    ensure => absent,
  }
}

你可以看到,布尔变量$install_perl的值决定了是否安装perl包。如果$install_perltrue,Puppet 将应用以下资源:

  package { 'perl':
    ensure => installed,
  }

另一方面,如果$install_perlfalse,应用的资源将是:

  package { 'perl':
    ensure => absent,
  }

你可以使用if语句来控制任何数量资源的应用,甚至控制 Puppet 清单的任何部分。如果你愿意,可以省略else子句;在这种情况下,当条件表达式的值为false时,Puppet 将不做任何操作。

使用case语句选择选项

if语句允许你根据布尔表达式的值做出是/否决策。但是,如果你需要在多个选项中做选择,可以使用case语句代替(case.pp):

$webserver = 'nginx'
case $webserver {
  'nginx': {
    notice("Looks like you're using Nginx! Good choice!")
  }
  'apache': {
    notice("Ah, you're an Apache fan, eh?")
  }
  'IIS': {
    notice('Well, somebody has to.')
  }
  default: {
    notice("I'm not sure which webserver you're using!")
  }
}

case语句中,Puppet 会将表达式的值与按顺序列出的每个情况进行比较。如果找到匹配项,则会应用相应的资源。名为default的特殊情况始终匹配,你可以使用它确保即使其他情况都不匹配,Puppet 也能做出正确的操作。

查找事实

Puppet 清单中很常见的需求是需要了解它们运行的系统的一些信息,例如其主机名、IP 地址或操作系统版本。Puppet 获取系统信息的内置机制叫做Facter,Facter 提供的每一条信息被称为事实

使用事实哈希

你可以在清单中使用facts 哈希来访问 Facter 事实。这是一个 Puppet 变量$facts,在清单中的任何地方都可以使用,若要获取某个特定的事实,只需提供该事实的键名(facts_hash.pp):

notice($facts['kernel'])

在 Vagrant 盒子中,或者任何 Linux 系统中,这将返回值Linux

在较旧版本的 Puppet 中,每个事实都是一个独立的全局变量,如下所示:

notice($::kernel)

你仍然会在一些 Puppet 代码中看到这种事实引用风格,尽管它现在已被弃用,并最终会停止工作,所以你应该始终使用$facts 哈希。

运行 facter 命令

你还可以使用facter命令查看某些特定事实的值,或仅查看哪些事实是可用的。例如,在命令行中运行facter os将显示可用的与操作系统相关的事实的哈希:

facter os
{
  architecture => "amd64",
  distro => {
    codename => "xenial",
    description => "Ubuntu 16.04 LTS",
    id => "Ubuntu",
    release => {
      full => "16.04",
      major => "16.04"
    }
  },
  family => "Debian",
  hardware => "x86_64",
  name => "Ubuntu",
  release => {
    full => "16.04",
    major => "16.04"
  },
  selinux => {
    enabled => false
  }
}

你还可以使用puppet facts命令来查看哪些事实将对 Puppet 清单可用。这还会包括由第三方 Puppet 模块定义的任何自定义事实(有关更多信息,请参阅第七章,精通模块)。

访问事实的哈希

正如前面的例子所示,许多事实实际上返回的是一个值的哈希,而不是单一的值。$facts['os']事实的值是一个哈希,其中包含architecturedistrofamilyhardwarenamereleaseselinux等键。其中一些也是哈希;一切都是哈希!

如你所知,要访问哈希中的特定值,你需要在方括号中指定键名。要访问哈希内部的值,你需要在第一个键名后加上另一个键名,如下例所示(facts_architecture.pp):

notice($facts['os']['architecture'])

你可以继续附加更多的键来获得越来越具体的信息(facts_distro_codename.pp):

notice($facts['os']['distro']['codename'])

注意

关键事实

操作系统的主要版本是一个非常有用的事实,你可能会经常用到它:

$facts['os']['release']['major']

在表达式中引用事实

就像普通变量或值一样,你可以在表达式中使用事实,包括条件表达式(fact_if.pp):

if $facts['os']['selinux']['enabled'] {
  notice('SELinux is enabled')
} else {
  notice('SELinux is disabled')
}

提示

虽然基于事实的条件表达式可能很有用,但在清单中基于事实做出决策的更好方法是使用 Hiera,我们将在下一章中介绍。例如,如果发现自己编写了根据操作系统版本选择不同资源的ifcase语句,请考虑改用 Hiera 查询。

使用内存事实

另一个有用的事实集是与系统内存相关的事实。您可以查看可用的总物理内存,当前使用的内存量,以及交换内存的相同数字。

其一常见用途是根据系统内存量动态配置应用程序。例如,MySQL 参数innodb_buffer_pool_size指定为数据库查询缓存和索引分配的内存量,通常应尽可能设置得高(根据文档,"尽可能大的值,留下足够的内存供节点上的其他进程运行而不过度分页")。因此,您可以决定将其设置为总内存的四分之三(例如),使用事实和算术表达式,如以下片段(fact_memory.pp)所示:

$buffer_pool = $facts['memory']['system']['total_bytes'] * 3/4
notice("innodb_buffer_pool_size=${buffer_pool}")

注意

关键事实

总系统内存事实将帮助您计算随内存分数变化的配置参数:

$facts['memory']['system']['total_bytes']

发现网络事实

大多数应用程序使用网络,因此对于任何涉及网络配置的事情,您会发现 Facter 的网络相关事实非常有用。最常用的事实是系统主机名、完全合格的域名(FQDN)和 IP 地址(fact_networking.pp):

notice("My hostname is ${facts['hostname']}")
notice("My FQDN is ${facts['fqdn']}")
notice("My IP is ${facts['networking']['ip']}")

注意

关键事实

系统主机名是您在清单中经常需要引用的内容:

$facts['hostname']

提供外部事实

虽然 Puppet 提供的内置事实提供了大量重要信息,但通过添加自定义事实(称为外部事实),您可以使$facts哈希表变得更加有用。例如,如果节点位于不同的云提供商中,每个提供商都需要稍有不同的网络设置,您可以创建一个名为cloud的自定义事实来记录这一点。然后,您可以在清单中使用此事实来做出决策。

Puppet 在/opt/puppetlabs/facter/facts.d/目录中查找外部事实。尝试在该目录中创建一个名为facts.txt的文件,并包含以下内容(fact_external.txt):

cloud=aws

快速实现此目标的方法是运行以下命令:

sudo cp /examples/fact_external.txt /opt/puppetlabs/facter/facts.d

现在你的清单中包含了cloud事实。您可以通过运行以下命令来检查该事实是否有效:

sudo facter cloud
aws

要在您的清单中使用该事实,请像使用内置事实(fact_cloud.pp)一样查询$facts哈希表:

case $facts['cloud'] {
  'aws': {
    notice('This is an AWS cloud node ')
  }
  'gcp': {
    notice('This is a Google cloud node')
  }
  default: {
    notice("I'm not sure which cloud I'm in!")
  }
}

您可以在单个文本文件中放置尽可能多的事实,或者您可以在单独的文件中放置每个事实:这没有任何区别。 Puppet 将读取facts.d/目录中的所有文件,并从每个文件中提取所有key=value对。

文本文件适用于简单的事实(那些返回单一值的事实)。如果您的外部事实需要返回结构化数据(例如数组或哈希),您可以改用 YAML 或 JSON 文件来实现这一点。我们将在下一章学习更多关于 YAML 的内容,但现在,如果您需要构建结构化的外部事实,请参考 Puppet 文档了解详细信息。

在构建时设置外部事实是很常见的,也许是自动引导脚本的一部分(有关引导过程的更多信息,请参见 第十二章,综合应用)。

创建可执行事实

外部事实不仅限于静态文本文件。它们也可以是脚本或程序的输出。例如,您可以编写一个脚本,调用 web 服务以获取某些数据,结果将是事实的值。这些被称为可执行事实

可执行事实与其他外部事实位于同一目录(/opt/puppetlabs/facter/facts.d/),但是它们通过在文件上设置可执行位来区分(请记住,类 Unix 系统上的文件都有一组位,指示其读、写和执行权限),并且它们不能使用 .txt.yaml.json 后缀命名。让我们构建一个简单的可执行事实,返回当前日期作为示例:

  1. 运行以下命令将可执行事实示例复制到外部事实目录中:

    sudo cp /examples/date.sh /opt/puppetlabs/facter/facts.d
    
    
  2. 使用以下命令为文件设置可执行位:

    sudo chmod a+x /opt/puppetlabs/facter/facts.d/date.sh
    
    
  3. 现在测试该事实:

    sudo facter date
    2017-04-12
    

这是生成该输出的脚本(date.sh):

#!/bin/bash
echo "date=`date +%F`"

请注意,脚本必须在实际日期值之前输出date=。这是因为 Facter 期望可执行的事实输出一个key=value对的列表(在这种情况下只有一个对)。key是事实的名称(date),value是通过`date +%F`返回的内容(ISO 8601 格式的当前日期)。顺便提一下,任何需要表示日期时都应该使用 ISO 8601 格式(YYYY-MM-DD),因为它不仅是国际标准日期格式,而且清晰、无歧义,并且按字母顺序排序。

如您所见,可执行事实非常强大,因为它们可以返回程序可以生成的任何信息(例如,程序可以进行网络请求或数据库查询)。然而,您应该小心使用可执行事实,因为 Puppet 必须在每次运行时评估节点上的所有外部事实,这意味着它会运行 /opt/puppetlabs/facter/facts.d 目录中的每个脚本。

提示

如果您不需要每次 Puppet 运行时重新生成可执行事实的信息,考虑从 cron 作业中定期运行脚本,并让它将输出写入事实目录中的静态文本文件。

遍历数组

迭代(反复做某事)是 Puppet 清单中一个有用的技巧,可以避免大量重复的代码。例如,考虑以下清单,它创建了多个具有相同属性的文件(iteration_simple.pp):

file { '/usr/local/bin/task1':
  content => "echo I am task1\n",
  mode    => '0755',
}

file { '/usr/local/bin/task2':
  content => "echo I am task2\n",
  mode    => '0755',
}

file { '/usr/local/bin/task3':
  content => "echo I am task3\n",
  mode    => '0755',
}

你可以看到这些资源每个都是相同的,除了任务编号:task1task2task3。显然,这样大量的输入是繁琐的,如果你稍后决定修改这些脚本的属性(例如,将它们移动到不同的目录),你需要在清单中找到并修改每一个。对于三个资源来说,这已经很麻烦了,但如果是三十个或者一百个资源,那简直无法忍受。我们需要更好的解决方案。

使用 each 函数

Puppet 提供了 each 函数来帮助处理这种情况。each 函数接收一个数组,并将一段 Puppet 代码应用于数组中的每个元素。这里是我们之前看到的相同示例,只不过这次使用了数组和 each 函数(iteration_each.pp):

$tasks = ['task1', 'task2', 'task3']
$tasks.each | $task | {
  file { "/usr/local/bin/${task}":
    content => "echo I am ${task}\n",
    mode    => '0755',
  }
}

现在这看起来更像是一个计算机程序!我们有了一个由 each 函数创建的 循环。这个循环一次又一次地运行,为每个 $tasks 数组中的元素创建一个新的 file 资源。让我们看一下 each 循环的示意图:

ARRAY.each | ELEMENT | {
  BLOCK
}

以下列表描述了 each 循环的组成部分:

  • ARRAY 可以是任何 Puppet 数组变量或字面量值(甚至可以是返回数组的 Hiera 调用)。在之前的示例中,我们使用了 $tasks 作为数组。

  • ELEMENT 是一个变量的名称,每次循环时它将保存数组中当前元素的值。在之前的示例中,我们决定将此变量命名为 $task,尽管我们本可以取任何名字。

  • BLOCK 是一段 Puppet 代码。这可以是一个函数调用、资源声明、包含语句、条件语句:你可以在 Puppet 清单中写入的任何内容,也可以放在循环块内。在之前的示例中,块中唯一的内容是 file 资源,它创建了 /usr/local/bin/$task

迭代哈希

each 函数不仅适用于数组,也适用于哈希。当迭代哈希时,循环需要两个 ELEMENT 参数:第一个是哈希的键,第二个是哈希的值。以下示例展示了如何使用 each 来迭代一个由 Facter 查询返回的哈希(iteration_hash.pp):

$nics = $facts['networking']['interfaces']
$nics.each | String $interface, Hash $attributes | {
  notice("Interface ${interface} has IP ${attributes['ip']}")
}

$facts['networking']['interfaces'] 返回的接口列表是一个哈希,其中键是接口的名称(例如,lo0 是本地回环接口的名称),值是接口属性的哈希(包括 IP 地址、子网掩码等)。应用之前示例中的清单会产生如下结果(在我的 Vagrant 虚拟机上):

sudo puppet apply /examples/iteration_hash.pp
Notice: Scope(Class[main]): Interface enp0s3 has IP 10.0.2.15
Notice: Scope(Class[main]): Interface lo has IP 127.0.0.1

摘要

在本章中,我们了解了 Puppet 的变量和数据类型系统如何工作,包括基本数据类型:字符串、数字、布尔值、数组和哈希。我们学习了如何在字符串中插入变量,以及如何通过资源名称数组快速创建一组相似的资源。我们还学会了如何通过属性-值对的哈希设置资源的常见属性,并使用属性展开操作符。

我们了解了如何在表达式中使用变量和值,包括算术表达式,并探讨了 Puppet 比较操作符的范围,以生成布尔表达式。我们使用条件表达式构建 if…elsecase 语句,并简要介绍了正则表达式。

我们学习了 Puppet 的 Facter 子系统如何通过 facts 哈希提供关于节点的信息,以及如何在自己的清单和表达式中使用 facts。我们指出了一些关键的 facts,包括操作系统版本、系统内存容量和系统主机名。我们还了解了如何创建自定义外部 facts,例如 cloud fact,以及如何使用可执行的 facts 动态生成 fact 信息。

最后,我们了解了如何使用 Puppet 中的 each 函数进行迭代,并基于数组或哈希中的数据(包括 Facter 查询)创建多个资源。

在下一章中,我们将继续讨论数据话题,探索 Puppet 强大的 Hiera 数据库。我们将看到 Hiera 解决了哪些问题,学习如何设置和查询 Hiera,如何编写数据源,如何直接从 Hiera 数据创建 Puppet 资源,以及如何使用 Hiera 加密来管理敏感数据。

第六章:使用 Hiera 管理数据

What you don't know can't hurt me.
--Edward S. Marshall

在本章中,你将学习为什么将数据和代码分开是有用的。你将看到如何设置 Puppet 内置的 Hiera 机制,如何使用它存储和查询配置信息,包括加密的秘密信息如密码,以及如何使用 Hiera 数据创建 Puppet 资源。

使用 Hiera 管理数据

为什么选择 Hiera?

我们所说的配置信息是什么意思?在你的清单中会有很多我们可以视为配置信息的内容:例如,你所有资源属性的值。看下面的例子:

package { 'puppet-agent':
  ensure => '5.2.0-1xenial',
}

上面的清单声明了要安装版本为 5.2.0-1xenialpuppet-agent 包。但是当 Puppet 发布新版本时,会发生什么呢?当你想要升级时,你必须找到这段代码,可能在多个目录层级的深处,并且编辑它来改变所需的版本号。

数据需要维护

将这一点乘以你在整个清单中管理的所有软件包,问题已经显现。但这只是需要维护的一部分数据,还有很多其他数据需要维护:例如,cron 任务的时间、报告发送的邮件地址、从网页上获取的文件 URL、监控检查的参数、为数据库服务器配置的内存大小等等。如果这些值嵌入在成百上千个清单文件中的代码里,你将为未来埋下麻烦。

如何使你的配置信息更容易查找和维护?

设置依赖于节点

将数据和代码混合在一起会使得查找和编辑数据变得更加困难。但还有另一个问题。如果你有两个节点需要用 Puppet 来管理,而且有一个配置值需要在每个节点上不同怎么办?例如,它们可能都有一个 cron 任务来运行备份,但任务需要在每个节点上不同的时间运行。

如何为不同的节点使用不同的值,而不在清单中加入大量复杂的逻辑?

操作系统有所不同

如果你有一些节点运行 Ubuntu 16,而有些运行 Ubuntu 18 呢?如果你曾经需要升级节点上的操作系统,你就会知道,从一个版本到下一个版本,许多事情都会发生变化。例如,数据库服务器包的名称可能从 mysql-server 改成了 mariadb-server

如何根据节点运行的操作系统找到在清单中使用的正确值?

Hiera 方法

我们希望的是在 Puppet 中拥有一个类似于中央数据库的地方,可以查找配置设置。数据应当与 Puppet 代码分开存储,并且使得查找和编辑值变得容易。应该能够通过 Puppet 代码或模板中的简单函数调用来查找值。此外,我们还需要根据节点的主机名、操作系统或其他因素来指定不同的值。我们还希望能够对值强制执行特定的数据类型,如 String 或 Boolean。数据库应当为我们完成所有这些工作,并返回需要的适当值。

幸运的是,Hiera 正是如此操作的。Hiera 允许你将配置数据存储在简单的文本文件中(实际上是 YAML、JSON 或 HOCON 文件,这些文件使用流行的结构化文本格式),其内容如下所示:

---
  test: 'This is a test'
  consul_node: true
  apache_worker_factor: 100
  apparmor_enabled: true
  ...

在你的清单中,你可以使用 lookup() 函数查询数据库,如以下示例所示(lookup.pp):

file { lookup('backup_path', String):
  ensure => directory,
}

lookup 的参数是你想要检索的 Hiera 键的名称(例如 backup_path)以及预期的数据类型(例如 String)。

设置 Hiera

在开始使用 Hiera 之前,Hiera 需要知道一两个信息,这些信息在 Hiera 配置文件中指定,文件名为 hiera.yaml(请不要将其与 Hiera 数据文件混淆,后者也是 YAML 文件,我们将在本章后面了解它们)。每个 Puppet 环境都有自己的本地 Hiera 配置文件,位于环境目录的根目录下(例如,对于 production 环境,本地 Hiera 配置文件的位置为 /etc/puppetlabs/code/environments/production/hiera.yaml)。

提示

Hiera 还可以使用位于 /etc/puppetlabs/puppet/hiera.yaml 的全局配置文件,该文件优先于每个环境的配置文件,但 Puppet 文档建议仅在某些特殊情况下使用此配置层,例如临时覆盖;所有常规的 Hiera 数据和配置应该保存在环境层中。

以下示例展示了一个最小的 hiera.yaml 文件(hiera_minimal.config.yaml):

---
version: 5

defaults:
  datadir: data
  data_hash: yaml_data

hierarchy:
  - name: "Common defaults"
    path: "common.yaml"

YAML 文件以三个破折号和换行符开始(---)。这是 YAML 格式的一部分,而不是 Hiera 的特性;它是指示新 YAML 文档开始的语法。

defaults 部分中最重要的设置是 datadir。它告诉 Hiera 在哪个目录查找数据文件。通常情况下,这些文件位于 Puppet 清单目录的 data/ 子目录中,但如果需要,你可以更改这个设置。

提示

大型组织可能会发现将 Hiera 数据文件与 Puppet 代码分开管理非常有用,例如,可以将其放在一个单独的 Git 仓库中(例如,你可能希望授予某些人编辑 Hiera 数据的权限,但不允许编辑 Puppet 清单)。

hierarchy 部分也很有趣。它告诉 Hiera 要读取哪些文件及其顺序。在示例中,仅定义了 Common defaults,指示 Hiera 在名为 common.yaml 的文件中查找数据。我们将在本章后面看到,您还可以在 hierarchy 部分做更多操作。

将 Hiera 数据添加到您的 Puppet 仓库

您的 Vagrant 虚拟机已经配置好了适当的 Hiera 配置和示例数据文件,位于 /etc/puppetlabs/code/environments/pbg 目录中。现在试试看:

运行以下命令:

sudo puppet lookup --environment pbg test
--- This is a test

提示

我们之前没有看到过 --environment 参数,因此现在简要介绍一下 Puppet 环境。Puppet 环境是一个包含 Hiera 配置文件、Hiera 数据和一组 Puppet 清单的目录——换句话说,这是一个完整的、独立的 Puppet 配置。每个环境都位于 /etc/puppetlabs/code/environments 下的一个命名目录中。默认环境是 production,但是您可以通过给 puppet lookup 命令传递 --environment 参数来使用任何您喜欢的环境。在示例中,我们告诉 Puppet 使用 /etc/puppetlabs/code/environments/pbg 目录。

当您将 Hiera 数据添加到自己的 Puppet 环境时,可以使用示例中的 hiera.yaml 和数据文件作为起点。

Hiera 故障排除

如果您没有得到结果 This is a test,说明您的 Hiera 设置未正常工作。如果您看到警告 Config file not found, using Hiera defaults,请检查您的 Vagrant 虚拟机是否包含 /etc/puppetlabs/code/environments/pbg 目录。如果没有,请销毁并重新配置您的 Vagrant 虚拟机,使用以下命令:

vagrant destroy
scripts/start_vagrant.sh

如果您看到类似以下的错误,一般意味着 Hiera 数据文件的语法存在问题:

Error: Evaluation Error: Error while evaluating a Function Call, (/etc/puppetlabs/code/environments/pbg/hiera.yaml): did not find expected key while parsing a block mapping at line 11 column 5  at line 1:8 on node ubuntu-xenial

如果出现这种情况,请检查您的 Hiera 数据文件的语法。

查询 Hiera

在 Puppet 清单中,您可以使用 lookup() 函数查询 Hiera 中指定的键(您可以将 Hiera 看作一个键值数据库,其中键是字符串,值可以是任何类型)。

通常,您可以在 Puppet 清单中任何可能使用文字值的地方使用 lookup() 调用。以下代码展示了这方面的一些示例(lookup2.pp):

notice("Apache is set to use ${lookup('apache_worker_factor', Integer)} workers")

unless lookup('apparmor_enabled', Boolean) {
  exec { 'apt-get -y remove apparmor': }
}

notice('dns_allow_query enabled: ', lookup('dns_allow_query', Boolean))

要在示例环境中应用此清单,请运行以下命令:

sudo puppet apply --environment pbg /examples/lookup2.pp
Notice: Scope(Class[main]): Apache is set to use 100 workers
Notice: Scope(Class[main]): dns_allow_query enabled:  true

类型化查找

正如我们所看到的,lookup() 函数有第二个参数,指定要检索的值的预期类型。虽然这是可选的,但您应该始终指定它,以帮助捕获错误。如果您不小心查询了错误的键,或在数据文件中错误地输入了值,您会看到类似这样的错误:

Error: Evaluation Error: Error while evaluating a Function Call, Found value has wrong type, expects a Boolean value, got String at /examples/lookup_type.pp:1:8 on node ubuntu-xenial

Hiera 数据的类型

正如我们所看到的,Hiera 数据存储在文本文件中,采用名为YAML Ain't Markup Language的格式进行结构化,这是组织数据的一种常见方式。以下是来自我们示例 Hiera 数据文件的另一段代码,您可以在虚拟机的 /etc/puppetlabs/code/environments/pbg/data/common.yaml 文件中找到:

  syslog_server: '10.170.81.32'
  monitor_ips:
    - '10.179.203.46'
    - '212.100.235.160'
    - '10.181.120.77'
    - '94.236.56.148'
  cobbler_config:
    manage_dhcp: true
    pxe_just_once: true

实际上,存在三种不同类型的 Hiera 数据结构:单一值数组哈希。稍后我们将详细讨论这些内容。

单一值

大多数 Hiera 数据由一个与单一值相关联的键组成,如前面的示例所示:

syslog_server: '10.170.81.32'

值可以是任何合法的 Puppet 值,例如字符串(如本例所示),或者它也可以是整数:

apache_worker_factor: 100

布尔值

你应该在 Hiera 中将布尔值指定为truefalse,且不加引号。然而,Hiera 对布尔值的解释较为宽松:trueonyes(无论是否带引号)都会被解释为真值,而falseoffno则会被解释为假值。但为了清晰起见,最好遵循以下格式:

consul_node: true

当你在 Puppet 代码中使用lookup()返回布尔值时,可以将其用作条件表达式,例如在if语句中:

if lookup('is_production', Boolean) {
  ...

数组

有用的是,Hiera 也可以存储与单一键关联的值数组:

monitor_ips:
  - '10.179.203.46'
  - '212.100.235.160'
  - '10.181.120.77'
  - '94.236.56.148'

键(monitor_ips)后面跟着一个值的列表,每个值单独列在一行并以短横线(-)开头。当你在代码中调用lookup('monitor_ips', Array)时,值会作为 Puppet 数组返回。

哈希

正如我们在第五章,变量、表达式与事实中看到的,哈希(在某些编程语言中也叫做字典)就像一个数组,其中每个值都有一个标识名称(称为),如以下示例所示:

cobbler_config:
  manage_dhcp: true
  pxe_just_once: true

哈希中的每对键值都会列出,并缩进在各自的行上。cobbler_config哈希包含两个键,manage_dhcppxe_just_once。与这些键关联的值是true

当你在清单中调用lookup('cobbler_config', Hash)时,数据会作为 Puppet 哈希返回,你可以使用常规的 Puppet 哈希语法引用其中的单个值,正如我们在第五章,变量、表达式与事实lookup_hash.pp)中看到的那样:

$cobbler_config = lookup('cobbler_config', Hash)
$manage_dhcp = $cobbler_config['manage_dhcp']
$pxe_just_once = $cobbler_config['pxe_just_once']
if $pxe_just_once {
  notice('pxe_just_once is enabled')
} else {
  notice('pxe_just_once is disabled')
}

由于 Hiera 数据常常是哈希中的哈希,你可以通过以下“点符号”(lookup_hash_dot.pp)从多层嵌套的哈希中检索值:

$web_root = lookup('cms_parameters.static.web_root', String)
notice("web_root is ${web_root}")

Hiera 数据中的插值

Hiera 数据不仅限于字面值;它还可以包括 Facter 事实或 Puppet 变量的值,如以下示例所示:

 backup_path: "/backup/%{facts.hostname}"

任何位于%{}定界符中的内容,都会被 Hiera 解析并插值。在这里,我们使用点符号引用$facts哈希中的一个值。

使用 lookup()

有帮助的是,你还可以通过将lookup()函数作为值的一部分来在 Hiera 数据中插值。这可以避免多次重复相同的值,并使数据更易于阅读,如以下示例(也来自hiera_sample.yaml)所示:

ips:
  home: '130.190.0.1'
  office1: '74.12.203.14'
  office2: '95.170.0.75'
firewall_allow_list:
  - "%{lookup('ips.home')}"
  - "%{lookup('ips.office1')}"
  - "%{lookup('ips.office2')}"

这比单纯列出一组 IP 地址而不说明它们代表什么要易读得多,而且它可以防止你在一个地方更新值却忘记在另一个地方更新,避免引入错误。使用 Hiera 插值使你的数据自文档化。

使用 alias()

当你在 Hiera 字符串值中使用 lookup() 函数时,结果总是一个字符串。如果你处理的是字符串数据,或者你想将一个 Hiera 值插入到包含其他文本的字符串中,这没问题。然而,如果你处理的是数组、哈希或布尔值,你需要使用 alias() 函数。这样,你就可以通过引用其名称在 Hiera 中重用任何 Hiera 数据结构:

firewall_allow_list:
  - "%{lookup('ips.home')}"
  - "%{lookup('ips.office1')}"
  - "%{lookup('ips.office2')}"
vpn_allow_list: "%{alias('firewall_allow_list')}"

不要被周围的引号所迷惑:看起来 vpn_allow_list 可能是一个字符串值,但由于我们使用了 alias(),它实际上将是一个数组,就像它所别名的值(firewall_allow_list)一样。

使用 literal()

因为百分号字符(%)告诉 Hiera 进行插值,你可能会想知道如何在数据中指定字面上的百分号。例如,Apache 在其配置中使用百分号来表示像 %{HTTP_HOST} 这样的变量名。要在 Hiera 数据中写入这样的值,我们需要使用 literal() 函数,它专门用于表示字面上的百分号字符。例如,要将值 %{HTTP_HOST} 写为 Hiera 数据,我们需要写成:

%{literal('%')}{HTTP_HOST}

你可以在示例 Hiera 数据文件中看到一个更复杂的例子:

force_www_rewrite:
  comment: "Force WWW"
  rewrite_cond: "%{literal('%')}{HTTP_HOST} !^www\\. [NC]"
  rewrite_rule: "^(.*)$ https://www.%{literal('%')}{HTTP_HOST}%{literal('%')}{REQUEST_URI} [R=301,L]"

层次结构

到目前为止,我们只使用了一个 Hiera 数据源(common.yaml)。实际上,你可以有任意多个数据源。每个数据源通常对应一个 YAML 文件,它们在 hiera.yaml 文件的 hierarchy 部分列出,优先级最高的源在前,优先级最低的源在后:

hierarchy:
  ...
  - name: "Host-specific data"
    path: "nodes/%{facts.hostname}.yaml"
  - name: "OS release-specific data"
    path: "os/%{facts.os.release.major}.yaml"
  - name: "OS distro-specific data"
    path: "os/%{facts.os.distro.codename}.yaml"
  - name: "Common defaults"
    path: "common.yaml"

然而,一般来说,你应该尽可能将更多的数据保存在 common.yaml 文件中,因为如果数据集中在一个地方,查找和维护会更加方便,而不是分散在多个文件中。

例如,如果你有一些只在 monitor 节点上使用的 Hiera 数据,你可能会倾向于将其放在 nodes/monitor.yaml 文件中。但是,除非它需要覆盖 common.yaml 中的一些设置,否则你只是在让它更难找到和更新。将你能放入的所有内容都放进 common.yaml,而把其他数据源只保留用于覆盖公共值。

处理多个值

你可能会想知道,如果同一个键出现在多个 Hiera 数据源中会发生什么。例如,假设第一个数据源包含以下内容:

consul_node: false

此外,假设 common.yaml 包含:

consul_node: true

当你用这些数据调用 lookup('consul_node', Boolean) 时,会发生什么?consul_node 在两个不同的文件中有两个不同的值,那么 Hiera 会返回哪个值?

答案是,Hiera 会按照hierarchy部分中列出的顺序搜索数据源;也就是说,按照优先级顺序。它返回第一个找到的值,因此,如果有多个值,只有来自第一个——即优先级最高——数据源的值会被返回(这就是“层级”部分)。

合并行为

我们在前一节中提到,如果有多个值匹配指定的键,第一个匹配的数据源优先于其他数据源。这是默认行为,也是你通常希望的行为。然而,有时你可能希望lookup()返回所有匹配值的并集,遍历整个层级。Hiera 允许你指定在多个值匹配时,它应该使用哪种策略。

这被称为合并行为,你可以在lookup()的第三个参数中指定你希望使用的合并行为,紧跟着键和值类型(lookup_merge.pp):

notice(lookup('firewall_allow_list', Array, 'unique'))

默认的合并行为称为first,它仅返回一个值,即第一个找到的值。相对而言,unique合并行为会返回所有找到的值,作为一个扁平化的数组,去除重复值(因此称为unique)。

如果你查找哈希数据,可以使用hash合并行为来返回一个合并后的哈希,包含所有匹配哈希中的键值对。如果 Hiera 找到两个具有相同名称的哈希键,则仅返回第一个的值。这被称为浅合并。如果你需要深度合并(即,匹配的哈希会在所有层级上进行合并,而不仅仅是顶层),请使用deep合并行为。

如果这一切听起来有点复杂,别担心。默认的合并行为通常是你大多数时候所需要的,如果你确实需要其他合并行为,可以在 Puppet 文档中找到更多信息。

基于 fact 的数据源

层级机制允许你为所有情况设置通用的默认值(通常在common.yaml中),但可以在特定情况下覆盖这些值。例如,你可以根据 Puppet fact 的值在层级中设置数据源,比如主机名:

  - name: "Host-specific data"
    path: "nodes/%{facts.hostname}.yaml"

Hiera 将查找指定 fact 的值,并在nodes/目录中搜索具有该名称的数据文件。在前面的例子中,如果节点的主机名是web1,Hiera 将会在 Hiera 数据目录中查找数据文件nodes/web1.yaml。如果该文件存在并包含指定的 Hiera 键,则web1节点将获得该值,而其他节点则会从common中获取默认值。

注意

请注意,如果你愿意,可以将 Hiera 数据文件组织在data/主目录下的子目录中,例如data/nodes/

层次结构中另一个有用的事实是操作系统的主要版本或代号。当你需要让清单在多个操作系统版本上工作时,这非常有用。如果你有多个节点,迁移到最新的操作系统版本通常是一个逐步的过程,每次升级一个节点。如果从一个版本到下一个版本有所变化,影响到你的 Puppet 清单,你可以使用 os.distro.codename 事实来选择适当的 Hiera 数据,示例如下:

  - name: "OS-specific data"
    path: "os/%{facts.os.distro.codename}.yaml"

或者,你可以使用 os.release.major 事实:

  - name: "OS-specific data"
    path: "os/%{facts.os.release.major}.yaml"

例如,如果你的节点正在运行 Ubuntu 16.04 Xenial,Hiera 会查找名为 os/xenial.yaml(如果使用 os.distro.codename)或 os/16.04.yaml(如果使用 os.release.major)的数据文件,文件位于 Hiera 数据目录中。

有关 Puppet 中事实的更多信息,请参见第五章,变量、表达式和事实

什么应该放在 Hiera 中?

你应该将哪些数据放在 Hiera 中,哪些应该放在 Puppet 清单中?一个很好的经验法则是在考虑何时分离数据和代码时,问问自己将来可能会变化的是什么。例如,包的确切版本是 Hiera 数据的一个很好的候选项,因为它很可能需要在未来进行更新。

属于 Hiera 的数据的另一个特点是它特定于你的网站或公司。如果你将 Puppet 清单交给其他公司或组织中的某个人,并且她需要修改代码中的某些值以使其在她的网站上运行,那么这些值可能应该放在 Hiera 中。这样做可以更容易地共享和重用代码;你只需要在 Hiera 中编辑一些值。

如果在多个地方都需要相同的数据,最好将该数据存储在 Hiera 中。否则,你要么不得不重复数据,这会增加维护的难度,要么使用全局变量,这在任何编程语言中都不推荐,尤其是在 Puppet 中。

如果在将清单应用到不同的操作系统时需要更改数据值,这也可以是 Hiera 数据的一个候选项。正如我们在本章中所看到的,你可以使用层次结构根据事实(例如操作系统或版本)选择正确的值。

另一类属于 Hiera 的数据是类和模块的参数值;我们将在第七章中进一步了解,掌握模块

使用 Hiera 数据创建资源

当我们开始使用 Puppet 时,我们是通过在清单中直接使用字面属性值来创建资源的。在这一章中,我们看到如何使用 Hiera 数据填充清单中资源的标题和属性。现在,我们可以更进一步,直接从 Hiera 查询中创建资源。这种方法的优势在于,我们可以根据数据创建任何类型的资源,且数量不限。

从 Hiera 数组构建资源

在第五章中,变量、表达式和事实,我们学习了如何使用 Puppet 的 each 函数来迭代数组或哈希,并在此过程中创建资源。让我们将这种技术应用到一些 Hiera 数据中。在我们的第一个示例中,我们将从 Hiera 数组中创建一些用户资源。

运行以下命令:

sudo puppet apply --environment pbg /examples/hiera_users.pp
Notice: /Stage[main]/Main/User[katy]/ensure: created
Notice: /Stage[main]/Main/User[lark]/ensure: created
Notice: /Stage[main]/Main/User[bridget]/ensure: created
Notice: /Stage[main]/Main/User[hsing-hui]/ensure: created
Notice: /Stage[main]/Main/User[charles]/ensure: created

这是我们使用的数据(来自 /etc/puppetlabs/code/environments/pbg/data/common.yaml 文件):

users:
  - 'katy'
  - 'lark'
  - 'bridget'
  - 'hsing-hui'
  - 'charles'

这是读取数据并创建相应用户实例的代码(hiera_users.pp):

lookup('users', Array[String]).each | String $username | {
  user { $username:
    ensure => present,
  }
}

将 Hiera 数据与资源迭代结合起来是一个强大的想法。这个简短的清单可以管理你基础设施中的所有用户,而无需编辑 Puppet 代码来进行更改。要添加新用户,只需编辑 Hiera 数据。

从 Hiera 哈希构建资源

当然,现实生活中的情况从来不会像编程语言示例那样简单。如果你真的用 Hiera 数据来管理用户,你需要包含比名字更多的数据:你需要能够管理用户的 shell、UID 等,还需要能够在必要时删除用户。为此,我们需要为 Hiera 数据添加一些结构。

运行以下命令:

sudo puppet apply --environment pbg /examples/hiera_users2.pp
Notice: Compiled catalog for ubuntu-xenial in environment pbg in 0.05 seconds
Notice: /Stage[main]/Main/User[katy]/uid: uid changed 1001 to 1900
Notice: /Stage[main]/Main/User[katy]/shell: shell changed '' to '/bin/bash'
Notice: /Stage[main]/Main/User[lark]/uid: uid changed 1002 to 1901
Notice: /Stage[main]/Main/User[lark]/shell: shell changed '' to '/bin/sh'
Notice: /Stage[main]/Main/User[bridget]/uid: uid changed 1003 to 1902
Notice: /Stage[main]/Main/User[bridget]/shell: shell changed '' to '/bin/bash'
Notice: /Stage[main]/Main/User[hsing-hui]/uid: uid changed 1004 to 1903
Notice: /Stage[main]/Main/User[hsing-hui]/shell: shell changed '' to '/bin/sh'
Notice: /Stage[main]/Main/User[charles]/uid: uid changed 1005 to 1904
Notice: /Stage[main]/Main/User[charles]/shell: shell changed '' to '/bin/bash'
Notice: Applied catalog in 0.17 seconds

与之前示例的第一个不同点是,这里数据不是简单的数组,而是哈希中的哈希:

users2:
  'katy':
    ensure: present
    uid: 1900
    shell: '/bin/bash'
  'lark':
    ensure: present
    uid: 1901
    shell: '/bin/sh'
  'bridget':
    ensure: present
    uid: 1902
    shell: '/bin/bash'
  'hsing-hui':
    ensure: present
    uid: 1903
    shell: '/bin/sh'
  'charles':
    ensure: present
    uid: 1904
    shell: '/bin/bash'

这是处理数据的代码(hiera_users2.pp):

lookup('users2', Hash, 'hash').each | String $username, Hash $attrs | {
  user { $username:
    * => $attrs,
  }
}

users2 哈希中的每个键是一个用户名,每个值是一个包含用户属性(如 uidshell)的哈希。

当我们对这个哈希调用 each 时,我们为循环指定了两个参数,而不是一个:

| String $username, Hash $attrs |

正如我们在第五章中看到的,变量、表达式和事实,在迭代哈希时,这两个参数分别接收哈希的键和值。

在循环内部,我们为哈希中的每个元素创建一个用户资源:

user { $username:
  * => $attrs,
}

你可能还记得上一章中提到的 * 运算符(属性展开运算符),它告诉 Puppet 将 $attrs 视为属性-值对的哈希。因此,在第一次循环时,对于用户 katy,Puppet 将创建一个等效于以下清单的用户资源:

user { 'katy':
  ensure => present,
  uid    => 1900,
  shell  => '/bin/bash',
}

每次我们在循环中处理 users 的下一个元素时,Puppet 将使用指定的属性创建另一个用户资源。

使用 Hiera 数据管理资源的优势

上面的示例使得在网络中管理用户变得更加简便,而无需编辑 Puppet 代码:例如,如果你想删除一个用户,你只需将她的 ensure 属性在 Hiera 数据中修改为 absent。尽管每个用户恰好都有相同的属性集,但这并非必须;你可以将 Puppet user 资源支持的任何属性添加到数据中的任何用户。此外,如果有一个属性的值对所有用户始终相同,则无需在每个用户的 Hiera 数据中列出它。你可以将其作为 user 资源中的文字属性值添加到循环内,这样每个用户都会拥有它。

这使得定期添加和更新用户变得更加容易,但也有其他优点:例如,你可以编写一个简单的 Web 应用程序,允许人力资源人员使用浏览器界面添加或编辑用户,并且它只需要输出一个包含所需数据的 YAML 文件。这比自动生成 Puppet 代码要容易得多、也更加稳健。更好的是,你可以从 LDAP 或 Active DirectoryAD)服务器中提取用户数据,并将其转换为 Hiera YAML 格式,供该清单使用。

这是一种非常强大且灵活的技术,当然你可以用它来管理任何类型的 Puppet 资源:文件、软件包、Apache 虚拟主机、MySQL 数据库——你能用资源做的任何事情都可以通过 Hiera 数据和 each 来实现。你还可以使用 Hiera 的重写机制为不同的节点、角色或操作系统创建不同的资源集。

然而,你不应该过度使用这种技术。根据 Hiera 数据创建资源增加了一层抽象,使得任何试图阅读或维护代码的人更难理解它。使用 Hiera 时,从检查中也很难确切知道在特定情况下节点将获得哪些数据。尽量保持你的层次结构尽可能简单,并将数据驱动的资源技巧保留用于需要频繁更新的大量可变资源的场景。在第十一章,编排云资源,我们将看到如何使用相同的技术来管理云实例。

管理机密数据

Puppet 经常需要了解你的秘密;例如,密码、私钥和其他凭据需要在节点上配置,并且 Puppet 必须能够访问这些信息。问题是如何确保没有其他人能访问这些信息。如果你将这些数据提交到 Git 仓库,它将对所有有权限访问该仓库的人可见,而如果是公共的 GitHub 仓库,任何人都能看到它。

显然,能够以某种方式加密秘密数据是至关重要的,以便 Puppet 能够在需要的各个节点上解密它,但对没有密钥的人来说是无法解密的。流行的 GnuPG 加密工具是一个不错的选择,它允许您使用公开密钥加密数据,并且可以广泛分发,但只有拥有对应私钥的人才能解密信息。

Hiera 具有可插拔的后端系统,允许它支持多种不同的存储数据方式。一个这样的后端叫做hiera-eyaml-gpg,它允许 Hiera 使用 GnuPG 加密的数据存储。与加密整个数据文件不同,hiera-eyaml-gpg允许您在同一个 YAML 文件中混合加密和明文数据。这样,即使没有私钥的人也可以编辑和更新 Hiera 数据文件中的明文值,尽管加密的数据值对他们来说是不可读的。

配置 GnuPG

首先,我们需要安装 GnuPG 并创建一个用于 Hiera 的密钥对。以下说明将帮助您完成这项工作:

  1. 运行以下命令:

    sudo apt-get install gnupg rng-tools
    
    
  2. 一旦安装了 GnuPG,运行以下命令生成一个新的密钥对:

    gpg --gen-key
    
    
  3. 当系统提示时,选择 RSA 和 RSA 密钥类型:

    Please select what kind of key you want:
       (1) RSA and RSA (default)
       (2) DSA and Elgamal
       (3) DSA (sign only)
       (4) RSA (sign only)
    Your selection? 1
    
    
  4. 选择 2048 位的密钥大小:

    RSA keys may be between 1024 and 4096 bits long.
    What keysize do you want? (2048) 2048
    
    
  5. 输入0作为密钥过期时间:

    Key is valid for? (0) 0
    Key does not expire at all
    Is this correct? (y/N) y
    
    
  6. 当系统提示输入真实姓名、电子邮件地址和用于密钥的评论时,输入适合您网站的内容:

    Real name: Puppet
    Email address: puppet@cat-pictures.com
    Comment:
    You selected this USER-ID:
        "Puppet <puppet@cat-pictures.com>"
    
    Change (N)ame, (C)omment, (E)mail or (O)kay/(Q)uit? o
    
    
  7. 当系统提示输入密码短语时,直接按Enter(该密钥不能有密码短语,因为 Puppet 无法提供它)。

生成密钥可能需要几分钟时间,但一旦完成,GnuPG 会打印出密钥指纹和详细信息(您的密钥指纹会与此不同):

pub   2048R/40486112 2016-09-30
      Key fingerprint = 6758 6CEE D221 7AA0 8369  FF3A FEC1 0055 4048 6112
uid                  Puppet <puppet@cat-pictures.com>
sub   2048R/472954EB 2016-09-30

该密钥现在存储在您的 GnuPG 密钥环中,Hiera 将能够在该节点上使用它来加密和解密您的秘密数据。稍后我们将看到如何将此密钥分发到 Puppet 管理的其他节点。

添加一个加密的 Hiera 数据源

使用 GPG 加密数据的 Hiera 源需要几个额外的参数。以下是示例hiera.yaml文件中的相关部分:

  - name: "Secret data (encrypted)"
    lookup_key: eyaml_lookup_key
    path: "secret.eyaml"
    options:
      gpg_gnupghome: '/home/ubuntu/.gnupg'

与普通数据源一样,我们有namepath指向数据文件,但我们还需要指定lookup_key函数,在这种情况下是eyaml_lookup_key,并将options['gpg_gnupghome']设置为指向 GnuPG 目录,解密密钥存放在该目录下。

创建加密的秘密

现在,您已经准备好将一些秘密数据添加到您的 Hiera 存储中。

  1. 使用以下命令创建一个新的空 Hiera 数据文件:

    cd /etc/puppetlabs/code/environments/pbg
    sudo touch data/secret.eyaml
    
    
  2. 运行以下命令,使用eyaml编辑器编辑数据文件(保存时,它会自动为您加密数据)。请用您在创建 GPG 密钥时输入的电子邮件地址替代puppet@cat-pictures.com

    sudo /opt/puppetlabs/puppet/bin/eyaml edit --gpg-always-trust --gpg-recipients=puppet@cat-pictures.com data/secret.eyaml
    
    
  3. 如果系统提示你选择默认编辑器,请选择你喜欢的编辑器。如果你熟悉 Vim,建议选择 Vim,但如果不熟悉,你可能会发现nano是最简单的选择。(你应该学习 Vim,但那是另一本书的内容。)

  4. 你选择的编辑器将启动,并且以下文本已经插入到文件中:

    #| This is eyaml edit mode. This text (lines starting with #| at the top of the
    #| file) will be removed when you save and exit.
    #|  - To edit encrypted values, change the content of the DEC(<num>)::PKCS7[]!
    #|    block (or DEC(<num>)::GPG[]!).
    #|    WARNING: DO NOT change the number in the parentheses.
    #|  - To add a new encrypted value copy and paste a new block from the
    #|    appropriate example below. Note that:
    #|     * the text to encrypt goes in the square brackets
    #|     * ensure you include the exclamation mark when you copy and paste
    #|     * you must not include a number when adding a new block
    #|    e.g. DEC::PKCS7[]! -or- DEC::GPG[]!
    
  5. 在注释信息下方输入以下文本,完全按照显示的内容输入,包括开头的三个短横线:

    ---
      test_secret: DEC::GPG[This is a test secret]!
    
  6. 保存文件并退出编辑器。

  7. 运行以下命令以测试 Puppet 是否能够读取并解密你的机密数据:

    sudo puppet lookup --environment pbg test_secret
    --- This is a test secret
    

Hiera 如何解密机密数据

为了验证数据确实是加密的,可以运行以下命令查看磁盘上数据文件中的内容:

cat data/secret.eyaml
---
  test_secret: ENC[GPG,hQEMA4+8DyxHKVTrAQf/QQPL4zD2kkU7T+FhaEdptu68RAw2m2KAXGujjnQPXoONrbh1QjtzZiJBlhqOP+7JwvzejED0NXNMkmWTGfCrOBvQlZS0U9Vrgsyq5mACPHyeLqFbdeOjNEIR7gLP99aykAmbO2mRqfXvns+cZgaTUEPXOPyipY5Q6w6/KeBEvekTIZ6ME9Oketj+1/zyDz4qWH+0nLwdD9L279d7hnokpts2tp+gpCUc0/qKsTXpdTRPE2R0kg9Bl84OP3fFlTSTgcT+pS8Dfa1/ZzALfHmULcC3hckG9ZSR+0cd6MyJzucwiJCreIfR/cDfqpsENNM6PNkTAHEHrAqPrSDXilg1KtJSAfZ9rS8KtRyhoSsk+XyrxIRH/S1Qg1dgFb8VqJzWjFl6GBJZemy7z+xjoWHyznbABVwp0KXNGgn/0idxfhz1mTo2/49POFiVF4MBo/6/EEU4cw==]

当然,实际的密文会对你有所不同,因为你使用了不同的加密密钥。关键点在于,消息完全被打乱了。GnuPG 的加密算法极其强大;即使同时使用地球上所有的计算机,解密使用 2,048 位密钥加密的数据平均需要比当前宇宙年龄多很多倍的时间。(换句话说,在合理的时间内解密数据的概率是几乎不可能的。)

当你在清单中引用像test_secret这样的 Hiera 密钥时,接下来会发生什么?Hiera 会查询在hiera.yaml中配置的数据源列表。层次结构中的第一个源是secret.eyaml,它包含我们感兴趣的密钥(test_secret)。以下是该值:

ENC[GPG,hQEMA4 … EEU4cw==]

ENC告诉 Hiera 这是一个加密值,而GPG标识正在使用的加密类型(hiera-eyaml支持多种加密方法,其中 GPG 是其中之一)。Hiera 调用 GPG 子系统处理加密数据,GPG 在密钥环中搜索找到适当的解密密钥。如果找到密钥,GPG 会解密数据并将结果传回 Hiera,Hiera 再返回给 Puppet,最终的结果是明文:

This is a test secret

系统的美妙之处在于所有这些复杂性都被隐藏起来;你需要做的只是调用lookup('test_secret', String)函数在你的清单中,然后你就能得到答案。

编辑或添加加密的机密数据

如果机密数据以加密形式存储,你可能会想知道当你想更改机密值时该如何编辑它。幸运的是,有方法可以做到这一点。记得当你第一次输入机密数据时,你使用了以下命令:

sudo /opt/puppetlabs/puppet/bin/eyaml edit --gpg-always-trust --gpg-recipients=puppet@cat-pictures.com data/secret.eyaml

如果你再次运行相同的命令,你会发现你正在查看原始的明文(以及一些解释性注释):

---
  test_secret: DEC(1)::GPG[This is a test secret]!

你可以编辑This is a test secret字符串(确保其他部分保持不变,包括DEC::GPG[]!分隔符)。当你保存文件并关闭编辑器时,如果密钥发生变化,数据将使用你的密钥重新加密。

不要删除DEC后面括号中的(1),它告诉 Hiera 这是一个已存在的密钥,而不是新的密钥。随着你向此文件添加更多的密钥,它们将被依次编号。

为了方便编辑,我建议你制作一个名为/usr/local/bin/eyaml_edit的 shell 脚本,用于运行eyaml edit命令。你可以在 Vagrant 盒子中的/examples/eyaml_edit.sh找到一个示例,复制到/usr/local/bin并进行编辑(像之前一样,用与你的 GPG 密钥关联的邮箱地址替换gpg-recipients):

#!/bin/bash
/opt/puppetlabs/puppet/bin/eyaml edit --gpg-always-trust --gpg-recipients=puppet@cat-pictures.com /etc/puppetlabs/code/environments/pbg/data/secret.eyaml

现在,每当你需要编辑秘密数据时,可以简单地运行以下命令:

sudo eyaml_edit

若要添加新的密钥,请添加一行如下所示:

  new_secret: DEC::GPG[Somebody wake up Hicks]!

当你保存并退出编辑器时,新的加密秘密将被存储在数据文件中。

分发解密密钥

现在你的 Puppet 清单使用了加密的 Hiera 数据,你需要确保每个运行 Puppet 的节点都拥有解密密钥的副本。使用以下命令将密钥导出到文本文件中(当然,使用你的密钥邮箱地址):

sudo sh -c 'gpg --export-secret-key -a puppet@cat-pictures.com >key.txt'

key.txt文件复制到需要密钥的节点,并运行以下命令导入密钥:

sudo gpg --import key.txt
sudo rm key.txt

确保在导入密钥后删除所有文本文件的副本。

注意

重要提示

由于所有 Puppet 节点都有解密密钥的副本,因此此方法只保护你的秘密数据不被没有访问节点权限的人获取。它仍然比将秘密数据以明文形式放入清单中要好得多,但它的缺点是,如果有人能够访问某个节点,他就可以解密、修改并重新加密秘密数据。为了提高安全性,你应该使用一个秘密管理系统,在该系统中节点没有密钥,而 Puppet 只能以只读方式访问秘密。一些可选的系统包括 Hashicorp 的 Vault 和 Conjur 的 Summon。

总结

在本章中,我们概述了一些在 Puppet 清单中维护配置数据的问题,并介绍了 Hiera 作为一个强大的解决方案。我们已经看到如何配置 Puppet 使用 Hiera 数据存储,并且如何在 Puppet 清单中使用lookup()查询 Hiera 键。

我们已经了解了如何编写 Hiera 数据源,包括字符串、数组和哈希数据结构,以及如何使用lookup()将值插入 Hiera 字符串,包括 Puppet 事实和其他 Hiera 数据,还了解了如何使用alias()复制 Hiera 数据结构。我们已经学习了 Hiera 的层级结构是如何工作的,以及如何使用hiera.yaml文件进行配置。

我们已经看到我们的示例 Puppet 基础设施是如何配置使用 Hiera 数据的,并通过在 Puppet 清单中查找数据值演示了这个过程。在出现问题时,我们还查看了一些常见的 Hiera 错误,并讨论了将数据放入 Hiera 的经验法则。

我们已经探索了使用 Hiera 数据创建资源的方法,使用 each 循环遍历数组或哈希。最后,我们介绍了如何使用加密数据与 Hiera,使用 hiera-eyaml-gpg 后端,并展示了如何创建一个 GnuPG 密钥并用它加密一个秘密值,然后通过 Puppet 再次检索它。我们探讨了 Hiera 查找和解密秘密数据的过程,开发了一个简单的脚本来方便地编辑加密数据文件,并概述了一种将解密密钥分发到多个节点的基本方法。

在下一章中,我们将学习如何从 Puppet Forge 查找和使用公共模块;如何使用公共模块管理软件,包括 Apache、MySQL 和归档文件;如何使用 r10k 工具部署和管理第三方模块;以及如何编写和构建自己的模块。

第七章. 掌握模块

没有大问题,只有很多小问题。
--亨利·福特

在本章中,你将了解 Puppet Forge,这是 Puppet 模块的公共仓库,你将学习如何使用 r10k 模块管理工具安装和使用来自 Puppet Forge 的第三方模块。你将看到如何使用三个重要的 Forge 模块的示例:puppetlabs/apachepuppetlabs/mysqlpuppet/archive。你还将了解 puppetlabs/stdlib 提供的一些有用功能,这是 Puppet 的标准库。最后,通过一个完整的示例,你将学习如何从零开始开发自己的 Puppet 模块,如何为模块添加适当的元数据,以及如何将其上传到 Puppet Forge。

掌握模块

使用 Puppet Forge 模块

虽然你可以为你想要管理的所有内容编写自己的清单,但通过尽可能使用公共 Puppet 模块,你可以节省大量的时间和精力。在 Puppet 中,模块 是一个自包含的可共享、可重用的代码单元,通常设计用来管理某一特定服务或软件,例如 Apache Web 服务器。

什么是 Puppet Forge?

Puppet Forge 是一个公共的 Puppet 模块仓库,其中许多模块由 Puppet 官方支持并维护,所有这些模块都可以下载和使用。你可以通过以下网址浏览 Forge:

forge.puppet.com/

使用像 Puppet 这样的成熟工具的一个优势是,存在大量成熟的公共模块,涵盖了你可能需要的大多数常见软件。例如,以下是你可以使用 Puppet Forge 中的公共模块管理的一小部分内容:

  • MySQL/PostgreSQL/SQL Server

  • Apache/Nginx

  • Java/Tomcat/PHP/Ruby/Rails

  • HAProxy

  • Amazon AWS

  • Docker

  • Jenkins

  • Elasticsearch/Redis/Cassandra

  • Git 仓库

  • 防火墙(通过 iptables)

寻找你需要的模块

Puppet Forge 首页顶部有一个搜索框。在此框中输入你要查找的内容,网站会显示所有与你搜索关键词匹配的模块。通常会有多个结果,那么你该如何决定使用哪个模块呢?

最好的选择是 Puppet 支持的 模块,如果有的话。这些模块由 Puppet 官方支持和维护,你可以确信,支持的模块将与广泛的操作系统和 Puppet 版本兼容。支持的模块在搜索结果中会显示黄色的 SUPPORTED 标志,或者你可以通过以下网址浏览所有支持模块的列表:

forge.puppet.com/modules?endorsements=supported

下一个最佳选择是Puppet 认证模块。虽然这些模块没有官方支持,但它们是 Puppet 推荐的,并且已经过检查,确保遵循最佳实践并符合一定的质量标准。认证模块在搜索结果中会显示绿色的认证通过(APPROVED)标志,或者你可以通过以下链接浏览所有认证模块的列表:

forge.puppet.com/modules?endorsements=approved

假设没有 Puppet 支持或 Puppet 认证的模块,另一种选择模块的有用方法是查看下载数量。在 Puppet Forge 搜索结果页面上选择最多下载标签,将根据下载量对结果进行排序,最受欢迎的模块排在前面。当然,下载量最多的模块不一定是最好的,但它们通常是一个不错的起点。

检查模块的最新发布日期也是值得的。如果你正在查看的模块一年多没有更新,最好选择一个维护更活跃的模块(如果有的话)。点击最新发布标签将按最新更新的顺序对搜索结果进行排序。

你也可以通过操作系统支持和 Puppet 版本兼容性来过滤搜索结果;这对于找到与您的系统兼容的模块非常有用。

选择好你想要的模块后,就可以将其添加到你的 Puppet 基础架构中了。

使用 r10k

过去,许多人习惯直接下载 Puppet Forge 模块,并将其复制到他们的代码库中,实际上是分叉了模块的仓库(至今仍有人这么做)。这种方法有很多缺点。其中一个缺点是,你的代码库中会充斥着不是你写的代码,这会让你很难找到你需要的代码。另一个问题是,测试你的代码与不同版本的公共模块兼容时很困难,你需要创建自己的 Git 分支、重新下载模块等等。你也无法获得 Puppet Forge 模块的未来错误修复和改进,除非你手动更新你的副本。在很多情况下,你需要对模块做一些小的更改或修复,以便在你的环境中使用,这样你的模块版本就会与上游版本产生差异,未来会积累维护问题。

因此,一个更好的模块管理方法是使用 r10k 工具,它可以解决这些问题。r10k 不直接下载所需的模块并将其添加到代码库中,而是通过一个名为 Puppetfile 的特殊文本文件,在每个 Puppet 管理的节点上安装所需的模块。r10k 会完全根据 Puppetfile 中的元数据来管理 modules/ 目录的内容。模块代码从不直接被检查到代码库中,而是在需要时从 Puppet Forge 下载。因此,如果你愿意,你可以保持模块的最新版本,或者将每个模块固定在一个已知与清单兼容的特定版本。

r10k 是 Puppet 部署的事实标准模块管理工具,在本书的后续内容中,我们将使用它来管理模块。

在这个例子中,我们将使用 r10k 安装 puppetlabs/stdlib 模块。示例仓库中的 Puppetfile 列出了本书中将使用的所有模块。它如下所示(稍后我们会详细分析语法):

forge 'http://forge.puppetlabs.com'

mod 'garethr/docker', '5.3.0'
mod 'puppet/archive', '1.3.0'
mod 'puppet/staging', '2.2.0'
mod 'puppetlabs/apache', '2.0.0'
mod 'puppetlabs/apt', '3.0.0'
mod 'puppetlabs/aws', '2.0.0'
mod 'puppetlabs/concat', '4.0.1'
mod 'puppetlabs/docker_platform', '2.2.1'
mod 'puppetlabs/mysql', '3.11.0'
mod 'puppetlabs/stdlib', '4.17.1'
mod 'stahnma/epel', '1.2.2'

mod 'pbg_ntp',
  :git => 'https://github.com/bitfield/pbg_ntp.git',
  :tag => '0.1.4'

按照以下步骤操作:

  1. 运行以下命令来清空 modules/ 目录中的内容(确保你已经备份了想要保留的任何内容):

    cd /etc/puppetlabs/code/environments/pbg
    sudo rm -rf modules/
    
    
  2. 运行以下命令,让 r10k 处理这里的示例 Puppetfile 并安装你所请求的模块:

    sudo r10k puppetfile install --verbose
    
    

r10k 会将 Puppetfile 中列出的所有模块下载到 modules/ 目录中。该目录中的所有模块会被 Puppet 自动加载,并可在你的清单中使用。为了测试 stdlib 模块是否正确安装,运行以下命令:

sudo puppet apply --environment pbg -e "notice(upcase('hello'))"
Notice: Scope(Class[main]): HELLO

upcase 函数将其字符串参数转换为大写字母,是 stdlib 模块的一部分。如果此功能无法正常工作,说明 stdlib 模块未正确安装。如前面的示例所示,我们使用 --environment pbg 开关告诉 Puppet 在 /etc/puppetlabs/code/environments/pbg 目录中查找代码、模块和数据。

理解 Puppetfile

示例 Puppetfile 以以下内容开始:

forge 'http://forge.puppetlabs.com'

forge 语句指定了模块应从哪个仓库获取。

紧接着会出现一组以 mod 开头的行:

mod 'garethr/docker', '5.3.0'
mod 'puppet/archive', '1.3.0'
mod 'puppet/staging', '2.2.0'
...

mod 语句指定了模块的名称(puppetlabs/stdlib)以及要安装的模块的具体版本(4.17.0)。

使用 generate-puppetfile 管理依赖关系

r10k 不会自动管理模块之间的依赖关系。例如,puppetlabs/apache 模块依赖于安装 puppetlabs/stdlibpuppetlabs/concat。除非你指定它们,r10k 不会自动为你安装这些模块,因此你还需要将它们添加到 Puppetfile 中。

然而,你可以使用 generate-puppetfile 工具来找出所需的依赖关系,然后将它们添加到你的 Puppetfile 中。

  1. 运行以下命令来安装 generate-puppetfile gem:

    sudo gem install generate-puppetfile
    
    
  2. 运行以下命令生成指定模块列表的 Puppetfile(在命令行中列出所有需要的模块,模块之间用空格分隔):

    generate-puppetfile puppetlabs/docker_platform
    Installing modules. This may take a few minutes.
    Your Puppetfile has been generated. Copy and paste between the markers:
    =============================================
    forge 'http://forge.puppetlabs.com'
    
    # Modules discovered by generate-puppetfile
    mod 'garethr/docker', '5.3.0'
    mod 'puppetlabs/apt', '3.0.0'
    mod 'puppetlabs/docker_platform', '2.2.1'
    mod 'puppetlabs/stdlib', '4.17.1'
    mod 'stahnma/epel', '1.2.2'
    =============================================
    
  3. 运行以下命令生成现有 Puppetfile 的更新版本和依赖项列表:

    generate-puppetfile -p /etc/puppetlabs/code/environments/pbg/Puppetfile
    
    

这是一个非常有用的工具,既可以用来查找你需要在 Puppetfile 中指定的依赖项,也可以帮助你保持 Puppetfile 与所使用的所有模块的最新版本同步。

在清单中使用模块

现在我们知道如何查找和安装公共 Puppet 模块,接下来看看如何使用它们。我们将通过几个示例来演示,使用 puppetlabs/mysql 模块来设置 MySQL 服务器和数据库,使用 puppetlabs/apache 模块来设置 Apache 网站,以及使用 puppet/archive 下载并解压一个压缩包。完成这些示例后,你应该会对如何找到合适的 Puppet 模块、将其添加到 Puppetfile 中并通过 r10k 部署感到非常有信心。

使用 puppetlabs/mysql

按照以下步骤运行 puppetlabs/mysql 示例:

  1. 如果你之前已经按照 使用 r10k 部分的步骤操作,那么所需的模块应该已经安装。如果没有,请运行以下命令安装它:

    cd /etc/puppetlabs/code/environments/pbg
    sudo r10k puppetfile install
    
    
  2. 运行以下命令应用清单:

    sudo puppet apply --environment=pbg /examples/module_mysql.pp
    Notice: Compiled catalog for ubuntu-xenial in environment pbg in 0.89 seconds
    Notice: /Stage[main]/Mysql::Server::Config/File[/etc/mysql]/ensure: created
    Notice: /Stage[main]/Mysql::Server::Config/File[/etc/mysql/conf.d]/ensure: created
    Notice: /Stage[main]/Mysql::Server::Config/File[mysql-config-file]/ensure: defined content as '{md5}44e7aa974ab98260d7d013a2087f1c77'
    Notice: /Stage[main]/Mysql::Server::Install/Package[mysql-server]/ensure: created
    Notice: /Stage[main]/Mysql::Server::Root_password/Mysql_user[root@localhost]/password_hash: password_hash changed '' to '*F4AF2E5D85456A908E0F552F0366375B06267295'
    Notice: /Stage[main]/Mysql::Server::Root_password/File[/root/.my.cnf]/ensure: defined content as '{md5}4d59f37fc8a385c9c50f8bb3286b7c85'
    Notice: /Stage[main]/Mysql::Client::Install/Package[mysql_client]/ensure: created
    Notice: /Stage[main]/Main/Mysql::Db[cat_pictures]/Mysql_database[cat_pictures]/ensure: created
    Notice: /Stage[main]/Main/Mysql::Db[cat_pictures]/Mysql_user[greebo@localhost]/ensure: created
    Notice: /Stage[main]/Main/Mysql::Db[cat_pictures]/Mysql_grant[greebo@localhost/cat_pictures.*]/ensure: created
    Notice: Applied catalog in 79.85 seconds
    

让我们来看一下示例清单(module_mysql.pp)。第一部分通过包含 mysql::server 类来安装 MySQL 服务器:

# Install MySQL and set up an example database
include mysql::server

mysql::server 类接受多个参数,其中大多数我们暂时不需要关注,但我们希望在此示例中设置其中几个。虽然你可以像设置资源属性一样直接在 Puppet 清单代码中为类参数设置值,但我将展示一种更好的方法:使用 Hiera 的自动参数查找机制。

提示

我们在第六章中简要提到过,使用 Hiera 管理数据,Hiera 可以为类和模块参数提供值,但它到底是如何工作的呢?当你包含一个类 x 并传入一个参数 y 时,Puppet 会自动在 Hiera 中搜索任何与 x::y 名称匹配的键。如果找到了,它就会使用该值作为参数的值。就像其他 Hiera 数据一样,你可以通过层次结构为不同的节点、角色或操作系统设置不同的值。

在这个示例中,我们的参数设置在示例 Hiera 数据文件(/etc/puppetlabs/code/environments/pbg/data/common.yaml)中:

mysql::server::root_password: 'hairline-quotient-inside-tableful'
mysql::server::remove_default_accounts: true

root_password 参数,如你所料,用来设置 MySQL root 用户的密码。我们还启用了 remove_default_accounts,这是一个安全特性。MySQL 默认带有各种用于测试的用户账户,在生产环境中应当关闭。此参数禁用了这些默认账户。

提示

请注意,尽管为了清晰起见我们在此明文指定了密码,但在生产环境的清单中,这些密码应该被加密,就像任何其他凭证或机密数据一样(参见 第六章,使用 Hiera 管理数据)。

接下来是资源声明:

mysql::db { 'cat_pictures':
  user     => 'greebo',
  password => 'tabby',
  host     => 'localhost',
  grant    => ['SELECT', 'UPDATE'],
}

正如你所看到的,这看起来和我们之前使用的内置资源一样,比如 filepackage 资源。实际上,mysql 模块为 Puppet 添加了一个新的资源类型:mysql::db。这个资源表示一个特定的 MySQL 数据库:在我们的例子中是 cat_pictures

资源的标题是数据库的名称,在本例中是 cat_pictures。接下来是属性列表。userpasswordhost 属性指定用户 greebo 应该被允许从 localhost 使用密码 tabby 连接到数据库。grant 属性指定用户应该具有的 MySQL 权限:对数据库的 SELECTUPDATE 权限。

当应用此清单时,Puppet 将创建 cat_pictures 数据库并设置 greebo 用户账户以便访问它。这是管理应用程序的 Puppet 清单中非常常见的模式:通常,应用程序需要某种数据库来存储其状态,并且需要用户凭据来访问它。mysql 模块让你非常容易地配置这一点。

现在我们可以看到使用 Puppet Forge 模块的一般原则:

  • 我们将模块及其依赖项添加到我们的 Puppetfile 中,并使用 r10k 部署它。

  • 我们在清单中include该类,并提供任何需要的参数作为 Hiera 数据。

  • 可选地,我们添加一个或多个由模块定义的自定义资源类型的资源声明(在本例中是 MySQL 数据库)。

几乎所有的 Puppet 模块都以类似的方式工作。在接下来的章节中,我们将介绍一些关键模块,这些模块在使用 Puppet 管理服务器时,你很可能会用到。

使用 puppetlabs/apache

大多数应用程序都有某种类型的 Web 界面,通常需要一个 Web 服务器,而久负盛名的 Apache 仍然是一个流行的选择。puppetlabs/apache 模块不仅安装和配置 Apache,还允许你管理虚拟主机(各个网站,例如应用程序的前端)。

这是一个示例清单,它使用 apache 模块来创建一个简单的虚拟主机来服务一个图像文件(module_apache.pp):

include apache

apache::vhost { 'cat-pictures.com':
  port          => '80',
  docroot       => '/var/www/cat-pictures',
  docroot_owner => 'www-data',
  docroot_group => 'www-data',
}

file { '/var/www/cat-pictures/index.html':
  content => "<img 
    src='http://bitfieldconsulting.com/files/happycat.jpg'>",
  owner   => 'www-data',
  group   => 'www-data',
}

按照以下步骤应用清单:

  1. 如果你之前已经按照 使用 r10k 章节的步骤操作,所需的模块应该已经安装。如果没有,请运行以下命令进行安装:

    cd /etc/puppetlabs/code/environments/pbg
    sudo r10k puppetfile install
    
    
  2. 运行以下命令来应用清单:

    sudo puppet apply --environment=pbg /examples/module_apache.pp
    
    
  3. 要测试新的网站,请将浏览器指向(对于 Vagrant 用户;如果你没有使用 Vagrant box,请浏览你用 Puppet 管理的服务器上的 80 端口)http://localhost:8080/

你应该看到一只快乐猫的图片:

使用 puppetlabs/apache

让我们仔细查看清单,了解它是如何工作的。

  1. 它从 include 声明开始,实际上在服务器上安装了 Apache(module_apache.pp):

    include apache
    
  2. 你可以为 apache 类设置许多参数,但在本示例中,我们只需要设置一个,像其他示例一样,我们通过示例 Hiera 文件中的 Hiera 数据来设置它:

    apache::default_vhost: false
    

    这将禁用默认的 Apache 2 测试页面 虚拟主机。

  3. 接下来是一个 apache::vhost 资源的声明,它创建了一个 Apache 虚拟主机或网站。

    apache::vhost { 'cat-pictures.com':
      port          => '80',
      docroot       => '/var/www/cat-pictures',
      docroot_owner => 'www-data',
      docroot_group => 'www-data',
    }
    

    资源的标题是虚拟主机响应的域名(cat-pictures.com)。port 告诉 Apache 听哪个端口上的请求。docroot 指定 Apache 在服务器上查找网站文件的目录路径。最后,docroot_ownerdocroot_group 属性指定应该拥有 docroot/ 目录的用户和组。

  4. 最后,我们创建一个 index.html 文件,为网站添加一些内容,在这种情况下是一个快乐猫咪的图片。

    file { '/var/www/cat-pictures/index.html':
      content => "<img 
        src='http://bitfieldconsulting.com/files/happycat.jpg'>",
      owner   => 'www-data',
      group   => 'www-data',
    }
    

注意

请注意,Vagrant 虚拟机中的端口 80 映射到本地机器上的端口 8080,因此浏览 http://localhost:8080 相当于直接访问 Vagrant 虚拟机上的端口 80。如果由于某些原因需要更改此端口映射,请编辑您的 Vagrantfile(在 Puppet 初学者指南仓库中),并查找以下行:

config.vm.network "forwarded_port", guest: 80, host: 8080

根据需要更改这些设置,并在本地机器的 PBG 仓库目录中运行以下命令:

vagrant reload

使用 puppet/archive

从软件包安装软件是常见的任务,但有时你也需要从归档文件中安装软件,例如 tarball(.tar.gz 文件)或 ZIP 文件。puppet/archive 模块在这方面非常有帮助,因为它提供了一种简单的方法来从互联网下载归档文件,并且还可以为你解压这些文件。

在下面的示例中,我们将使用 puppet/archive 模块来下载并解压流行的 WordPress 博客软件的最新版本。按照以下步骤应用清单:

  1. 如果你之前已经按照 使用 r10k 部分中的步骤操作,所需的模块应该已经安装。如果没有,请运行以下命令来安装它:

    cd /etc/puppetlabs/code/environments/pbg
    sudo r10k puppetfile install
    
    
  2. 运行以下命令以应用清单:

    sudo puppet apply --environment=pbg /examples/module_archive.pp
    Notice: Compiled catalog for ubuntu-xenial in environment production in 2.50 seconds
    Notice: /Stage[main]/Main/Archive[/tmp/wordpress.tar.gz]/ensure: download archive from https://wordpress.org/latest.tar.gz to /tmp/wordpress.tar.gz and extracted in /var/www with cleanup
    

与本章之前的模块不同,archive 不需要安装任何东西,因此我们不需要包含该类。你只需要声明一个 archive 资源。让我们详细看看这个示例,了解它是如何工作的(module_archive.pp):

archive { '/tmp/wordpress.tar.gz':
  ensure       => present,
  extract      => true,
  extract_path => '/var/www',
  source       => 'https://wordpress.org/latest.tar.gz',
  creates      => '/var/www/wordpress',
  cleanup      => true,
}
  1. 标题给出了你希望归档文件下载到的位置(/tmp/wordpress.tar.gz)。假设你在解压后不需要保留归档文件,通常最好将其放在 /tmp 中。

  2. extract 属性决定 Puppet 是否应该解压归档文件;通常情况下,这应该设置为 true

  3. extract_path属性指定了解压归档内容的位置。在本例中,将其解压到/var/www/的子目录是有意义的,但根据归档的性质,这个路径会有所不同。例如,如果归档文件包含需要编译和安装的软件,最好将其解压到/tmp/,这样在下次重启后,文件会被自动清理。

  4. source属性告诉 Puppet 从哪里下载归档文件,通常(如本例所示)是一个网页 URL。

  5. creates属性的作用与exec资源中的creates完全相同,我们在第四章,理解 Puppet 资源中有详细讲解。它指定了一个文件,这个文件会在解压归档文件时创建。如果这个文件已经存在,Puppet 就知道归档文件已经被解压,因此不需要再次解压。

  6. cleanup属性告诉 Puppet 是否在解压完归档文件后删除该文件。通常,这会设置为true,除非你需要保留归档文件,或者你根本不需要解压它。

提示

一旦文件被cleanup删除,Puppet 在下次应用清单时不会重新下载归档文件/tmp/wordpress.tar.gz,即使它的ensure => presentcreates子句告诉 Puppet 这个归档文件已经下载并解压过。

探索标准库

puppetlabs/stdlib是最古老的 Puppet Forge 模块之一,也是官方的 Puppet 标准库。我们在本章前面简要地看过它,当时用它作为使用r10k安装模块的示例,但现在让我们更仔细地看看它提供了什么功能,以及你可以在哪里使用它。

标准库的目标并不是管理某些特定的软件或文件格式,而是提供一组可以在任何 Puppet 代码中使用的功能和资源。因此,编写良好的 Forge 模块会利用标准库的功能,而不是自己实现相同功能的工具函数。

你应该在自己的 Puppet 代码中做到这一点:当你需要某个特定功能时,先检查标准库,看看它是否能解决你的问题,而不是自己实现。

在尝试本节中的示例之前,请确保按照以下步骤安装stdlib模块:如果你之前按照使用 r10k一节的步骤操作过,所需的模块已经安装。如果没有,请运行以下命令来安装它:

cd /etc/puppetlabs/code/environments/pbg
sudo r10k puppetfile install

安全安装软件包与 ensure_packages

如你所知,你可以使用package资源来安装软件包,像这样(package.pp):

package { 'cowsay':
  ensure => installed,
}

如果你在清单的另一个类中安装了相同的软件包,会发生什么呢?Puppet 会拒绝运行,并出现类似这样的错误:

Error: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: Package[cowsay] is already declared in file /examples/package.pp:1; cannot redeclare at /examples/package.pp:4 at /examples/package.pp:4:1 on node ubuntu-xenial

如果你的两个类都确实需要这个包,那么你就会遇到问题。你可以创建一个简单声明包的类,然后将它包含在这两个类中,但对于一个包来说,这会带来很大的开销。更糟糕的是,如果重复声明存在于第三方模块中,可能无法,也不建议修改该代码。

我们需要的是一种声明包的方法,如果该包也在其他地方声明,则不会导致冲突。标准库在ensure_packages()函数中提供了这一功能。调用ensure_packages()并传入一个包名数组,如果这些包尚未在其他地方声明,它们将被安装(package_ensure.pp):

ensure_packages(['cowsay'])

要应用这个示例,请运行以下命令:

sudo puppet apply --environment=pbg /examples/package_ensure.pp

你可以以相同的方式尝试本章中的所有剩余示例。确保将--environment=pbg开关传递给puppet apply,因为所需的模块仅在pbg环境中安装。

如果你需要向package资源传递额外的属性,可以将它们作为第二个参数通过哈希传递给ensure_packages(),像这样(package_ensure_params.pp):

ensure_packages(['cowsay'],
  {
    'ensure' => 'latest',
  }
)

为什么这比直接使用package资源更好?当你在多个地方声明相同的package资源时,Puppet 会给出错误消息并拒绝执行。然而,如果该包是通过ensure_packages()声明的,Puppet 将成功执行。

由于它提供了一种安全的方式来安装包而不会发生资源冲突,你应该始终使用ensure_packages(),而不是内置的package资源。如果你正在编写公开发布的模块,这肯定是必不可少的,但我建议你在所有代码中都使用它。我们将在本书的其余部分使用它来管理包。

使用file_line就地修改文件

通常,在使用 Puppet 管理配置时,我们希望改变或添加文件中的某一行,而不必承担用 Puppet 管理整个文件的开销。有时候,管理整个文件本来就不可行,因为另一个 Puppet 类或另一个应用程序可能已经在管理它。我们可以编写一个exec资源来修改文件,但标准库提供了一个正是为此目的而设计的资源类型:file_line

这是使用file_line资源向系统配置文件添加一行的示例(file_line.pp):

file_line { 'set ulimits':
  path => '/etc/security/limits.conf',
  line => 'www-data         -       nofile          32768',
}

如果有可能某个其他 Puppet 类或应用程序需要修改目标文件,请使用file_line,而不是直接管理文件。这可以确保你的类不会与任何其他试图控制文件的操作发生冲突。

你还可以使用file_line查找并修改现有行,使用match属性(file_line_match.pp):

file_line { 'adjust ulimits':
  path  => '/etc/security/limits.conf',
  line  => 'www-data         -       nofile          9999',
  match => '^www-data .* nofile',
}

match 的值是一个正则表达式,如果 Puppet 在文件中找到匹配此表达式的行,它将用 line 的值替换该行。(如果你需要更改多行,请将 multiple 属性设置为 true,否则当多行匹配表达式时,Puppet 会报错。)

你还可以使用 file_line 来删除文件中的某一行,如果该行存在的话(file_line_absent.pp):

file_line { 'remove dash from valid shells':
  ensure            => absent,
  path              => '/etc/shells',
  match             => '^/bin/dash',
  match_for_absence => true,
}

请注意,当使用 ensure => absent 时,如果希望 Puppet 实际删除匹配的行,还需要将 match_for_absence 属性设置为 true

介绍其他一些有用的函数

grep() 函数将在数组中搜索正则表达式,并返回所有匹配的元素(grep.pp):

$values = ['foo', 'bar', 'baz']
notice(grep($values, 'ba.*'))

# Result: ['bar', 'baz']

member()has_key() 函数如果给定的值分别在指定的数组或哈希中,则返回 truemember_has_key.pp):

$values = [
  'foo',
  'bar',
  'baz',
]
notice(member($values, 'foo'))

# Result: true

$valuehash = {
  'a' => 1,
  'b' => 2,
  'c' => 3,
}
notice(has_key($valuehash, 'b'))

# Result: true

empty() 函数在其参数为空字符串、数组或哈希时返回 trueempty.pp):

notice(empty(''))

# Result: true

notice(empty([]))

# Result: true

notice(empty({}))

# Result: true

join() 函数将提供的数组元素连接成一个字符串,使用指定的分隔符字符或字符串(join.pp):

$values = ['1', '2', '3']
notice(join($values, '... '))

# Result: '1... 2... 3'

pick() 函数是一个很好的方式,当变量为空时提供默认值。它接受任意数量的参数,并返回第一个非未定义或非空的参数(pick.pp):

$remote_host = ''
notice(pick($remote_host, 'localhost'))

# Result: 'localhost'

有时你需要解析来自外部源的结构化数据。如果数据是 YAML 格式的,可以使用 loadyaml() 函数将其读取并解析为本地 Puppet 数据结构(loadyaml.pp):

$db_config = loadyaml('/examples/files/database.yml')
notice($db_config['development']['database'])

# Result: 'dev_db'

dirname() 函数非常有用,如果你有一个指向文件或目录的路径字符串,并且你想引用它的父目录,例如将其声明为 Puppet 资源(dirname.pp):

$file = '/var/www/vhosts/mysite'
notice(dirname($file))

# Result: '/var/www/vhosts'

Pry 调试器

当 Puppet 清单没有按预期工作时,故障排除可能会很困难。使用 notice() 打印变量和数据结构的值可能有帮助,或者运行 puppet apply -d 查看详细的调试输出,但如果这些方法都失败了,你可以使用标准库的 pry() 方法进入交互式调试会话(pry.pp):

pry()

在 Puppet 的上下文中安装了 pry gem 后,你可以在代码的任何位置调用 pry()。当你应用清单时,Puppet 会在调用 pry() 函数的地方启动一个交互式 Pry shell。你可以运行 catalog 命令来检查 Puppet 的目录,该目录包含当前在清单中声明的所有资源:

sudo puppet apply --environment=pbg /examples/pry_install.pp
sudo puppet apply --environment=pbg /examples/pry.pp
...
[1] pry(#<Puppet::Parser::Scope>)> catalog
=> #<Puppet::Resource::Catalog:0x00000001bbcf78
...
 @resource_table={["Stage", "main"]=>Stage[main]{}, ["Class", "Settings"]=>Class[Settings]{}, ["Class", "main"]=>Class[main]{}},
 @resources=[["Stage", "main"], ["Class", "Settings"], ["Class", "main"]],
...

完成目录检查后,键入 exit 退出调试器并继续应用 Puppet 清单。

编写你自己的模块

如我们所见,Puppet 模块是一种将一组相关代码和资源组合在一起的方式,执行某些特定任务,比如管理 Apache web 服务器或处理归档文件。但你到底如何创建一个模块呢?在这一部分,我们将开发一个自己的模块来管理 NTP 服务,这对于大多数系统管理员来说是最简单的将服务器时钟与互联网时间标准同步的方式。(当然,你不必为此编写自己的模块,因为 Puppet Forge 上已经有一个非常好的现成模块。但为了学习的目的,我们还是自己写一个。)

为你的模块创建一个仓库

如果我们打算将新模块与从 Puppet Forge 安装的其他模块一起使用,那么我们应该仅为我们的模块创建一个新的 Git 仓库。然后我们可以将其详细信息添加到 Puppetfile 中,并使用r10k为我们安装它。

如果你已经完成了第三章,使用 Git 管理你的 Puppet 代码,你应该已经创建了一个 GitHub 帐户。如果没有,请前往该章节并按照创建 GitHub 帐户和项目部分的指示操作,然后再继续:

  1. 登录到你的 GitHub 帐户,点击开始项目按钮。

  2. 创建新仓库页面,输入一个合适的仓库名称(我使用pbg_ntp作为 Puppet 初学者指南的 NTP 模块名称)。

  3. 勾选初始化该仓库时添加 README框。

  4. 点击创建仓库

  5. GitHub 将引导你进入新仓库的项目页面。点击克隆或下载按钮。如果你使用的是带 SSH 密钥的 GitHub,如我们在第三章,使用 Git 管理你的 Puppet 代码中讨论过的那样,请复制通过 SSH 克隆链接。否则,点击使用 HTTPS并复制通过 HTTPS 克隆链接。

  6. 在你的计算机上,或者在你开发 Puppet 代码的地方,运行以下命令以克隆新仓库(请使用你在上一步复制的 GitHub URL,而不是这里的 URL):

    git clone https://github.com/bitfield/pbg_ntp.git
    
    

当克隆操作成功完成后,你就准备好开始创建你的新模块了。

编写模块代码

正如你在查看已安装的 Puppet Forge 模块时会看到的那样,模块具有标准的目录结构。这是为了让 Puppet 能够自动找到模块中的清单文件、模板和其他组件。虽然复杂的模块有许多子目录,但在本例中,我们只关心manifestsfiles。在本部分中,我们将创建必要的子目录,编写管理 NTP 的代码,并添加一个配置文件,代码将安装它。

注意

该模块的所有代码和文件都可以在以下 GitHub 仓库的 URL 中找到:

github.com/bitfield/pbg_ntp

  1. 运行以下命令以创建manifestsfiles子目录:

    cd pbg_ntp
    mkdir manifests
    mkdir files
    
    
  2. 创建文件 manifests/init.pp,并包含以下内容:

    # Manage NTP
    class pbg_ntp {
      ensure_packages(['ntp'])
    
      file { '/etc/ntp.conf':
        source  => 'puppet:///modules/pbg_ntp/ntp.conf',
        notify  => Service['ntp'],
        require => Package['ntp'],
      }
    
      service { 'ntp':
        ensure => running,
        enable => true,
      }
    }
    
  3. 创建文件 files/ntp.conf,并包含以下内容:

    driftfile /var/lib/ntp/ntp.drift
    
    pool 0.ubuntu.pool.ntp.org iburst
    pool 1.ubuntu.pool.ntp.org iburst
    pool 2.ubuntu.pool.ntp.org iburst
    pool 3.ubuntu.pool.ntp.org iburst
    pool ntp.ubuntu.com
    
    restrict -4 default kod notrap nomodify nopeer noquery limited
    restrict -6 default kod notrap nomodify nopeer noquery limited
    restrict 127.0.0.1
    restrict ::1
    
  4. 运行以下命令将你的更改添加、提交并推送到 GitHub(如果没有使用 SSH 密钥,你需要输入你的 GitHub 用户名和密码):

    git add manifests/ files/
    git commit -m 'Add module manifest and config file'
    [master f45dc50] Add module manifest and config file
     2 files changed, 29 insertions(+)
     create mode 100644 files/ntp.conf
     create mode 100644 manifests/init.pp
    git push origin master
    
    

请注意,ntp.conf 文件的 source 属性如下所示:

puppet:///modules/pbg_ntp/ntp.conf

我们以前没有见过这种文件来源,它通常仅在模块代码中使用。puppet:// 前缀表示文件来自 Puppet 仓库内,而路径 /modules/pbg_ntp/ 告诉 Puppet 在 pbg_ntp 模块中查找它。尽管 ntp.conf 文件实际上位于目录 modules/pbg_ntp/files/ 中,但我们无需指定 files 部分:这是默认的,因为这是一个 file 资源。(这不仅仅是你:这让每个人都感到困惑。)

我们不是通过 package 资源安装 ntp 包,而是使用标准库中的 ensure_packages(),正如本章之前所述。

创建并验证模块元数据

每个 Puppet 模块应该在其顶层目录中包含一个名为 metadata.json 的文件,该文件包含有关该模块的有用信息,供模块管理工具使用,包括 Puppet Forge。

创建文件metadata.json,并包含以下内容(请使用你自己的名字和 GitHub URL):

{
  "name": "pbg_ntp",
  "version": "0.1.1",
  "author": "John Arundel",
  "summary": "Example module to manage NTP",
  "license": "Apache-2.0",
  "source": "https://github.com/bitfield/pbg_ntp.git",
  "project_page": "https://github.com/bitfield/pbg_ntp",
  "tags": ["ntp"],
  "dependencies": [
    {"name":"puppetlabs/stdlib",
      "version_requirement":">= 4.17.0 < 5.0.0"}
  ],
  "operatingsystem_support": [
    {
      "operatingsystem": "Ubuntu",
      "operatingsystemrelease": [ "16.04" ]
    }
  ]
}

这些大多是比较显而易见的。tags 是一个字符串数组,它将帮助人们在 Puppet Forge 上找到你的模块,通常会使用你的模块所管理的软件或服务的名称来标记模块(在本例中是 ntp)。

如果你的模块依赖于其他 Puppet 模块,这很有可能(例如,本模块依赖于 puppetlabs/stdlib 提供 ensure_packages() 函数),你可以使用 dependencies 元数据来记录这一点。你应该列出模块使用的每个模块,以及与模块兼容的最早和最新版本。(如果当前发布的版本有效,请指定下一个主要版本作为最新版本。例如,如果你的模块与 stdlib 版本 4.17.0 兼容且这是当前最新版本,请指定 5.0.0 作为最高兼容版本。)

最后,operatingsystem_support 元数据允许你指定模块支持的操作系统及其版本。这对于那些寻找与自己操作系统兼容的 Puppet 模块的人非常有帮助。如果你知道你的模块与 Ubuntu 16.04 兼容,就像示例模块一样,你可以在 operatingsystem_support 部分列出这一点。你的模块支持的操作系统越多越好,所以如果可能的话,在其他操作系统上测试你的模块,并在确认它们可以正常工作后将其列入元数据中。

提示

关于模块元数据的详细信息以及如何使用它,请参阅 Puppet 文档:

docs.puppet.com/puppet/latest/reference/modules_metadata.html

确保模块的元数据正确非常重要,有一个小工具可以帮助你完成这项工作,叫做metadata-json-lint

  1. 运行以下命令安装metadata-json-lint并检查你的元数据:

    sudo gem install metadata-json-lint
    metadata-json-lint metadata.json
    
    
  2. 如果metadata-json-lint没有产生输出,说明你的元数据是有效的,可以继续进行下一步。如果看到错误信息,修复问题后再继续。

  3. 运行以下命令将你的元数据文件添加、提交并推送到 GitHub:

    git add metadata.json
    git commit -m 'Add metadata.json'
    git push origin master
    
    

给模块打标签

就像使用第三方 Puppet Forge 模块一样,能够在 Puppetfile 中指定要安装的模块的确切版本是非常重要的。你可以通过使用 Git 标签,将版本标签附加到模块仓库中的特定提交上来实现这一点。随着模块的进一步开发并发布新版本,你可以为每个发布添加一个新的标签。

对于模块的首次发布,根据元数据,它的版本是 0.1.1,运行以下命令来创建并推送发布标签:

git tag -a 0.1.1 -m 'Release 0.1.1'
git push origin 0.1.1

安装你的模块

我们可以使用r10k来安装我们的新模块,就像我们使用 Puppet Forge 模块一样,唯一的不同之处是,由于我们的模块尚未在 Puppet Forge 上,因此仅在 Puppetfile 中指定模块名称是不够的;我们需要提供 Git URL,以便r10k可以从 GitHub 克隆该模块。

  1. 将以下mod语句添加到你的 Puppetfile 中(用你的 GitHub URL 代替我的):

    mod 'pbg_ntp',
      :git => 'https://github.com/bitfield/pbg_ntp.git',
      :tag => '0.1.1'
    
  2. 因为该模块还依赖于puppetlabs/stdlib,所以也要添加这个mod语句:

    mod 'puppetlabs/stdlib', '4.17.0'
    
  3. 现在以正常方式使用r10k安装模块:

    sudo r10k puppetfile install --verbose
    
    

r10k可以从你有访问权限的任何 Git 仓库安装模块;你只需要在 Puppetfile 中的mod语句中添加:git:tag参数。

应用你的模块

现在你已经创建、上传并安装了模块,我们可以在清单中使用它:

sudo puppet apply --environment=pbg -e 'include pbg_ntp'

如果你使用的是 Vagrant 盒子或较新的 Ubuntu 版本,服务器很可能已经在运行 NTP,因此你在 Puppet 应用时唯一看到的变化将是ntp.conf文件。尽管如此,它仍然确认了你的模块有效。

更复杂的模块

当然,我们开发的模块是一个非常简单的示例。然而,它展示了 Puppet 模块的基本要求。随着你成为一个更高级的 Puppet 开发者,你将创建和维护更加复杂的模块,类似于你从 Puppet Forge 下载并使用的那些模块。

现实世界中的模块通常包含以下一个或多个组件:

  • 多个清单文件和子目录

  • 参数(可以直接提供或从 Hiera 数据中查找)

  • 自定义事实和自定义资源类型与提供者

  • 示例代码,展示如何使用该模块

  • 开发者可以用来验证更改的规范和测试

  • 依赖其他模块(必须在模块的元数据中声明)

  • 支持多个操作系统

提示

你可以在 Puppet 文档中找到关于模块及模块高级功能的更详细信息:

docs.puppet.com/puppet/latest/reference/modules_fundamentals.html

上传模块到 Puppet Forge

上传模块到 Puppet Forge 非常简单:你只需要注册一个账户,使用 puppet module build 命令创建模块的归档文件,然后通过 Puppet Forge 网站上传即可。

然而,在决定编写一个模块之前,你应该先检查是否已经有一个模块在 Puppet Forge 上可以实现你的需求。写本文时,Puppet Forge 上已有超过 4,500 个模块,因此你很有可能可以使用现有的 Puppet Forge 模块,而不是编写自己的模块。如果已有模块存在,贡献一个新的模块只会增加用户选择的难度。例如,目前有 150 个管理 Nginx Web 服务器的模块。显然,这至少多了 149 个,所以只有在确认 Puppet Forge 上没有类似模块时,才提交一个新模块。

如果已有模块覆盖了你想管理的软件,但不支持你的操作系统或版本,可以考虑改进这个模块,而不是开始一个新模块。联系模块作者,看看你是否能帮助改进他们的模块,并扩展对你操作系统的支持。类似地,如果你发现模块中有 bug 或想要对其进行改进,可以在模块的 issue 跟踪系统中打开一个问题(如果有的话),或者在 GitHub 上分叉该模块仓库(如果模块有 GitHub 版本),或者联系作者,了解如何提供帮助。绝大多数 Puppet Forge 模块都是由志愿者编写和维护的,因此你的支持和贡献将有利于整个 Puppet 社区。

如果你不想为现有模块进行分支或贡献,可以考虑编写一个小的包装模块,扩展或覆盖现有模块,而不是从头开始创建一个新模块。

如果你决定编写并发布自己的模块,尽量使用标准库中的功能,例如 ensure_packages()。这将使你的模块有更高的兼容性,与其他 Forge 模块兼容的机会更大。

注意

如果你想为 Puppet 模块社区做出更多贡献,可以考虑加入 Vox Pupuli 小组,该小组维护着超过一百个开源 Puppet 模块:

voxpupuli.org/

总结

在本章中,我们了解了 Puppet 模块,包括 Puppet Forge 模块库的介绍。我们已了解如何搜索所需的模块以及如何评估结果,包括 Puppet ApprovedPuppet Supported 模块、操作系统支持以及下载量。

我们已经介绍了如何使用r10k工具下载和管理基础设施中的 Puppet 模块,并且如何在 Puppetfile 中指定所需的模块和版本。我们通过详细的示例展示了如何使用三个重要的 Forge 模块:puppetlabs/apachepuppetlabs/mysqlpuppet/archive

引入 Puppet 标准库后,我们介绍了如何使用ensure_packages()避免模块之间的包冲突,file_line资源(用于对配置文件进行行级编辑),以及一系列用于数据操作的有用函数,同时还介绍了 Pry 调试器。

为了充分理解模块的工作原理,我们从零开始开发了一个简单的模块来管理 NTP 服务,该模块托管在自己的 Git 仓库中,并通过 Puppetfile 和r10k进行管理。我们了解了模块所需的元数据,以及如何使用metadata-json-lint创建和验证这些元数据。

最后,我们探讨了一些更复杂模块的特性,讨论了将模块上传到 Puppet Forge,并概述了在决定是开始一个新模块还是扩展和改进现有模块时需要考虑的事项。

在下一章中,我们将探讨如何将 Puppet 代码组织为类,如何将参数传递给类,如何创建定义的资源类型,以及如何使用角色、配置文件来结构化清单,并且如何使用 Hiera 数据在节点上包含类。

第八章:类、角色和配置文件

我们的生活被琐事浪费了。简化,简化!
--亨利·戴维·梭罗

在本章中,你将探索 Puppet 类的细节,定义类和包含类的区别,如何为类提供参数,如何声明带参数的类并为其指定合适的数据类型。你将学习如何创建定义的资源类型,以及它们与类的不同。你还将了解如何使用节点、角色和配置文件的概念来组织你的 Puppet 代码。

类、角色和配置文件

在本书中,我们已经多次提到的概念,但并没有真正解释它。现在让我们进一步探索,并看看如何使用这个关键的 Puppet 语言构建块。

类关键字

你可能注意到,在我们示例 NTP 模块的代码中(见第七章, 掌握模块部分中的编写模块代码部分),我们使用了class关键字:

class pbg_ntp {
  ...
}

如果你在想class关键字的作用,出乎意料的答案是:什么也不做。也就是说,除了通知 Puppet 将其中的资源分组在一起并赋予一个名称(pbg_ntp),并且这些资源不应该被应用。

然后你可以在其他地方使用这个名称,告诉 Puppet 一起应用该类中的所有资源。我们通过使用include关键字来声明我们的示例模块:

include ntp

以下示例展示了一个类定义,它使得类对 Puppet 可用,但并不会(还没有)应用其中的任何资源:

class CLASS_NAME {
  ...
}

以下示例展示了CLASS_NAME类的声明。声明告诉 Puppet 应用该类中的所有资源(并且该类必须已经定义):

include CLASS_NAME

你可能还记得在第七章, 掌握模块中,我们使用了 Hiera 的自动参数查找机制来为类提供参数。我们很快会进一步了解这个,但首先,我们如何编写一个接受参数的类呢?

向类声明参数

如果一个类仅仅是将相关资源分组在一起,那也是有用的,但如果我们能使用参数,类将变得更强大。参数就像资源属性:它们让你传递数据给类,以改变类的应用方式。

以下示例展示了如何定义一个接受参数的类。这是我们为 NTP 模块开发的pbg_ntp类的简化版本(class_params.pp):

# Manage NTP
class pbg_ntp_params (
  String $version = 'installed',
) {
  ensure_packages(['ntp'],
    {
      'ensure' => $version,
    }
  )
}

需要关注的重要部分是类定义开始后的括号中的内容。这部分指定了类接受的参数:

String $version = 'installed',

String 告诉 Puppet 我们期望这个值是一个字符串,如果我们尝试传递其他类型的值,例如整数,它会抛出错误。$version 是参数的名称。最后,'installed' 部分指定了该参数的默认值。如果有人声明了这个类而没有提供 pbg_ntp_params::version 参数,Puppet 会自动使用这个默认值进行填充。

如果你没有为某个参数提供默认值,那么该参数就是必需的,因此 Puppet 不允许你声明这个类而不为该参数提供值。

当你声明这个类时,你可以像之前使用 Puppet Forge 模块时那样,使用 include 关键字和类名:

include pbg_ntp_params

这个类没有必需的参数,因此你不必提供任何参数,但如果你提供了参数,可以像下面这样在 Hiera 数据中添加一个值,Puppet 在类被包含时会自动查找它:

pbg_ntp_params::version: 'latest'

当然,类可以接受多个参数,以下(虚构的)示例展示了如何声明多个不同类型的参数(class_params2.pp):

# Manage NTP
class pbg_ntp_params2 (
  Boolean $start_at_boot,
  String[1] $version                        = 'installed',
  Enum['running', 'stopped'] $service_state = 'running',
) {
  ensure_packages(['ntp'],
    {
      'ensure' => $version,
    }
  )

  service { 'ntp':
    ensure => $service_state,
    enable => $start_at_boot,
  }
}

要向这个类传递参数,可以添加如下的 Hiera 数据:

pbg_ntp_params2::start_at_boot: true
pbg_ntp_params2::version: 'latest'
pbg_ntp_params2::service_state: 'running'

让我们仔细看看参数列表:

  Boolean $start_at_boot,
  String[1] $version                        = 'installed',
  Enum['running', 'stopped'] $service_state = 'running',

第一个参数是 Boolean 类型,名为 $start_at_boot。没有默认值,因此这个参数是必需的。必需参数必须先声明,在任何可选参数(即有默认值的参数)之前。

我们在前面的例子中看到的 $version 参数,但现在它是一个 String[1] 类型,而不是一个 String 类型。这有什么区别?String[1] 是一个至少包含一个字符的字符串。这意味着,你不能将空字符串传递给这样的参数。例如,如果合适的话,为字符串参数指定最小长度是个好主意,以防不小心传递空字符串到类中。

最后的参数 $service_state 是一种新类型,Enum,我们之前没有遇到过。对于Enum 参数,我们可以精确地指定它可以接受的值的列表。

如果你的类期望一个字符串类型的参数,并且该参数只能接受一些有限的值,你可以在 Enum 参数声明中列出所有允许的值,Puppet 不会允许传递任何不在该列表中的值。例如,在我们的示例中,如果你尝试声明 pbg_ntp_params2 类,并将值 bogus 传递给 $service_state 参数,你会收到这个错误:

Error: Evaluation Error: Error while evaluating a Resource Statement, Class[Pbg_ntp_params2]: parameter 'service_state' expects a match for Enum['running', 'stopped'], got String at /examples/class_params2.pp:22:1 on node ubuntu-xenial

就像任何其他参数一样,Enum 类型的参数也可以具有默认值,就像我们在示例中所做的那样。

从 Hiera 数据中自动查找参数

我们在本章和上一章中看到过,我们可以使用 Hiera 数据将参数传递给类。如果我们包含一个名为 ntp 的类,它接受一个名为 version 的参数,并且 Hiera 中存在名为 ntp::version 的键,则其值将作为 version 的值传递给 ntp 类。例如,如果 Hiera 数据如下所示:

ntp::version: 'latest'

Puppet 将自动找到该值,并在声明 ntp 类时将其传递给 ntp 类。

通常,Puppet 按照以下优先顺序确定参数值,优先级从高到低:

  1. 在类声明中指定的文字参数(你可能会看到旧代码使用这种方式)

  2. 来自 Hiera 的自动参数查找(键名必须为 CLASS_NAME::PARAMETER_NAME

  3. 在类定义中指定的默认值

参数数据类型

你应该始终为类参数指定类型,因为这有助于更容易捕捉到错误,比如错误的参数或值被传递到类中。例如,如果你使用的是 String 参数,尽可能将其设为 Enum 参数,并列出类接受的确切值。如果不能限制为一组允许的值,可以指定最小长度,如 String[x]。(如果还需要指定最大长度,语法为 String[min, max]。)

可用数据类型

到目前为止,在本章中,我们遇到了数据类型 String、Enum 和 Boolean。以下是其他类型:

  • Integer(整数)

  • Float(浮动小数,具有可选的小数部分)

  • Numeric(匹配整数或浮动小数)

  • Array

  • Hash

  • Regexp

  • Undef(匹配尚未赋值的变量或参数)

  • Type(表示 Puppet 数据类型的字面值的数据类型,如 String、Integer 和 Array)

还有一些 抽象 数据类型,它们更加通用:

  • Optional(匹配一个可能未定义或未提供的值)

  • Pattern(匹配符合指定正则表达式的字符串)

  • Scalar(匹配 Numeric、String、Boolean 或 Regexp 值,但不匹配 Array、Hash 或 Undef)

  • Data(匹配 Scalar 值,但也匹配 Array、Hash 和 Undef)

  • Collection(匹配 Array 或 Hash)

  • Variant(匹配指定数据类型列表中的一种)

  • Any(匹配任何数据类型)

通常,你应该尽可能使用更具体的数据类型。例如,如果你知道某个参数将始终是一个整数,使用 Integer。如果它也需要接受浮动小数值,使用 Numeric。如果它可以是字符串或数字,使用 Scalar

内容类型参数

代表一组值的类型,例如 ArrayHash(或它们的父类型 Collection)也可以接受一个参数,指示它们包含的值的类型。例如,Array[Integer] 匹配一个整数值的数组。

如果你声明一个内容类型参数给一个集合,那么该集合中的所有值必须与声明的类型匹配。如果没有指定内容类型,默认值为 Data,它匹配(几乎)任何类型的值。内容类型参数本身也可以接受参数:Array[Integer[1]] 声明了一个正整数数组。

Hash 需要两个内容类型参数,第一个表示键的数据类型,第二个表示值的数据类型。Hash[String, Integer] 声明一个哈希,其键是字符串,每个键关联一个整数值(例如,这将匹配哈希 {'eggs' => 61})。

范围参数

大多数类型也可以接受方括号中的参数,这些参数使类型声明更加具体。例如,我们已经看到 String 可以接受一对参数,表示字符串的最小和最大长度。

大多数类型可以接受范围****参数Integer[0] 匹配任何大于或等于零的整数,而 Float[1.0, 2.0] 匹配 1.0 到 2.0 之间(包括 1.0 和 2.0)的任何浮点数。

如果任一范围参数为特殊值 default,则将使用该类型的默认最小值或最大值。例如,Integer[default, 100] 匹配任何小于或等于 100 的整数。

对于数组和哈希,范围参数指定元素或键的最小和最大数量:Array[Any, 16] 指定一个包含不少于 16 个 Any 类型元素的数组。Hash[Any, Any, 5, 5] 指定一个哈希,其中包含恰好五个键值对。

你可以同时指定范围和内容类型参数:Array[String, 1, 10] 匹配一个包含 1 到 10 个字符串的数组。Hash[String, Hash, 1] 指定一个哈希,其键是字符串,值是哈希,且至少包含一对键值对,键是字符串,值的类型是哈希。

灵活的数据类型

如果你不确定值的类型,可以使用 Puppet 更灵活的抽象类型,如 Variant,它指定一个允许类型的列表。例如,Variant[String, Integer] 允许其值为字符串或整数。

类似地,Array[Variant[Enum['true', 'false'], Boolean]] 声明一个数组,其中的值可以是字符串值 'true''false',也可以是布尔值 truefalse

Optional 类型在值可能未定义时非常有用。例如,Optional[String] 指定了一个可以传递也可以不传递的字符串参数。通常,如果一个参数没有声明默认值,Puppet 会在没有提供值时抛出错误。然而,如果声明为 Optional,则可以省略,或设置为 Undef(意味着该标识符已定义,但没有值)。

Pattern 类型允许你指定一个正则表达式。所有符合该正则表达式的字符串都将被视为该参数的有效值。例如,Pattern[/a/] 会匹配任何包含小写字母 a 的字符串。实际上,你可以指定任意多个正则表达式。Pattern[/a/, /[0-9]/] 会匹配任何包含字母 a 的字符串,或任何包含数字的字符串。

定义的资源类型

与类让你将相关资源组合在一起不同,定义的资源类型让你创建新的资源类型,并声明任意多个实例。定义资源类型的定义看起来非常像一个类(defined_resource_type.pp):

# Manage user and SSH key together
define user_with_key(
  Enum[
    'ssh-dss',
    'dsa',
    'ssh-rsa',
    'rsa',
    'ecdsa-sha2-nistp256',
    'ecdsa-sha2-nistp384',
    'ecdsa-sha2-nistp521',
    'ssh-ed25519',
    'ed25519'
  ] $key_type,
  String $key,
) {
  user { $title:
    ensure     => present,
    managehome => true,
  }

  file { "/home/${title}/.ssh":
    ensure => directory,
    owner  => $title,
    group  => $title,
    mode   => '0700',
  }

  ssh_authorized_key { $title:
    user => $title,
    type => $key_type,
    key  => $key,
  }
}

你可以看到,我们使用的是 define 关键字,而不是 class 关键字。这告诉 Puppet 我们正在创建一个定义的资源类型,而不是类。这个类型叫做 user_with_key,一旦定义,我们就可以像其他 Puppet 资源一样声明它的多个实例:

user_with_key { 'john':
  key_type => 'ssh-rsa',
  key      => 'AAAA...AcZik=',
}

当我们这样做时,Puppet 会应用 user_with_key 中的所有资源:一个用户、该用户的 .ssh 目录,以及该用户的 ssh_authorized_key,其中包含指定的密钥。

提示

等等,我们似乎在示例代码中引用了一个名为 $title 的参数。这个参数来自哪里?$title 是一个特殊的参数,它在类和定义的资源类型中始终可用,它的值是该类或类型声明的标题。在这个示例中,它是 john,因为我们给 user_with_key 的声明指定了标题 john

那么,定义的资源类型和类有什么区别呢?它们看起来几乎一样。它们似乎也有相似的作用。为什么你会选择使用其中一个而不是另一个呢?最重要的区别在于,你在一个节点上只能有一个给定类的声明,而你可以拥有任意多个不同实例的定义资源类型。唯一的限制是,像所有 Puppet 资源一样,每个定义资源类型实例的标题必须是唯一的。

回想一下我们的 ntp 类,它安装并运行 NTP 守护进程。通常,你每个节点只希望有一个 NTP 服务。运行两个几乎没有意义。所以我们只声明一次该类,这就是我们需要的。

将此与定义的 user_with_key 资源类型进行对比。很可能你希望在某个节点上拥有多个 user_with_key,可能有好几个。在这种情况下,定义的资源类型是正确的选择。

定义的资源类型在模块中非常理想,特别是当你想让模块的用户使用某个资源时。例如,在 puppetlabs/apache 模块中,apache::vhost 资源是由 apache 类提供的定义资源类型。你可以将定义的资源类型看作是多个资源的包装器。

提示

在决定是创建类还是定义资源类型时,请记住这个经验法则:如果在给定节点上有多个实例是合理的,那么它应该是一个定义的资源类型;如果只有一个实例,它应该是一个类。

类型别名

使用type关键字定义新的类型别名非常简单(type_alias.pp):

type ServiceState = Enum['running', 'stopped']

define myservice(ServiceState $state) {
  service { $name:
    ensure => $state,
  }
}

myservice { 'ntp':
  state => 'running',
}

创建类型别名非常有用,例如,当你想确保参数值匹配一个复杂的模式时,手动复制会很麻烦。你可以在一个地方定义这个模式,并声明多个该类型的参数(type_alias_pattern.pp):

type IPAddress = Pattern[/\A([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])){3}\z/]

define socket_server(
  IPAddress $listen_address,
  IPAddress $public_address,
) {
  # ...
}

socket_server { 'myserver':
  listen_address => '0.0.0.0',
  public_address => $facts['networking']['ip'],
}

在模块中创建类型别名时,它应该位于模块的types子目录下,文件名与类型名称相同。例如,一个名为IPAddress的类型应该在types/ipaddress.pp文件中定义。

使用 Hiera 管理类

在第三章,使用 Git 管理 Puppet 代码中,我们看到了如何在多个节点上设置 Puppet 仓库,并使用 cron 作业和run-puppet脚本自动应用清单。run-puppet脚本运行以下命令:

cd /etc/puppetlabs/code/environments/production && git pull/opt/puppetlabs/bin/puppet apply manifests/

你可以看到,manifests/目录中的所有内容将在每个节点上应用。显然,当我们能够在每个节点上应用不同的清单时,Puppet 的作用就更大了;一些节点将是 Web 服务器,另一些将是数据库服务器,等等。实际上,我们希望在所有节点上包括一些类,用于通用管理,例如管理用户帐户,其他类则仅在特定节点上使用。那么我们该怎么做呢?

使用includelookup()一起

之前,在我们的清单中包含类时,我们使用了带有字面类名的include关键字,如下例所示:

include postgresql
include apache

然而,include也可以作为一个函数使用,它接受一个包含类名的数组:

include(['postgresql', 'apache'])

我们已经知道,可以使用 Hiera 根据节点名称(或层次结构中定义的任何其他内容)返回不同的查询值,因此我们可以在 Hiera 数据中定义一个合适的数组,如下例所示:

classes:
- postgresql
- apache

现在我们可以简单地使用lookup()来获取这个 Hiera 值,并将结果传递给include()函数:

include(lookup('classes'), Array[String], 'unique')

实际上,这就是你整个 Puppet 清单的内容。每个节点都会应用这个清单,从而包括 Hiera 数据中为其分配的类。由于顶级清单文件通常命名为site.pp,因此你可以将这行include放入manifests/site.pp中,papplyrun-puppet脚本将应用它,因为它们会应用manifests/目录中的所有内容。

公共类和每节点类

我们可以在common.yaml中指定一组类,这些类将应用于所有节点:例如用户帐户、SSH 和sudoers配置、时区、NTP 设置等。在第十二章中,将一切整合的完整示例仓库定义了一组典型的类,并且这些类都在common.yaml中。

然而,某些类只在特定节点上需要。将这些类添加到每节点的 Hiera 数据文件中。例如,我们在 Vagrant 盒子上的pbg环境在hiera.yaml中包含以下内容:

  - name: "Host-specific data"
    path: "nodes/%{facts.hostname}.yaml"

所以,名为node1的节点的每节点数据将保存在data/目录下的nodes/node1.yaml文件中。

让我们看一个完整的示例。假设你的common.yaml文件包含如下内容:

classes:
- postgresql
- apache

假设你的每节点文件(nodes/node1.yaml)也包含以下内容:

classes:
- tomcat
- my_app

现在,当你在node1上应用以下清单(manifests/site.pp)时会发生什么?

include(lookup('classes'), Array[String], 'unique')

哪些类将会被应用?你可能还记得第六章,使用 Hiera 管理数据中提到的,unique合并策略会查找整个层次结构中给定键的所有值,将它们合并在一起,并作为一个扁平化的数组返回,重复项被移除。因此,这个lookup()调用的结果将是以下数组:

[apache, postgresql, tomcat, my_app]

这是 Puppet 将应用于节点的完整类列表。当然,如果需要,你可以在层次结构的其他任何级别添加类,但你可能会发现常规层级和每个节点的层级最适合用于包含类。

自然,尽管一些节点可能包含与其他节点相同的类,它们可能需要为这些类提供不同的配置值。你可以像之前在本章的从 Hiera 数据自动查找参数部分中描述的那样,使用 Hiera 为包含的类提供不同的参数。

角色和配置文件

现在我们知道如何根据节点要执行的任务,在给定节点上包含不同的类集,让我们更深入地思考如何以最有帮助的方式命名这些类。例如,考虑下面某个节点的包含类列表:

classes:
- postgresql
- apache
- java
- tomcat
- my_app

类名为我们提供了一些关于这个节点可能在做什么的线索。看起来它可能是一个运行 Java 应用程序my_app的应用服务器,通过 Tomcat 在 Apache 后端提供服务,并由 PostgreSQL 数据库支持。这是一个很好的开始,但我们可以做得更好,我们将在下一节看到如何做。

角色

为了明确节点是一个应用服务器,为什么不创建一个名为role::app_server的类,该类仅用于封装节点包含的类呢?该类的定义可能如下所示(role_app_server.pp):

# Be an app server
class role::app_server {
  include postgresql
  include apache
  include java
  include tomcat
  include my_app
}

我们将这个概念称为角色类。角色类可以仅仅是一个独立的模块,或者为了明确表示这是一个角色类,我们可以将其组织成一个特殊的role模块。如果你将所有的角色类放在一个模块中,那么它们的名称将都以role::something命名,具体取决于它们实现的角色。

提示

需要注意的是,角色类在 Puppet 中并不特别。它们只是普通的类;我们之所以称它们为角色类,仅仅是为了提醒自己它们是用来表达分配给特定节点的角色。

Hiera 中 classes 的值现在简化为以下内容:

classes:
- role::app_server

看着 Hiera 数据,现在很容易看出节点的工作是什么——它的角色是什么——所有应用服务器现在只需要包括 role::app_server。当应用服务器所需的类列表发生变化时,你不需要找到并更新每个应用服务器的 Hiera classes 值;只需要编辑 role::app_server 类。

配置文件

通过采用一个经验法则,我们可以整理清单:除了在 common.yaml 中的公共配置外,节点应该只包含角色类。这使得 Hiera 数据更具自我文档性,而且我们的角色类都整齐地组织在 role 模块中,每个角色类都封装了该角色所需的所有功能。这是一个很大的改进。但我们能做得更好吗?

让我们看看像 role::app_server 这样的角色类。它包含很多行,包括模块,像这样:

  include tomcat

如果你只需要包括一个模块,并且让参数自动从 Hiera 数据中查找,那么就没有问题。这种简单、鼓励性的、不切实际的示例通常会出现在产品文档或会议幻灯片上。

然而,真实的 Puppet 代码通常更复杂,包含逻辑、条件语句、特殊情况以及需要添加的额外资源等。我们不想在将 Tomcat 用作另一个角色的一部分时重复所有这些代码(例如,提供另一个基于 Tomcat 的应用)。我们如何在合适的抽象层次封装它并避免重复?

我们当然可以为每个应用创建一个自定义模块,将所有这些杂乱的支持代码隐藏起来。然而,为了几行代码创建一个新模块是一个很大的开销,因此似乎应该有一个小层代码填补角色与模块之间的空隙。

我们称之为 配置文件类。配置文件封装了角色所需的一些特定软件或功能。在我们的例子中,app_server 角色需要几种软件:PostgreSQL、Tomcat、Apache 等。现在,每个软件都可以有它自己的配置文件。

让我们重写 app_server 角色以包括配置文件,而不是模块(role_app_server_profiles.pp):

# Be an app server
class role::app_server {
  include profile::postgresql
  include profile::apache
  include profile::java
  include profile::tomcat
  include profile::my_app
}

这些配置文件类中会有什么内容?例如,profile::tomcat 类将设置 Tomcat 所需的特定配置,同时还包括任何特定应用或站点所需的资源,如防火墙规则、logrotate 配置、文件和目录权限等。配置文件封装了模块,配置它,并提供模块未涵盖的部分,以支持该特定应用或站点。

profile::tomcat 类可能看起来像以下示例,改编自一个真实的生产清单(profile_tomcat.pp):

# Site-specific Tomcat configuration
class profile::tomcat {
  tomcat::install { '/usr/share/tomcat7':
    install_from_source => false,
    package_ensure      => present,
    package_name        => ['libtomcat7-java','tomcat7-common','tomcat7'],
  }

  exec { 'reload-tomcat':
    command     => '/usr/sbin/service tomcat7 restart',
    refreshonly => true,
  }

  lookup('tomcat_allowed_ips', Array[String[7]]).each |String $source_ip| {
    firewall { "100 Tomcat access from ${source_ip}":
      proto  => 'tcp',
      dport  => '8080',
      source => $source_ip,
      action => 'accept',
    }
  }

  file { '/usr/share/tomcat7/logs':
    ensure  => directory,
    owner   => 'tomcat7',
    require => Tomcat::Install['/usr/share/tomcat7'],
  }

  file { '/etc/logrotate.d/tomcat7':
    source => 'puppet:///site-modules/profile/tomcat/tomcat7.logrotate',
  }
}

这类配置文件的具体内容在这里并不重要,但你应该记住的是,这种特定站点的“胶水”代码,用来包装第三方模块并将它们与特定应用程序连接,应该放在配置文件类中。

通常,一个配置文件类应该包含使该特定软件组件或服务正常工作的所有内容,包括必要时的其他配置文件。例如,每个需要特定 Java 配置的配置文件应该包含该 Java 配置文件。你可以从多个其他配置文件中包含配置文件,而不会发生冲突。

以这种方式使用配置文件类,既可以让你的角色类更加整洁、有序且易于维护,还可以让你重用这些配置文件来适应不同的角色。app_server角色包含了这些配置文件,其他角色也可以包含它们。这样,我们的代码组织得更加有序,减少了重复,并鼓励重用。第二条经验法则是,角色应该仅包含配置文件

如果你仍然对角色和配置文件之间的确切区别感到困惑,不用担心:你并不孤单。让我们尽可能简洁地定义它们:

  • 角色标识节点的特定功能,例如作为应用服务器或数据库服务器。角色存在的目的是记录节点的用途。角色应该仅包含配置文件,但可以包含任意数量的配置文件。

  • 配置文件标识为角色提供特定软件或功能的部分;例如,tomcat配置文件是app_server角色所需要的。配置文件通常安装和配置特定的软件组件或服务、其相关的业务逻辑以及任何其他所需的 Puppet 资源。配置文件是“胶水层”,位于角色和模块之间。

你的清单可能非常简单,以至于你只需要使用角色或仅使用配置文件来组织它。这没有问题,但当事情变得更加复杂,并且你发现自己在重复代码时,可以考虑按照我们在这里看到的方式重构代码,采用角色和配置文件模式。

总结

在本章中,我们讨论了多种组织 Puppet 代码的方式。我们详细介绍了类的定义,说明了如何使用class关键字定义一个新类,如何使用include关键字声明类,以及如何使用 Hiera 的自动参数查找机制为包含的类提供参数。

声明参数涉及指定参数的允许数据类型,我们简要概述了 Puppet 的数据类型,包括标量、集合、内容类型和范围参数、抽象类型、灵活类型,并介绍了如何创建你自己的类型别名。我们还介绍了定义资源类型,解释了定义资源类型与类之间的区别,以及何时使用其中的一个或另一个。

我们还讨论了如何在 Hiera 中使用 classes 数组将公共类包含在所有节点上,而将其他类仅包含在特定节点上。我们介绍了角色类的概念,角色类封装了节点完成特定角色所需的所有内容,比如应用服务器。

最后,我们看到如何使用配置文件类来配置和支持特定的软件包或服务,并且如何将多个配置文件类组合成一个角色类。角色和配置文件类之间架起了 Hiera classes 数组(位于顶层)与模块和配置数据(位于最底层)之间的桥梁。我们可以通过总结规则来说,节点应只包含角色,角色应只包含配置文件

在下一章,我们将探讨如何使用 Puppet 利用模板、迭代和 Hiera 数据创建文件。

第九章. 使用模板管理文件

简洁并非复杂之前的状态,而是复杂之后的产物。
--艾伦·珀里斯

在本章中,我们将了解 Puppet 的一个重要且强大的功能:模板。我们将学习如何使用一个简单的模板将 Puppet 变量、事实和 Hiera 数据的值插入到文件中,还将介绍使用迭代和条件语句的更复杂模板来生成动态配置文件。

使用模板管理文件

什么是模板?

在之前的章节中,我们通过多种方式使用 Puppet 管理节点上的 文件内容,包括使用 content 属性将内容设置为字面字符串,以及使用 source 属性从 Puppet 模块复制文件。虽然这些方法非常有用,但它们在一个方面是有限制的:它们只能使用 静态文本,而不能基于 Puppet 数据动态生成文件内容。

动态数据问题

为了了解为什么这是一个问题,考虑一个常见的 Puppet 文件管理任务,如备份脚本。备份脚本需要知道一些特定于站点和节点的内容:本地备份的目录、将其复制到的目标位置,以及访问备份存储所需的任何凭据。虽然我们可以将这些内容作为字面值插入脚本中,但这并不灵活。我们可能不得不维护多个版本的脚本,每个版本与其他版本相同,除了备份位置不同。这显然是不令人满意的。

考虑一个应用程序的配置文件,其中一些设置依赖于关于节点的特定信息:例如可用的内存。显然,我们不希望维护多个几乎相同的配置文件版本,每个版本都包含适合我们可能遇到的不同内存大小的值。正如我们在第五章 变量、表达式和事实中看到的那样,我们有一种方法可以直接在 Puppet 中获取这些信息,我们也有一个灵活且强大的配置数据数据库,正如我们在第六章 使用 Hiera 管理数据中看到的那样。问题是,我们如何能将这些数据动态插入到文本文件中。

Puppet 模板语法

Puppet 的 模板机制就是实现这一目标的一种方式。模板只是一个普通的文本文件,包含特殊的占位符标记,Puppet 会用相关的数据值替换这些标记。以下示例展示了这些标记的样子(aws_credentials.epp):

aws_access_key_id = <%= $aws_access_key %>

<%=%> 分隔符之外的内容是字面文本,Puppet 会原样渲染这些内容。

然而,分隔符内的文本会被解释为 Puppet 表达式(在这个例子中,仅为变量$aws_access_key),在模板编译时会被评估,并将结果插入到文本中。

例如,如果变量$aws_access_key的值为AKIAIAF7V6N2PTOIZVA2,那么当模板被 Puppet 处理时,生成的输出文本将如下所示:

aws_access_key_id = AKIAIAF7V6N2PTOIZVA2

你可以在模板中使用任意多的这些分隔符表达式(称为标签),它们将在模板使用时被评估并插入。

Puppet 的模板机制称为EPP嵌入式 Puppet),模板文件的扩展名为.epp

在你的清单中使用模板

由于模板的最终结果是一个文件,你不会惊讶于我们使用 Puppet 的file资源来处理模板。实际上,我们使用的是file资源的一个属性,你之前见过:content属性。

引用模板文件

回想一下第二章,创建第一个清单,你可以使用content属性将文件的内容设置为字面字符串:

file { '/tmp/hello.txt':
  content => "hello, world\n",
}

当然,你可以将 Puppet 表达式的值插入到该字符串中:

file { "/usr/local/bin/${task}":
  content => "echo I am ${task}\n",
  mode    => '0755',
}

到此为止,你应该已经很熟悉了,但我们可以再进一步,使用epp()函数(file_epp.pp)替代字面字符串:

file { '/usr/local/bin/backup':
  content => epp('/examples/backup.sh.epp',
    {
      'data_dir' => '/examples',
      }
  ),
  mode    => '0755',
}

Puppet 将编译由backup.sh.epp引用的模板文件,替换任何标签为其表达式的值,生成的文本将写入文件/usr/local/bin/backup。模板文件可能如下所示(backup.sh.epp):

<%- | String $data_dir | -%>
#!/bin/bash
mkdir -p /backup
tar cvzf /backup/backup.tar.gz <%= $data_dir %>

你可以在任何期望字符串的地方使用epp()函数,但最常见的用法是在管理文件时使用它,如示例所示。

要在模块内引用模板文件(例如,在我们的 NTP 模块中,来自第七章,掌握模块),将文件放入modules/pbg_ntp/templates/目录,并将文件名以pbg_ntp/为前缀,如以下示例所示:

file { '/etc/ntp.conf':
  content => epp('pbg_ntp/ntp.conf.epp'),
}

提示

记住

不要将templates/作为路径的一部分。Puppet 知道这是一个模板,因此它会自动在指定模块的templates/目录中查找。

内联模板

你的模板文本不必放在单独的文件中:如果是一个简短的模板,你可以将其放在 Puppet 清单中的字面字符串中,并使用inline_epp()函数编译它(file_inline_epp.pp):

$web_root = '/var/www'
$backup_dir = '/backup/www'

file { '/usr/local/bin/backup':
  content => inline_epp('rsync -a <%= $web_root %>/ <%= $backup_dir %>/'),
  mode    => '0755',
}

请注意,我们使用了单引号字符串来指定内联模板文本。如果我们使用了双引号字符串,Puppet 会在处理模板之前插入$web_root$backup_dir的值,这并不是我们想要的结果。

一般来说,最好且更具可读性的是,为除了最简单的模板之外的所有模板使用单独的模板文件。

模板标签

我们在本章示例中使用的标签被称为表达式打印标签

<%= $aws_access_key %>

Puppet 期望此标签的内容具有一个值,该值将被插入模板中,代替标签的位置。

非打印标签与之非常相似,但不会生成任何输出。它的开头定界符中没有=符号:

<% notice("This has no effect on the template output") %>

你还可以使用注释标签来添加文本,这些文本将在 Puppet 编译模板时被移除:

<%# This is a comment, and it will not appear in the output of the template %>

模板中的计算

到目前为止,我们只是将一个变量的值插入到模板中,但我们可以做得更多。模板标签可以包含任何有效的 Puppet 表达式。

在配置文件中,某些值通常是从其他值计算出来的,比如节点的物理内存量。我们在第五章,变量、表达式和事实中看到过一个示例,我们基于$facts['memory']['system']['total_bytes']的值计算了一个配置值。

自然地,我们在 Puppet 代码中能做的事情,也能在模板中做。所以这是相同的计算逻辑,用模板形式表示(template_compute.epp):

innodb_buffer_pool_size=<%= $facts['memory']['system']['total_bytes'] * 3/4 %>

生成的输出(在我的 Vagrant 环境中)如下所示:

sudo puppet epp render --environment pbg /examples/template_compute.epp
innodb_buffer_pool_size=780257280

你不局限于数字计算;你可以做任何 Puppet 表达式能做的事情,包括字符串操作、数组和哈希查找、事实引用、函数调用等等。

模板中的条件语句

到目前为止,你可能对模板并不印象深刻,因为你已经可以在字符串中插入 Puppet 表达式的值,从而无需使用模板就可以处理文件。话虽如此,模板允许你将数据插入到比通过 Puppet 清单中的字面字符串更大且更实际的文件中。

模板还允许你做其他非常有用的事情:根据某些 Puppet 条件表达式的结果包含或排除文本块

我们在第五章,变量、表达式和事实中已经遇到过清单中的条件语句,我们使用它们来有条件地包含 Puppet 资源集(if.pp):

if $install_perl {
  ...
} else {
  ...
}

由于模板标签的内容就是 Puppet 代码,你也可以在模板中使用if语句。这是与之前类似的示例,但这次控制的是在模板中是否包含配置块(template_if.epp):

<% if $ssl_enabled { -%>
  ## SSL directives
  SSLEngine on
  SSLCertificateFile      "<%= $ssl_cert %>"
  SSLCertificateKeyFile   "<%= $ssl_key %>"
  ...
<% } -%>

这看起来稍微复杂一些,但实际上它与前面的示例逻辑完全相同。我们有一个if语句,用来测试一个布尔变量$ssl_enabled的值,根据结果,以下的代码块要么被包含,要么被排除。

你可以看到,if语句和结束的}被包含在非打印标签中,因此它们本身不会生成任何输出,而当 Puppet 编译模板时,它将执行标签内的 Puppet 代码,这将决定输出内容。如果$ssl_enabled为真,模板生成的文件将包含以下内容:

  ## SSL directives
  SSLEngine on
  SSLCertificateFile      "<%= $ssl_cert %>"
  SSLCertificateKeyFile   "<%= $ssl_key %>"
  ...

否则,这部分模板将被省略。这是一种非常有用的条件包含配置文件中块的方法。

就像在清单文件中的if语句一样,你也可以使用else来包含一个备用块,如果条件语句为假。

提示

请注意,在前面的示例中,关闭标签有一个额外的前导连字符:-%>

当你使用这个语法时,Puppet 会抑制标签后面的任何尾随空格和换行符。通常我们会将此语法与非打印模板标签一起使用,否则你会在输出中看到空行。

模板中的迭代

如果我们能够从 Puppet 表达式生成文件的部分内容,并且根据条件包括或排除文件的部分内容,那么我们能否通过 Puppet 循环生成文件的部分内容呢?也就是说,我们能否对数组或哈希进行迭代,为每个元素生成模板内容?实际上,我们可以。这是一个非常强大的机制,它使我们能够基于 Puppet 变量、Hiera 和 Facter 数据生成任意大小的文件。

遍历 Facter 数据

我们的第一个示例生成了一个应用程序的配置文件的一部分,该应用程序用于捕获网络数据包。为了告诉它监听哪些接口,我们需要生成节点上所有活动网络接口的列表。

我们如何生成这个输出?我们知道 Facter 可以通过$facts['networking']['interfaces']给我们一个所有可用网络接口的列表。实际上,这是一个哈希,键是接口的名称,值是接口属性的哈希,例如 IP 地址和子网掩码。

你可能还记得在第五章,变量、表达式与事实中提到过,为了遍历哈希,我们使用类似以下的语法:

HASH.each | KEY, VALUE | {
  BLOCK
}

那么我们来将这个模式应用到 Facter 数据中,看看输出结果是什么样的(template_iterate.epp):

<% $facts['networking']['interfaces'].each |String $interface, Hash $attrs| { -%>
interface <%= $interface %>;
<% } -%>

每次循环时,$interface$attrs的值将设置为由$facts['networking']['interfaces']返回的哈希中的下一个键和值。实际上,我们不会使用$attrs的值,但我们仍然需要将其声明为循环语法的一部分。

每次循环时,$interface的值将设置为列表中下一个接口的名称,并生成像下面这样的新输出行:

interface em1;

在循环结束时,我们生成了与接口数量相同的输出行,这是我们期望的结果。以下是最终输出,在一个有很多网络接口的节点上:

interface em1;
interface em2;
interface em3;
interface em4;
interface em5;
interface lo;

遍历结构化事实

我们应用程序所需的下一个配置数据是与节点相关的 IP 地址列表,这可以通过类似前一个示例的方法生成。

我们可以使用与前一个示例中几乎相同的 Puppet 代码,不过这一次我们将使用每个接口的 $attrs 哈希表来获取相关接口的 IP 地址。

以下示例展示了这一过程是如何工作的(template_iterate2.epp):

<% $facts['networking']['interfaces'].each |String $interface, Hash $attrs| { -%>
local_address <%= $attrs['bindings'][0]['address'] %>;
<% } -%>

循环与前一个示例相同,但这次每行输出的不是 $interface 的值,而是 $attrs['bindings'][0]['address'] 的值,其中包含每个接口的 IP 地址。

这是最终输出:

local_address 10.170.81.11;
local_address 75.76.222.21;
local_address 204.152.248.213;
local_address 66.32.100.81;
local_address 189.183.255.6;
local_address 127.0.0.1;

遍历 Hiera 数据

在第六章,使用 Hiera 管理数据中,我们使用了一个 Hiera 用户数组来为每个用户生成 Puppet 资源。现在我们使用相同的 Hiera 数据,通过在模板中进行迭代来构建一个动态配置文件。

SSH 守护进程 sshd 可以配置为仅允许通过一个用户列表进行 SSH 访问(使用 AllowUsers 指令),而且,实际上,最好这样做。

提示

安全提示

大多数可以从公共互联网访问的服务器会定期收到针对随机用户名的暴力登录尝试,处理这些登录尝试可能会消耗大量资源。如果 sshd 配置为仅允许指定的用户,它可以迅速拒绝任何不在此列表中的用户,而无需进一步处理该请求。

如果我们的用户列在 Hiera 中,那么使用模板生成 sshd_config 文件的 AllowUsers 列表就变得简单了。

就像我们在生成 Puppet user 资源时所做的那样,我们将调用 lookup() 来获取用户数组,并使用 each 对其进行迭代。以下示例展示了在模板中(template_hiera.epp)是如何实现的:

AllowUsers<% lookup('users').each | $user | { -%>
 <%= $user -%>
<% } %>

注意第二行前面的空格,这会导致输出中的用户名之间以空格分隔。还要注意前导连字符的使用(-%>),正如我们在本章前面所看到的,它将抑制该行末尾的任何空白字符。

这是结果:

AllowUsers katy lark bridget hsing-hui charles

使用模板

模板的一个潜在问题(因为它们可以包含 Puppet 代码、变量和 Hiera 数据)是,从 Puppet 清单中并不总是能清楚地知道模板将使用哪些变量。相反,从模板代码中也不容易看出任何引用的变量来自哪里。这可能使得维护或更新模板变得困难,也增加了调试因错误数据传入模板而导致问题的难度。

理想情况下,我们希望能够在 Puppet 代码中精确地指定模板将接收哪些变量,并且这些变量的列表也会出现在模板中。作为附加内容,我们希望能够指定输入变量的数据类型,就像我们为类和定义的资源类型所做的那样(有关更多信息,请参见第八章,类、角色和配置文件)。

好消息是,EPP 模板允许你像为类那样声明传递给模板的参数,并指定所需的数据类型。虽然不强制要求为 EPP 模板声明参数,但这么做是一个非常好的主意。通过声明并类型化的参数,你将能够在模板编译阶段捕捉到大多数数据错误,从而使故障排除变得更加容易。

向模板传递参数

要声明模板的参数,请将它们列在管道符号(|)之间,放在一个非打印标签内,如以下示例所示(template_params.epp):

<% | String[1] $aws_access_key,
     String[1] $aws_secret_key,
| -%>
aws_access_key_id = <%= $aws_access_key %>
aws_secret_access_key = <%= $aws_secret_key %>

在模板中声明参数时,必须将这些参数以哈希形式显式地传递作为epp()函数调用的第二个参数。以下示例展示了如何做到这一点(epp_params.pp):

file { '/root/aws_credentials':
  content => epp('/examples/template_params.epp',
    {
      'aws_access_key' => 'AKIAIAF7V6N2PTOIZVA2',
      'aws_secret_key' => '7IBpXjoYRVbJ/rCTVLaAMyud+i4co11lVt1Df1vt',
    }
  ),
}

这种形式的epp()函数调用接受两个参数:模板文件的路径和一个包含所有必需模板参数的哈希。哈希的键是参数名,值是对应的值。(这些值不一定是字面量值;例如,它们可以是 Hiera 查找的结果。)

很可能你会在模板中使用 Hiera 数据,虽然在我们之前的AllowUsers示例中我们直接从模板中调用lookup()来查找数据,但这并不是最好的做法。现在我们知道如何声明并传递参数到模板,我们应该用相同的方法来处理 Hiera 数据。

这是更新后的AllowUsers示例,我们在清单中执行了 Hiera 查找,作为epp()调用的一部分。首先,我们需要在模板中声明一个$users参数(template_hiera_params.epp):

<% | Array[String] $users | -%>
AllowUsers<% $users.each | $user | { -%>
 <%= $user -%>
<% } %>

然后,当我们使用epp()编译模板时,我们通过在参数哈希中调用lookup()来传递 Hiera 数据(epp_hiera.pp):

file { '/tmp/sshd_config_example':
  content => epp('/examples/template_hiera_params.epp',
    {
      'users' => lookup('users'),
    }
  ),
}

如果你在模板中声明了参数列表,必须在epp()调用中准确地传递这些参数,而不能传递其他任何参数。EPP 模板声明参数的方式与类相同:参数可以具有默认值,任何没有默认值的参数都是必需的。

从前面的示例中可以清楚地看出,声明参数使得查看模板将从调用代码中使用哪些信息变得更加容易,现在我们还可以享受到自动检查参数及其类型的好处。

但请注意,即使是包含参数列表的模板,仍然可以访问模板主体中的任何 Puppet 变量或事实;Puppet 不会阻止模板使用未声明为参数的变量,或者直接从 Hiera 获取数据。不过,应该清楚的是,通过这种方式绕过参数检查机制是一个不好的做法。

小贴士

最佳实践

使用 EPP 模板来生成动态文件,在模板中声明类型化参数,并将这些参数作为哈希传递给 epp() 函数。为了使模板代码更易于理解和维护,始终显式地将数据传递给模板。如果模板需要查找 Hiera 数据,应在 Puppet 清单中执行查找,并让模板声明一个参数来接收数据。

验证模板语法

我们在本章中看到,模板可以包含复杂的逻辑和迭代,几乎可以生成任何所需的输出。这种强大灵活性的缺点是,模板代码可能很难阅读和调试。

幸运的是,Puppet 提供了一个工具,可以在命令行中检查和验证你的模板:puppet epp validate。要使用它,请运行以下命令检查你的模板文件:

puppet epp validate /examples/template_params.epp

如果没有输出,模板是有效的。如果模板包含错误,你将看到错误信息,类似如下:

Error: Syntax error at '%' at /examples/template_params.epp:3:4
Error: Errors while validating epp
Error: Try 'puppet help epp validate' for usage

在命令行渲染模板

正如任何程序员所知道的,即使程序具有有效的语法,也不一定会产生正确的结果。查看模板将要生成的输出非常有用,Puppet 也提供了一个工具来实现这一点:puppet epp render

使用该工具时,运行以下命令:

puppet epp render --values "{ 'aws_access_key' => 'foo', 'aws_secret_key' => 'bar' }" /examples/template_params.epp
aws_access_key_id = foo
aws_secret_access_key = bar

--values 参数允许你传入一对参数值的哈希,就像你在 Puppet 清单中调用 epp() 函数时一样。

你也可以使用 --values_file 参数来引用一个包含参数哈希的 Puppet 清单文件:

echo "{ 'aws_access_key' => 'foo', 'aws_secret_key' => 'bar' }" >params.pp
puppet epp render --values_file params.pp /examples/template_params.epp
aws_access_key_id = foo
aws_secret_access_key = bar

你可以通过命令行使用 --values 传递参数,也可以通过 --values_file 从文件中传递参数,二者可以同时使用。命令行传递的参数将优先于文件中的参数:

puppet epp render --values_file params.pp --values "{ 'aws_access_key' => 'override' }" /examples/template_params.epp
aws_access_key_id = override
aws_secret_access_key = bar

你还可以使用 puppet epp render 来测试内联模板代码,通过 -e 选项传入一个字面模板字符串:

puppet epp render --values "{ 'name' => 'Dave' }" -e 'Hello, <%= $name %>'
Hello, Dave

就像测试你的清单一样,你也可以直接使用 puppet apply 来测试模板,使用类似以下的命令:

sudo puppet apply -e "file { '/tmp/result': content => epp('/examples/template_iterate.epp')}"

这种方法的一个优势是,所有 Puppet 变量、事实和 Hiera 数据都将可供模板使用。

旧版 ERB 模板

你可能会在旧代码和文档中遇到另一种类型的 Puppet 模板:ERB 模板。ERB(嵌入式 Ruby)是 Puppet 在版本 3.5 之前提供的唯一模板机制,直到增加了对 EPP 的支持,现在 EPP 已经取代 ERB 成为 Puppet 的默认模板格式。

ERB 模板的语法与 EPP 相似。以下是一个 ERB 模板的代码片段:

AllowUsers <%= @users.join(' ') %><%= scope['::ubuntu'] == 'yes' ? ',ubuntu' : '' %>

区别在于,标签内部的模板语言是 Ruby,而不是 Puppet。早期版本的 Puppet 在语言特性上相对有限(例如,缺少each函数来遍历变量),因此通常在模板中嵌入 Ruby 代码来解决这一问题。

这需要一些复杂的操作来管理 Puppet 和 Ruby 之间的接口;例如,在 ERB 模板中访问非本地作用域的变量需要使用scope哈希,如前面的示例所示。同样,为了访问 Puppet 函数,如strftime(),你必须调用:

scope.call_function('strftime', ...)

ERB 模板也不支持声明参数或类型检查。我建议你在自己的代码中仅使用 EPP 模板。

总结

在本章中,我们讨论了 Puppet 工具箱中最强大的工具之一——模板文件。我们研究了 EPP 标签语法,并了解了不同种类的标签,包括打印标签和非打印标签。

我们已经了解到,你不仅可以简单地将变量中的值插入模板中,还可以根据 Puppet 表达式的值包含或排除整个文本块,或者通过遍历数组和哈希生成任意大小的模板。

我们看了一些从 Facter 和 Hiera 数据动态生成配置文件的实际示例,并且了解了如何在模板文件中声明类型化参数,并在调用epp()函数时为这些参数传入值。

我们已经了解了如何使用puppet epp validate检查模板的语法,如何使用puppet epp render渲染模板的输出,并使用--values--values_file传递模板参数的预设值,或者直接使用puppet apply渲染模板。

最后,我们讨论了遗留的 ERB 模板,它们的来源,如何与 EPP 模板进行比较,以及为什么尽管你仍可能在实际使用中遇到 ERB 模板,但你应该仅在自己的代码中使用 EPP。

在下一章,我们将探索一个热门话题——容器,了解如何使用 Puppet 管理 Docker 引擎和 Docker 容器,以及如何处理如何在容器中管理配置这一棘手问题。

第十章:控制容器

计算机的内部运作是如此愚笨,但它的速度却快得惊人!
--理查德·费曼

在本章中,我们将探讨新兴的容器话题,并看看它与配置管理的关系。我们将看到如何使用 Puppet 管理 Docker 守护进程,以及镜像和容器,另外还会探索在容器中管理配置的不同策略。

控制容器

了解容器

尽管容器背后的技术至少已有三十年的历史,但容器真正大规模应用还是近几年的事(为了更形象的比喻,可以说它们终于起飞了)。这主要得益于 Docker 的崛起,Docker 是一个软件平台,使得创建和管理容器变得更加简便。

部署问题

Docker 解决的问题主要是软件部署:也就是说,Docker 使得在各种环境中安装和运行软件变得轻而易举。举个例子,假设有一个典型的 PHP web 应用。为了运行这个应用,至少需要以下内容在节点上:

  • PHP 源代码

  • PHP 解释器

  • 相关的依赖项和库

  • 应用程序所需的 PHP 模块

  • 用于构建 PHP 模块原生二进制文件的编译器和构建工具

  • Web 服务器(例如 Apache)

  • 用于提供 PHP 应用的模块(例如 mod_php

  • 应用程序的配置文件

  • 用于运行应用程序的用户

  • 用于存放日志文件、图片和上传数据等内容的目录

那么,如何管理这些东西呢?你可以使用系统软件包格式,比如 RPM 或 DEB,这些格式使用元数据来描述它们之间的依赖关系,并且包含能够完成大部分系统配置的脚本。

然而,这种打包方式是特定于某个操作系统版本的。例如,为 Ubuntu 18.04 打包的程序包无法在 Ubuntu 16.04 或 Red Hat Enterprise Linux 上安装。为多个流行操作系统维护多个程序包,将给维护应用程序本身增加很大的工作量。

部署选项

解决这个问题的一种方式是作者为软件提供配置管理****清单,比如 Puppet 模块或 Chef 配方来安装软件。然而,如果软件的预期用户没有使用 CM 工具,或者使用了不同的工具,那么这种方式就无济于事。即使他们使用的是完全相同版本的相同工具,并且操作系统也是一样的,他们在集成第三方模块时可能仍然会遇到问题,而该模块本身又依赖于其他模块,等等。这显然不是一个即插即用的解决方案。

另一个选项是综合包,它包含了软件运行所需的一切。举例来说,针对我们 PHP 应用的综合包可能包含 PHP 二进制文件及其所有依赖,还有应用程序需要的其他任何内容。然而,这些包通常非常庞大,而且综合包依然是针对特定操作系统和版本的,并且需要进行大量的维护工作。

大多数包管理器并没有提供高效的二进制更新功能,因此即使是最小的更新也需要重新下载整个包。一些大型包甚至还包括它们自己的配置管理工具!

另一种解决方案是提供整个虚拟机镜像,例如 Vagrant box(我们在本书中使用的 Vagrant box 就是一个很好的例子)。这个镜像不仅包含应用程序及其依赖和配置,还包括整个操作系统。这是一个相对便捷的解决方案,因为任何能够运行虚拟机宿主软件的平台(例如 VirtualBox 或 VMware)都能够运行这个虚拟机。

然而,虚拟机存在性能损失,并且它们也消耗大量资源,如内存和磁盘空间,虚拟机镜像本身体积庞大,且在网络中移动时十分笨重。

虽然理论上你可以通过构建一个虚拟机镜像并将其推送到生产环境的虚拟机主机来部署你的应用程序,一些人也确实这么做,但这远不是一种高效的分发方法。

引入容器

近年来,许多操作系统增加了自包含执行环境的功能,更简洁地称之为容器,在这些环境中,程序可以直接在 CPU 上运行,但对机器其他部分的访问受到严格限制。容器就像一个安全沙箱,里面运行的任何程序只能访问容器内的文件和程序,而无法访问外部资源。

这在原理上类似于虚拟机,只是底层技术截然不同。容器中的程序不是通过虚拟处理器和软件仿真层运行,而是直接在底层物理硬件上运行。这使得容器比虚拟机更高效。换句话说,运行容器所需的硬件性能远低于运行相同性能虚拟机所需的硬件性能。

单个虚拟机会消耗大量宿主机的资源,这意味着在同一主机上运行多个虚拟机可能非常消耗资源。相比之下,在容器中运行一个进程所消耗的资源与直接在宿主机上运行相同的进程所消耗的资源没有区别。

因此,您可以在单个主机上运行非常多的容器,并且每个容器都是完全自包含的,无法访问主机或任何其他容器(除非您明确允许)。容器在内核层面上其实只是一个命名空间。运行在该命名空间中的进程无法访问外部的任何内容,反之亦然。机器上的所有容器都使用主机操作系统的内核,因此,尽管容器可以在不同的 Linux 发行版之间移植,但例如 Linux 容器不能直接在 Windows 主机上运行。不过,Linux 容器可以通过 Docker for Windows 提供的虚拟化层在 Windows 上运行。

Docker 对容器的作用

那么,如果容器本身是由内核提供的,Docker 是做什么的呢?事实证明,拥有一个引擎并不等于拥有一辆车。操作系统内核可能提供了容器化的基本设施,但您还需要:

  • 构建容器的规范

  • 容器镜像的标准文件格式

  • 用于存储、版本控制、组织和检索容器镜像的协议

  • 启动、运行和管理容器的软件

  • 允许容器之间网络流量传输的驱动程序

  • 容器之间的通信方式

  • 将数据传入容器的功能

这些需要通过额外的软件提供。事实上,有许多软件前端可以帮助您管理容器:Docker、OCID、CoreOS/rkt、Apache Mesos、LXD、VMware Photon、Windows Server 容器等。然而,Docker 无疑是市场的领导者,目前生产环境中大多数容器都运行在 Docker 下(最近的一项调查显示,比例超过 90%)。

使用 Docker 部署

使用容器部署软件的原理非常简单:软件及其运行所需的一切都包含在容器 镜像 中,镜像类似于一个包文件,但可以直接由容器运行时执行。

要运行软件,您只需执行类似下面的命令(如果您已经安装了 Docker,可以尝试一下!):

docker run bitfield/hello
Hello, world

Docker 将从您配置的 仓库 中下载指定的镜像(这可以是公共仓库,称为 Docker Hub,或者是您自己的私有 Docker 仓库),并执行它。您可以使用数以千计的 Docker 镜像,许多软件公司也越来越多地使用 Docker 镜像作为部署产品的主要方式。

构建 Docker 容器

那么,这些 Docker 镜像来自哪里呢?Docker 镜像就像一个存档或包文件,包含容器内部所有文件的文件和目录结构,包括可执行二进制文件、共享库和配置文件。要创建这个镜像文件,您可以使用 docker build 命令。

docker build 使用一个叫做 Dockerfile 的特殊文本文件,该文件指定容器中应该包含什么。通常,一个新的 Docker 镜像是基于现有镜像,并做了一些修改。例如,有一个 Ubuntu Linux 的 Docker 镜像,它包含了一个已经完全安装的操作系统,可以直接运行。

你的 Dockerfile 可能会指定使用 Ubuntu Docker 镜像作为起点,然后安装 nginx 包。最终的 Docker 容器包含了 Ubuntu 镜像中的所有内容,以及 nginx 包。你现在可以将这个镜像上传到注册中心,并使用 docker run 在任何地方运行它。

如果你想用 Docker 打包自己的软件,你可以选择一个合适的基础镜像(如 Ubuntu),然后编写一个 Dockerfile,将你的软件安装到该基础镜像上。当你使用 docker build 构建容器镜像时,结果将是一个包含你软件的容器,任何人都可以使用 docker run 运行。唯一需要安装的就是 Docker。

这使得 Docker 成为一个非常适合软件供应商将产品打包成易于安装的格式的方式,同时也方便用户快速尝试不同的软件,以查看它是否满足他们的需求。

分层文件系统

Docker 文件系统有一个叫做 分层 的特性。容器是通过分层构建的,所以如果某些内容发生变化,只有受影响的层和它之上的层需要重新构建。这使得一旦容器镜像被构建并部署后,更新镜像变得更加高效。

例如,如果你在应用程序中更改了一行代码并重建容器,那么只有包含你应用程序的层需要重新构建,以及任何其上方的层。基础镜像和受影响层下方的其他层保持不变,并且可以被新容器重复使用。

使用 Puppet 管理容器

使用 Docker 打包和运行软件时,你需要能够做到以下几件事:

  • 安装、配置和管理 Docker 服务本身

  • 构建你的镜像

  • 当 Dockerfile 更改或依赖项更新时,重新构建镜像

  • 管理运行中的镜像、它们的数据存储和配置

除非你希望将镜像公开,否则你还需要为自己的镜像托管一个镜像注册中心。

这些听起来像是配置管理工具可以解决的问题,幸运的是,我们有一个很棒的配置管理工具可用。有趣的是,虽然大多数人都认识到传统的服务器需要由像 Puppet 这样的工具自动构建和管理,但容器似乎还没有(至少目前)面临同样的需求。

问题在于,制作并运行一个简单的容器太容易了,以至于许多人认为容器的配置管理是多余的。虽然在你首次尝试 Docker 并实验简单容器时可能如此,但当你在生产环境中运行复杂的多容器服务时,规模一大,问题就变得更加复杂。

首先,将非简单应用程序容器化并非简单。这些应用程序需要依赖项、配置设置和数据,还需要与其他应用程序和服务通信的方式。虽然 Docker 为你提供了相关工具,但它并不会为你做所有的工作。

其次,你需要一个基础设施来构建容器、更新容器、存储和检索结果镜像,并在生产环境中部署和管理这些容器。容器的配置管理与传统服务器应用的配置管理非常相似,只是它发生在稍高的层次上。

容器非常棒,但它们并不能完全取代配置管理工具的需求(还记得第一章中提到的痛苦守恒定律吗?):

“如果你在某个地方避免了痛苦,它会在另一个地方再次出现。无论什么新技术出现,都无法解决我们所有的问题;顶多,它会用不同的、令人耳目一新的问题来取代它们。”

使用 Puppet 管理 Docker

Puppet 当然可以像管理其他软件一样为你安装和管理 Docker 服务,但它还能做更多的事情。它可以下载并运行 Docker 镜像、从 Dockerfile 构建镜像、挂载容器中的文件和目录,并管理 Docker 卷和网络。在本章中,我们将学习如何完成这些操作。

安装 Docker

在进行其他操作之前,我们需要在节点上安装 Docker(当然是使用 Puppet)。puppetlabs/docker_platform模块非常适合这个目的。

  1. 如果你已经安装并运行了r10k模块管理工具,如第七章中使用 r10k部分所示,所需的模块将已经安装。如果没有,请运行以下命令来安装它:

    cd /etc/puppetlabs/code/environments/pbg
    sudo r10k puppetfile install
    
    
  2. 一旦模块安装完成,你只需应用如下清单(docker_install.pp)即可在节点上安装 Docker:

    include docker
    
  3. 运行以下命令以应用清单:

    sudo puppet apply --environment pbg /examples/docker_install.pp
    
    
  4. 要检查 Docker 是否已安装,请运行以下命令(你可能会看到不同的版本号,但没关系):

    docker --version
    Docker version 17.05.0-ce, build 89658be
    

运行 Docker 容器

要运行一个 Docker 容器,我们首先需要从 Docker 注册表中下载它,Docker 注册表是一个存储容器镜像的服务器。默认的注册表是 Docker Hub,官方的公共 Docker 注册表。

要通过 Puppet 实现这一点,可以使用docker::image资源(docker_image.pp):

docker::image { 'bitfield/hello':
  ensure => 'latest',
}

package资源一样,如果你指定ensure => latest,Puppet 每次运行时会检查注册表,并确保你拥有最新版本的镜像。

要运行刚刚下载的镜像,向清单中添加一个docker::run资源(docker_run.pp):

docker::run { 'hello':
  image   => 'bitfield/hello',
  command => '/bin/sh -c "while true; do echo Hello, world; sleep 1; done"',
}

使用以下命令应用此清单:

sudo puppet apply /examples/docker_run.pp

docker::run 资源告诉 Docker 从本地镜像缓存中获取 bitfield/hello 镜像并运行指定的命令,在本例中,该命令会无限循环并打印 Hello, world。(我告诉过你,容器非常有用。)

容器现在在你的节点上运行,你可以通过以下命令来检查:

sudo docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED
STATUS              PORTS               NAMES
ba1f4aced778        bitfield/hello      „/bin/sh -c ‚while tr"   4 minutes ago       Up 4 minutes                            hello

docker ps 命令显示所有当前正在运行的容器(docker ps -a 也会显示停止的容器),并提供以下信息:

  • 容器 ID,Docker 对容器的内部标识符

  • 镜像名称(在我们的示例中是 bitfield/hello

  • 当前正在容器中执行的命令

  • 创建时间

  • 当前状态

  • 容器映射的所有端口

  • 容器的可读名称(即我们在清单中为 docker::run 资源指定的标题)

容器正在作为服务运行,我们可以通过以下命令来检查:

systemctl status docker-hello
* docker-hello.service - Daemon for hello
   Loaded: loaded (/etc/systemd/system/docker-hello.service; enabled; vendor preset: enabled)
   Active: active (running) since Tue 2017-05-16 04:07:23 PDT; 1min 4s ago
 Main PID: 24385 (docker)
   CGroup: /system.slice/docker-hello.service
           `-24385 /usr/bin/docker run --net bridge -m 0b --name hello bitfield/hello...
...

停止容器

根据 Docker 文档,你可以通过运行 sudo docker stop NAME 来停止容器。但是,如果你尝试这样做,然后再运行 sudo docker ps,你会看到容器仍在运行。这是怎么回事?

Puppet 模块默认假设你希望将所有容器作为服务运行;即配置 systemd 以保持容器运行,并在启动时启动它。

因此,如果你想停止作为服务运行的容器,你需要通过 Puppet 来实现,将 docker::run 资源的 ensure 参数设置为 absent,如下示例所示(docker_absent.pp):

docker::run { 'hello':
  ensure => absent,
  image  => 'bitfield/hello',
}

或者,你可以在命令行中使用 systemctl 命令来停止服务:

sudo systemctl stop docker-hello

提示

如果你不希望容器由 systemd 作为服务管理,可以在 docker::run 资源中指定参数 restart => always。这会告诉 Docker 在容器退出时自动重启容器;因此,Puppet 不需要创建 systemd 服务来管理它。

运行多个容器实例

当然,自动化的真正强大之处在于可扩展性。我们不局限于运行单个容器实例;Puppet 会非常乐意启动任意多个容器实例。

每个 docker::run 资源必须具有唯一名称,和其他 Puppet 资源一样,所以你可以在 each 循环中创建它们,如下示例所示(docker_run_many.pp):

range(1,20).each | $instance | {
  docker::run { "hello-${instance}":
    image   => 'bitfield/hello',
    command => '/bin/sh -c "while true; do echo Hello, world; sleep 1; done"',
  }
}

range() 函数来自 stdlib 模块,正如你可能预期的那样,range(1,20) 返回的是从 1 到 20(包括 20)之间的整数序列。我们使用 each 函数遍历这个序列,每次循环时,$instance 被设置为下一个整数。

docker::run资源标题在每次迭代时包含$instance的值,因此每个容器都会有唯一的名称:hello-1hello-2、… hello-20。我随机选择了数字 20,仅作为示例;你可以根据可用资源(例如系统的 CPU 数量或可用内存)计算要运行的实例数量。

别忘了在之后停止这些容器(编辑示例清单,向docker::run资源添加ensure => absent并重新应用)。

管理 Docker 镜像

当然,从 Docker Hub 或其他注册中心下载和运行公共镜像是非常有用的,但要解锁 Docker 的真正威力,我们还需要能够构建和管理我们自己的镜像。

从 Dockerfiles 构建镜像

正如我们在之前的示例中所看到的,如果你的系统中没有指定的容器镜像,Puppet 的docker::image资源会从 Docker Hub 拉取该镜像并将其保存在本地。

然而,docker::image资源最常用的功能是构建Docker 镜像。这通常是通过 Dockerfile 来完成的,下面是一个我们可以用来构建镜像的示例 Dockerfile(Dockerfile.hello):

FROM library/alpine:3.6
CMD /bin/sh -c "while true; do echo Hello, world; sleep 1; done"

LABEL org.label-schema.vendor="Bitfield Consulting" \
  org.label-schema.url="http://bitfieldconsulting.com" \
  org.label-schema.name="Hello World" \
  org.label-schema.version="1.0.0" \
  org.label-schema.vcs-url="github.com:bitfield/puppet-beginners-guide.git" \
  org.label-schema.docker.schema-version="1.0"

FROM语句告诉 Docker 从许多可用的公共镜像中选择一个作为基础镜像。FROM scratch会从一个完全空的容器开始。FROM library/ubuntu则会使用官方的 Ubuntu Docker 镜像。

当然,容器的一个主要优势是它们可以根据需要小到多小,大到多大,因此如果你只需要运行/bin/echo,下载包含整个 Ubuntu 的 188MB 镜像是没有必要的。

Alpine 是另一种 Linux 发行版,旨在尽可能小巧和轻量化,非常适合用于容器。library/alpine镜像仅有 4MB,比ubuntu小四十倍;这节省了大量空间。此外,如果你从相同的基础镜像构建所有容器,Docker 的分层系统意味着它只需要下载和存储基础镜像一次。

提示

Dockerfile 可以非常简单,如本示例,或者非常复杂。你可以通过 Docker 文档了解更多关于 Dockerfile 格式和命令的内容:

docs.docker.com/engine/reference/builder/

以下代码展示了如何从这个文件创建一个 Docker 镜像(docker_build_hello.pp):

docker::image { 'pbg-hello':
  docker_file => '/examples/Dockerfile.hello',
  ensure      => latest,
}

一旦docker::image资源被应用,生成的pbg-hello镜像将可以用于以容器形式运行(docker_run_hello.pp):

docker::run { 'pbg-hello':
  image => 'pbg-hello',
}

管理 Dockerfiles

当你在容器中运行自己的应用程序,或者在自己的容器中运行第三方应用程序时,你可以通过 Puppet 管理相关的 Dockerfile。下面是一个简单的 Dockerfile 示例,它构建一个使用 Nginx 来提供带有友好问候信息的网页的容器(Dockerfile.nginx):

FROM nginx:1.13.3-alpine
RUN echo "Hello, world" >/usr/share/nginx/html/index.html

LABEL org.label-schema.vendor="Bitfield Consulting" \
  org.label-schema.url="http://bitfieldconsulting.com" \
  org.label-schema.name="Nginx Hello World" \
  org.label-schema.version="1.0.0" \
  org.label-schema.vcs-url="github.com:bitfield/puppet-beginners-guide.git" \
  org.label-schema.docker.schema-version="1.0"

这是管理此 Dockerfile 并从中构建镜像的 Puppet 清单(docker_build_nginx.pp):

file { '/tmp/Dockerfile.nginx':
  source => '/examples/Dockerfile.nginx',
  notify => Docker::Image['pbg-nginx'],
}

docker::image { 'pbg-nginx':
  docker_file => '/tmp/Dockerfile.nginx',
  ensure      => latest,
}

运行以下命令以应用此清单:

sudo puppet apply /examples/docker_build_nginx.pp

每当 Dockerfile 的内容发生更改时,应用此清单将导致镜像被重建。

提示

出于本示例的目的,我们在同一节点上构建并运行容器。然而,在实际应用中,你应该在专用的构建节点上构建容器,并将生成的镜像上传到注册表,这样生产节点就可以下载并运行它们。

这是运行我们刚刚构建的容器的清单(docker_run_nginx.pp):

docker::run { 'pbg-nginx':
  image         => 'pbg-nginx:latest',
  ports         => ['80:80'],
  pull_on_start => true,
}

提示

请注意pull_on_start属性,它告诉 Puppet 在启动或重新启动容器时始终下载最新版本的容器。

如果你完成了第七章,掌握模块,Apache Web 服务器将运行并监听端口80,因此你需要运行以下命令将其移除,然后再应用此清单:

sudo apt-get -y --purge remove apache2
sudo service docker restart
sudo puppet apply --environment pbg /examples/docker_run_nginx.pp

你可以通过在本地机器上访问以下 URL 来检查容器是否正常工作:

http://localhost:8080

你应该看到文本Hello, world

提示

如果你使用的是 Vagrant box,本地机器上的端口8080将自动映射到虚拟机的端口80,然后 Docker 将其映射到pbg-nginx容器的端口80。如果由于某种原因你需要更改此端口映射,请编辑你的 Vagrantfile(在 Puppet 初学者指南仓库中)并查找以下行:

  config.vm.network "forwarded_port", guest: 80, host: 8080

根据需要更改这些设置,并在本地机器的 PBG 仓库目录中运行以下命令:

vagrant reload

如果你没有使用 Vagrant box,容器的端口80将暴露在本地端口80上,所以 URL 将简单地显示如下:

http://localhost

构建动态容器

尽管 Dockerfile 是一种相当强大和灵活的构建容器的方式,但它们只是静态文本文件,通常你需要将信息传递到容器中,告诉它该做什么。我们可以称这些容器为——其配置灵活并基于构建时可用的数据——动态容器

使用模板配置容器

配置容器的一个方法是使用 Puppet 管理 Dockerfile 作为 EPP 模板(参见第九章,管理文件与模板),并插入所需的数据(这些数据可能来自 Hiera、Facter 或直接来自 Puppet 代码)。

让我们升级之前的Hello, world示例,让 Nginx 在构建时由 Puppet 提供任何任意的文本字符串。

这是从模板生成 Dockerfile 并运行生成的镜像的清单(docker_template.pp):

file { '/tmp/Dockerfile.nginx':
  content => epp('/examples/Dockerfile.nginx.epp',
    {
      'message' => 'Containers rule!'
    }
  ),
  notify => Docker::Image['pbg-nginx'],
}

docker::image { 'pbg-nginx':
  docker_file => '/tmp/Dockerfile.nginx',
  ensure      => latest,
  notify      => Docker::Run['pbg-nginx'],
}

docker::run { 'pbg-nginx':
  image         => 'pbg-nginx:latest',
  ports         => ['80:80'],
  pull_on_start => true,
}

使用以下命令应用此清单:

sudo puppet apply --environment pbg /examples/docker_template.pp

当你应用了清单并构建了容器时,你会发现如果你更改 message 的值并重新应用,容器将会使用更新后的文本重新构建。docker::image 资源使用 notify 告诉 docker::run 资源在镜像变化时重启容器。

提示

像这样模板化 Dockerfile 是一种强大的技术。因为你可以让 Puppet 将任何任意数据放入 Dockerfile 中,你可以配置容器及其构建过程的任何内容:基础镜像、安装的软件包列表、应添加到容器中的文件和数据,甚至是容器的命令入口点。

自配置容器

让我们进一步拓展这个思路,使用 Puppet 动态配置一个可以从 Git 获取数据的容器。我们不再像构建时那样提供静态文本,而是让容器本身从 Git 仓库中检出网站内容。

上一个示例中的大部分代码保持不变,唯一不同的是 Dockerfile 资源(docker_website.pp):

file { '/tmp/Dockerfile.nginx':
  content => epp('/examples/Dockerfile.website.epp',
    {
      'git_url' => 'https://github.com/bitfield/pbg-website.git'
    }
  ),
  notify  => Docker::Image['pbg-nginx'],
}

docker::image { 'pbg-nginx':
  docker_file => '/tmp/Dockerfile.nginx',
  ensure      => latest,
  notify      => Docker::Run['pbg-nginx'],
}

docker::run { 'pbg-nginx':
  image         => 'pbg-nginx:latest',
  ports         => ['80:80'],
  pull_on_start => true,
}

Dockerfile 本身稍微复杂一些,因为我们需要在容器中安装 Git,并使用它来检出提供的 Git 仓库(Dockerfile.website.epp):

<% | String $git_url | -%>
FROM nginx:1.13.3-alpine
RUN apk update \
  && apk add git \
  && cd /usr/share/nginx \
  && mv html html.orig \
  && git clone <%= $git_url %> html

LABEL org.label-schema.vendor="Bitfield Consulting" \
  org.label-schema.url="http://bitfieldconsulting.com" \
  org.label-schema.name="Nginx Git Website" \
  org.label-schema.version="1.0.0" \
  org.label-schema.vcs-url="github.com:bitfield/puppet-beginners-guide.git" \
  org.label-schema.docker.schema-version="1.0"

当你应用这个清单并访问http://localhost:8080时,你应该会看到如下文本:

Hello, world!
This is the demo website served by the examples in Chapter 10, 'Controlling containers', from the Puppet Beginner's Guide.

尽管我们直接将 git_url 参数传递给 Dockerfile 模板,但这些数据当然可以来自任何地方,包括 Hiera。使用这种技术,你只需更改传递给它的 Git URL 就可以构建一个容器来服务任何网站。

使用我们在本章前面看到的 docker_run_many 示例中的迭代模式,你可以从一组 git_url 值中构建一组容器,每个容器服务一个不同的网站。现在我们真正开始发挥 Docker 和 Puppet 的强大功能了。

在继续下一个示例之前,运行以下命令停止容器:

sudo docker stop pbg-nginx

这个想法有一个小问题。尽管让容器能够在构建时确定的 Git 仓库中提供内容是很好的,但每次容器启动或重启时,它都必须再次执行 git clone 过程。这需要时间,并且如果由于某些原因仓库或网络不可用,可能会导致容器无法正常工作。

更好的解决方案是从持久化存储中提供内容,我们将在下一节中看到如何做到这一点。

容器的持久化存储

容器被设计为临时的;它们运行一段时间后就消失。容器内的任何东西都会随着容器的消失而消失,包括容器运行期间创建的文件和数据。当然,这并不是我们总是希望的结果。例如,如果你在容器中运行数据库,通常你希望容器消失后数据能够持久保存。

有两种方法可以在容器中持久化数据:第一种是从主机机器挂载一个目录到容器内,这种方式被称为 主机挂载卷;第二种是使用所谓的 Docker 卷。我们将在以下章节中讨论这两种方法。

主机挂载卷

如果你希望容器能够访问主机机器的文件系统上的文件(例如,你正在工作的应用程序代码,且希望进行测试),最简单的方法是将主机上的目录挂载到容器中。以下示例演示了如何做到这一点(docker_mount.pp):

docker::run { 'mount_test':
  image   => 'library/alpine:3.6',
  volumes => ['/tmp/container_data:/mnt/data'],
  command => '/bin/sh -c "echo Hello, world >/mnt/data/hello.txt"',
}

volumes 属性指定了要附加到容器的卷的数组。如果卷的形式是 HOST_PATH:CONTAINER_PATH,Docker 会假定你想要将主机上的目录 HOST_PATH 挂载到容器中,容器中的路径将是 CONTAINER_PATH。挂载目录中已经存在的任何文件将对容器可访问,容器写入该目录的任何内容,在容器停止后仍然可用。

如果应用此示例清单,容器将挂载主机机器的 /tmp/container_data/ 目录(如果该目录不存在,将会创建)到容器中的 /mnt/data/

command 属性告诉容器将字符串 Hello, world 写入文件 /mnt/data/hello.txt

运行以下命令以应用此清单:

sudo puppet apply /examples/docker_mount.pp

容器将启动,写入数据,然后退出。如果一切顺利,你会看到文件 /tmp/container_data/hello.txt 现在已经存在,并包含容器写入的数据:

cat /tmp/container_data/hello.txt
Hello, world

主机挂载的卷在容器需要访问或与主机机器上运行的应用程序共享数据时非常有用。例如,你可以使用一个主机挂载的卷,与一个容器一起运行语法检查、代码检查或持续集成测试,针对你的源代码目录进行操作。

然而,使用主机挂载卷的容器不可移植,它们依赖于主机机器上存在特定的目录。你无法在 Dockerfile 中指定主机挂载卷,因此你不能发布一个依赖于主机挂载卷的容器。尽管主机挂载卷在测试和开发中可能很有用,但在生产环境中,更好的解决方案是使用 Docker 卷。

Docker 卷

一种为容器添加持久存储的更具移植性的方法是使用 Docker 卷。这是一种持久的数据对象,存储在 Docker 的存储区域内,并可以附加到一个或多个容器。

以下示例演示了如何使用 docker::run 启动一个带有 Docker 卷(docker_volume.pp)的容器:

docker::run { 'volume_test':
  image   => 'library/alpine:3.6',
  volumes => ['pbg-volume:/mnt/volume'],
  command => '/bin/sh -c "echo Hello from inside a Docker volume >/mnt/volume/index.html"',
}

提示

volumes属性与前面的例子稍有不同。它的形式为VOLUME_NAME:CONTAINER_PATH,这告诉 Docker 这不是一个主机挂载卷,而是一个名为VOLUME_NAME的 Docker 卷。如果冒号前的值是路径,Docker 假定你想从主机机器挂载该路径,否则,它假定你想挂载一个具有指定名称的 Docker 卷。

如前面的例子所示,容器的command参数将消息写入挂载卷上的文件。

如果你应用此清单,一旦容器退出,你可以运行以下命令查看卷是否仍然存在:

sudo docker volume ls
DRIVER              VOLUME NAME
local               pbg-volume

Docker 卷是一种存储数据的好方式,即使容器不运行时也需要保留这些数据(例如数据库)。它也是一种让数据对容器可用的好方式,无需每次容器启动时都加载数据。

在本章早些时候的 Web 网站示例中,与你让每个容器检出自己的 Git 仓库副本不同,你可以将仓库检出到一个 Docker 卷中,然后让每个容器在启动时挂载该卷。

让我们通过以下清单(docker_volume2.pp)测试这个想法:

docker::run { 'volume_test2':
  image   => 'nginx:alpine',
  volumes => ['pbg-volume:/usr/share/nginx/html'],
  ports   => ['80:80'],
}

这就是我们在本章早些时候使用的相同nginx容器,它将其/usr/share/nginx/html目录中的内容作为网站提供。

volumes属性告诉容器将pbg-volume卷挂载到/usr/share/nginx/html

运行以下命令以应用此清单:

sudo docker stop pbg-nginx
sudo puppet apply /examples/docker_volume2.pp

如果一切按预期工作,我们应该能够在本地机器上浏览以下 URL:http://localhost:8080/

我们应该看到以下文本:

Hello from inside a Docker volume

这是容器的一个非常强大的功能。它们可以读取、写入和修改由其他容器创建的数据,保持自己的持久化存储,并通过卷与其他正在运行的容器共享数据。

在 Docker 中运行应用程序的常见模式是使用多个相互通信的容器,每个容器提供一个特定的服务。例如,一个 Web 应用程序可能使用 Nginx 容器向用户提供应用程序,同时将会话数据存储在一个 MySQL 容器中,该容器挂载了一个持久化卷。它还可以使用链接的 Redis 容器作为内存中的键值存储。

除了通过卷共享数据之外,这些容器如何通过网络进行通信呢?我们将在下一节看到答案。

网络与调度

我们在本章开始时说过,容器是完全自包含的,彼此之间没有访问权限,即使它们在同一主机上运行。但为了运行真正的应用程序,我们需要容器之间进行通信。幸运的是,有一种方法可以做到这一点:Docker 网络

连接容器

Docker 网络就像是容器的私人聊天室:网络内的所有容器可以相互通信,但它们不能与网络外或其他网络中的容器通信,反之亦然。你只需要让 Docker 创建一个网络,给它命名,然后就可以在这个网络内启动容器,容器们就能相互通信。

让我们开发一个示例来试验一下。假设我们想在容器中运行 Redis 数据库,并从另一个容器向其发送数据。这是许多应用程序的常见模式。

在我们的示例中,我们将创建一个 Docker 网络,并在其中启动两个容器。第一个容器是一个公共的 Docker Hub 镜像,将运行 Redis 数据库服务器。第二个容器将安装 Redis 客户端工具,并将一些数据写入 Redis 服务器容器。然后,为了检查是否成功,我们可以尝试从服务器读取数据。

运行以下命令应用 Docker 网络示例清单:

sudo puppet apply /examples/docker_network.pp

如果一切按预期工作,我们的 Redis 数据库现在应该包含一个名为message的字段,里面有一条友好的问候信息,证明我们已经通过 Docker 网络将数据从一个容器传输到另一个容器。

运行以下命令连接到客户端容器并检查情况:

sudo docker exec -it pbg-redis redis-cli get message
"Hello, world"

那么它是如何工作的呢?让我们看看示例清单。首先,我们使用 Puppet 中的docker_network资源(docker_network.pp)为两个容器创建网络:

docker_network { 'pbg-net':
  ensure => present,
}

现在,我们运行 Redis 服务器容器,使用公开的redis:4.0.1-alpine镜像。

docker::run { 'pbg-redis':
  image => 'redis:4.0.1-alpine',
  net   => 'pbg-net',
}

注意

你注意到我们为docker::run资源提供了net属性吗?这指定了容器应该运行的 Docker 网络。

接下来,我们构建一个安装了 Redis 客户端(redis-cli)的容器,这样我们就可以用它向 Redis 容器写入一些数据。

这是客户端容器的 Dockerfile(Dockerfile.pbg-demo):

FROM nginx:1.13.3-alpine
RUN apk update \
  && apk add redis

LABEL org.label-schema.vendor="Bitfield Consulting" \
  org.label-schema.url="http://bitfieldconsulting.com" \
  org.label-schema.name="Redis Demo" \
  org.label-schema.version="1.0.0" \
  org.label-schema.vcs-url="github.com:bitfield/puppet-beginners-guide.git" \
  org.label-schema.docker.schema-version="1.0"

我们通过常规方式使用docker::image构建这个容器:

docker::image { 'pbg-demo':
  docker_file => '/examples/Dockerfile.pbg-demo',
  ensure      => latest,
}

最后,我们使用docker::run运行客户端容器的实例,并传入一个命令给redis-cli,将一些数据写入另一个容器。

docker::run { 'pbg-demo':
  image   => 'pbg-demo',
  net     => 'pbg-net',
  command => '/bin/sh -c "redis-cli -h pbg-redis set message \"Hello, world\""',
}

如你所见,这个容器也有net => 'pbg-net'属性。因此,它将在与pbg-redis容器相同的 Docker 网络中运行,两个容器将能够互相通信。

当容器启动时,command属性调用redis-cli并执行以下命令:

redis-cli -h pbg-redis set message "Hello, world"

-h pbg-redis参数告诉 Redis 连接到主机pbg-redis

注意

使用pbg-redis名称是如何连接到正确的容器的?当你在网络中启动一个容器时,Docker 会自动配置容器内的 DNS 查找,以便通过名称找到网络中的其他容器。当你引用一个容器名称(即容器的docker::run资源的标题,在我们的例子中是pbg-redis)时,Docker 会将网络连接路由到正确的位置。

命令set message "Hello, world"创建了一个名为message的 Redis 键,并赋值为"Hello, world"

我们现在拥有了容器化一个真实应用所需的所有技术:使用 Puppet 管理多个容器,这些容器由动态数据构建,推送到注册表,按需更新,通过网络通信,监听外部端口,并通过卷持久化和共享数据。

容器编排

在本章中,我们已经看到了一些管理单个容器的方法,但如何在大规模和跨多个主机上配置和管理容器——我们称之为容器编排——的问题仍然存在。

例如,如果你的应用运行在容器中,你可能不会仅仅运行一个容器实例:你需要运行多个实例,并将流量路由和负载均衡到它们。你还需要能够将容器分布到多个主机上,以便应用能够抵御任何单个容器主机的故障。

什么是编排?

当在分布式集群中运行容器时,你还需要能够处理诸如容器和主机之间的网络连接、故障转移、健康监控、滚动更新、服务发现以及通过键值数据库在容器间共享配置数据等问题。

尽管容器编排是一个广泛的任务,不同的工具和框架侧重于它的不同方面,但编排的核心要求包括:

  • 调度:在集群中运行容器,并决定将哪些容器运行在哪些主机上,以提供给定的服务。

  • 集群管理:监控和调度集群中容器和主机的活动,并添加或删除主机。

  • 服务发现:赋予容器找到并连接它们所需的服务和数据的能力。

有哪些编排工具可用?

谷歌的 Kubernetes 和 Docker 的 Swarm 都是为容器编排而设计的。另一个产品,Apache Mesos,是一个集群管理框架,可以在不同类型的资源上操作,包括容器。

今天大多数生产环境中的容器都在这三种编排系统之一下运行。Kubernetes 存在时间最久,且拥有最大的用户基础,而 Swarm 虽然是一个相对较新的工具,但它是 Docker 官方堆栈的一部分,因此正迅速被采纳。

由于所有这些产品都必然需要相对复杂的设置和操作,因此也有 平台即服务PaaS)编排的选项:本质上,就是在托管的云平台上运行你的容器。Google 容器引擎GKE)是作为服务的 Kubernetes;亚马逊的 EC2 容器服务ECS)是一个类似于 Kubernetes 的专有系统。

到目前为止,Puppet 与容器编排器的集成仍然相对有限,处于初期阶段。不过,考虑到容器的流行,这一领域很可能会迅速发展。目前,Puppet 支持从 Puppet 资源生成 Kubernetes 配置,以及管理 Amazon ECS 资源,但可以公平地说,使用 Puppet 在规模化的容器编排自动化方面仍处于起步阶段。不过,值得关注这一领域的动态。

在容器中运行 Puppet。

如果一个容器可以包含整个操作系统,例如 Ubuntu,你可能会想:“我能不能直接在容器内部运行 Puppet?”

你可以这样做,而且有些人确实采用了这种管理容器的方法。这种方法也有不少优点:

  • 你可以使用现有的 Puppet 清单或 Forge 模块;无需编写复杂的 Dockerfile。

  • Puppet 会持续保持容器更新;当某些内容发生变化时,无需重新构建。

当然,也有一些缺点:

  • 安装 Puppet 会显著增加镜像大小,并且会拉入各种依赖项。

  • 在容器中运行 Puppet 会减慢构建过程,并消耗容器中的资源。

还有一些混合选项,例如在构建阶段在容器中运行 Puppet,然后在保存最终镜像之前,移除 Puppet 及其依赖项,以及任何中间构建产物。

Puppet 的 image_build 模块是一个有前景的新方法,可以直接从 Puppet 清单构建容器,我预计在不久的将来这一领域会有快速进展。

容器是迷你虚拟机还是单一进程?

你倾向于哪种选择,可能取决于你对容器的基本看法。你是否将它们视为迷你虚拟机,和你当前管理的服务器没有太大区别?还是你认为它们是临时的、轻量级的、单进程的包装?

如果你把容器当作迷你虚拟机来对待,你可能会想在容器内运行 Puppet,就像在物理和虚拟服务器上运行 Puppet 一样。另一方面,如果你认为容器只应该运行单一的进程,那么在容器中运行 Puppet 似乎不太合适。对于单进程的容器,几乎没有什么可配置的内容。

我可以理解支持迷你虚拟机方法的观点。首先,这使得将现有的应用程序和服务迁移到容器变得更加容易;你无需将它们运行在虚拟机中,而是直接将整个应用程序、支持服务和数据库连同当前的管理和监控工具一起移入容器。

然而,虽然这是一个有效的方法,但它并没有真正发挥容器固有优势的最大作用:小的镜像大小、快速部署、高效重建和可移植性。

使用 Puppet 配置容器

就个人而言,我是一个容器极简主义者:我认为容器应该只包含完成工作所需的内容。因此,我更喜欢从外部使用 Puppet 来管理、配置和构建我的容器,而不是从内部管理,这也是我在本章中采用这种方法的原因。

这意味着生成来自模板和 Hiera 数据的 Dockerfile,如我们在示例中所看到的,以及模板化容器所需的配置文件。你可以让 Dockerfile 在构建过程中将这些文件复制到容器中,或者将主机上的单个文件和目录挂载到容器中。

正如我们所看到的,处理共享数据的一个好方法是让 Puppet 将其写入 Docker 卷或主机上的文件,然后由所有正在运行的容器挂载(通常是只读的)。

这样做的好处是,在配置更改后,你不需要重新构建所有容器。你只需让 Puppet 将更改写入配置卷,并使用 docker::exec 资源触发每个容器重新加载其配置,该资源在正在运行的容器上执行指定命令。

容器也需要 Puppet

以免重复强调,容器化并不是使用像 Puppet 这样的配置管理工具的替代方案。事实上,对配置管理的需求更大,因为你不仅需要构建和配置容器本身,还需要存储、部署和运行它们:所有这些都需要基础设施。

和往常一样,Puppet 让这类任务变得更容易、更愉快,而且——最重要的是——更具可扩展性。

总结

在本章中,我们探讨了与软件部署相关的一些问题,解决这些问题的部分方案,以及容器解决方案的优势。我们简要介绍了容器技术的基础,特别是 Docker,并了解到容器是另一种配置管理问题,而 Puppet 可以帮助解决这些问题。

我们安装了 docker_platform 模块,并使用它在我们的虚拟机上设置了 Docker,构建并运行了简单的 Docker 容器。我们看到,当底层 Dockerfile 更改时,如何自动重建容器镜像,并了解如何在构建时使用 Puppet 动态配置 Dockerfile。

我们介绍了容器的持久化存储话题,包括主机挂载卷和 Docker 卷,并讨论了如何使用 Puppet 来管理这些存储。我们设置了一个 Docker 网络,包含两个通过网络端口交换数据的通信容器。

我们分析了在容器内部运行 Puppet 相比于使用 Puppet 从外部配置和构建容器的优缺点,还提出了一种混合策略,即 Puppet 管理附加到运行中容器的卷上的配置数据。

最后,我们已经讨论了容器编排中涉及的一些问题,并介绍了当前最受欢迎的一些平台和框架。

在下一章,我们将学习如何使用 Puppet 管理云计算资源,并通过一个深入的示例来开发一个软件定义的 Amazon EC2 基础设施。

第十一章:编排云资源

休息不是懒散,有时在夏日的树荫下躺在草地上,听着水流的低语,或看着云朵在天空中漂浮,这绝不是浪费时间。
--约翰·拉博克

在本章中,你将学习如何使用puppetlabs/aws模块来创建和管理 Amazon AWS 云实例,以及关联的资源,如子网、安全组和 VPC。你还将学习如何直接从 Hiera 数据构建整个云基础设施。

编排云资源

云的介绍

在探索云计算的优势之前,也许我们应该先定义一下它是什么。在云计算普及之前,如果你需要计算能力,你会购买一台实际的、物理存在的计算机。但从客户的角度来看,我们并不一定想要一台计算机:我们只想要计算能力。我们希望能够根据需要,随时购买所需的计算资源,而不是为一台专用计算机支付高昂的固定费用。

进入虚拟化。一台物理服务器可以提供大量的虚拟服务器,每台虚拟服务器(理论上)彼此完全隔离。托管服务提供商建立了一个平台(由许多物理服务器通过网络连接而成),从客户的角度来看,这个平台提供了一个庞大的、无形的虚拟计算资源(因此得名)。

自动化云资源配置

创建新的云实例比购买物理硬件更便宜、更容易,但你仍然需要做出一些选择:实例的 CPU 或内存大小,硬盘空间的多少,硬盘的种类(物理硬盘、固态硬盘、网络附加存储),应安装的操作系统,实例是否需要公共 IP 地址,应该配置哪些防火墙规则等等。

如果你已经阅读到本书的这一部分,你应该已经认识到这是一个配置管理问题。你可能已经有了一些关于我将推荐什么方案来解决它的想法,但首先让我们来看一下几种可用的选项。

使用 CloudFormation

CloudFormation 是专门针对Amazon Web ServicesAWS)的模板语言。它以声明的方式描述 AWS 资源,有点像 Puppet 资源。你将 CloudFormation 模板上传到 AWS 门户(或 API),应用它,AWS 会创建所有指定的资源。以下示例展示了一段 CloudFormation 代码:

 "Resources" : {
    "EC2Instance" : {
      "Type" : "AWS::EC2::Instance",
      "Properties" : {
        "InstanceType" : { "Ref" : "InstanceType" },
        "SecurityGroups" : [ { "Ref" : "InstanceSecurityGroup" } ],
        "KeyName" : { "Ref" : "KeyName" },
        "ImageId" : { "Fn::FindInMap" : [ "AWSRegionArch2AMI", { "Ref" : "AWS::Region" },
                          { "Fn::FindInMap" : [ "AWSInstanceType2Arch", { "Ref" : "InstanceType" }, "Arch" ] } ] }
      }
    },

坦率地说,这个编程环境并不怎么有趣。尽管它从技术上来说是基础设施即代码,但它相当基础。尽管如此,它仍然比通过网页浏览器手动设置 AWS 基础设施有所进步。

使用 Terraform

Terraform 是一个相对复杂的云资源配置工具。它允许你以声明性方式描述资源,像 CloudFormation 一样,但在一个略高的抽象层次上,并且不是 AWS 特定的。以下示例展示了 Terraform 代码的样子:

resource "aws_instance" "web" {
  ami           = "${data.aws_ami.ubuntu.id}"
  instance_type = "t2.micro"

  tags {
    Name = "HelloWorld"
  }
}

Terraform 是一项有前景的技术,但可以说它仍处于开发的早期阶段。

使用 Puppet

用于管理云基础设施的独立工具是可以的,但如果我们用 Puppet 做其他所有事情,引入一个全新的工具仅仅为了这个似乎有些不值。所以我们能否用 Puppet 来管理云资源呢?

幸运的是,Puppet 提供了一个优秀的 Forge 模块(puppetlabs/aws),它正是为此而设计的。在本章的其余部分,我们将通过一些示例,展示如何使用 puppetlabs/aws 来管理 AWS 云资源。

设置 Amazon AWS 账户

如果你已经拥有 AWS 账户,可以跳到下一部分。否则,你可以按照以下说明设置一个新账户,并获取你开始使用 Puppet 构建基础设施所需的凭证。

创建 AWS 账户

按照以下步骤创建一个新的 AWS 账户:

  1. 浏览到以下 URL:

    aws.amazon.com/

  2. 点击 登录到控制台

  3. 按照指示创建并验证你的账户。

为了使用 Puppet 管理 AWS 资源,我们将创建一个额外的 AWS 用户账户专门用于 Puppet,使用 Amazon 的 身份与访问管理IAM)框架。接下来的章节将演示如何操作。

创建一个 IAM 策略

在创建 Puppet 用户账户之前,我们需要为它需要执行的任务授予特定权限,例如读取和创建 EC2 实例。这涉及到创建一个 IAM 策略,它是一组你可以与用户账户关联的命名权限。

IAM 策略以 JSON 格式文档的形式表达。在示例仓库中有一个策略 JSON 文件,名为 /examples/iam_policy.json。打开此文件并复制其内容,准备粘贴到你的网页浏览器中。

按照以下步骤创建策略并将其与 Puppet 用户关联:

  1. 在 AWS 控制台中,选择 服务 | IAM

  2. 选择 策略

  3. 点击 创建策略

  4. 创建策略 屏幕上,选择 创建你自己的策略

  5. 输入 策略名称(例如,puppet)。

  6. 策略文档 文本框中,粘贴从 iam_policy.json 文件中复制的文本。

  7. 点击底部的 创建策略 以保存此设置。创建 IAM 策略

创建一个 IAM 用户

要创建 Puppet IAM 用户并将其与策略关联,请按照以下步骤操作:

  1. 登录 AWS 控制台。

  2. 选择 服务 | IAM | 用户

  3. 点击 添加用户

  4. 输入你希望用于该账户的用户名(例如,puppet)。

  5. 访问类型 部分,选择 编程访问创建 IAM 用户

  6. 点击 下一步:权限

  7. 直接创建附加现有策略

  8. 策略类型搜索框中输入puppet并按回车创建 IAM 用户

  9. 您应该会看到我们在上一节中创建的策略,勾选旁边的框并点击下一步:审查

  10. 检查设置是否正确,然后点击创建用户

当您完成创建 IAM 用户和策略后,应该会看到成功屏幕,其中列出了您的访问凭证。复制访问密钥 ID 和秘密访问密钥(点击显示查看秘密访问密钥)。您将在接下来的步骤中需要这些凭证(但请妥善保管)。

存储您的 AWS 凭证

按照以下步骤配置您的虚拟机以使用新生成的凭证访问 AWS:

  1. 在您的 Vagrant 虚拟机上,运行以下命令以创建一个目录来存放凭证文件:

    mkdir /home/ubuntu/.aws
    
    
  2. 创建一个名为/home/ubuntu/.aws/credentials的文件,内容如下(从 AWS 控制台屏幕中替换您的访问密钥 ID 和秘密访问密钥值):

    [default]
    aws_access_key_id = AKIAINSZUVFYMBFDJCEQ
    aws_secret_access_key = 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
    

提示

手动创建文件在本例中是可以的,但对于生产环境,您应该使用 Puppet 来管理凭证文件,使用加密的 Hiera 数据,如第六章,使用 Hiera 管理数据中所示。

准备使用 puppetlabs/aws

在接下来的部分中,我们将演示如何生成一个 SSH 密钥对以连接到您的 EC2 实例,并安装puppetlabs/aws模块及其依赖项。

创建密钥对

您需要一个 SSH 密钥对才能连接到您创建的任何 EC2 实例。在本节中,我们将生成并下载您的密钥对。

  1. 在 AWS 控制台中,进入EC2部分,在左侧窗格中选择密钥对下的网络与安全

  2. 点击创建密钥对按钮。创建密钥对

  3. 系统会提示您输入密钥对的名称。对于本示例,请输入pbg

  4. 一个名为pbg.pem的文件将由浏览器自动下载。将该文件移动到您计算机的~/.ssh目录(或者,如果您更愿意从那里访问 AWS 实例,也可以将其复制到 Vagrant 虚拟机中ubuntu用户的~/.ssh目录)。

  5. 使用以下命令设置密钥文件的正确权限:

    chmod 600 ~/.ssh/pbg.pem
    
    

安装 puppetlabs/aws 模块

按照以下步骤安装puppetlabs/aws模块:

如果您已经设置了r10k模块管理工具,如第七章,掌握模块中所示,所需的模块应该已经安装。如果没有,请运行以下命令来安装:

cd /etc/puppetlabs/code/environments/pbg
sudo r10k puppetfile install

安装 AWS SDK gem

puppetlabs/aws模块需要一些 gems,我们可以使用 Puppet 轻松安装,使用以下清单(aws_sdk.pp):

ensure_packages([
  'aws-sdk-core',
  'retries'
],
  { provider => puppet_gem })

提示

你注意到示例中的 provider => puppet_gem 吗?你可能还记得在第四章,理解 Puppet 资源 中提到过,puppet_gem 会在 Puppet 环境中安装 Ruby gem(与系统 Ruby 环境完全独立)。Puppet 模块所需的 gem 需要这样安装,否则 Puppet 无法加载它们。

  1. 使用以下命令应用清单:

    sudo puppet apply --environment pbg /examples/aws_sdk.pp
    
    
  2. 创建 /home/ubuntu/.aws/config 文件,并添加以下内容:

    [default]
    region=us-east-1
    

使用 Puppet 创建 EC2 实例

尽管你可以使用 Puppet 管理许多不同类型的 AWS 资源,但最重要的资源是 EC2 实例(虚拟服务器)。在这一部分,我们将学习如何创建你的第一个 EC2 实例。

选择 Amazon Machine Image (AMI)

为了运行 EC2 实例,即 AWS 虚拟机,你需要从众多可用的虚拟机中选择一个。每个虚拟机快照称为 Amazon Machine ImageAMI),并且有一个唯一的 ID。你将使用这个 ID 添加到 Puppet 清单中,以告知它要启动哪种类型的实例。

对于本示例,选择哪个 AMI 并不太重要,但我们将使用官方的 Ubuntu 镜像。按照以下步骤查找:

  1. 浏览到以下 URL:

    cloud-images.ubuntu.com/locator/ec2/

  2. Search 框中,输入 us-east-1 xenial

  3. 你应该能看到 us-east-1 区域中列出的 Ubuntu Xenial AMI,它们有不同的实例类型,类似以下截图:选择 Amazon Machine Image (AMI)

  4. 在列表中找到 Instance Typeebs-ssd 的 AMI。在之前的截图中,列表中的第三个 AMI(ami-26d6d131)是合适的。

AMI-ID 列中的十六进制代码,以 ami- 开头,就是 AMI ID。请记下这个 ID 以备后用。点击链接查看 AWS 实例类型选择页面,并确认你选择的 AMI 上有标记 Free tier eligible;这些 AMI 不会产生费用。如果启动非免费层的 AMI 实例,你将会被收费。

创建 EC2 实例

现在我们已经选择了合适的 AMI,准备使用 Puppet 创建 EC2 实例。

然而,在此之前,我们需要对 AWS 设置做一些更改,请按照以下步骤操作:

  1. 在 AWS 控制台中,从 Services 菜单中选择 VPC

  2. 在左侧窗格中选择 Your VPCs

  3. 将只列出一个 VPC。点击 Name 字段并将其名称设置为 default-vpc

  4. 在左侧窗格中选择 Subnets

  5. 会列出几个子网,每个子网对应一个可用区。找到与 us-east-1a 可用区相关联的子网。

  6. 点击子网的 Name 字段,并将名称设置为 default-subnet

    提示

    为什么在运行示例之前我们必须为 VPC 和子网设置名称?puppetlabs/aws 模块通过资源的“名称”来引用它们,而不是通过其 ID(像 AMI ID 这样的长十六进制代码)。尽管 AWS 会自动为你创建默认的 VPC 和子网,但它不会为它们分配名称,这意味着在我们为它们设置名称之前,我们无法在 Puppet 代码中引用它们。只要你在 Puppet 代码中的名称与 AWS 控制面板中分配的名称相同,名称的具体内容并不重要。我们将在本章后面了解更多关于 VPC 和子网的功能以及如何使用它们。

  7. 编辑文件 /examples/aws_instance.pp,并将第一行中的 $ami 的值更改为你之前选择的 AMI ID(在我们的示例中是 ami-26d6d131):

    sudo vi /examples/aws_instance.pp
    $ami = 'ami-26d6d131'
    
  8. 保存文件,并运行以下命令:

    sudo puppet apply --environment pbg /examples/aws_instance.pp
    
    
  9. 你应该会看到类似下面的 Puppet 输出:

    Notice: /Stage[main]/Main/Ec2_securitygroup[pbg-sg]/ensure: created
    Notice: /Stage[main]/Main/Ec2_instance[pbg-demo]/ensure: changed absent to running
    
  10. 如果你检查 AWS 控制台中的 EC2 部分,你应该看到你的新实例的状态为 初始化中,很快就会准备好使用。

访问你的 EC2 实例

一旦新创建的实例的状态从 初始化中 变为 运行中(你可能需要点击 AWS 控制台中的刷新按钮),你就可以使用 SSH 和之前下载的密钥文件连接到它。

  1. 在 AWS 控制台中,查找实例的公共 IP 地址并复制它。

  2. 从你自己的机器(或者如果你将 pbg.pem 文件复制到 Vagrant VM 上,从 Vagrant VM 中)运行以下命令(将 YOUR_INSTANCE_IP 替换为实例的公共 IP 地址):

    ssh -i ~/.ssh/pbg.pem -l ubuntu YOUR_INSTANCE_IP
    The authenticity of host 'YOUR_INSTANCE_IP (YOUR_INSTANCE_IP)' can't be established.
    ECDSA key fingerprint is SHA256:T/pyWVJYWys2nyASJVHmDqOkQf8PbRGru3vwwKH71sk.
    Are you sure you want to continue connecting (yes/no)? yes
    Warning: Permanently added 'YOUR_INSTANCE_IP' (ECDSA) to the list of known hosts.
    Welcome to Ubuntu 16.04.3 LTS (GNU/Linux 4.4.0-1030-aws x86_64)
    

提示

现在你已经获得了实例的 SSH 访问权限,你可以像对待物理节点一样使用 Puppet 对其进行引导,或者手动安装 Puppet 和 Git,并检查清单仓库。(我们将在第十二章中开发一个完整的自动化引导过程,整合所有内容。)

恭喜你!你刚刚用 Puppet 创建了你的第一个 EC2 实例。在下一节中,我们将查看代码并详细检查资源。

VPC、子网和安全组

让我们通过示例清单来看一下它是如何工作的。但首先,我们需要了解一些关于 AWS 资源的信息。

一个 EC2 实例位于一个子网中,子网是一个自包含的虚拟网络。所有位于子网内的实例可以相互通信。子网是虚拟私有云VPC)的划分,VPC 是特定于你 AWS 账户的私有内部网络。

实例还拥有一个安全组,它是一组管理网络访问实例的防火墙规则。

当你创建 AWS 账户时,你会获得一个默认的 VPC,该 VPC 被划分为多个子网,每个 AWS 可用区AZ)一个。我们在示例实例中使用的是默认 VPC 和其中一个默认子网,但由于我们还需要一个安全组,因此我们首先在 Puppet 代码中创建它。

ec2_securitygroup 资源

示例清单的第一部分创建了所需的ec2_securitygroup资源(aws_instance.pp):

ec2_securitygroup { 'pbg-sg':
  ensure      =>  present,
  description => 'PBG security group',
  region      => $region,
  vpc         => 'default-vpc',
  ingress     => [
    {
      description => 'SSH access from world',
      protocol    => 'tcp',
      port        => 22,
      cidr        => '0.0.0.0/0',
    },
    {
      description => 'Ping access from world',
      protocol    => 'icmp',
      cidr        => '0.0.0.0/0',
    },
  ],
}

首先,ec2_securitygroup有一个标题(pbg-sg),我们将使用这个标题从其他资源(如ec2_instance资源)引用它。它还有一个description,仅仅是为了提醒我们它的用途。

它属于一个region和一个vpc,并具有一组ingress规则。这些是你的防火墙规则。你希望允许的每个防火墙端口或协议都需要单独的入口规则。

每个入口规则都是类似以下内容的哈希:

{
  description => 'SSH access from world',
  protocol    => 'tcp',
  port        => 22,
  cidr        => '0.0.0.0/0',
}

protocol指定了流量类型(tcpudp等)。

port是要打开的端口号(22是 SSH 端口,我们需要它来登录到实例)。

最后,cidr键指定了允许访问的网络地址范围。 (0.0.0.0/0表示“所有地址”。)

ec2_instance资源

ec2_instance资源,顾名思义,用于管理单个 EC2 实例。以下是示例清单(aws_instance.pp)中的相关部分:

ec2_instance { 'pbg-demo':
  ensure                      => present,
  region                      => $region,
  subnet                      => 'default-subnet',
  security_groups             => 'pbg-sg',
  image_id                    => $ami,
  instance_type               => 't1.micro',
  associate_public_ip_address => true,
  key_name                    => 'pbg',
}

首先,ensure => present告诉 AWS 该实例应该处于运行状态。(你也可以使用running作为present的同义词。)将ensure => absent设置为会终止并删除实例(以及任何附加的临时存储)。

EC2 实例还可以处于第三种状态,即stopped。停止的实例会保留其存储,并可以重新启动。由于 AWS 按实例小时计费,因此停止的实例不需要付费,所以最好停止不需要立即运行的实例。

实例属于一个region和一个subnet,并且具有一个或多个security_groups

image_id属性告诉 AWS 为实例使用哪个 AMI ID。

instance_type属性从 AWS 提供的多种类型中进行选择,这些类型大致对应于实例的计算能力(不同类型在内存大小、虚拟 CPU 数量以及其他一些因素上有所不同)。

由于我们处于一个私有网络中,实例无法从互联网访问,除非我们为其分配公共 IP 地址。将associate_public_ip_address设置为true启用此功能。(除非实例确实需要暴露端口到互联网,否则应将其设置为false。)

最后,实例有一个key_name属性,它告诉 AWS 我们将使用哪个 SSH 密钥来访问该实例。在这种情况下,我们使用的是在本章前面创建的名为pbg的密钥。

提示

在进入下一个示例之前,请终止实例以避免浪费免费小时。你可以通过在 AWS 控制面板中选择实例,然后点击操作 | 实例状态 | 终止,或者重新应用 Puppet 清单,将实例的ensure属性设置为absent来实现。

管理自定义 VPC 和子网

在前面的示例中,我们使用了预先存在的默认 VPC 和子网来创建实例。对于演示目的来说这样是可以的,但在生产环境中,您会希望使用专用的 VPC 来管理您的 Puppet 资源,以便将其与 AWS 账户中的其他资源以及其他 Puppet 管理的 VPC 区分开来。例如,您可以有一个暂存 VPC 和一个生产 VPC。

默认情况下,新的 VPC 无法访问 Internet;我们还需要一个Internet 网关(它将 Internet 流量路由到 VPC 并从 VPC 返回)和一个路由表(它告诉某个子网将非本地流量发送到网关)。puppetlabs/aws模块提供了用于创建和管理这些实体的 Puppet 资源。

在自定义 VPC 中创建实例

在本节中,我们将使用一个更复杂的示例清单来创建一个新的 VPC 和子网,并与之关联一个 Internet 网关和路由表,然后添加一个安全组和 EC2 实例。

按照以下步骤应用清单:

  1. 编辑文件/examples/aws_vpc.pp,并将第一行中的$ami值更改为您之前选择的 AMI ID(在我们的示例中,ami-26d6d131):

    sudo vi /examples/aws_vpc.pp
    $ami = 'ami-26d6d131'
    
  2. 保存文件并运行以下命令:

    sudo puppet apply --environment pbg /examples/aws_vpc.pp
    
    
  3. 您应该会看到如下 Puppet 输出:

    Notice: /Stage[main]/Main/Ec2_vpc[pbg-vpc]/ensure: created
    Notice: /Stage[main]/Main/Ec2_vpc_internet_gateway[pbg-igw]/ensure: created
    Notice: /Stage[main]/Main/Ec2_vpc_routetable[pbg-rt]/ensure: created
    Notice: /Stage[main]/Main/Ec2_vpc_subnet[pbg-vpc-subnet]/ensure: created
    Notice: /Stage[main]/Main/Ec2_securitygroup[pbg-vpc-sg]/ensure: created
    Notice: /Stage[main]/Main/Ec2_instance[pbg-vpc-demo]/ensure: changed absent to running
    
  4. 如果您检查 AWS 控制台的EC2部分,您应该会看到新实例的状态是初始化中,很快就可以使用了。

ec2_vpc 资源

让我们详细看一下示例清单。以下是ec2_vpc资源(aws_vpc.pp):

ec2_vpc { 'pbg-vpc':
  ensure     => present,
  region     => $region,
  cidr_block => '10.99.0.0/16',
}

VPC 需要一个region属性和cidr_block,即 VPC 将使用的网络地址范围。(实际上,这不是必须的,因为如果不指定,AWS 会随机为您分配一个。我们在这里指定一个仅用于演示。)

提示

实际上,您的网络范围并不重要,因为它完全是内部的。然而,采用官方分配给私有网络的地址范围(如10.x.y.z)是一种好习惯。为了减少与您组织中其他分配地址范围发生冲突的可能性,您可以为x选择一个随机数字(我们在示例中使用了99)。

ec2_vpc_internet_gateway 资源

我们之前看到,VPC 默认情况下是无法连接到 Internet 的。将 Internet 流量引入 VPC 的方式有很多,包括 VPN 和 Amazon Elastic Load BalancersELB),但在本示例中,我们将使用ec2_vpc_internet_gateway资源,其格式如下:

ec2_vpc_internet_gateway { 'pbg-igw':
  ensure => present,
  region => $region,
  vpc    => 'pbg-vpc',
}

网关有一个标题(pbg-igw),并且与特定的regionvpc关联。

ec2_vpc_routetable 资源

在配置了ec2_vpc_internet_gateway后,我们现在需要设置一个路由表,以确定哪些流量需要发送到它。以下是示例中的ec2_vpc_routetable资源:

ec2_vpc_routetable { 'pbg-rt':
  ensure => present,
  region => $region,
  vpc    => 'pbg-vpc',
  routes => [
    {
      destination_cidr_block => '10.99.0.0/16',
      gateway                => 'local'
    },
    {
      destination_cidr_block => '0.0.0.0/0',
      gateway                => 'pbg-igw'
    },
  ],
}

和往常一样,路由表有一个标题,regionvpc。它还包含一个或多个路由的数组。

路由就像是网络数据包的路标。它告诉你:“如果你要去这个目的地,请走这个路口。”每一条路由都是一个哈希表,包含destination_cidr_blockgateway键。

在我们示例中的第一条路由是用于本地流量的(目的地为10.99.0.0/16网络,这是我们分配给 VPC 的网络):

{
  destination_cidr_block => '10.99.0.0/16',
  gateway                => 'local'
}

这告诉目的地为10.99.0.0/16的流量它是本地流量;也就是说,不需要使用网关,因为它已经在所需的网络上。

第二条路由是针对所有其他流量的:

{
  destination_cidr_block => '0.0.0.0/0',
  gateway                => 'pbg-igw'
}

网络地址0.0.0.0/0匹配所有可能的网络地址(10.99.0.0/16的流量已经通过之前的路由被过滤掉,所以剩下的所有流量必须是互联网流量)。指定的网关是pbg-igw,即我们之前创建的ec2_vpc_internet_gateway

所以,这个路由表等同于以下的流量路由指令:

  • 目的地为10.99.0.0/16的流量,保持在此网络内

  • 所有其他流量,请通过pbg-igw网关

这些路由对于单个 VPC 来说已经足够;如果你在 AWS 中有更复杂的网络配置,你将需要更复杂的路由表,但基本原则是相同的。

ec2_vpc_subnet资源

如我们所见,子网是 VPC 网络的一个子部分,它使你能够为不同的资源组逻辑地划分 VPC。例如,你可能有一个子网,它可以通过互联网访问,供公共节点使用,另一个子网则供内部资源使用,如数据库或日志服务器。

在这个示例中,我们只有一个子网:

ec2_vpc_subnet { 'pbg-vpc-subnet':
  ensure            => present,
  vpc               => 'pbg-vpc',
  region            => $region,
  cidr_block        => '10.99.0.0/24',
  availability_zone => "${region}a",
  route_table       => 'pbg-rt',
}

它有一个标题,vpcregion。因为它是 VPC 网络的一个子部分,它还需要一个cidr_block来指定它占用网络地址空间的具体部分。这必须是你分配给包含 VPC 的网络地址的子集,就像在这个示例中一样。

子网存在于 AWS 的可用区内(相当于数据中心)。这些可用区以区域命名;例如,us-east-1区域有us-east-1aus-east-1b等可用区。这使得你可以在不同的可用区中配置冗余资源,以便在一个可用区故障时,另一个可用区能够接管。然而,在这个示例中,我们只使用了一个可用区us-east-1a,并将其传递给availability_zone属性。

默认情况下,子网中的资源只能在子网内进行通信。为了允许子网内外的流量通信,我们需要将其与route_table关联。通过使用我们之前创建的pbg-rt路由表,我们可以通过pbg-igw网关发送互联网流量。

就是这样。ec2_securitygroupec2_instance资源和我们之前的示例差不多,唯一不同的是使用了新的子网。

其他 AWS 资源类型

Puppet 不仅仅限于管理 EC2 实例;puppetlabs/aws模块还支持 ELB 负载均衡器、Cloudwatch 警报、自动扩展组、弹性 IP、DHCP、VPN、IAM 用户和策略、RDS 数据库、S3 存储桶、SQS 队列、Route 53 DNS 管理和EC2 容器服务ECS)。由于空间、时间和精力的限制,我没有提供所有这些的示例,但你可以查阅该模块极为全面的文档,网址如下:

forge.puppet.com/puppetlabs/aws

从 Hiera 数据中配置 AWS 资源

直接在代码中管理 AWS 资源没有任何问题,就像我们在前面的示例中做的那样,但我们可以做得稍微更好一点。

在第六章,使用 Hiera 管理数据中,我们展示了如何直接从 Hiera 数据创建 Puppet 资源。在那个例子中(从 Hiera 哈希创建资源),我们将所有基础设施的用户存储在一个名为users的 Hiera 哈希中,然后使用each关键字迭代该哈希,为每个用户创建一个用户资源。以下是再次展示的示例代码(hiera_users2.pp):

lookup('users2', Hash, 'hash').each | String $username, Hash $attrs | {
  user { $username:
    * => $attrs,
  }
}

魔术字符*属性展开操作符)告诉 Puppet 将$attrs哈希的内容作为资源的属性。

将资源描述为 Hiera 数据的优点在于,当我们需要添加一个新用户或更改现有用户的详细信息时,根本不需要触及 Puppet 代码。一切都在 Hiera 中定义。

遍历 Hiera 数据以创建资源

读者可能会问:“我们能不能用这些 AWS 资源做同样的事情?能不能直接在 Hiera 哈希中定义所有内容,然后让 Puppet 迭代它来创建资源?”

实际上,我们可以这么做。用于创建所有这些资源的清单出奇地简洁(aws_hiera.pp):

$aws_resources = lookup('aws_resources', Hash, 'hash')
$aws_resources.each | String $r_type, Hash $resources | {
  $resources.each | String $r_title, Hash $attrs | {
    Resource[$r_type] { $r_title:
      * => $attrs,
    }
  }
}

要应用清单,请按照以下步骤操作:

  1. 编辑 Hiera 数据文件aws.yaml,并将第一行中ami:设置的值更改为你之前选择的 AMI ID(在我们的示例中是ami-26d6d131):

    sudo vi /etc/puppetlabs/code/environments/pbg/data/aws.yaml
    ami: 'ami-26d6d131'
    
  2. 保存文件并运行以下命令:

    sudo puppet apply --environment pbg /examples/aws_hiera.pp
    
    

如果你已经运行了前面的示例并且 AWS 资源仍然存在,你将不会看到 Puppet 的任何输出,因为这些资源完全相同。

注意

记住,如果系统的状态已经与清单中表达的期望状态相同,Puppet 将不会做任何事情。

如果你想证明示例清单确实有效,可以使用 AWS 控制面板删除资源(或通过将 Hiera 数据中的present更改为absent来使用 Puppet 删除它们),然后重新应用清单。

如果你将该清单与 Hiera 用户示例中的清单进行比较,你会发现它由两个嵌套的循环组成,而不是一个单独的循环。外部循环遍历$aws_resources哈希的内容:

$aws_resources = lookup('aws_resources', Hash, 'hash')
$aws_resources.each | String $r_type, Hash $resources | {
  ...
  }
}

$aws_resources哈希的每个键都是一个 Puppet 资源类型的名称。以下是第一个(来自hiera_aws.yaml):

 'ec2_vpc':
      ...

因此,在第一次循环时,$r_type的值将是ec2_vpc,而$resources的值将是这个哈希:

'pbg-vpc':
  ensure: present
  region: "%{lookup('region')}"
  cidr_block: '10.99.0.0/16'

现在我们进入内循环,创建所有类型为$r_type的资源:

$resources.each | String $r_title, Hash $attrs | {
  Resource[$r_type] { $r_title:
    * => $attrs,
  }
}

实际情况是,只有一个ec2_vpc资源,因此在第一次循环时,$r_title的值将是pbg-vpc,而$attrs的值将是这个哈希:

ensure: present
region: "%{lookup('region')}"
cidr_block: '10.99.0.0/16'

所以 Puppet 将创建这个资源:

ec2_vpc { 'pbg-vpc':
  ensure     => present,
  region     => 'us-east-1',
  cidr_block => '10.99.0.0/16',
}

这与前面示例中的ec2_vpc资源相同,在我们遍历外部循环时,我们将以相同的方式创建其他资源。

Resource[$r_type]是什么?这是一种 Puppet 的技巧。问题在于我们需要声明一个其类型我们还不知道的 Puppet 资源;它将由$r_type变量提供。你可能最初会尝试使用类似以下的语法:

$r_type = 'ec2_vpc'
$r_type { 'pbg-vpc':
  ...
}

不幸的是,Puppet 不允许这种语法,但有一种方法可以绕过这个问题。抽象数据类型Resource匹配任何资源类型(你可以在第八章,类、角色和配置文件中了解更多关于 Puppet 数据类型的信息)。

我们可以通过在方括号中包含实际的资源类型来使Resource更具体:Resource['ec2_vpc']。这是声明资源的有效语法。

所以这就是我们声明一个其类型来自变量的资源的方法:

$r_type = 'ec2_vpc'
Resource[$r_type] { 'pbg-vpc':
  ...
}

现在,由 Hiera 数据描述的 AWS 资源应该更容易维护和扩展,当你在生产中使用 Puppet 时。

清理未使用的资源

为了关闭你的 EC2 实例,从而避免使用你的免费小时数或被收费,编辑你的 Hiera 数据,将ec2_instance资源的ensure设置为absent

'ec2_instance':
  'pbg-vpc-demo':
    ensure: absent
    region: "%{lookup('region')}"
    subnet: 'pbg-vpc-subnet'
    security_groups: 'pbg-vpc-sg'
    image_id: "%{lookup('ami')}"
    instance_type: 't1.micro'
    associate_public_ip_address: true
    key_name: 'pbg'

当你重新应用清单时,Puppet 将停止该实例。你可以保持其他资源不变,因为它们不会产生费用。

总结

在本章中,我们介绍了云计算的基本概念,并考察了管理云资源的一些选项,包括 CloudFormation 和 Terraform,然后介绍了puppetlabs/aws模块。

我们已经完成了创建 AWS 账户、设置 IAM 用户和策略、生成凭证和 SSH 密钥、安装 AWS SDK gem 以及选择合适的 AMI(Amazon 机器映像)的过程。

使用 Puppet,我们创建了一个 EC2 实例和安全组,并且看到了如何使用 SSH 连接到运行中的实例。更进一步,我们从头开始创建了整个 VPC,包括子网、互联网网关、路由表、安全组和 EC2 实例。

最后,我们已经看到如何直接从 Hiera 数据构建所有这些云资源,这种方式是描述 Puppet 资源最灵活和强大的方法。

在接下来的最后一章,我们将汇集本书前面各章的思路和技巧,创建一个完整、可操作的 Puppet 基础设施示例,您可以将其作为自己项目的基础。

第十二章:将一切汇集在一起

男人的气度是耐心。掌握之道是九分耐心。
--厄休拉·K·勒古恩,《地海巫师》

在本章中,我们将应用前面所有章节中的思想,看看完整的、工作的 Puppet 基础设施是什么样子,使用一个展示所有本书中解释的原则的示例仓库。你可以将其作为自己 Puppet 代码库的基础,并根据需要进行调整和扩展。

将一切汇集在一起

获取示例仓库

示例仓库可以在 GitHub 上找到,你可以像克隆本书示例仓库一样,通过运行以下命令来克隆它:

git clone -b production https://github.com/bitfield/control-repo-3

它包含了你管理节点所需的一切内容:

  • 用户账户和 SSH 密钥

  • SSH 和 sudoers 配置

  • 时区和 NTP 设置

  • Hiera 数据

  • 自动 Puppet 更新和应用脚本

  • 新节点的引导脚本

它还包含一个 Vagrantfile,以便你可以在 Vagrant 虚拟机上尝试这个仓库。

复制仓库

如果你打算将示例仓库作为自己 Puppet 仓库的基础,你需要将其复制一份,以便自己编辑和维护。

你可以通过两种方式做到这一点。一种是将仓库 fork 到自己的 GitHub 账户中。为此,请登录 GitHub 并浏览到示例仓库 URL:

github.com/bitfield/control-repo-3.git

在页面右上角找到 Fork 按钮并点击它。这将会在你的账户下创建一个新仓库,包含示例仓库中的所有代码和历史。

或者,你也可以按照以下步骤操作:

  1. 在你的 GitHub 账户中创建一个新仓库(命名为 puppetcontrol-repo,或其他你喜欢的名字)。

  2. 记下仓库的 URL。

  3. 将示例仓库克隆到你的个人机器上:

    git clone -b production https://github.com/bitfield/control-repo-3
    cd control-repo-3
    
    
  4. 重命名原始仓库的远程(以便将来能获得更新):

    git remote rename origin upstream
    
    
  5. 将你的新仓库添加为 origin 远程仓库(使用你之前记下的仓库 URL):

    git remote add origin YOUR_GIT_URL
    
    
  6. 推送到新的远程仓库:

    git push origin production
    
    

现在,你的仓库包含了完整的示例仓库副本,你可以根据需要编辑和定制它。

随着原始仓库未来的更新,你将能够将这些更改拉取到你自己的版本中。要从上游获取更改,请运行以下命令:

git fetch upstream
git rebase upstream/production

理解示例仓库

现在是时候看看前面章节中的所有思想如何结合起来了。了解完整的 Puppet 基础设施是如何工作的对你应该很有帮助,你也可以将这个仓库作为自己项目的基础。我们将在本章稍后介绍如何做到这一点,但首先,我们来简单了解一下仓库的整体结构。

控制仓库

控制仓库是一个 Puppet 代码库,它不包含模块,或者只包含特定站点的模块,是组织 Puppet 代码的好方法。

在第七章,精通模块中,我们学习了如何使用 r10k 工具通过 Puppetfile 管理模块。Puppetfile 指定我们使用哪些模块,及其确切版本和来源(通常是 Puppet Forge,也可以来自远程 Git 仓库)。

因此,我们的 Puppet 仓库只需要包含一个 Puppetfile,以及我们的 Hiera 数据和 roleprofile 模块。

模块管理

因为 r10k 期望通过 Puppetfile 管理 modules/ 目录中的所有内容,我们的 站点特定模块 保存在控制仓库中一个名为 site-modules/ 的单独目录下。

为了启用此功能,我们需要将以下设置添加到 environment.conf 文件中:

modulepath = "modules:site-modules:$basemodulepath"

这将 site-modules/ 添加到 Puppet 查找模块的路径列表中。

如第七章,精通模块中详细说明的那样,我们将使用 r10k 和 Puppetfile 来管理所有第三方模块。因此,演示仓库中没有 modules/ 目录:r10k 会在安装所需模块时创建此目录。

这是我们初始仓库所需模块列表的 Puppetfile。当然,随着你根据自己的需求调整仓库,你会将更多的模块添加到这个列表中(Puppetfile):

forge "http://forge.puppetlabs.com"

# Modules from the Puppet Forge
mod 'puppetlabs/accounts', '1.1.0'
mod 'puppetlabs/ntp', '6.2.0'
mod 'puppetlabs/stdlib', '4.19.0'
mod 'saz/sudo', '4.2.0'
mod 'saz/timezone', '3.5.0'
mod 'stm/debconf', '2.0.0'

我们将在接下来的章节中看到这些模块的使用方式。

定期使用 generate-puppetfile 工具自动更新你的模块版本和依赖项(更多内容请参见第七章,精通模块)。在仓库目录中运行以下命令:

generate-puppetfile -p Puppetfile

将输出复制并粘贴回你的 Puppetfile,替换现有的 mod 语句。

正如你可能还记得的,第八章,类、角色和配置文件中,我们使用 Hiera 数据来确定哪些类和资源应该应用到节点。常见的类列在 common.yaml 中,demo 节点有一个专门的数据文件,其中包括 role::demo 类。这些类通过 manifests/site.pp 中的以下行包含:

include(lookup('classes', Array[String], 'unique'))

角色

角色类通过名称标识节点的功能,并定义应包含哪些配置文件类(更多内容请参见第八章,类、角色和配置文件)。

常见做法是将角色类保存在一个 role 模块中,由于这是一个站点特定的模块,它被归档在 site-modules/ 下。

这是 role::demo 角色清单(site-modules/role/manifests/demo.pp):

# Be the demo node
class role::demo {
  include profile::common
}

配置文件

配置文件类通过名称标识角色所需的某个特定软件或功能,并声明管理该功能所需的资源(有关配置文件的更详细说明,请参见第八章,类、角色和配置文件)。

通常,所有节点都会有一些公共的配置文件:例如我们的用户账户,还有一些其他的文件。将这些配置文件保存在common.yaml的 Hiera 数据文件中是合理的,这样所有节点都会包含这些配置文件。

这里是common.yaml中包含的类:

classes:
- profile::ntp
- profile::puppet
- profile::ssh
- profile::sudoers
- profile::timezone
- profile::users

我们将在接下来的章节中查看这些配置文件的作用。

提示

在 Hiera 数据中,类按字母顺序列出:当你包含许多类时,这非常有帮助,能够让你更容易查看某个类是否已经在列表中。当你添加新的类时,请确保保持列表的字母顺序。

用户和访问控制

puppetlabs/accounts模块提供了一种标准方法,通过accounts::user类来处理用户账户。因此,我们将使用这个模块在profile::users类中管理我们的用户。

注意

如果你更倾向于直接在 Puppet 中使用userssh_authorized_key资源来管理用户账户,请参阅第四章,理解 Puppet 资源,了解更多信息。

当然,你也可以直接在 Puppet 清单中列出所需的用户作为字面量资源。但我们将采用第六章,使用 Hiera 管理数据中描述的数据驱动方法,通过 Hiera 数据定义用户。

这是数据结构的样子(data/common.yaml):

users:
  'john':
    comment: 'John Arundel'
    uid: '1010'
    sshkeys:
      - 'ssh-rsa AAAA ...'
  'bridget':
    comment: 'Bridget X. Zample'
    uid: '1011'
    sshkeys:
      - 'ssh-rsa AAAA ...'

这是users配置文件中的代码,用于读取数据并创建相应的accounts::user资源(site-modules/profile/manifests/users.pp):

# Set up users
class profile::users {
  lookup('users', Hash, 'hash').each | String $username, Hash $attrs | {
    accounts::user { $username:
      * => $attrs,
    }
  }
}

正如你所见,我们通过调用lookup()将所有用户数据提取到一个$users哈希表中。然后我们遍历这个哈希表,为每个用户声明一个accounts::user资源,其属性从哈希表数据中加载。

请注意,使用accounts::user资源时,sshkeys属性必须包含一个用户的授权 SSH 公钥数组。

SSH 配置

限制 SSH 登录仅允许特定用户使用是一个良好的安全实践,使用/etc/ssh/sshd_config中的AllowUsers指令。我们在第九章,使用模板管理文件中使用了一个 Puppet 模板来构建此配置文件。在那个例子中,我们从 Hiera 获取了允许的用户列表,并且在这里我们也将使用相同的方法。

这是sshd_config文件的模板(site-modules/profile/templates/ssh/sshd_config.epp):

<%- | Array[String] $allow_users | -%>
# File is managed by Puppet

AcceptEnv LANG LC_*
ChallengeResponseAuthentication no
GSSAPIAuthentication no
PermitRootLogin no
PrintMotd no
Subsystem sftp internal-sftp
AllowUsers <%= join($allow_users, ' ') %>
UseDNS no
UsePAM yes
X11Forwarding yes

我们声明模板接受一个$allow_users参数,该参数是一个字符串数组。由于sshd_config中的AllowUsers参数需要一个用空格分隔的用户列表,我们使用标准库中的join()函数从 Puppet 数组中创建这个列表(参见第七章,掌握模块,了解更多有关此及其他标准库函数的信息)。

这里是相关的 Hiera 数据(data/common.yaml):

allow_users:
  - 'john'
  - 'bridget'
  - 'ubuntu'

提示

我们本可以仅从$users哈希构建这个列表,$users哈希包含了所有已知的用户,但我们不一定希望列表中的每个人都能够登录到每个节点。相反,我们可能需要允许一些由 Puppet 未管理的帐户登录。例如,ubuntu账户是 Vagrant 所需的,以便正确管理虚拟机。如果你不使用 Vagrant 盒子,可以从这个列表中移除ubuntu用户。

以下是读取 Hiera 数据并填充模板的代码(site-modules/profile/manifests/ssh.pp):

# Manage sshd config
class profile::ssh {
  ensure_packages(['openssh-server'])

  file { '/etc/ssh/sshd_config':
    content => epp('profile/ssh/sshd_config.epp', {
      'allow_users' => lookup('allow_users', Array[String], 
        'unique'),
    }),
    notify  => Service['ssh'],
  }

  service { 'ssh':
    ensure => running,
    enable => true,
  }
}

这是一个包-文件-服务模式,你可能还记得来自第二章,创建你的第一个清单

首先,我们安装openssh-server包(通常它已经安装,但声明这个包仍然是好的做法,因为我们依赖它来完成接下来的操作)。

接下来,我们使用模板管理/etc/ssh/sshd_config文件,并通过调用lookup('allow_users', Array[String], 'unique')从 Hiera 数据中填充该文件。每当文件发生更改时,它都会通知ssh服务。

最后,我们声明ssh服务,并指定它应该在启动时运行并启用。

Sudoers 配置

sudo命令是控制用户权限的标准 Unix 机制。它通常用于允许普通用户以root用户的权限运行命令。

提示

使用sudo优于允许用户以root身份登录并运行 shell,而且sudo还会审计并记录哪个用户运行了哪些命令。你还可以指定非常细粒度的权限,例如允许用户以root身份运行某个命令,但不允许运行其他命令。

管理sudo权限的最流行 Forge 模块是saz/sudo,我们将在这里使用它。以下是列出具有sudo访问权限的用户的 Hiera 数据(data/common.yaml):

sudoers:
  - 'john'
  - 'bridget'
  - 'ubuntu'

提示

如果你不使用 Vagrant,可以从这个列表中移除ubuntu用户。

以下是读取数据的profile类(site-modules/profile/manifests/sudoers.pp):

# Manage user privileges
class profile::sudoers {
  sudo::conf { 'secure_path':
    content  => 'Defaults      secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/puppetlabs/puppet/bin:/opt/puppetlabs/bin"',
    priority => 0,
  }
  $sudoers = lookup('sudoers', Array[String], 'unique', [])
  $sudoers.each | String $user | {
    sudo::conf { $user:
      content  => "${user} ALL=(ALL) NOPASSWD: ALL",
      priority => 10,
    }
  }
}

这允许我们以普通用户身份运行像sudo puppet这样的命令。这部分清单就是这么做的:

  sudo::conf { 'secure_path':
    content  => 'Defaults      secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/puppetlabs/puppet/bin:/opt/puppetlabs/bin"',
    priority => 0,
  }

sudo::conf资源由saz/sudo模块提供,它允许我们将任意的sudoers配置作为字符串编写:在这种情况下,设置secure_path变量。

配置文件的其余部分负责为每个在 Hiera 数组sudoers中列出的用户配置无密码sudo权限。像往常一样,我们从 Hiera 获取数组,然后使用each遍历它,为每个命名的用户创建sudo::conf资源。

时区和时钟同步

有一个方便的 Forge 模块可以用来管理服务器时区:saz/timezone。以下是我们的timezone配置文件,它使用该模块将所有节点设置为 UTC(site-modules/profile/manifests/timezone.pp):

# Set the time zone for all nodes
class profile::timezone {
  class { 'timezone':
    timezone => 'Etc/UTC',
  }
}

提示

可能会让人很想将节点的时区设置为你自己的本地时区,而不是 UTC。然而,这种做法无法扩展。当你的节点分布在多个时区,或者分布在世界各地时,它们会处于不同的时区,这将导致你在尝试比较来自不同日志文件的时间戳时产生非常混乱的结果。始终将节点的时区设置为 UTC,这样你就不会感到困惑了(至少,不是关于这个问题)。

同样,我们希望确保所有节点的时钟不仅相互同步,而且与全球时间标准同步。我们将使用puppetlabs/ntp模块来实现这一点,这里是相关的配置文件(site-modules/profile/manifests/ntp.pp):

# Synchronize with NTP
class profile::ntp {
  include ::ntp
}

实际上,NTP 不需要进行特殊配置(尽管如果你愿意,你可以指定一个时间服务器的列表,例如)。

Puppet 配置

我们需要配置一个定期的 cron 任务,从 Git 仓库拉取更新并运行 Puppet 以应用更新后的清单。

profile::puppet类设置了这个配置(site-modules/profile/manifests/puppet.pp):

# Set up Puppet config and cron run
class profile::puppet {
  service { ['puppet', 'mcollective', 'pxp-agent']:
    ensure => stopped, # Puppet runs from cron
    enable => false,
  }

  cron { 'run-puppet':
    ensure  => present,
    command => '/usr/local/bin/run-puppet',
    minute  => '*/10',
    hour    => '*',
  }

  file { '/usr/local/bin/run-puppet':
    source => 'puppet:///modules/profile/puppet/run-puppet.sh',
    mode   => '0755',
  }

  file { '/usr/local/bin/papply':
    source => 'puppet:///modules/profile/puppet/papply.sh',
    mode   => '0755',
  }
}

这个配置文件中有相当多的资源,让我们逐一查看它们。

首先,我们停止并禁用 Puppet 包启动的一些不需要的服务:

  service { ['puppet', 'mcollective', 'pxp-agent']:
    ensure => stopped, # Puppet runs from cron
    enable => false,
  }

接下来是定期执行 Git 更新和 Puppet 运行的 cron 任务。run-puppet脚本如下所示(site-modules/profile/files/run-puppet.sh):

#!/bin/bash
cd /etc/puppetlabs/code/environments/production && git pull
/opt/puppetlabs/puppet/bin/r10k puppetfile install
/opt/puppetlabs/bin/puppet apply --environment production manifests/

这是运行该脚本的cron资源:

  cron { 'run-puppet':
    ensure  => present,
    command => '/usr/local/bin/run-puppet',
    minute  => '*/10',
    hour    => '*',
  }

这个任务被设置为每 10 分钟运行一次,但你可以根据需要进行调整。

这看起来很像你可能记得的run-puppet脚本,来自于第三章,使用 Git 管理 Puppet 代码。唯一的区别是额外的步骤来运行r10k puppetfile install(如果你在 Puppetfile 中添加了新的外部模块),以及向puppet apply添加了--environment开关。

profile::puppet中的下一个资源部署了一个名为papply的便利脚本,它能帮助你避免手动输入整个puppet apply命令(site-modules/profile/files/papply.sh):

#!/bin/bash
environment=${PUPPET_ENV:-production}
/opt/puppetlabs/puppet/bin/r10k puppetfile install
/opt/puppetlabs/bin/puppet apply --environment ${environment} --strict=warning /etc/puppetlabs/code/environments/${environment}/manifests/ $*

只需从命令行运行papply就会立即应用 Puppet,而不需要拉取任何 Git 更改。

如果你想从不同的环境中测试 Puppet 的更改(例如,如果你在/etc/puppetlabs/code/environments/staging中检出了一个 staging 分支),你可以通过以下方式使用PUPPET_ENV变量来控制这一点:

PUPPET_ENV=staging papply

请注意,papply会将其命令行参数传递给 Puppet(使用$*),因此你可以添加任何puppet apply命令支持的参数:

papply --noop --show_diff

小贴士

我们还为 puppet apply 命令提供了 --strict=warning 标志,这将导致 Puppet 在遇到潜在问题代码时发出警告(例如引用了尚未定义的变量)。如果你希望 Puppet 更加严格,可以改为设置 --strict=error,这将阻止在所有问题解决之前应用清单。

引导过程

为了使用示例仓库为 Puppet 管理准备新节点,我们需要做一些事情:

  • 安装 Puppet

  • 克隆 Git 仓库

  • 第一次运行 Puppet

在第三章,使用 Git 管理你的 Puppet 代码,我们手动执行了这些步骤,但示例仓库自动化了这个过程(通常称为引导)。这里是引导脚本(scripts/bootstrap.sh):

#!/bin/bash
PUPPET_REPO=$1
HOSTNAME=$2
BRANCH=$3
if [ "$#" -ne 3 ]; then
  echo "Usage: $0 PUPPET_REPO HOSTNAME BRANCH"
  exit 1
fi
hostname ${HOSTNAME}
echo ${HOSTNAME} >/etc/hostname
source /etc/lsb-release
apt-key adv --fetch-keys http://apt.puppetlabs.com/DEB-GPG-KEY-puppet
wget http://apt.puppetlabs.com/puppetlabs-release-${DISTRIB_CODENAME}.deb
dpkg -i puppetlabs-release-${DISTRIB_CODENAME}.deb
apt-get update
apt-get -y install git puppet-agent
cd /etc/puppetlabs/code/environments
mv production production.orig
git clone ${PUPPET_REPO} production
cd production
git checkout ${BRANCH}
/opt/puppetlabs/puppet/bin/gem install r10k --no-rdoc --no-ri
/opt/puppetlabs/puppet/bin/r10k puppetfile install --verbose
/opt/puppetlabs/bin/puppet apply --environment=production /etc/puppetlabs/code/environments/production/manifests/

它期望通过三个参数运行(稍后我们将看到如何做到这一点):PUPPET_REPO,要克隆的 Puppet 仓库的 Git URL,HOSTNAME,目标节点的主机名,和 BRANCH,使用的 Puppet 仓库的分支。

首先,脚本设置指定的主机名:

hostname ${HOSTNAME}
echo ${HOSTNAME} >/etc/hostname

接下来,它查看 /etc/lsb-release 文件以找出已安装的 Ubuntu 版本。

提示

这个脚本是专门针对 Ubuntu 的,但如果需要,你可以轻松修改它以适配其他 Linux 发行版。

使用 wget 下载适当的 Puppet Labs APT 仓库软件包并安装。然后安装 puppet-agent 包以及 git

source /etc/lsb-release
apt-key adv --fetch-keys http://apt.puppetlabs.com/DEB-GPG-KEY-puppet
wget http://apt.puppetlabs.com/puppetlabs-release-${DISTRIB_CODENAME}.deb
dpkg -i puppetlabs-release-${DISTRIB_CODENAME}.deb
apt-get update && apt-get -y install git puppet-agent

引导过程中的下一步是将 Git 仓库克隆到 Puppet 期望找到其清单的位置:

cd /etc/puppetlabs/code/environments
mv production production.orig
git clone ${PUPPET_REPO} production
cd production
git checkout ${BRANCH}

接下来,我们安装 r10k(在 Puppet 的 gem 环境中,使用 Puppet 特定的 gem 命令)并运行 r10k puppetfile install,以安装 Puppetfile 中列出的所有必要模块:

/opt/puppetlabs/puppet/bin/gem install r10k --no-rdoc --no-ri
/opt/puppetlabs/puppet/bin/r10k puppetfile install --verbose

现在我们可以第一次运行 Puppet,它将配置我们所需的其他所有内容:

/opt/puppetlabs/bin/puppet apply --environment=production /etc/puppetlabs/code/environments/production/manifests/

当然,为了在目标节点上运行这个脚本,我们必须先将它复制到目标节点。这一步通过 puppify 脚本(scripts/puppify)完成:

#!/bin/bash
PUPPET_REPO=https://github.com/bitfield/control-repo-3.git
IDENTITY="-i /Users/john/.ssh/pbg.pem"
if [ "$#" -lt 2 ]; then
  cat <<USAGE
Usage: $0 TARGET HOSTNAME [BRANCH]
Install Puppet on the node TARGET (IP address or DNS name) and run
the bootstrap process. Set the hostname to HOSTNAME, and optionally use
the control repo branch BRANCH.
USAGE
  exit 1
fi
TARGET=$1
HOSTNAME=${2}
BRANCH=${3:-production}
OPTIONS="-oStrictHostKeyChecking=no"
echo -n "Copying bootstrap script... "
scp ${IDENTITY} ${OPTIONS} $(dirname $0)/bootstrap.sh ubuntu@${TARGET}:/tmp
echo "done."
echo -n "Bootstrapping... "
ssh ${IDENTITY} ${OPTIONS} ubuntu@${TARGET} "sudo bash /tmp/bootstrap.sh ${PUPPET_REPO} ${HOSTNAME} ${BRANCH}"
echo "done."

首先,脚本设置要克隆的 Git 仓库的 URL(在调整示例仓库以供自己使用时,你需要更改为自己的 URL):

PUPPET_REPO=https://github.com/bitfield/control-repo-3.git

接下来,我们指定用于通过 SSH 连接目标节点的密钥文件(再次,修改为你自己的密钥):

IDENTITY="-i /Users/john/.ssh/pbg.pem"

在显示使用信息并处理命令行参数之后,脚本继续将 bootstrap.sh 文件复制到目标节点:

scp ${IDENTITY} ${OPTIONS} $(dirname $0)/bootstrap.sh ubuntu@${TARGET}:/tmp

最后的步骤是,在节点上运行引导脚本,并传递所需的命令行参数:

ssh ${IDENTITY} ${OPTIONS} ubuntu@${TARGET} "sudo bash /tmp/bootstrap.sh ${PUPPET_REPO} ${HOSTNAME} ${BRANCH}"

将仓库调整为适合自己的使用

你需要更改示例仓库中的一些数据和设置,以便能自己使用。为了帮助你入门,这里有一个表格,列出了需要更改的文件以及需要提供的信息,详细解释将在后续章节中提供:

文件 需要更改的内容
data/common.yaml users: 所有节点通用的用户和 SSH 密钥allow_users: 允许登录所有节点的用户sudoers: 允许在所有节点上使用sudo的用户classes: 所有节点包含的类
data/nodes/[NODE NAME].yaml users: 仅在此节点上存在的用户和 SSH 密钥allow_users: 仅允许登录此节点的用户sudoers: 仅允许在此节点上使用sudo的用户classes: 仅此节点包含的类
site-modules/role/manifests/ 节点的角色类(在每个类中包括profile::common
scripts/puppify PUPPET_REPO: 你的 Puppet 仓库的 Git URLIDENTITY: 初始引导节点时使用的 SSH 密钥路径(如果需要的话)

配置用户

正如我们在本章前面所看到的,Puppet 管理的用户账户是通过 Hiera 数据进行配置的。编辑data/common.yaml文件,文件内容如下所示:

users:
  'john':
    comment: 'John Arundel'
    uid: '1010'
    sshkeys:
      - 'ssh-rsa AAAA... john@susie'
...

将现有的用户替换为你希望在节点上创建的用户账户(最开始可能只有一个账户,用于你自己)。将你希望与这些账户一起使用的任何 SSH 密钥添加到sshkeys数组中。

每个节点上允许的用户列表由allow_users数组控制。将列出的用户替换为你自己的用户。

拥有sudo权限的用户列表由sudoers数组控制。将那里列出的用户替换为你希望拥有 root 权限的用户。

添加每个节点的数据文件和角色类

每个节点的 Hiera 数据,包括类,保存在data/nodes/目录下。当你添加一个新节点时,为其添加一个名为data/nodes/NODE_NAME.yaml的数据文件,将NODE_NAME替换为该节点的主机名。

包括适合该节点的角色类(详见第八章,类、角色和配置文件,了解更多信息)。如果你在每个节点的文件中没有指定任何类,节点将仅包括common.yaml中列出的类。这将足以通过你的 SSH 账户和密钥设置节点,并验证引导过程是否正常工作。之后,你可以开始将角色类添加到每个节点的文件中,以完成实际的工作。

将你的角色类添加到site-modules/role/manifests/目录中,类似role::demo

如果某些用户只需要在特定节点上存在,而不希望它们出现在所有节点上,请将它们列在每个节点的数据文件中的users下。如果这些用户需要通过 SSH 登录,也请将它们添加到allow_users中。同样,如果你只希望某个用户在此节点上拥有sudo权限,请将他们列在每个节点数据文件中的sudoers下。

修改引导凭据

scripts/puppify文件中,编辑PUPPET_REPO设置为你自己 Git 仓库的 URL。如果你需要 SSH 密钥来连接目标节点(例如,如果你使用的是 Amazon EC2,此时你会有一个.pem文件,其中包含你从 AWS 控制台下载的密钥),请将其位置添加到IDENTITY变量中。

启动新节点

如果你希望在 Vagrant 盒子中尝试演示仓库,可以在仓库目录中找到适用的 Vagrantfile。

注意

如果你没有安装 Vagrant,请先按照第一章中安装 VirtualBox 和 Vagrant部分的说明进行操作,完成 Puppet 的初步设置。

启动 Vagrant 虚拟机

在仓库目录中运行以下命令以启动你的 Vagrant 虚拟机:

scripts/start_vagrant.sh

启动物理或云节点

或者,你也可以使用仓库来引导物理或云节点。你只需要目标节点的 IP 地址或 DNS 名称。

在 Puppet 仓库中运行以下命令,将TARGET_SERVER替换为目标节点的地址或名称,将HOSTNAME替换为你希望设置的主机名(例如demo):

scripts/puppify TARGET_SERVER HOSTNAME

你将看到一些与复制引导脚本、安装 Puppet 包、克隆仓库、安装 Forge 模块以及首次运行 Puppet 相关的输出。完成后,节点应该已经准备好,你可以尝试使用自己的 SSH 账户登录。

使用其他发行版和提供者

演示仓库中包含的puppifybootstrap脚本适用于 Amazon EC2 上的 Ubuntu 节点,但你可以修改它们以适用于任何 Linux 发行版或服务器提供者。

例如,如果你使用的是Google 计算引擎GCE)实例,可以编辑puppify脚本,将ssh命令替换为gcloud compute ssh。如果你使用的是 Digital Ocean 的 Droplet,可以在通过 Web 界面配置时将你的 SSH 密钥添加到 Droplet,并修改puppify脚本以使用root用户而不是ubuntu用户登录。

如果你在多个不同平台上管理节点,可能会发现为每个平台使用定制的puppify脚本更为方便,命名方式可以是(例如)puppify_ec2puppify_linode等。

如果你不是使用 Ubuntu 或 Debian,可能需要对bootstrap.sh脚本做一些修改。例如,如果你使用的是 Red Hat Linux 或 CentOS,你需要让脚本通过yum而不是apt安装 Puppet。同样,如果你管理多个操作系统节点,可能需要为每个操作系统维护一个自定义的引导脚本。

总结

在本章中,我们介绍了示例控制仓库,并演示了如何下载它。我们解释了控制仓库模式,以及它如何与r10k和 Puppetfile 一起使用来管理第三方和本地模块。我们还学习了如何分叉仓库并从上游拉取更新。

我们查看了示例角色和配置文件类,了解了 Puppet 如何使用 Hiera 数据来配置用户帐户、SSH 密钥、允许的用户和sudoers权限。我们还介绍了使用 Forge 模块来管理时区设置和 NTP 同步。此外,我们探索了控制自动 Puppet 更新和运行所需的资源和脚本。

演示仓库包含了启动脚本,帮助你将新配置的节点纳入 Puppet 控制范围,我们已经详细研究了这些脚本是如何工作的。

最后,我们已经学会了如何调整演示仓库以适应你自己的网站,概述了如何添加自己的用户和访问设置,如何添加自己的常用配置文件和每个节点的角色类。我们还看到如何将自己的信息插入到启动脚本中,以及如何使用它们启动一个新节点。

开始

希望你喜欢这本书,并从中学到了一些有用的东西;我在写这本书时也学到了很多。然而,从书本中你能学到的东西是有限的。正如普鲁斯特所写,“我们并非接受智慧,而是必须在一场无人能代替的旅程中自己发现它。”

有一个朋友指引我们走上正确的道路并在旁边给予一些精神支持是好的,但最终我们还需要自己独立前行。我希望这本书能成为你旅程的开始,而不是结束。

世界著名的古典吉他演奏家约翰·威廉姆斯曾被问到学会弹吉他花了多长时间。他回答:“我还在学习。”

posted @ 2025-07-08 12:23  绝不原创的飞龙  阅读(39)  评论(0)    收藏  举报