Ansible2-学习指南第二版-全-

Ansible2 学习指南第二版(全)

原文:annas-archive.org/md5/864c03427f572505b04fa06729d4a7e7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

信息技术行业是一个快速发展的领域,总是试图加速。为了跟上这一变化,企业需要能够快速行动并频繁迭代。直到几年前,这主要适用于软件,但现在我们开始看到需要以类似的速度改变基础设施。未来,我们将需要以软件本身的速度来改变我们运行软件的基础设施。

在这种情况下,许多技术,如软件定义的一切(存储、网络、计算等),将是关键,但这些技术也需要以同样可扩展的方式进行管理,而这种方式就是使用 Ansible 和类似的产品。

Ansible 在今天非常相关,因为与竞争产品不同,它是无代理的,这使得部署更快、安全性更高,并且审计能力更强。

本书涵盖内容

第一章,Ansible 入门,解释了如何安装 Ansible。

第二章,自动化简单任务,解释了如何创建简单的 playbook,帮助你自动化日常已经执行的一些简单任务。

第三章,扩展到多主机,解释了如何以易于扩展的方式在 Ansible 中处理多个主机。

第四章,处理复杂部署,解释了如何创建具有多个阶段和多台机器的部署。

第五章,上云,解释了 Ansible 如何与各种云服务集成,以及它如何简化你的工作,自动化管理云服务。

第六章,从 Ansible 获取通知,解释了如何设置 Ansible 返回有价值的信息给你和其他相关方。

第七章,创建自定义模块,解释了如何创建自定义模块,以利用 Ansible 提供的灵活性。

第八章,调试和错误处理,解释了如何调试和测试 Ansible,以确保你的 playbook 始终能够正常运行。

第九章,复杂环境,解释了如何使用 Ansible 管理多层级、多环境和多部署。

第十章,企业级 Ansible 入门,解释了如何通过 Ansible 管理 Windows 节点,并如何利用 Ansible Galaxy 和 Ansible Tower 最大化你的生产力。

本书所需工具

本书适用于所有 Linux 发行版。由于为所有可能的发行版提供相同的信息并不实际,示例命令默认适用于控制机上的 Fedora 和受控机器上的 CentOS(如果没有特别说明)。有经验的用户可以轻松将命令转换为自己喜欢的发行版。

本书适用对象

本书面向希望使用 Ansible 2 自动化组织基础设施的开发人员和系统管理员。无需提前了解 Ansible。

约定

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

文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名会以如下方式显示:“ec2.py文件将根据区域、可用区、标签等创建多个组。”

代码块的格式如下:

---
- hosts: all
  remote_user: ansible
  vars:
    users:
    - alice
    - bob
    folders:
    - mail
    - public_html

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

---
- hosts: all
  remote_user: ansible
  vars:
    users:
    - alice
    - bob
    folders:
    - mail
    - public_html

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

$ ansible-playbook -i test01.fale.io, webserver.yaml

新术语重要词汇以粗体显示。您在屏幕上看到的词汇,例如菜单或对话框中的内容,会像这样出现在文本中:“点击‘下一步’按钮将带您进入下一屏。”

注意

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

提示

小贴士和技巧会以这种形式出现。

读者反馈

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

要向我们发送一般反馈,只需发送电子邮件到 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.com/PacktPublishing/Learning-Ansible-2-Second-Edition。我们还有其他来自丰富书籍和视频目录的代码包,地址为github.com/PacktPublishing/。快去看看吧!

勘误

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

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

盗版

盗版问题一直是网络上所有媒体面临的持续性问题。在 Packt,我们非常重视版权和许可证的保护。如果你在互联网上发现任何形式的非法盗版作品,请立即提供其网址或网站名称,以便我们采取相应措施。

如果你发现涉嫌盗版的内容,请通过 copyright@packtpub.com 联系我们,并提供相关链接。

我们感谢你帮助保护我们的作者和我们向你提供有价值内容的能力。

问题

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

第一章:开始使用 Ansible

信息与通信技术(ICT)常被描述为一个快速增长的行业。我认为 ICT 行业的最佳品质与其能以超高速度增长无关,而在于其能以惊人的速度革新自己和整个世界。

每隔 10 到 15 年,这个行业就会发生重大变化,每一次变化都解决了之前很难管理的问题,同时创造了新的挑战。此外,在每次重大变化中,许多前一轮的最佳实践被视为反模式,新的最佳实践则应运而生。尽管这些变化看似难以预测,但事实并非总是如此。显然,我们无法精确知道会发生什么变化,也无法预知这些变化何时发生,但观察拥有大量服务器和代码行数的公司,通常能揭示下一步会是什么。

当前的转变已经在像亚马逊 Web 服务、Facebook 和 Google 这样的公司中发生。这就是实施 IT 自动化系统来创建和管理服务器的过程。

在本章中,我们将涵盖:

  • IT 自动化

  • 什么是 Ansible?

  • 安全外壳

  • 安装 Ansible

  • 使用 QEMU 和 KVM 创建测试环境

  • 版本控制系统

  • 使用 Ansible 与 Git

IT 自动化

IT 自动化广义上是指帮助管理 IT 基础设施(服务器、网络和存储)的过程和软件。在当前的转变中,我们正在见证这种过程和软件的巨大实施。

IT 自动化的历史

在 IT 历史的早期,服务器数量很少,通常需要大量的人力来使它们正常工作,通常每台机器需要一个以上的人来操作。随着时间的推移,服务器变得更加可靠且易于管理,因此单个系统管理员可以管理多个服务器。在那一时期,管理员手动安装软件,手动升级软件,并手动更改配置文件。显然,这是一项劳动密集型且容易出错的过程,因此许多管理员开始实施脚本和其他手段来简化工作。这些脚本(通常)相当复杂,且不太具备可扩展性。

本世纪初,随着企业需求的增长,数据中心开始迅速扩展。虚拟化有助于保持低成本,而许多服务是 Web 服务,这意味着许多服务器彼此非常相似。此时,需要新的工具来替代之前使用的脚本,即配置管理工具。

CFEngine 是上世纪 90 年代最早展示配置管理能力的工具之一;最近出现了 Puppet、Chef、Salt,以及 Ansible。

IT 自动化的优势

人们常常会疑惑,考虑到实施 IT 自动化会有一些直接和间接的成本,是否真的带来了足够的好处。IT 自动化的主要优点是:

  • 能够快速配置机器

  • 能够在几分钟内从头创建一台机器

  • 能够追踪基础设施上执行的任何更改

因此,通过减少系统管理员经常执行的重复性操作,可以降低管理 IT 基础设施的成本。

IT 自动化的缺点

和其他技术一样,IT 自动化也有一些缺点。依我看,这些是最大的缺点:

  • 自动化所有曾经用于培训新系统管理员的小任务

  • 如果发生错误,它将被传播到各个地方

第一个后果是,需要实施新的方式来培训初级系统管理员。

限制错误传播可能带来的损害

第二个问题更棘手。有很多方法可以限制这种损害,但没有任何方法可以完全防止它。以下是可用的缓解选项:

  • 始终有备份:备份不能防止你摧毁机器,它们只是让恢复过程成为可能。

  • 始终在非生产环境中测试你的基础设施代码(剧本/角色):公司已经开发了不同的流水线来部署代码,这些流水线通常包括开发、测试、预发布和生产等环境。使用相同的流水线来测试你的基础设施代码。如果一个有问题的应用程序进入生产环境,可能会出现问题。如果一个有问题的剧本进入生产环境,可能会造成灾难。

  • 始终进行基础设施代码的同行评审:一些公司已经为应用程序代码引入了同行评审,但很少有公司为基础设施代码引入同行评审。正如我在前面提到的,我认为基础设施代码比应用程序代码更为关键,所以无论是否对应用程序代码进行同行评审,你都应该始终进行基础设施代码的同行评审。

  • 启用 SELinux:SELinux 是一个安全内核模块,适用于所有 Linux 发行版(在 Fedora、Red Hat Enterprise Linux、CentOS、Scientific Linux 和 Unbreakable Linux 中默认安装)。它允许你以非常细粒度的方式限制用户和进程的权限。我建议使用 SELinux,而不是其他类似模块(如 AppArmor),因为它能处理更多的情况和权限。SELinux 将防止大量的损害,因为如果正确配置,它将防止许多危险命令的执行。

  • 从有限账户运行 playbook:尽管用户和权限提升方案在 UNIX 代码中已经存在了 40 多年,但似乎并不是很多公司在使用它们。为所有 playbook 使用一个有限用户,并且只为需要更高权限的命令提升权限,将有助于在尝试清理应用程序临时文件夹时防止误操作导致机器毁灭。

  • 使用水平权限提升sudo是一个众所周知的命令,但通常以更危险的形式使用。sudo命令支持-u参数,允许你指定要模拟的用户。如果你必须更改由另一个用户拥有的文件,请不要升级为root,而是升级为那个用户。在 Ansible 中,你可以使用become_user参数来实现这一点。

  • 可能时,不要同时在所有机器上运行 playbook:分阶段部署可以帮助你在为时已晚之前检测到问题。有许多问题在开发、测试、暂存和质量保证环境中无法检测到。其中大多数与很难在这些非生产环境中正确模拟的负载相关。你刚刚添加到 Apache HTTPd 或 MySQL 服务器的新配置在语法上可能完全正确,但在生产负载下可能对你的特定应用程序造成灾难性影响。分阶段部署将允许你在实际负载下测试新配置,而不会在出现问题时面临停机风险。

  • 避免猜测命令和修改器:许多系统管理员会试图记住正确的参数,如果记不清楚就会猜测。我也经常这样做,但这是非常危险的。查看手册页或在线文档通常不会花费超过两分钟的时间,并且经常通过阅读手册,你会发现一些有趣的注记你之前并不知道。猜测修改器是危险的,因为你可能会被非标准的修改器愚弄(比如,-v不是grep的详细模式,-h不是 MySQL CLI 的帮助命令)。

  • 避免容易出错的命令:并非所有命令都是平等的。有些命令比其他命令(要)危险得多。如果你认为cat命令是安全的,那么你必须认为dd命令是危险的,因为dd用于复制和转换文件和卷。我见过很多人在脚本中使用dd来将 DOS 文件转换为 UNIX(而不是使用dos2unix),还有其他很多非常危险的例子。请避免使用这些命令,因为如果出现问题,可能会造成巨大的灾难。

  • 避免不必要的修饰符:如果你需要删除一个简单的文件,请使用rm ${file}而不是rm -rf ${file}。后者通常是那些学会了"为了保险起见,总是使用rm -rf"的用户执行的,因为在他们的过去,可能需要删除一个文件夹。这将防止你在${file}变量设置错误时误删整个文件夹。

  • 始终检查如果变量未设置可能发生的情况:如果你想删除文件夹中的内容,并使用rm -rf ${folder}/*命令,可能会遇到麻烦。如果由于某种原因,${folder}变量没有设置,shell 会读取rm -rf /*命令,这将非常危险(考虑到rm -rf /命令在大多数当前操作系统上不起作用,因为它需要--no-preserve-root选项,而rm -rf /*命令则会按预期工作)。我使用这个特定的命令作为例子,因为我曾经见过类似的情况:变量是从数据库中提取的,由于某些维护工作,数据库出现故障,导致该变量被赋值为空字符串。接下来发生的事情可能很容易猜到。如果你无法避免在危险的地方使用变量,至少在使用之前检查它们是否为空。这不能解决所有问题,但可以捕获一些最常见的问题。

  • 仔细检查你的重定向:重定向(以及管道)是 Linux Shell 中最强大的元素。它们也可能非常危险:一个cat /dev/rand > /dev/sda命令可以摧毁一个磁盘,即使cat命令通常是被忽略的,因为它通常不危险。始终仔细检查所有包含重定向的命令。

  • 尽可能使用特定模块:在这个列表中,我使用了 Shell 命令,因为许多人会尝试将 Ansible 当作仅仅是分发 Shell 命令的工具来使用:它不是。Ansible 提供了很多模块,我们将在本书中看到它们。它们将帮助你创建更加可读、可移植和安全的 Playbooks。

IT 自动化类型

有很多方法来分类 IT 自动化系统,但最重要的还是与配置如何传播有关。基于此,我们可以区分基于代理的系统和无代理的系统。

基于代理的系统

基于代理的系统有两个不同的组件:一个服务器和一个叫做代理的客户端。

只有一个服务器,它包含了整个环境的所有配置,而代理的数量与环境中的机器数量相同。

注意

在某些情况下,为了确保高可用性,可能会有多个服务器,但请将其视为单一服务器,因为它们将以相同的方式进行配置。

客户端会定期联系服务器,以查看是否有新的配置文件。如果有新的配置,客户端将下载并应用它。

无代理的系统

在无代理系统中,不存在特定的代理。无代理系统并不总是遵循服务器/客户端范式,因为可以有多个服务器,甚至服务器和客户端的数量相同。通信由服务器初始化,服务器将使用标准协议(通常是 SSH 或 PowerShell)联系客户端。

基于代理与无代理系统

除了上述差异外,还有其他一些因为这些差异而产生的对比因素。

从安全角度来看,基于代理的系统可能不如无代理的系统安全。由于所有机器都必须能够发起与服务器机器的连接,因此该机器可能比无代理系统更容易受到攻击,因为无代理系统中的机器通常位于防火墙后面,防火墙不会接受任何传入连接。

从性能角度来看,基于代理的系统存在服务器饱和的风险,因此部署可能会变慢。此外,还需要考虑到,在纯粹的基于代理的系统中,无法立即强制推送更新到一组机器。它必须等到那些机器检查到更新后才能应用。因此,多个基于代理的系统已经实现了带外等待以实现这一功能。像 Chef 和 Puppet 这样的工具是基于代理的,但也可以在没有中央服务器的情况下运行,从而扩展大量的机器,通常被称为 无服务器 Chef无主 Puppet

无代理系统更容易集成到现有基础架构中,因为它将被客户端视为普通的 SSH 连接,因此不需要额外的配置。

什么是 Ansible?

Ansible 是一款无需代理的 IT 自动化工具,于 2012 年由 Michael DeHaan(前 Red Hat 员工)开发。Ansible 的设计目标是:简洁、一致、安全、高度可靠且易于学习。Ansible 公司最近被 Red Hat 收购,现在作为 Red Hat, Inc. 的一部分运营。

Ansible 主要通过 SSH 以推送模式运行,但你也可以使用 ansible-pull 来运行 Ansible,方法是在每个代理上安装 Ansible,下载本地的 playbook,并在单独的机器上运行它们。如果有大量的机器(“大量”是一个相对概念;在我们看来,大于 500 并且需要并行更新的机器),并且你计划对这些机器进行并行更新,那么这种方式可能是最合适的选择。

安全外壳(SSH)

安全外壳(也称为 SSH)是一种网络服务,允许你通过完全加密的连接远程登录并访问一个 shell。SSH 守护进程如今已经成为 UNIX 系统管理的标准,取代了未加密的 telnet。SSH 协议最常用的实现是 OpenSSH。

在过去几个月中,微软展示了 OpenSSH 在 Windows 上的实现(截至本文写作时)。

由于 Ansible 执行 SSH 连接和命令的方式与其他任何 SSH 客户端相同,因此对 OpenSSH 服务器没有做特殊配置。

为了加速默认的 SSH 连接,你可以始终启用 ControlPersist 和管道模式,这样可以使 Ansible 更快且更安全。

为什么选择 Ansible?

在本书中,我们将尝试将 Ansible 与 Puppet 和 Chef 进行比较,因为许多人对这些工具有很好的使用经验。我们还将特别指出 Ansible 相对于 Chef 或 Puppet 解决问题的方法。

Ansible、Puppet 和 Chef 都是声明性的,旨在将机器移到配置中指定的期望状态。例如,在这些工具中,为了在某个时间点启动服务并在重启时自动启动,你需要编写一个声明性块或模块;每次工具在机器上运行时,它都会努力实现你在 playbook(Ansible)、cookbook(Chef)或 manifest(Puppet)中定义的状态。

在简单层面上,工具集之间的差异是最小的,但随着情况的增加和复杂性的提高,你将开始发现不同工具集之间的差异。在 Puppet 中,你需要注意顺序,每次在不同的主机上运行时,Puppet 服务器都会创建执行指令的顺序。为了充分利用 Chef 的功能,你需要一个优秀的 Ruby 团队。你的团队需要精通 Ruby 语言,才能定制 Puppet 和 Chef,这两款工具的学习曲线都较大。

使用 Ansible 的情况有所不同。它在执行顺序上借鉴了 Chef 的简洁性,采用自上而下的方式,并允许你以 YAML 格式定义最终状态,这使得代码非常易读,开发团队到运维团队的每个人都能轻松理解并做出修改。在许多情况下,甚至没有 Ansible 时,运维团队也会得到 playbook 手册,以便在遇到问题时执行指令。Ansible 模拟了这种行为。如果你发现你的项目经理因为其简洁性而修改 Ansible 代码并将其提交到 Git,不要感到惊讶!

安装 Ansible

安装 Ansible 既快速又简单。你可以直接使用源代码,通过从 GitHub 项目中克隆它(github.com/ansible/ansible),使用系统的包管理器进行安装,或者使用 Python 的包管理工具(pip)。你可以在任何 Windows、Mac 或类 UNIX 系统上使用 Ansible。Ansible 不需要任何数据库,也不需要运行任何守护进程。这使得维护 Ansible 版本和升级变得更加轻松,不会出现中断。

我们将安装 Ansible 的机器称为我们的 Ansible 工作站。有些人也称其为指挥中心。

使用系统的包管理器安装 Ansible

通过系统的包管理器安装 Ansible 是可行的,我个人认为,如果你的系统的包管理器提供至少 Ansible 2.0 的版本,这是首选方案。我们将探讨如何通过 YumAptHomebrewpip 安装 Ansible。

通过 Yum 安装

如果你正在运行 Fedora 系统,你可以直接安装 Ansible,因为从 Fedora 22 开始,Ansible 2.0+ 已经可以在官方仓库中找到。你可以按如下方式安装:

$ sudo dnf install ansible

对于 RHEL 和基于 RHEL 的系统(CentOS、Scientific Linux、Unbreakable Linux),版本 6 和 7 在 EPEL 仓库中提供 Ansible 2.0+,因此你应确保在安装 Ansible 之前已启用 EPEL 仓库,如下所示:

$ sudo yum install ansible

注意

在 Cent 6 或 RHEL 6 上,你必须运行命令rpm -Uvh。有关如何安装 EPEL 的说明,请参阅dl.fedoraproject.org/pub/epel/6/x86_64/epel-release-6-8.noarch.rpm

通过 Apt 安装

Ansible 可用于 Ubuntu 和 Debian。要在这些操作系统上安装 Ansible,请使用以下命令:

$ sudo apt-get install ansible

通过 Homebrew 安装

你可以通过 Homebrew 在 Mac OS X 上安装 Ansible,如下所示:

$ brew update
$ brew install ansible

通过 pip 安装

你可以通过 pip 安装 Ansible。如果你的系统没有安装 pip,请先安装它。你也可以使用 pip 在 Windows 上安装 Ansible,使用以下命令行:

$ sudo easy_install pip

你现在可以使用pip安装 Ansible,如下所示:

$ sudo pip install ansible

安装完成后,运行ansible --version来验证是否已安装:

$ ansible --version

你将从前面的命令行获得以下输出:

ansible 2.0.2

从源代码安装 Ansible

如果之前的方法不适合你的使用场景,你可以直接从源代码安装 Ansible。源代码安装不需要任何管理员权限。让我们克隆一个仓库并激活virtualenv,这是 Python 中的一个隔离环境,你可以在其中安装包而不会干扰系统的 Python 包。以下是克隆仓库的命令及其输出:

$ git clone git://github.com/ansible/ansible.git
Cloning into 'ansible'...
remote: Counting objects: 116403, done.
remote: Compressing objects: 100% (18/18), done.
remote: Total 116403 (delta 3), reused 0 (delta 0), pack-reused 116384
Receiving objects: 100% (116403/116403), 40.80 MiB | 844.00 KiB/s, done.
Resolving deltas: 100% (69450/69450), done.
Checking connectivity... done.
$ cd ansible/
$ source ./hacking/env-setup
Setting up Ansible to run out of checkout...
PATH=/home/vagrant/ansible/bin:/usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/sbin:/home/vagrant/bin
PYTHONPATH=/home/vagrant/ansible/lib:
MANPATH=/home/vagrant/ansible/docs/man:
Remember, you may wish to specify your host file with -i
Done!

Ansible 需要一些 Python 包,你可以使用pip安装。如果你的系统没有安装 pip,可以使用以下命令安装它。如果没有安装easy_install,你可以通过 Red Hat 系统上的 Python setuptools 包或通过 Mac 上的 Brew 安装它:

$ sudo easy_install pip
<A long output follows>

一旦你安装了 pip,使用以下命令安装 paramikoPyYAMLjinja2httplib2 包:

$ sudo pip install paramiko PyYAML jinja2 httplib2
Requirement already satisfied (use --upgrade to upgrade): paramiko in /usr/lib/python2.6/site-packages
Requirement already satisfied (use --upgrade to upgrade): PyYAML in /usr/lib64/python2.6/site-packages
Requirement already satisfied (use --upgrade to upgrade): jinja2 in /usr/lib/python2.6/site-packages
Requirement already satisfied (use --upgrade to upgrade): httplib2 in /usr/lib/python2.6/site-packages
Downloading/unpacking markupsafe (from jinja2)
 Downloading MarkupSafe-0.23.tar.gz
 Running setup.py (path:/tmp/pip_build_root/markupsafe/setup.py) egg_info for package markupsafe
Installing collected packages: markupsafe
 Running setup.py install for markupsafe
 building 'markupsafe._speedups' extension
 gcc -pthread -fno-strict-aliasing -O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector --param=ssp-buffer-size=4 -m64 -mtune=generic -D_GNU_SOURCE -fPIC -fwrapv -DNDEBUG -O2 -g -pipe -Wall -Wp,-D_FORTIFY_SOURCE=2 -fexceptions -fstack-protector --param=ssp-buffer-size=4 -m64 -mtune=generic -D_GNU_SOURCE -fPIC -fwrapv -fPIC -I/usr/include/python2.6 -c markupsafe/_speedups.c -o build/temp.linux-x86_64-2.6/markupsafe/_speedups.o
 gcc -pthread -shared build/temp.linux-x86_64-2.6/markupsafe/_speedups.o -L/usr/lib64 -lpython2.6 -o build/lib.linux-x86_64-2.6/markupsafe/_speedups.so
Successfully installed markupsafe
Cleaning up...

注意

默认情况下,Ansible 将运行在开发分支上。你可能想查看最新的稳定分支。使用以下命令行检查最新稳定版本:

$ git branch -a

复制你想使用的最新版本。2.0.2 版本是撰写本文时可用的最新版本。使用以下命令检查最新版本:

[node ansible]$ git checkout v2.0.2
Note: checking out 'v2.0.2'.
[node ansible]$ ansible --version
ansible 2.0.2 (v2.0.2 268e72318f) last updated 2014/09/28 21:27:25 (GMT +000)

现在,你有一个工作中的 Ansible 设置了。运行 Ansible 从源代码的好处之一是,你可以立即享受新特性,而无需等待包管理器为你提供它们。

使用 QEMU 和 KVM 创建测试环境

为了能够学习 Ansible,我们需要制作相当多的 playbook 并运行它们。

提示

直接在你的电脑上操作会非常危险。因此,我建议使用虚拟机。

虽然可以在几秒钟内使用云服务提供商创建测试环境,但通常更有用的是在本地拥有这些机器。为此,我们将使用基于内核的虚拟机KVM)和快速模拟器QEMU)。

第一件事是安装qemu-kvmvirt-install。在 Fedora 上,只需要运行:

$ sudo dnf install -y @virtualization

在 Red Hat/CentOS/Scientific Linux/Unbreakable Linux 上,只需运行:

$ sudo yum install -y qemu-kvm virt-install virt-manager

如果你使用 Ubuntu,你可以通过以下方式安装:

$ sudo apt install virt-manager

在 Debian 上,你需要执行:

$ sudo apt install qemu-kvm libvirt-bin

对于我们的示例,我将使用 CentOS 7。这有多个原因,主要有以下几点:

  • CentOS 是免费的,并且与 Red Hat、Scientific Linux 和 Unbreakable Linux 完全兼容。

  • 许多公司使用 Red Hat/CentOS/Scientific Linux/Unbreakable Linux 来运行他们的服务器。

  • 这些发行版是唯一内建 SELinux 支持的,正如我们之前看到的,SELinux 可以帮助你使环境更加安全。

在撰写本书时,最新的 CentOS 云镜像是 cloud.centos.org/centos/7/images/CentOS-7-x86_64-GenericCloud-1603.qcow2,所以让我们使用以下命令下载这个镜像:

$ wget http://cloud.centos.org/centos/7/images/CentOS-7-x86_64-GenericCloud-1603.qcow2

由于我们可能需要创建许多机器,因此最好创建它的副本,以免修改原始版本:

$ cp CentOS-7-x86_64-GenericCloud-1603.qcow2 centos_1.qcow2

由于qcow2镜像将运行 cloud-init 来设置网络、用户等,我们需要提供几个文件。让我们从创建一个网络配置的元数据文件开始:

instance-id: centos_1 
local-hostname: centos_1.local 
network-interfaces: | 
  iface eth0 inet static 
  address (An IP in your virtual bridge class) 
  network (The first IP of the virtual bridge class) 
  netmask (Your virtual bridge class netmask) 
  broadcast (Your virtual bridge class broadcast) 
  gateway (Your virtual bridge class gateway) 

要查找你的虚拟桥接数据,你需要寻找一个名称为virbrX或类似名称的设备,在我的情况下是virtbr0,所以我可以使用以下命令查找它的所有信息:

$ ip addr show virbr0

上一个命令的输出如下:

5: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
 link/ether 52:54:00:38:1a:e6 brd ff:ff:ff:ff:ff:ff
 inet 192.168.124.1/24 brd 192.168.124.255 scope global virbr0
 valid_lft forever preferred_lft forever

所以,对我来说,元数据文件看起来像这样:

instance-id: centos_1 
local-hostname: centos_1.local 
network-interfaces: | 
  iface eth0 inet static 
  address 192.168.124.10 
  network 192.168.124.1 
  netmask 255.255.255.0 
  broadcast 192.168.124.255 
  gateway 192.168.124.1 

这个文件将在虚拟机启动时设置 eth0 接口。我们还需要另一个文件(user-data)来正确设置 users

users: 
- name: (yourname) 
  shell: /bin/bash 
  sudo: ['ALL=(ALL) NOPASSWD:ALL'] 
  ssh-authorized-keys: 
  - (insert ssh public key here) 

对我来说,文件看起来像这样:

users: 
- name: fale 
  shell: /bin/bash 
  sudo: ['ALL=(ALL) NOPASSWD:ALL'] 
  ssh-authorized-keys: 
  - ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDRoZzfNif+wXFqzsmvHg4jJt8+ZO/dQxm5k7pXYAwdWVbiFrZYGhMQl5FPfzC7rkDaC31fod3Y85QkQVgNKCVYUy5QR5LfxUjSQDv+y2Nfao4be/BKla0ffc7JVSzFFAELGGDLn1lMN0e0D9syqQbKgSRdOdvweq/0Et3KNIF9e7XgEdSuAHls17NDtMkWUfyi5yvEtdtMcp9gO4OlG6Vh0iCXOdx+f0QA2hh1JnvePvzJ4a8CeckN5JwL7Q027nlsHPBYq9K1jvv+diUs48FflPJI4fgMq3Zo7zyCpf8qE7Dlx+u7OvR5kxNdrpnOsDgHeAGNkrzfcmxU7kbU29NX4VFgWd0sdlzu1nOWFEH7Cnd547tx5VFxBzJwEAUCh7QSiU2Ne/hCnjFkZuDZ5pN4pNw+yu+Feoz79gV/utoLHuCodYyAvSQlQ7VSfC+djLD/9wHC2yGksvc9ICnSUv3JyQEEEG4K26z6szF9+a3vU0qIq7YYa8QHgWIHtzSxztYRIWJOzTZlwyuNmhbRNYDaMC5BMzvQ8JREv0obMLmrlvolJPWT4gn1N9sDNNXIC6RDRE5yGsIEf0CliYW1X/8XG40U+g9LG+lrYOGWD4OymZ2P/VDIzZbVT6NG/rdSSGnf4D1AwlOGR7eNTv30AK9o0LVjqGaJWKWYUF9zY6I3+Q== 

为了在启动时提供这些文件,我们需要创建一个包含它们的 ISO 文件:

$ genisoimage -output centos_1.iso -volid cidata -joliet -rock user-data meta-data

在 ISO 文件准备好后,我们可以指示virt-install来实际创建虚拟机:

virt-install --name CentOS_1 \ 
--ram 2048 \ 
--disk centos_1.qcow2 \ 
--vcpus 2 \ 
--os-variant fedora21 \ 
--connect qemu:///system \ 
--network bridge:br0,model=virtio \ 
--cdrom centos_1.iso \ 
--boot hd 
virt-install --name CentOS_1 \ --ram 2048 \ --disk centos_1.qcow2 \ --vcpus 2 \ --os-variant fedora21 \ --connect qemu:///system \ --network bridge:br0,model=virtio \ --cdrom centos_1.iso \ --boot hd 

由于我们的网络配置在 ISO 文件中,我们在每次启动时都需要它。遗憾的是,默认情况下不会发生这种情况,因此我们需要做一些额外的步骤。首先,运行virsh

$ virsh

此时,应该出现一个 virsh 命令行,输出如下:

Welcome to virsh, the virtualization interactive terminal.
Type:  'help' for help with commands
 'quit' to quit
virsh #

这意味着我们从 bash(或者如果你没有使用 bash 的话,你的 shell)切换到了虚拟化 shell。请输入以下命令:

virsh # edit CentOS_1

通过这样做,我们将能够调整CentOS_1机器的配置。在磁盘部分,你需要找到应该如下所示的cdrom设备:

    <disk type='block' device='cdrom'> 
      <driver name='qemu' type='raw'/> 
      <target dev='hda' bus='ide'/> 
      <readonly/> 
      <address type='drive' controller='0' bus='0' target='0'
      unit='0'/> 
    </disk> 

你需要将其更改为如下所示,已加粗的部分:

    <disk type='file' device='cdrom'> 
      <driver name='qemu' type='raw'/> 
        <source file='(Put here your ISO path)/centos_1.iso'/> 
      <target dev='hda' bus='ide'/> 
      <readonly/> 
      <address type='drive' controller='0' bus='0' target='0'
      unit='0'/> 
    </disk> 

此时,我们的虚拟机将始终以挂载 ISO 文件作为cdrom启动,因此cloud-init将能够正确初始化网络。

版本控制系统

在本章中,我们已经遇到过表达式基础设施代码,用来描述将创建和维护你的基础设施的 Ansible 代码。我们使用“基础设施代码”这个术语,以便与应用程序代码区分开来,应用程序代码是构成你的应用、网站等的代码。这样的区分是为了清晰起见,但最终这两者都是一些文本文件,软件可以读取并解释它们。

因此,版本控制系统将对你大有帮助。它的主要优点包括:

  • 能够让多人同时在同一个项目上工作。

  • 能够以简单的方式进行代码审查。

  • 能够为多个环境(即开发、测试、质量保证、预发布和生产)创建多个分支。

  • 能够追踪一个变更,以便我们知道它是什么时候引入的,以及是谁引入的。这使得我们在几年(或几个月)后,理解那段代码为何存在变得更加容易。

这些优点是大多数版本控制系统所提供的。

版本控制系统可以根据它们能够实现的三种不同模型,分为三个主要类别:

  • 本地数据模型

  • 客户端-服务器模型

  • 分布式模型

第一类,本地数据模型,是最古老的(大约 1972 年)方法,通常用于非常具体的应用场景。此模型要求所有用户共享相同的文件系统。著名的例子有修订控制系统RCS)和源代码控制系统SCCS)。

第二类,即客户端-服务器模型,稍晚出现(大约在 1990 年),旨在解决本地数据模型的局限性,创建了一个遵循本地数据模型的服务器,并设立了一组客户端与服务器交互,而不是直接与仓库本身交互。这一附加层使得多个开发人员能够使用本地文件,并将其与集中式服务器进行同步。此方法的著名例子有 Apache SubversionSVN)和并行版本控制系统CVS)。

第三类模型,分布式模型,在二十一世纪初出现,试图解决客户端-服务器模型的局限性。事实上,在客户端-服务器模式下,你可以离线工作,但需要在线才能提交更改。分布式模型允许你在本地仓库(如本地数据模型)上处理所有内容,并能轻松地合并不同机器上的不同仓库。在这个新模型中,所有操作都可以像在客户端-服务器模型中一样执行,且增加了能够完全离线工作以及在不经过集中式服务器的情况下合并对等节点间更改的优势。此模型的例子有 BitKeeper(专有软件)、Git、GNU Bazaar 和 Mercurial。

只有分布式模型才能提供的一些额外优势包括:

  • 即使服务器不可用,也能进行提交、浏览历史记录和执行任何其他操作的可能性

  • 更容易管理不同环境的多个分支

当涉及到基础设施代码时,我们必须考虑到,通常,保存和管理基础设施代码的基础设施本身也包含在基础设施代码中。这是一个递归的情况,可能会导致问题。事实上,在没有代码服务器之前,你无法部署你的 Ansible,而在没有 Ansible 之前,你无法部署你的代码服务器。分布式版本控制系统将避免这个问题。

至于管理多个分支的简易性,尽管这不是硬性规则,但通常分布式版本控制系统比其他版本控制系统具有更好的合并处理能力。

使用 Ansible 与 Git

由于我们刚刚看到的原因以及其巨大的流行性,我建议始终在你的 Ansible 仓库中使用 Git。

我经常给我所谈论的人提供一些建议,以便 Ansible 能从 Git 中获得最大收益:

  • 创建环境分支:创建诸如 dev、prod、test 和 stg 等环境分支,可以帮助你轻松追踪不同环境及其各自的更新状态。我常建议将 master 分支用于开发环境,因为我发现很多人习惯直接将新更改推送到 master。如果你将 master 用作生产环境,可能会不小心将更改推送到生产环境,而原本想推送到开发环境。

  • 始终保持环境分支的稳定性:拥有环境分支的一个大优势是,可以随时从零开始销毁并重新创建任何环境。这只有在环境分支处于稳定(未损坏)状态时才可能实现。

  • 使用特性分支:为特定的大型开发特性(如重构或其他大改动)使用不同的分支,将使你能够在 Git 仓库中保留你的日常操作,同时进行新特性的开发(这样你就不会失去对谁做了什么、什么时候做的记录)。

  • 频繁推送:我总是建议人们尽可能频繁地推送提交。这将使 Git 同时充当版本控制系统和备份系统。我曾多次看到笔记本电脑损坏、丢失或被盗,而上面有几天甚至几周没有推送的工作。不要浪费时间,频繁推送。另外,频繁推送还能让你更早发现合并冲突,而冲突在被及时发现时更容易处理,而不是等到有多个更改后再去解决。

  • 每次做出更改后都要部署:我曾见过有开发者在基础设施代码中做了更改,在开发和测试环境中进行测试,推送到生产分支后去吃午餐,却没有立即在生产环境中部署更改。结果他的午餐没有吃好。因为他的同事不小心将代码部署到了生产环境(他当时正尝试部署自己所做的小改动),并且没有准备好处理另一个开发者的部署。生产环境出现了问题,他们花了大量时间搞清楚为什么一个这么小的更改(部署者知道的那个)会导致如此大的麻烦。

  • 选择多个小改动,而不是几个大改动:尽可能进行小改动,将使调试更容易。调试基础设施并不容易。没有编译器可以帮助你发现明显的问题(即使 Ansible 会对代码进行语法检查,也不会执行其他测试),而且用来查找问题的工具并不像你想象的那样好。基础设施即代码的理念还很新,相关工具还不如应用代码的工具那么成熟。

  • 尽量避免二进制文件:我总是建议将二进制文件保存在 Git 仓库之外,无论是应用程序代码仓库还是基础设施代码仓库。在应用程序代码的例子中,我认为保持仓库轻便非常重要(Git 以及大多数版本控制系统在处理二进制大块数据时表现不佳),而在基础设施代码的例子中尤为关键,因为你会很容易将大量二进制文件放入仓库,因为很多时候将二进制文件放进仓库比找到一个更清洁(更好的)解决方案更为简便。

总结

在本章中,我们了解了什么是 IT 自动化、它的优点和缺点、可以找到哪些工具,以及 Ansible 在这一大框架中的作用。我们还了解了如何安装 Ansible 以及如何创建基于 KVM 的虚拟机。最后,我们分析了版本控制系统,并讨论了如果正确使用,Git 为 Ansible 带来的优势。

在下一章,我们将开始探讨本章提到的基础设施代码,虽然我们还没有解释它是什么以及如何编写。此外,在下一章中,我们还将学习如何自动化一些你可能每天都会执行的简单操作,比如管理用户、管理文件以及文件内容。

第二章:自动化简单任务

正如我们在上一章中提到的,Ansible 可以用于创建和管理整个基础设施,也可以集成到已经存在的基础设施中。

在这一章中,我们将看到:

  • 什么是 playbook,以及它是如何工作的

  • 如何使用 Ansible 创建一个 Web 服务器

  • 深入了解 Jinja2 模板引擎

但首先我们将讨论 YAML Ain't Markup LanguageYAML),这是一种人类可读的数据序列化语言,在 Ansible 中被广泛使用。

YAML

YAML 像许多其他数据序列化语言(如 JSON)一样,只有非常少数、基本的概念:

  • 声明

  • 列表

  • 关联数组

声明与任何其他编程语言中的变量非常相似,也就是说:

name: 'This is the name' 

要创建一个列表,我们需要使用 '-':

- 'item1' 
- 'item2' 
- 'item3' 

YAML 使用缩进来逻辑性地划分父子关系。所以,如果我们想要创建关联数组(也称为对象),只需添加缩进:

item: 
  name: TheName 
  location: TheLocation 

显然,我们可以将这些结合起来,也就是说:

people: 
  - name: Albert 
    number: +1000000000 
    country: USA 
  - name: David 
    number: +44000000000 
    country: UK 

这些就是 YAML 的基础。YAML 可以做更多的事情,但目前这些足够了。

你好,Ansible

正如我们在上一章所见,Ansible 可以用于自动化你可能每天都会做的简单任务。

让我们首先检查远程机器是否可达;换句话说,让我们开始 ping 一台机器。最简单的做法是运行以下命令:

$ ansible all -i HOST, -m ping

在这里,HOST 是一个 IP 地址、完全限定域名FQDN)或你有 SSH 访问权限的机器别名(如我们在上一章所看到的,你可以使用 基于内核的虚拟机KVM))。

提示

在 "HOST," 后面的逗号是必需的,否则它将不会被视为一个列表,而是一个字符串。

在这种情况下,我们对系统中的虚拟机进行了操作:

$ ansible all -i test01.fale.io, -m ping

你应该收到如下结果:

test01.fale.io | SUCCESS => {
 "changed": false,
 "ping": "pong"
}

现在,让我们看看我们做了什么以及为什么这样做。我们从 Ansible 的帮助开始。查询帮助文档可以使用以下命令:

$ ansible --help

为了便于阅读,我们删除了所有与未使用的选项相关的输出:

Usage: ansible <host-pattern> [options]
Options:
 -i INVENTORY, --inventory-file=INVENTORY
 specify inventory host path
 (default=/etc/ansible/hosts) or comma
 separated host list.
 -m MODULE_NAME, --module-name=MODULE_NAME
 module name to execute (default=command)

所以,我们做的就是:

  1. 我们调用了 Ansible。

  2. 我们指示 Ansible 在所有主机上运行。

  3. 我们指定了我们的清单(也就是主机列表)。

  4. 我们指定了我们想要运行的模块(ping)。

现在我们可以 ping 服务器了,接下来让我们echo hello ansible!

$ ansible all -i test01.fale.io, -m shell -a '/bin/echo hello ansible!'

你应该收到如下结果:

test01.fale.io | SUCCESS | rc=0 >>
hello ansible!

在这个示例中,我们使用了一个额外的选项。让我们查看帮助文档,看看它的作用:

Usage: ansible <host-pattern> [options]
Options:
 -a MODULE_ARGS, --args=MODULE_ARGS
 module arguments

正如你从上下文和名称中可能已经猜到的那样,args 选项允许你将额外的参数传递给模块。一些模块(比如 ping)不支持任何参数,而其他模块(如 shell)则需要参数。

使用 playbooks

Playbook 是 Ansible 的核心功能之一,用于告诉 Ansible 执行什么操作。它们像是 Ansible 的待办事项列表,包含了一系列任务;每个任务内部都链接到一段被称为 模块 的代码。Playbook 是简单、易读的 YAML 文件,而模块则是一段可以用任何语言编写的代码,前提是其输出必须是 JSON 格式。你可以在 Playbook 中列出多个任务,这些任务会被 Ansible 按顺序执行。你可以把 Playbook 看作是 Puppet 中的清单、Salt 中的状态或 Chef 中的食谱;它们让你能够列出希望在远程系统上执行的任务或命令。

学习 Playbook 的结构

Playbook 可以包含远程主机列表、用户变量、任务、处理程序等。你还可以通过 Playbook 重写大多数配置设置。让我们开始查看 Playbook 的结构。

我们现在要讨论的 Playbook 目的是确保 httpd 包已安装,并且服务已 启用启动。以下是 setup_apache.yaml 文件的内容:

--- 
- hosts: all 
  remote_user: fale 
  tasks: 
  - name: Ensure the HTTPd package is installed 
    yum: 
      name: httpd 
      state: present 
      become: True 
  - name: Ensure the HTTPd service is enabled and running 
    service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 

setup_apache.yaml 文件是一个 Playbook 的示例。该文件由三个主要部分组成,如下所示:

  • hosts:这列出了我们希望在哪些主机上执行任务的主机或主机组。hosts 字段是必填项,每个 Playbook 都应该有它。它告诉 Ansible 在哪些主机上运行列出的任务。当提供了主机组时,Ansible 会从 Playbook 中获取主机组并尝试在库存文件中查找它。如果没有找到匹配项,Ansible 会跳过该主机组的所有任务。使用 --list-hosts 选项和 Playbook(ansible-playbook <playbook> --list-hosts)将告诉你 Playbook 会在哪些主机上执行。

  • remote_user:这是 Ansible 的配置参数之一(例如,tom -remote_user),它告诉 Ansible 在登录系统时使用特定的用户(在此例中为 tom)。

  • tasks:最后,我们来讲任务。所有的 Playbook 都应该包含任务。任务是你想要执行的一系列操作。一个任务字段包含任务的名称(即任务的帮助文本)、应该执行的模块以及模块所需的参数。让我们来看一下在 Playbook 中列出的单个任务,如前面代码片段所示:

注意

本书中的所有示例将在 CentOS 上执行,但同一组示例经过一些更改后,也能在其他发行版上运行。

在上述情况下,有两个任务。name 参数表示任务正在执行的操作,present 主要是为了提高可读性,正如我们在 playbook 执行过程中会看到的。name 参数是可选的。modules,如 yumservice,有各自的一组参数。几乎所有模块都有 name 参数(例如 debug 模块是例外),它指示执行操作的组件。让我们来看一下其他参数:

  • yum 模块的情况下,state 参数的最新值表示应该安装最新的 httpd 包。这个命令大致等同于 yum install httpd

  • service 模块的场景中,state 参数的 started 值表示 httpd 服务应该启动,这大致等同于 /etc/init.d/httpd start。在这个模块中,我们还可以使用 "enabled" 参数来定义服务是否在启动时自动启动。

  • become: True 参数表示任务应该以 sudo 权限执行。如果 sudo 用户的文件不允许该用户运行特定的命令,那么当 playbook 执行时,它将失败。

注意

你可能会有疑问,为什么没有一个包模块可以自动根据系统架构来选择并运行 yumapt 或其他包管理选项。Ansible 将包管理器的值填充到一个名为 ansible_pkg_manager 的变量中。

一般来说,我们需要记住,跨不同操作系统的具有通用名称的包的数量仅是实际存在的包的一个小子集。例如,httpd 包在 Red Hat 系统中被称为 httpd,在基于 Debian 的系统中被称为 apache2。我们还需要记住,每个包管理器都有一组独特的选项,使其功能强大;因此,使用明确的包管理器名称会更合理,这样就能确保完整的选项集对编写 playbook 的最终用户可用。

执行 playbook

现在,是时候(是的,终于!)运行 playbook 了。为了让 Ansible 执行 playbook 而不是模块,我们需要使用一个不同的命令(ansible-playbooks),该命令的语法与我们之前看到的 "ansible" 命令非常相似:

$ ansible-playbook -i HOST, setup_apache.yaml

正如你所看到的,除了 playbook 中指定的主机模式(已消失)和被 playbook 名称替代的模块选项外,其他没有变化。所以,要在我的机器上执行这个命令,确切的命令是:

$ ansible-playbook -i test01.fale.io, setup_apache.yaml

结果如下:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Ensure the HTTPd package is installed] *********************
changed: [test01.fale.io] 
TASK [Ensure the HTTPd service is enabled and running] ***********
changed: [test01.fale.io] 
PLAY RECAP *******************************************************
test01.fale.io    : ok=3    changed=2    unreachable=0    failed=0 

哇!示例成功了。现在让我们检查一下 httpd 包是否已安装并且正在运行。检查 HTTPd 是否安装的最简单方法是使用 rpm

$ rpm -qa | grep httpd

如果一切正常,你应该看到类似以下的输出:

httpd-tools-2.4.6-40.el7.centos.x86_64
httpd-2.4.6-40.el7.centos.x86_64

要查看服务的状态,我们可以询问 systemd

$ systemctl status httpd

预期的结果类似于以下内容:

httpd.service - The Apache HTTP Server
 Loaded: loaded (/usr/lib/systemd/system/httpd.service; enabled; vendor preset: disabled)
 Active: active (running) since Sat 2016-05-07 13:22:14 EDT; 7min ago
 Docs: man:httpd(8)
 man:apachectl(8)
 Main PID: 2214 (httpd)
 Status: "Total requests: 0; Current requests/sec: 0; Current traffic:   0 B/sec"
 CGroup: /system.slice/httpd.service
 -2214 /usr/sbin/httpd -DFOREGROUND
 -2215 /usr/sbin/httpd -DFOREGROUND
 -2216 /usr/sbin/httpd -DFOREGROUND
 -2217 /usr/sbin/httpd -DFOREGROUND
 -2218 /usr/sbin/httpd -DFOREGROUND
 -2219 /usr/sbin/httpd -DFOREGROUND

根据剧本,最终状态已经实现。我们简要回顾一下剧本运行过程中到底发生了什么:

PLAY [all] *******************************************************

这一行提醒我们,一个剧本将在此处启动,并将在“all”主机上执行:

TASK [setup] *****************************************************
ok: [test01.fale.io]

TASK行显示任务的名称(在本例中是setup),以及它对每个主机的影响。有时人们会对setup任务感到困惑。事实上,如果你查看剧本,会发现并没有setup任务。这是因为在执行我们要求的任务之前,Ansible 会尝试连接到机器并收集关于它的信息,这些信息可能在后续任务中有用。正如你所见,这个任务的结果是绿色的ok状态,说明它成功执行且服务器没有发生任何变化:

TASK [Ensure the HTTPd package is installed] *********************
changed: [test01.fale.io]
TASK [Ensure the HTTPd service is enabled and running] ***********
changed: [test01.fale.io]

这两个任务的状态是黄色,并显示“changed”。这意味着这些任务已经执行成功,但实际上改变了机器上的某些内容:

PLAY RECAP *******************************************************
test01.fale.io       : ok=3    changed=2    unreachable=0    failed=0

最后几行是对剧本执行过程的总结。现在让我们重新运行任务,看看两个任务实际运行后的输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io]
TASK [Ensure the HTTPd package is installed] *********************
ok: [test01.fale.io]
TASK [Ensure the HTTPd service is enabled and running] ***********
ok: [test01.fale.io]
PLAY RECAP *******************************************************
test01.fale.io    : ok=3    changed=0    unreachable=0    failed=0

正如你预期的那样,这两个任务的输出是ok,这意味着在运行任务之前,期望的状态已经达成。需要记住的是,许多任务(如Gathering facts任务)获取有关系统某个组件的信息,并不一定会改变系统上的任何内容;因此,这些任务之前没有显示“已更改”的输出。

第一次和第二次运行中的PLAY RECAP部分如下所示。你将在第一次运行时看到以下输出:

PLAY RECAP ******************************************************
test01.fale.io    : ok=3    changed=2    unreachable=0    failed=0 

你将在第二次运行时看到以下输出:

PLAY RECAP *******************************************************
test01.fale.io    : ok=3    changed=0    unreachable=0    failed=0

如你所见,区别在于第一个任务的输出显示changed=2,这意味着系统状态由于两个任务而发生了两次变化。查看此输出非常有用,因为如果系统已经达到了期望的状态,然后你再次运行剧本,预期的输出应该是changed=0

如果此时你想到幂等性这个词,那你完全正确,值得给自己一个赞!幂等性是配置管理的一个关键原则。维基百科定义幂等性为:如果对任何值应用两次操作,结果与应用一次操作时相同。你在童年时期可能会遇到的最早的幂等性例子就是对数字1的乘法操作,其中1*1=1每次都成立。

大多数配置管理工具已经采用了这一原则,并将其应用于基础设施。在大型基础设施中,强烈建议监控或跟踪基础设施中已更改任务的数量,并在发现异常时警告相关任务;这一点适用于任何配置管理工具。理想的情况是,只有在你引入新的更改时,才应该看到更改,这些更改形式可能是任何 创建删除更新删除CRUD)操作。如果你在想如何用 Ansible 来实现这一点,继续阅读这本书,你最终会找到答案!

让我们继续。你也可以按如下方式编写之前的任务,但从终端用户的角度来看,任务的可读性非常高(我们将把这个文件称为 setup_apache_no_com.yaml):

--- 
- hosts: all 
  remote_user: fale 
  tasks: 
  - yum: 
      name: httpd 
      state: present 
    become: True 
  - service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 

让我们重新运行 playbook 以查看输出是否有任何变化:

$ ansible-playbook -i test01.fale.io, setup_apache_no_com.yaml

输出将是:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [yum] *******************************************************
ok: [test01.fale.io] 
TASK [service] ***************************************************
ok: [test01.fale.io] 
PLAY RECAP *******************************************************
test01.fale.io    : ok=3    changed=0    unreachable=0    failed=0 

如你所见,区别在于可读性。尽可能地,建议将任务保持简单(KISS 原则:Keep It Simple Stupid),以便于长期维护脚本。

现在我们已经了解了如何编写基本的 playbook 并在主机上运行,接下来让我们看看其他可以帮助你在运行 playbooks 时的选项。

Ansible 可见性

第一个常用的选项就是调试选项。为了理解在运行 playbook 时发生了什么,你可以使用 verbose (-v) 选项运行它。每增加一个 v,都会为终端用户提供更多的调试输出。

让我们看一个使用 playbook 调试单个任务的示例,使用以下调试选项:

  • -v 选项提供默认输出,如前面的示例所示。

  • -vv 选项提供稍微多一点的信息,如以下示例所示:

 Using /etc/ansible/ansible.cfg as config file 

    PLAYBOOK: setup_apache.yaml ******************************* 
    1 plays in setup_apache.yaml 

    PLAY [all] ************************************************ 

    TASK [setup] ********************************************** 
    ok: [test01.fale.io] 

    TASK [Ensure the HTTPd package is installed] ************** 
    task path: /home/fale/setup_apache.yaml:5 
    ok: [test01.fale.io] => {"changed": false, "msg": "", "rc": 0, "results": ["httpd-2.4.6-40.el7.centos.x86_64 providing httpd is already installed"]} 

    TASK [Ensure the HTTPd service is enabled and running] **** 
    task path: /home/fale/setup_apache.yaml:10 
    ok: [test01.fale.io] => {"changed": false, "enabled": true, "name": "httpd", "state": "started"} 

    PLAY RECAP ************************************************ 
    test01.fale.io   : ok=3  changed=0   unreachable=0  failed=0 

  • -vvv 选项会提供更多信息,如以下代码所示。这展示了 Ansible 用于在远程主机上创建临时文件并远程运行脚本的 ssh 命令:
TASK [Ensure the HTTPd package is installed] **************
task path: /home/fale/setup_apache.yaml:5
<test01.fale.io> ESTABLISH SSH CONNECTION FOR USER: fale
<test01.fale.io> SSH: EXEC ssh -C -q -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=fale -o ConnectTimeout=10 -o ControlPath=/home/fale/.ansible/cp/ansible-ssh-%C test01.fale.io '/bin/sh -c '"'"'( umask 22 && mkdir -p "` echo $HOME/.ansible/tmp/ansible-tmp-1462644055.19-51001413558638 `" && echo "` echo $HOME/.ansible/tmp/ansible-tmp-1462644055.19-51001413558638 `" )'"'"''
<test01.fale.io> PUT /tmp/tmp9JSYiP TO /home/fale/.ansible/tmp/ansible-tmp-1462644055.19-51001413558638/yum
<test01.fale.io> SSH: EXEC sftp -b - -C -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=fale -o ConnectTimeout=10 -o ControlPath=/home/fale/.ansible/cp/ansible-ssh-%C '[test01.fale.io]'
<test01.fale.io> ESTABLISH SSH CONNECTION FOR USER: fale
<test01.fale.io> SSH: EXEC ssh -C -q -o ControlMaster=auto -o ControlPersist=60s -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=fale -o ConnectTimeout=10 -o ControlPath=/home/fale/.ansible/cp/ansible-ssh-%C -tt test01.fale.io '/bin/sh -c '"'"'sudo -H -S -n -u root /bin/sh -c '"'"'"'"'"'"'"'"'echo BECOME-SUCCESS-axnwopicemeccmdhnlmhawtwlysgfgjc; LANG=en_US.utf8 LC_ALL=en_US.utf8 LC_MESSAGES=en_US.utf8 /usr/bin/python -tt /home/fale/.ansible/tmp/ansible-tmp-1462644055.19-51001413558638/yum; rm -rf "/home/fale/.ansible/tmp/ansible-tmp-1462644055.19-51001413558638/" > /dev/null 2>&1'"'"'"'"'"'"'"'"''"'"''
ok: [test01.fale.io] => {"changed": false, "invocation": {"module_args": {"conf_file": null, "disable_gpg_check": false, "disablerepo": null, "enablerepo": null, "exclude": null, "install_repoquery": true, "list": null, "name": ["httpd"], "state": "present", "update_cache": false}, "module_name": "yum"}, "msg": "", "rc": 0, "results": ["httpd-2.4.6-40.el7.centos.x86_64 providing httpd is already installed"]}

Playbook 中的变量

有时在 playbook 中设置和获取变量是很重要的。

很多时候,你需要自动化多个相似的操作。在这种情况下,你会希望创建一个可以通过不同变量调用的单一 playbook,以确保代码的可重用性。

变量非常重要的另一个情况是当你有多个数据中心,并且某些值将是数据中心特定的。一个常见的例子是 DNS 服务器。让我们分析以下简单代码,它将向我们介绍如何在 Ansible 中设置和获取变量:

--- 
- hosts: all 
  remote_user: fale 
  tasks: 
  - name: Set variable 'name' 
    set_fact: 
      name: Test machine 
  - name: Print variable 'name' 
    debug: 
      msg: '{{ name }}' 

让我们按常规方式运行它:

$ ansible-playbook -i test01.fale.io, variables.yaml

你应该看到如下结果:

PLAY [all] **********************************************************
TASK [setup] ********************************************************
ok: [test01.fale.io] 
TASK [Set variable 'name'] ******************************************
ok: [test01.fale.io] 
TASK [Print variable 'name'] ****************************************
ok: [test01.fale.io] => {
 "msg": "Test machine"
} 
PLAY RECAP **********************************************************
test01.fale.io       : ok=3    changed=0    unreachable=0    failed=0

如果我们分析刚刚执行的代码,应该很清楚发生了什么。我们设置了一个变量(在 Ansible 中称为 facts),然后通过 debug 函数将其打印出来。

提示

当你使用这种扩展版本的 YAML 时,变量应该始终用引号括起来。

Ansible 允许你以多种方式设置变量,也就是通过传递变量文件、在 playbook 中声明变量、通过 -e / --extra-vars 传递给 ansible-playbook 命令,或者在清单文件中声明变量(我们将在下一章中更深入地讨论这个话题)。

现在是时候开始使用 Ansible 在设置阶段获取的一些元数据了。我们先来看看 Ansible 收集的数据。为此,我们将执行:

$ ansible all -i HOST, -m setup

在我们的具体情况下,这意味着执行以下操作:

$ ansible all -i test01.fale.io, -m setup

我们显然也可以使用 playbook 来实现这一点,但这种方式更快。而且,对于“设置”类的情况,你只需要在开发过程中查看输出,以确保使用正确的变量名来实现你的目标。

输出将类似于以下内容:

test01.fale.io | SUCCESS => {
 "ansible_facts": {
 "ansible_all_ipv4_addresses": [
 "178.62.36.208",
 "10.16.0.7"
 ],
 "ansible_all_ipv6_addresses": [
 "fe80::601:e2ff:fef1:1301"
 ],
 "ansible_architecture": "x86_64",
 "ansible_bios_date": "04/25/2016",
 "ansible_bios_version": "20160425",
 "ansible_cmdline": {
 "ro": true,
 "root": "LABEL=DOROOT"
 },
 "ansible_date_time": {
 "date": "2016-05-14",
 "day": "14",
 "epoch": "1463244633",
 "hour": "12",
 "iso8601": "2016-05-14T16:50:33Z",
 "iso8601_basic": "20160514T125033231663",
 "iso8601_basic_short": "20160514T125033",
 "iso8601_micro": "2016-05-14T16:50:33.231770Z",
 "minute": "50",
 "month": "05",
 "second": "33",
 "time": "12:50:33",
 "tz": "EDT",
 "tz_offset": "-0400",
 "weekday": "Saturday",
 "weekday_number": "6",
 "weeknumber": "19",
 "year": "2016"
 },
 "ansible_default_ipv4": {
 "address": "178.62.36.208",
 "alias": "eth0",
 "broadcast": "178.62.63.255",
 "gateway": "178.62.0.1",
 "interface": "eth0",
 "macaddress": "04:01:e2:f1:13:01",
 "mtu": 1500,
 "netmask": "255.255.192.0",
 "network": "178.62.0.0",
 "type": "ether"
 },
 "ansible_default_ipv6": {},
 "ansible_devices": {
 "vda": {
 "holders": [],
 "host": "",
 "model": null,
 "partitions": {
 "vda1": {
 "sectors": "41943040",
 "sectorsize": 512,
 "size": "20.00 GB",
 "start": "2048"
 }
 },
 "removable": "0",
 "rotational": "1",
 "scheduler_mode": "",
 "sectors": "41947136",
 "sectorsize": "512",
 "size": "20.00 GB",
 "support_discard": "0",
 "vendor": "0x1af4"
 }
 },
 "ansible_distribution": "CentOS",
 "ansible_distribution_major_version": "7",
 "ansible_distribution_release": "Core",
 "ansible_distribution_version": "7.2.1511",
 "ansible_dns": {
 "nameservers": [
 "8.8.8.8",
 "8.8.4.4"
 ]
 },
 "ansible_domain": "",
 "ansible_env": {
 "HOME": "/home/fale",
 "LANG": "en_US.utf8",
 "LC_ALL": "en_US.utf8",
 "LC_MESSAGES": "en_US.utf8",
 "LESSOPEN": "||/usr/bin/lesspipe.sh %s",
 "LOGNAME": "fale",
 "MAIL": "/var/mail/fale",
 "PATH": "/usr/local/bin:/usr/bin",
 "PWD": "/home/fale",
 "SHELL": "/bin/bash",
 "SHLVL": "2",
 "SSH_CLIENT": "86.187.141.39 37764 22",
 "SSH_CONNECTION": "86.187.141.39 37764 178.62.36.208 22",
 "SSH_TTY": "/dev/pts/0",
 "TERM": "rxvt-unicode-256color",
 "USER": "fale",
 "XDG_RUNTIME_DIR": "/run/user/1000",
 "XDG_SESSION_ID": "180",
 "_": "/usr/bin/python"
 },
 "ansible_eth0": {
 "active": true,
 "device": "eth0",
 "ipv4": {
 "address": "178.62.36.208",
 "broadcast": "178.62.63.255",
 "netmask": "255.255.192.0",
 "network": "178.62.0.0"
 },
 "ipv4_secondaries": [
 {
 "address": "10.16.0.7",
 "broadcast": "10.16.255.255",
 "netmask": "255.255.0.0",
 "network": "10.16.0.0"
 }
 ],
 "ipv6": [
 {
 "address": "fe80::601:e2ff:fef1:1301",
 "prefix": "64",
 "scope": "link"
 }
 ],
 "macaddress": "04:01:e2:f1:13:01",
 "module": "virtio_net",
 "mtu": 1500,
 "pciid": "virtio0",
 "promisc": false,
 "type": "ether"
 },
 "ansible_eth1": {
 "active": false,
 "device": "eth1",
 "macaddress": "04:01:e2:f1:13:02",
 "module": "virtio_net",
 "mtu": 1500,
 "pciid": "virtio1",
 "promisc": false,
 "type": "ether"
 },
 "ansible_fips": false,
 "ansible_form_factor": "Other",
 "ansible_fqdn": "test",
 "ansible_hostname": "test",
 "ansible_interfaces": [
 "lo",
 "eth1",
 "eth0"
 ],
 "ansible_kernel": "3.10.0-327.10.1.el7.x86_64",
 "ansible_lo": {
 "active": true,
 "device": "lo",
 "ipv4": {
 "address": "127.0.0.1",
 "broadcast": "host",
 "netmask": "255.0.0.0",
 "network": "127.0.0.0"
 },
 "ipv6": [
 {
 "address": "::1",
 "prefix": "128",
 "scope": "host"
 }
 ],
 "mtu": 65536,
 "promisc": false,
 "type": "loopback"
 },
 "ansible_machine": "x86_64",
 "ansible_machine_id": "fd8cf26e06e411e4a9d004010897bd01",
 "ansible_memfree_mb": 6,
 "ansible_memory_mb": {
 "nocache": {
 "free": 381,
 "used": 108
 },
 "real": {
 "free": 6,
 "total": 489,
 "used": 483
 },
 "swap": {
 "cached": 0,
 "free": 0,
 "total": 0,
 "used": 0
 }
 },
 "ansible_memtotal_mb": 489,
 "ansible_mounts": [
 {
 "device": "/dev/vda1",
 "fstype": "ext4",
 "mount": "/",
 "options": "rw,relatime,data=ordered",
 "size_available": 18368385024,
 "size_total": 21004894208,
 "uuid": "c5845b43-fe98-499a-bf31-4eccae14261b"
 }
 ],
 "ansible_nodename": "test",
 "ansible_os_family": "RedHat",
 "ansible_pkg_mgr": "yum",
 "ansible_processor": [
 "GenuineIntel",
 "Intel(R) Xeon(R) CPU E5-2630L v2 @ 2.40GHz"
 ],
 "ansible_processor_cores": 1,
 "ansible_processor_count": 1,
 "ansible_processor_threads_per_core": 1,
 "ansible_processor_vcpus": 1,
 "ansible_product_name": "Droplet",
 "ansible_product_serial": "NA",
 "ansible_product_uuid": "NA",
 "ansible_product_version": "20160415",
 "ansible_python_version": "2.7.5",
 "ansible_selinux": {
 "status": "disabled"
 },
 "ansible_service_mgr": "systemd",
 "ansible_ssh_host_key_dsa_public": "AAAAB3NzaC1kc3MAAACBAPEf4dzeET6ukHemTASsamoRLxo2R8iHg5J1bYQUyuggtRKlbRrHMtpQ8qN5CQNtp8J+2Hq6/JKiDF+cdxgOehf9b7F4araVvJxqx967RvLNBrMWXv7/4hi+efgXG9eejGoGQNAD66up/fkLMd0L8fwSwmTJoZXwOxFwcbnxCZsFAAAAFQDgK7fka+1AKjYZNFIfCB2b0ZitGQAAAIADeofiC5q+SLgEvkBCUCTyJ+EVb6WHeHbVdrpE2GdnUr03R6MmmYhYZMijruS/rcpzBLmi8juDkqAWy6Xqxd+DwixykntXPeUFS3F7LK5vNwFalaRltPwr4Azh+EeSUQ2Zz2AdKx6zSqtLOD8ZMPkRDvz4WGHGmeR+i7UFsFDZdgAAAIEAy26Tx0jAlY3mEaTW9lQ9DoGXgPBxsSX/XqeLh5wBaBO6AJaIrs0dQJdNeHcMhFy0seVkOMN1SpeoBTJSoTOx15HAGsKsAcmnA5mcJeUZqptVR6JxROztHw3zQePQ3/V3KQzAN31tIm3PbKztlEZbXRUM7RV5WsdRHTb8rutENhY=",
 "ansible_ssh_host_key_ecdsa_public": "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPDXQ9rjgDmUKsEWH4U2vg4iqtK+75urlj9nwW+rNNTFHTE5oG82sOlO6o0tUY8LXgB/tJnIcJ1hINdrWrZNpn4=",
 "ansible_ssh_host_key_rsa_public": "AAAAB3NzaC1yc2EAAAADAQABAAABAQCwQx5EElH7FeD/agB/gCJfBUEVhk44tldzdEzwc2IEbI59relTGNOU7soCCMcSH7nwlEbOOvmLa2R/YaXdHv/cb1aXBC/wj/m4ZHylBeF5qzECUkeaB3+CT+hp8qHHApclFr2lm2CwZ+YXjEyjJ3en4K3gLlIQyQjgE2F57kmD1FVVDSJFvNTn+NQvb3DPppND+HKEeHwrJ0GgznoP62yobEgriAIBSGf//0WHCO/9shEvauoRpPM+U9pU7lv637s7qyubIqyrs5fz3u34qBj8oCATOefRN1wsfJDeMG0D5ryI6BI6t/eAi8BPr7VHJSQBk+buM9Jr1yoMQTEasq2J",
 "ansible_swapfree_mb": 0,
 "ansible_swaptotal_mb": 0,
 "ansible_system": "Linux",
 "ansible_system_vendor": "DigitalOcean",
 "ansible_uptime_seconds": 603067,
 "ansible_user_dir": "/home/fale",
 "ansible_user_gecos": "",
 "ansible_user_gid": 1000,
 "ansible_user_id": "fale",
 "ansible_user_shell": "/bin/bash",
 "ansible_user_uid": 1000,
 "ansible_userspace_architecture": "x86_64",
 "ansible_userspace_bits": "64",
 "ansible_virtualization_role": "host",
 "ansible_virtualization_type": "kvm",
 "module_setup": true
 },
 "changed": false
}

如你所见,从这个庞大的选项列表中,你可以获得大量信息,并且可以像使用其他变量一样使用它们。让我们打印操作系统的名称和版本。为此,我们可以创建一个新的 playbook,名为 setup_variables.yaml,内容如下:

--- 
- hosts: all 
  remote_user: fale 
  tasks: 
  - name: Print OS and version 
    debug: 
      msg: '{{ ansible_distribution }} {{ ansible_distribution_version }}' 

使用以下方式执行:

$ ansible-playbook -itest01.fale.io, setup_variables.yaml

这将给我们以下输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Print OS and version] **************************************
ok: [test01.fale.io] => {
 "msg": "CentOS 7.2.1511" 
} 
PLAY RECAP *******************************************************
test01.fale.io     : ok=2   changed=0    unreachable=0    failed=0

如你所见,它按预期打印了操作系统的名称和版本。除了之前看到的方法,还可以通过命令行参数传递变量。事实上,如果我们查看 Ansible 的帮助文档,我们会注意到以下内容:

-e EXTRA_VARS, --extra-vars=EXTRA_VARS
set additional variables as key=value or YAML/JSON

这些相同的行也出现在 ansible-playbook 命令中。让我们做一个简单的 playbook,叫做 cli_variables.yaml,内容如下:

--- 
- hosts: all 
  remote_user: fale 
  tasks: 
  - name: Print variable 'name' 
    debug: 
      msg: '{{ name }}' 

使用以下方式执行:

$ ansible-playbook -i test01.fale.io, cli_variables.yaml -e 'name=test01'

我们将收到以下内容:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io]
TASK [Print variable 'name'] *************************************
ok: [test01.fale.io] => {
 "msg": "test01"
}
PLAY RECAP *******************************************************
test01.fale.io     : ok=2   changed=0    unreachable=0    failed=0

如果我们忘记添加额外的参数来指定变量,我们将按如下方式执行:

$ ansible-playbook -i test01.fale.io, cli_variables.yaml

我们将收到以下输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Print variable 'name'] *************************************
fatal: [test01.fale.io]: FAILED! => {"failed": true, "msg": "'name' is undefined"} 
NO MORE HOSTS LEFT ***********************************************
to retry, use: --limit @cli_variables.retry 
PLAY RECAP *******************************************************
test01.fale.io     : ok=1   changed=0    unreachable=0    failed=1

既然我们已经学习了 playbook 的基础知识,接下来让我们使用它们从零开始创建一个 Web 服务器。为此,我们从头开始,首先创建一个 Ansible 用户,然后再继续进行。

创建 Ansible 用户

当你创建一台机器(或从任何托管公司租用一台机器)时,它默认只会有 root 用户。让我们开始创建一个 playbook,确保创建一个 Ansible 用户,该用户可以通过 SSH 密钥访问,并且能够以其他用户的身份执行操作(sudo),且无需输入密码。我通常称这个 playbook 为 firstrun.yaml,因为我在创建新机器后立刻执行它,但之后就不再使用它了,因为它使用了出于安全原因禁用的 root 用户。我们的脚本将如下所示:

--- 
- hosts: all 
  user: root 
  tasks: 
  - name: Ensure ansible user exists 
    user: 
      name: ansible 
      state: present 
      comment: Ansible 
  - name: Ensure ansible user accepts the SSH key 
    authorized_key: 
      user: ansible 
      key: https://github.com/fale.keys 
   state: present 
  - name: Ensure the ansible user is sudoer with no password required 
    lineinfile: 
      dest: /etc/sudoers 
      state: present 
      regexp: '^ansible ALL\=' 
      line: 'ansible ALL=(ALL) NOPASSWD:ALL' 
      validate: 'visudo -cf %s' 

在运行之前,我们先看一下它。我们使用了三个不同的模块(userauthorized_keylineinfile),这些模块我们之前从未见过。顾名思义,user 模块可以确保某个用户存在(或不存在)。

authorized_key 模块允许我们确保某个 SSH 密钥可以用来作为特定用户登录到该机器。这个模块不会替换该用户已经启用的所有 SSH 密钥,而只是添加(或删除)指定的密钥。如果你想更改此行为,可以使用 exclusive 选项,它允许你删除所有未在此步骤中指定的 SSH 密钥。

lineinfile 模块允许我们修改文件的内容。它的工作方式非常类似于 sed(流编辑器),你需要指定将用于匹配行的正则表达式,然后指定将替换匹配行的新行。如果没有匹配的行,将在文件末尾添加该行。现在让我们用以下方式运行它:

$ ansible-playbook -i test01.fale.io, firstrun.yaml

这将给我们以下结果:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Ensure ansible user exists] ********************************
changed: [test01.fale.io] 
TASK [Ensure ansible user accepts the SSH key] *******************
changed: [test01.fale.io] 
TASK [Ensure the anisble user is sudoer with no password required] *
changed: [test01.fale.io] 
PLAY RECAP *******************************************************
test01.fale.io     : ok=4   changed=3    unreachable=0    failed=0

配置基础服务器

在为 Ansible 创建了具有必要权限的用户之后,我们可以继续对操作系统进行一些其他小的更改。为了更清楚地了解,我们将逐步查看每个操作的执行方式,然后再来看整个 playbook。

启用 EPEL

EPEL 是企业 Linux 最重要的仓库,它包含了大量额外的包。它也是一个安全的仓库,因为 EPEL 中的任何包都不会与基础仓库中的包发生冲突。要在 RHEL/CentOS 7 中启用 EPEL,只需安装 epel-release 包即可。为了在 Ansible 中执行此操作,我们将使用:

- name: Ensure EPEL is enabled 
  yum: 
    name: epel-release 
    state: present 
  become: True 

如你所见,我们使用了 yum 模块,就像在本章的第一个示例中一样,指定了包名并要求它处于 present 状态。

安装 SELinux 的 Python 绑定

由于 Ansible 是用 Python 编写的,并且主要使用 Python 绑定来操作操作系统,我们将需要安装 SELinux 的 Python 绑定:

- name: Ensure libselinux-python is present 
  yum: 
    name: libselinux-python 
    state: present 
  become: True 
- name: Ensure libsemanage-python is present 
  yum: 
    name: libsemanage-python 
    state: present 
  become: True 

提示

这可以用更简短的方式写成,使用一个循环,但我们将在下一章看到如何做到这一点。

升级所有已安装的包

要升级所有已安装的包,我们需要再次使用 yum 模块,但使用不同的参数,实际上我们会使用:

- name: Ensure we have last version of every package 
  yum: 
    name: "*" 
    state: latest 
  become: True 

如你所见,我们将 "*" 作为包名(这表示一个通配符,用于匹配所有已安装的包),并且 statelatest。这将把所有已安装的包升级到最新版本。

如果你记得,我们在讲解 "present" 状态时提到,它会安装最后一个可用版本。那么,"present" 和 "latest" 有什么区别呢?Present 会在包未安装时安装最新版本,而如果包已安装(无论版本如何),它会继续前进,不做任何更改。Latest 会在包未安装时安装最新版本,而如果包已安装,则会检查是否有更新版本,如果有,Ansible 将更新该包。

确保 NTP 已安装、配置并正在运行

为了确保 NTP 已安装,我们使用了yum模块:

- name: Ensure NTP is installed 
  yum: 
    name: ntp 
    state: present 
  become: True 

现在我们知道 NTP 已经安装,我们应该确保服务器使用的是我们需要的timezone。为此,我们将在/etc/localtime中创建一个符号链接,指向所需的zoneinfo文件:

- name: Ensure the timezone is set to UTC 
  file: 
    src: /usr/share/zoneinfo/GMT 
    dest: /etc/localtime 
    state: link 
  become: True 

正如你所看到的,我们使用了file模块告诉 Ansible,指定它需要是一个符号链接(state: link)。

为了完成 NTP 配置,我们需要启动ntpd服务,并确保它在每次启动时都会运行:

- name: Ensure the NTP service is running and enabled 
  service: 
    name: ntpd 
    state: started 
    enabled: True 
  become: True 

确保 FirewallD 已安装并启用

如你所想,第一步是确保 FirewallD 已安装:

- name: Ensure FirewallD is installed 
  yum: 
    name: firewalld 
    state: present 
  become: True 

由于我们想确保启用 FirewallD 时不会丢失 SSH 连接,我们确保 SSH 流量始终可以通过防火墙:

- name: Ensure SSH can pass the firewall 
  firewalld: 
    service: ssh 
    state: enabled 
    permanent: True 
    immediate: True 
  become: True 

为此,我们使用了firewalld模块。该模块的参数与firewall-cmd控制台非常相似。你需要指定允许通过防火墙的服务,是否希望此规则立即生效,以及是否希望此规则是永久性的,以便在重启后规则仍然存在。

提示

你可以使用service参数指定服务名称(例如'ssh'),也可以使用port参数指定端口(例如'22/tcp')。

现在我们已经安装了 FirewallD,并且确认我们的 SSH 连接会继续保持,我们可以像启用任何其他服务一样启用它:

- name: Ensure FirewallD is running 
  service: 
    name: firewalld 
    state: started 
    enabled: True 
  become: True 

添加定制的 MOTD

要添加 MOTD,我们需要一个模板,所有服务器都使用相同的模板,并且需要一个任务来使用该模板。

我发现为每个服务器添加 MOTD 非常有用。如果使用 Ansible,它更加有用,因为你可以用它来提醒用户,系统的更改可能会被 Ansible 覆盖。我的常用模板叫做'motd',内容如下:

                This system is managed by Ansible 
  Any change done on this system could be overwritten by Ansible 

OS: {{ ansible_distribution }} {{ ansible_distribution_version }} 
Hostname: {{ inventory_hostname }} 
eth0 address: {{ ansible_eth0.ipv4.address }} 
All connections are monitored and recorded 
    Disconnect IMMEDIATELY if you are not an authorized user

这是一个jinja2模板,它允许我们使用在 playbooks 中设置的所有变量。这也使我们能够使用复杂的条件和循环语法,这些将在本章稍后看到。要使用模板填充文件,我们需要使用:

- name: Ensure the MOTD file is present and updated 
  template: 
    src: motd 
    dest: /etc/motd 
    owner: root 
    group: root 
    mode: 0644 
  become: True 

template模块允许我们指定一个本地文件(src),该文件将由jinja2解释,并且此操作的输出将保存在远程机器上的特定路径(dest),由特定用户(owner)和组(group)拥有,并具有特定的访问模式(mode)。

更改主机名

为了简化操作,我发现一个有用的方法是将机器的主机名设置为有意义的名称。为此,我们可以使用一个非常简单的 Ansible 模块,名为hostname

- name: Ensure the hostname is the same of the inventory 
  hostname: 
    name: "{{ inventory_hostname }}" 
  become: True 

审查并运行 playbook

将所有内容整合起来,我们现在有以下 playbook(为简化起见,命名为common_tasks.yaml):

--- 
- hosts: all 
  remote_user: ansible 
  tasks: 
  - name: Ensure EPEL is enabled 
    yum: 
      name: epel-release 
      state: present 
    become: True 
  - name: Ensure libselinux-python is present 
    yum: 
      name: libselinux-python  
      state: present 
    become: True 
  - name: Ensure libsemanage-python is present 
    yum: 
      name: libsemanage-python 
      state: present 
    become: True 
  - name: Ensure we have last version of every package 
    yum: 
      name: "*" 
      state: latest 
    become: True 
  - name: Ensure NTP is installed 
    yum: 
      name: ntp 
      state: present 
    become: True 
  - name: Ensure the timezone is set to UTC 
    file: 
      src: /usr/share/zoneinfo/GMT 
      dest: /etc/localtime 
      state: link 
    become: True 
  - name: Ensure the NTP service is running and enabled 
    service: 
      name: ntpd 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure FirewallD is installed 
    yum: 
      name: firewalld 
      state: present 
    become: True 
  - name: Ensure FirewallD is running 
    service: 
      name: firewalld 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure SSH can pass the firewall 
    firewalld: 
      service: ssh 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 
  - name: Ensure the MOTD file is present and updated 
    template: 
      src: motd 
      dest: /etc/motd 
      owner: root 
      group: root 
      mode: 0644 
    become: True 
  - name: Ensure the hostname is the same of the inventory 
    hostname: 
      name: "{{ inventory_hostname }}" 
    become: True  

由于这个playbook相当复杂,我们可以运行以下命令:

$ ansible-playbook common_tasks.yaml --list-tasks

这要求 Ansible 以简短的形式打印出所有任务,以便我们可以快速查看 playbook 执行了哪些任务。输出应该类似于以下内容:

playbook: common_tasks.yaml 
  play #1 (all): all TAGS: [] 
    tasks: 
      Ensure EPEL is enabled TAGS: [] 
      Ensure libselinux-python is present TAGS: [] 
      Ensure libsemanage-python is present TAGS: [] 
      Ensure we have last version of every package TAGS: [] 
      Ensure NTP is installed TAGS: [] 
      Ensure the timezone is set to UTC TAGS: [] 
      Ensure the NTP service is running and enabled TAGS: [] 
      Ensure FirewallD is installed TAGS: [] 
      Ensure FirewallD is running TAGS: [] 
      Ensure SSH can pass the firewall TAGS: [] 
      Ensure the MOTD file is present and updated TAGS: [] 
      Ensure the hostname is the same of the inventory TAGS: [] 

现在我们可以使用以下命令运行 playbook

$ ansible-playbook -itest01.fale.io, common_tasks.yaml

我们将收到以下输出:

PLAY [all] ******************************************************* 

TASK [setup] ***************************************************** 
ok: [test01.fale.io] 

TASK [Ensure EPEL is enabled] ************************************ 
changed: [test01.fale.io] 

TASK [Ensure libselinux-python is present] *********************** 
ok: [test01.fale.io] 

TASK [Esure libsemanage-python is present] *********************** 
ok: [test01.fale.io] 

TASK [Ensure we have last version of every package] ************** 
changed: [test01.fale.io] 

TASK [Ensure NTP is installed] *********************************** 
ok: [test01.fale.io] 

TASK [Ensure the timezone is set to UTC] ************************* 
changed: [test01.fale.io] 

TASK [Ensure the NTP service is running and enabled] ************* 
changed: [test01.fale.io] 

TASK [Ensure FirewallD is installed] ***************************** 
ok: [test01.fale.io] 

TASK [Ensure FirewallD is running] ******************************* 
changed: [test01.fale.io] 

TASK [Ensure SSH can pass the firewall] ************************** 
ok: [test01.fale.io] 

TASK [Ensure the MOTD file is present and updated] *************** 
changed: [test01.fale.io] 

TASK [Ensure the hostname is the same of the inventory] ********** 
changed: [test01.fale.io] 

PLAY RECAP ******************************************************* 
test01.fale.io     : ok=9   changed=7    unreachable=0    failed=0

安装和配置 Web 服务器

现在我们已经对操作系统做了一些通用的更改,让我们开始实际创建一个 Web 服务器。我们将这两个阶段分开,以便可以在每台机器上共享第一阶段,并仅将第二阶段应用于 Web 服务器。

在第二阶段,我们将创建一个名为 webserver.yaml 的新 playbook,内容如下:

--- 
- hosts: all 
  remote_user: ansible 
  tasks: 
  - name: Ensure the HTTPd package is installed 
    yum: 
      name: httpd 
      state: present 
    become: True 
  - name: Ensure the HTTPd service is enabled and running 
    service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure HTTP can pass the firewall 
    firewalld: 
      service: http 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 
  - name: Ensure HTTPS can pass the firewall 
    firewalld: 
      service: https 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True   

如你所见,前两个任务与本章开始时示例中的任务相同,最后两个任务用于指示 FirewallD 允许 HTTP 和 HTTPS 流量通过。

让我们使用以下命令运行这个脚本:

$ ansible-playbook -i test01.fale.io, webserver.yaml

这将导致以下结果:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Ensure the HTTPd package is installed] *********************
changed: [test01.fale.io] 
TASK [Ensure the HTTPd service is enabled and running] ***********
changed: [test01.fale.io] 
TASK [Ensure HTTP can pass the firewall] *************************
changed: [test01.fale.io] 
TASK [Ensure HTTPS can pass the firewall] ************************
changed: [test01.fale.io] 
PLAY RECAP *******************************************************
test01.fale.io    : ok=5    changed=4    unreachable=0    failed=0

现在我们有了 Web 服务器,让我们发布一个小型的单页静态网站。

发布一个网站

由于我们的网站将是一个简单的单页网站,我们可以通过一个 Ansible 任务轻松创建并发布它。为了让这个页面更有趣,我们将从一个模板开始,Ansible 会用一些关于机器的数据填充该模板。发布这个页面的脚本将被命名为 deploy_website.yaml,并具有以下内容:

--- 
- hosts: all 
  remote_user: ansible 
  tasks: 
  - name: Ensure the website is present and updated 
    template: 
      src: index.html.j2 
      dest: /var/www/html/index.html 
      owner: root 
      group: root 
      mode: 0644 
    become: True 

让我们从一个简单的模板开始,我们将其命名为 index.html.j2

<html> 
    <body> 
        <h1>Hello World!</h1> 
    </body> 
</html> 

现在我们可以通过运行以下命令测试我们的网站部署:

$ ansible-playbook -i test01.fale.io, deploy_website.yaml

我们应该收到以下输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [test01.fale.io] 
TASK [Ensure the website is present and updated] *****************
changed: [test01.fale.io] 
PLAY RECAP *******************************************************
test01.fale.io    : ok=2    changed=1    unreachable=0    failed=0

如果你现在在浏览器中访问你的测试机器的 IP/FQDN,你将看到 "Hello World!" 页面。

Jinja2 模板

Jinja2 是一个广泛使用且功能全面的 Python 模板引擎。我们来看看一些有助于 Ansible 的语法。这个段落并不打算替代官方文档,但其目的是教你一些在使用 Ansible 时非常有用的组件。

变量

如我们所见,我们可以通过 '{{ VARIABLE_NAME }}' 语法简单地打印变量内容。如果我们想打印数组中的某个元素,可以使用 '{{ ARRAY_NAME['KEY'] }}',如果我们想打印对象的属性,则可以使用 '{{ OBJECT_NAME.PROPERTY_NAME }}'。

所以我们可以通过以下方式改进我们之前的静态页面:

<html> 
    <body> 
        <h1>Hello World!</h1> 
        <p>This page was created on {{ ansible_date_time.date }}.</p> 
    </body> 
</html> 

过滤器

有时我们可能想稍微改变字符串的样式,而不为此编写特定的代码。例如,我们可能希望将某些文本转换为大写。为此,我们可以使用 Jinja2 的一个过滤器,例如:'{{ VARIABLE_NAME | capitalize }}'。Jinja2 提供了许多过滤器,你可以在以下地址找到完整的过滤器列表:jinja.pocoo.org/docs/dev/templates/#builtin-filters

条件语句

在模板引擎中,你可能经常会发现一个有用的功能,那就是根据字符串的内容(或存在与否)打印不同的字符串。因此,我们可以通过以下方式改善我们的静态网页:

<html> 
    <body> 
        <h1>Hello World!</h1> 
        <p>This page was created on {{ ansible_date_time.date }}.</p> 
{% if ansible_eth0.active == True %} 
        <p>eth0 address {{ ansible_eth0.ipv4.address }}.</p> 
{% endif %} 
    </body> 
</html> 

如你所见,我们已经添加了打印主 IPv4 地址的功能,针对的是 eth0 连接,前提是连接为 active。使用条件语句时,我们还可以使用这些测试。

注意

如需完整列表,请参考:jinja.pocoo.org/docs/dev/templates/#builtin-tests

因此,为了获得相同的结果,我们也可以写出以下内容:

<html> 
    <body> 
        <h1>Hello World!</h1> 
        <p>This page was created on {{ ansible_date_time.date }}.</p> 
{% if ansible_eth0.active is equalto True %} 
        <p>eth0 address {{ ansible_eth0.ipv4.address }}.</p> 
{% endif %} 
    </body> 
</html> 

有许多不同的测试可以帮助你创建易于阅读且有效的模板。

循环

jinja2 模板系统还提供了创建循环的功能。让我们在页面中添加一个功能,打印每个设备的主 IPv4 网络地址,而不仅仅是 eth0。我们将得到以下代码:

<html> 
    <body> 
        <h1>Hello World!</h1> 
        <p>This page was created on {{ ansible_date_time.date }}.</p> 
        <p>This machine can be reached on the following IP addresses</p> 
        <ul> 
{% for address in ansible_all_ipv4_addresses %} 
            <li>{{ address }}</li> 
{% endfor %} 
        </ul> 
    </body> 
</html> 

如你所见,如果你已经了解 Python,循环的语法就会很熟悉。

这几页关于 Jinja2 模板的内容并不能替代官方文档。事实上,Jinja2 模板比我们所看到的要强大得多。这里的目标仅仅是给你提供一些在 Ansible 中最常用的基本 Jinja2 模板。

总结

在本章中,我们开始了解 YAML,了解了什么是 playbook,它是如何工作的,以及如何使用它来创建一个 web 服务器(以及用于静态网站的部署)。我们还看到了多个 Ansible 模块,如 user、yum、service、FirewalID、lineinfile 和 template 模块。在本章结束时,我们重点讨论了模板。

在下一章,我们将讨论清单,以便我们能够轻松管理多个机器。

第三章. 扩展到多个主机

在前几章中,我们在命令行中指定了主机。虽然在只有一个主机的情况下,这种方式运行良好,但在管理多个服务器时效果不好。在本章中,我们将具体了解如何管理多个服务器。

我们将探讨以下主题:

  • Ansible 清单

  • Ansible 主机/组变量

  • Ansible 循环

使用清单文件

清单文件是 Ansible 的真实数据来源(还有一个称为动态清单的高级概念,我们稍后将介绍)。它遵循初始化INI)格式,并告诉 Ansible 用户提供的远程主机是否真实存在。

Ansible 可以并行地在多个主机上执行任务。为此,你可以通过清单文件将主机列表直接传递给 Ansible。为了进行并行执行,Ansible 允许你在清单文件中将主机分组;该文件将组名传递给 Ansible。Ansible 会在清单文件中查找该组,并在该组中列出的所有主机上执行任务。

你可以通过使用-i--inventory-file选项,后跟文件路径,将清单文件传递给 Ansible。如果你没有明确指定任何清单文件给 Ansible,它将使用ansible.cfghost_file参数的默认路径,该默认路径为/etc/ansible/hosts

提示

使用-i参数时,如果值是一个list(即包含至少一个逗号),它将被用作清单列表,而如果该变量是一个字符串,它将被用作清单文件路径。

基本清单文件

在深入了解这个概念之前,让我们先看一个基本的清单文件,叫做hosts,我们可以使用它代替之前示例中使用的列表:

test01.fale.io 

提示

Ansible 可以在清单文件中使用 FQDN 或 IP 地址。

我们现在可以执行与上一章相同的操作,调整 Ansible 命令参数。例如,要安装 Web 服务器,我们使用了这个命令:

$ ansible-playbook -i test01.fale.io, webserver.yaml

相反,我们可以使用以下内容:

$ ansible-playbook -i hosts webserver.yaml

如你所见,我们已经用清单文件名替代了主机列表。

清单文件中的组

当我们面临更复杂的情况时,清单文件的优势就显现出来了。假设我们的网站变得更加复杂,现在需要一个更复杂的环境。在我们的示例中,我们的网站将需要一个 MySQL 数据库。此外,我们决定使用两台 Web 服务器。在这种情况下,根据机器在我们基础设施中的角色来分组不同的机器是有意义的。我们的hosts文件将更改为:

[webserver] 
ws01.fale.io 
ws02.fale.io 

[database] 
db01.fale.io 

现在我们可以指示 playbook 仅在特定组中的主机上运行。我们为我们的网站示例创建了三个不同的 playbook:

  • firstrun.yaml是通用的,需要在每台机器上运行

  • common_tasks.yaml是通用的,需要在每台机器上运行

  • webserver.yaml 文件专门用于 Web 服务器,因此不应在其他机器上运行

我们只需要更改 webserver.yaml 文件,当前该文件指定它必须在所有机器上运行,而应该仅限于 Web 服务器。为了做到这一点,让我们打开 webserver.yaml 文件,并将内容从:

- hosts: all 

到:

- hosts: webserver 

仅凭这三个 playbook,我们无法继续创建带有三台服务器的环境。由于我们还没有设置数据库的 playbook(我们将在下一章中看到),我们将完全配置两台 Web 服务器,而对于数据库服务器,我们只配置基础系统。

我们可以通过以下方式运行 firstrun playbook:

$ ansible-playbook -i hosts firstrun.yaml

以下将是结果:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [ws02.fale.io]
ok: [db01.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure ansible user exists] ********************************
changed: [ws01.fale.io]
changed: [db01.fale.io]
changed: [ws02.fale.io] 
TASK [Ensure ansible user accepts the SSH key] *******************
changed: [ws02.fale.io]
changed: [ws01.fale.io]
changed: [db01.fale.io] 
TASK [Ensure the ansible user is sudoer with no password required]
changed: [ws01.fale.io]
changed: [db01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
db01.fale.io      : ok=4    changed=3    unreachable=0    failed=0
ws01.fale.io      : ok=4    changed=3    unreachable=0    failed=0
ws02.fale.io      : ok=4    changed=3    unreachable=0    failed=0

如你所见,输出与我们在单一主机上收到的结果非常相似,但每个步骤每个主机都有一行。在这种情况下,所有机器的状态相同,且执行了相同的步骤,因此我们看到它们的行为一致。但在更复杂的场景中,可能会有不同的机器在同一步骤上返回不同的状态。我们也可以执行其他两个 playbook,得到类似的结果。

库存文件中的正则表达式

当你有大量服务器时,给它们起个可预测的名字通常是很常见也很有帮助的。例如,可以将所有 Web 服务器命名为 wsXYwebXY,将数据库服务器命名为 dbXY。这样,你可以减少 hosts 文件中的行数,从而提高可读性。例如,我们的 hosts 文件可以简化为:

[webserver] 
ws[01:02].fale.io 

[database] 
db01.fale.io 

在这个例子中,我们使用了 [01:02],它会匹配从第一个数字(在我们这个例子中是 01)到最后一个数字(在我们这个例子中是 02)的所有出现情况。虽然我们这个例子的收益不大,但如果你有 40 台 Web 服务器,你就能从 hosts 文件中减少 39 行。

使用变量

Ansible 允许你通过多种方式定义变量:可以通过 playbook 中的变量文件,使用 -e / --extra-vars 选项从 Ansible 命令传递,或通过库存文件传递。你可以在库存文件中定义变量,可以按每个主机、按整个组,或者在库存文件所在的目录中创建一个变量文件。

主机变量

可以在 hosts 文件中为特定主机声明变量。例如,我们可能想为 Web 服务器指定不同的引擎。假设其中一台需要响应一个特定的域名,而另一台需要响应不同的域名。在这种情况下,我们可以通过以下 hosts 文件来实现:

[webserver] 
ws01.fale.io domainname=example1.fale.io 
ws02.fale.io domainname=example2.fale.io 

[database] 
db01.fale.io 

这样,所有在 Web 服务器上运行的 playbook 都能够引用域名变量。

分组变量

还有其他情况,你可能希望设置适用于整个组的变量。假设我们要将变量 https_enabled 设置为 True,并且它的值必须对所有 Web 服务器一致。在这种情况下,我们可以创建一个 [webserver:vars] 部分,所以下面是我们将使用的 hosts 文件:

[webserver] 
ws01.fale.io 
ws02.fale.io 

[webserver:vars] 
https_enabled=True 

[database] 
db01.fale.io 

注意

请记住,如果同一变量在主机和组中都被声明,host 变量将覆盖 group 变量。

变量文件

有时,你需要为每个 hostgroup 声明很多变量,hosts 文件变得难以阅读。在这种情况下,你可以将变量移到特定的文件中。对于主机级变量,你需要在 host_vars 文件夹中创建一个与主机名称相同的文件,而对于 group 变量,你需要使用组名作为文件名,并将其放入 group_vars 文件夹中。

所以,如果我们想使用文件复制前面基于主机的变量示例,我们需要创建一个 host_vars/ws01.fale.io 文件,文件内容如下:

domainname=example1.fale.io 

创建 host_vars/ws02.fale.io 文件,文件内容如下:

domainname=example2.fale.io 

如果我们想要复制基于组的变量示例,我们需要有一个 group_vars/webserver 文件,文件内容如下:

https_enabled=True 

注意

库存变量遵循层级结构;在顶部是公共变量文件(我们在前一节 Working with inventory files 中讨论过),它将覆盖任何主机变量、组变量和库存变量文件。接下来是主机变量,它将覆盖组变量;最后,组变量将覆盖库存变量文件。

通过库存文件覆盖配置参数

你可以通过库存文件直接覆盖某些 Ansible 的配置参数。这些配置参数将覆盖通过 ansible.cfg、环境变量或在 playbook 中设置的所有其他参数。传递给 ansible-playbook/ansible 命令的变量优先于任何其他变量,包括在库存文件中设置的变量。

以下是你可以从库存文件中覆盖的参数列表:

  • ansible_user:此参数用于覆盖与远程主机通信时使用的用户。有时,某些机器需要使用不同的用户,在这种情况下,使用此变量会对你有所帮助。

  • ansible_port:此参数将覆盖默认的 SSH 端口,使用用户指定的端口。有时,系统管理员选择在非标准端口运行 SSH。在这种情况下,你需要告知 Ansible 这个变化。

  • ansible_host:此参数用于覆盖别名的主机。

  • ansible_connection:指定与远程主机的连接类型。可选值有 SSH、Paramiko 或 local。

  • ansible_private_key_file:此参数将覆盖用于 SSH 的私钥;如果你想为特定主机使用特定的密钥,这将非常有用。一种常见的用例是,如果你有主机分布在多个数据中心、多个 AWS 区域或不同类型的应用程序中。此类场景下,私钥可能会有所不同。

  • ansible__type:默认情况下,Ansible 使用 sh shell;你可以通过 ansible_shell_type 参数覆盖这个设置。将其更改为 cshksh 等,会使 Ansible 使用该 shell 的命令。

使用动态清单

在某些环境中,你可能有一个系统自动创建和销毁机器。我们将在 第五章,云计算之路 中看到如何使用 Ansible 实现这一点。在这样的环境中,机器列表变化非常快,维护 hosts 文件变得复杂。在这种情况下,我们可以使用动态清单来解决问题。

动态清单的理念是,Ansible 不会读取 hosts 文件,而是执行一个脚本,该脚本将以 JSON 格式将主机列表返回给 Ansible。例如,这使得你可以直接向你的云服务提供商查询,了解在任何时刻,你整个基础设施中哪些机器正在运行。

许多常见云服务提供商的脚本已经在 Ansible 中提供,位置在:github.com/ansible/ansible/tree/devel/contrib/inventory,但如果你有不同的需求,也可以创建自定义脚本。Ansible 清单脚本可以使用任何语言编写,但出于一致性考虑,动态清单脚本应该使用 Python 编写。记住,这些脚本需要直接可执行,因此请确保为其设置可执行标志(chmod + x inventory.py)。

在本章中,我们将介绍可以从官方 Ansible 仓库下载的 Amazon Web Services 和 DigitalOcean 脚本。

Amazon Web Services

为了允许 Ansible 从 Amazon Web Services (AWS) 获取你的 EC2 实例数据,你需要从 Ansible 的 GitHub 仓库下载以下两个文件:github.com/ansible/ansible

  • ec2.py 清单脚本

  • ec2.ini 文件,其中包含 EC2 清单脚本的配置。

Ansible 使用 AWS Python 库 boto 通过 API 与 AWS 进行通信。为了允许这种通信,你需要导出 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY 环境变量。

你可以通过两种方式使用清单:

  • 直接将其传递给 ansible-playbook 命令,使用 -i 选项,并将 ec2.ini 文件复制到你运行 Ansible 命令的当前目录。

  • ec2.py 文件复制到 /etc/ansible/hosts,使用 chmod +x 命令使其可执行,并将 ec2.ini 文件复制到 /etc/ansible/ec2.ini

ec2.py文件将根据区域、可用区、标签等创建多个组。你可以通过运行./ec2.py --list来查看清单文件的内容。

让我们看一个使用 EC2 动态清单的示例 playbook,它将简单地 ping 我账户中的所有机器。

ansible -i ec2.py all -m ping

如预期的那样,我在账户上的两个 Droplet 返回了以下信息:

52.28.138.231 | SUCCESS => { 
    "changed": false, 
    "ping": "pong" 
} 

在前面的例子中,我们使用了ec2.py脚本,而不是使用静态清单文件,并通过-i选项和ping命令。

同样,你可以使用这些清单脚本执行各种类型的操作。例如,如果你按照区域(一个区域代表一个数据中心)在 AWS 中逐区部署,你可以将它与部署脚本集成,找出所有位于同一区域的节点并部署。

如果你只是想知道云中有哪些 Web 服务器,并且已经使用某种约定标记了它们,你可以通过过滤标签来使用动态清单脚本。更进一步,如果你有特殊场景,这些场景未被当前的脚本覆盖,你可以增强脚本,使其提供所需的 JSON 格式的节点集,然后从 playbooks 中操作这些节点。如果你使用数据库管理清单,你的清单脚本可以查询数据库并导出 JSON,甚至可以与云同步并定期更新数据库。

DigitalOcean

我们使用了github.com/ansible/ansible/tree/devel/contrib/inventory中的 EC2 文件从 AWS 获取数据,现在我们可以对 DigitalOcean 做同样的操作。唯一的区别是,我们需要获取digital_ocean.inidigital_ocean.py文件。

如之前所述,我们可能需要调整digital_ocean.ini的选项(如果需要),并使 Python 文件可执行。你可能需要更改的唯一选项是api_token

现在我们可以尝试通过以下命令 ping 所有在digital_ocean上可用的机器:

ansible -i digital_ocean.py all -m ping

如预期的那样,我在账户上的两个 Droplet 返回了以下信息:

188.166.150.79 | SUCCESS => { 
    "changed": false, 
    "ping": "pong" 
} 
46.101.77.55 | SUCCESS => { 
    "changed": false, 
    "ping": "pong" 
} 

现在我们已经看到从多个不同的云服务提供商获取数据是多么容易。

在 Ansible 中使用迭代器

你可能注意到到目前为止我们从未使用过循环,因此每次需要执行多个相似的操作时,我们都会多次编写相同的代码。一个例子就是webserver.yaml代码。

实际上,这就是webserver.yaml文件的内容:

--- 
- hosts: webserver 
  remote_user: ansible 
  tasks: 
  - name: Ensure the HTTPd package is installed 
    yum: 
      name: httpd 
      state: present 
    become: True 
  - name: Ensure the HTTPd service is enabled and running 
    service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure HTTP can pass the firewall 
    firewalld: 
      service: http 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 
  - name: Ensure HTTPS can pass the firewall 
    firewalld: 
      service: https 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 

如你所见,最后两个模块执行了相同的操作(确保防火墙的某个端口是开放的)。

标准迭代 - with_items

为了改进上述代码,我们可以使用一个简单的迭代器:with_items

这使我们能够在项列表中进行迭代,每次迭代时,列表中的指定项将通过item变量供我们使用。

因此,我们可以将代码更改为以下内容:

--- 
- hosts: webserver 
  remote_user: ansible 
  tasks: 
  - name: Ensure the HTTPd package is installed 
    yum: 
      name: httpd 
      state: present 
    become: True 
  - name: Ensure the HTTPd service is enabled and running 
    service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure HTTP and HTTPS can pass the firewall 
    firewalld: 
      service: '{{ item }}' 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 
    with_items: 
    - http 
    - https 

我们可以按如下方式执行它:

ansible-playbook -i hosts webserver.yaml

我们接收到以下内容:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure the HTTPd package is installed] *********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure the HTTPd service is enabled and running] ***********
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure HTTP can pass the firewall] *************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure HTTP and HTTPS can pass the firewall] ***************
ok: [ws02.fale.io] => (item=http)
ok: [ws01.fale.io] => (item=http)
ok: [ws02.fale.io] => (item=https)
ok: [ws01.fale.io] => (item=https) 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=5    changed=0    unreachable=0    failed=0 
ws02.fale.io      : ok=5    changed=0    unreachable=0    failed=0 

如你所见,输出与之前的执行略有不同,实际上在涉及循环操作的行中,我们可以看到处理的项——“确保 HTTP 和 HTTPS 能通过防火墙”块

我们现在已经了解了如何在一个项目列表上进行迭代,但 Ansible 也支持其他类型的迭代。

嵌套循环 - with_nested

有些情况下,你需要对一个列表中的所有元素与其他列表中的所有项进行迭代(笛卡尔积)。一个非常常见的情况是,当你需要在多个路径中创建多个文件夹时。在我们的示例中,我们将分别在用户 alicebobhome 文件夹中创建 mailpublic_html 文件夹。

我们可以使用以下来自 with_nested.yaml 文件的代码实现:

--- 
- hosts: all 
  remote_user: ansible 
  vars: 
    users: 
    - alice 
    - bob 
    folders: 
    - mail 
    - public_html 
  tasks: 
  - name: Ensure the users exist 
    user: 
      name: '{{ item }}' 
    become: True 
    with_items: 
    - '{{ users }}' 
  - name: Ensure the folders exist 
    file: 
      path: '/home/{{ item.0 }}/{{ item.1 }}' 
      state: directory 
    become: True 
    with_nested: 
    - '{{ users }}' 
    - '{{ folders }}' 

使用以下命令运行:

ansible-playbook -i hosts with_nested.yaml

我们收到以下结果:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [db01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure the users exist] ************************************
changed: [db01.fale.io] => (item=alice)
changed: [ws01.fale.io] => (item=alice)
changed: [ws02.fale.io] => (item=alice)
changed: [db01.fale.io] => (item=bob)
changed: [ws01.fale.io] => (item=bob)
changed: [ws02.fale.io] => (item=bob) 
TASK [Ensure the folders exist] **********************************
changed: [ws02.fale.io] => (item=[u'alice', u'mail'])
changed: [ws01.fale.io] => (item=[u'alice', u'mail'])
changed: [db01.fale.io] => (item=[u'alice', u'mail'])
changed: [ws01.fale.io] => (item=[u'alice', u'public_html'])
changed: [ws02.fale.io] => (item=[u'alice', u'public_html'])
changed: [db01.fale.io] => (item=[u'alice', u'public_html'])
changed: [ws02.fale.io] => (item=[u'bob', u'mail'])
changed: [ws01.fale.io] => (item=[u'bob', u'mail'])
changed: [db01.fale.io] => (item=[u'bob', u'mail'])
changed: [ws02.fale.io] => (item=[u'bob', u'public_html'])
changed: [ws01.fale.io] => (item=[u'bob', u'public_html'])
changed: [db01.fale.io] => (item=[u'bob', u'public_html']) 
PLAY RECAP *******************************************************
db01.fale.io      : ok=3    changed=2    unreachable=0    failed=0 
ws01.fale.io      : ok=3    changed=2    unreachable=0    failed=0 
ws02.fale.io      : ok=3    changed=2    unreachable=0    failed=0 

文件通配符循环 - with_fileglobs

有时,我们希望对某个文件夹中每个文件执行某种操作。如果你想将多个具有相似名称的文件从一个文件夹复制到另一个文件夹,这可能会非常方便。为此,你可以创建一个名为 with_fileglobs.yaml 的文件,并在其中写入以下代码:

--- 
- hosts: all 
  remote_user: ansible 
  tasks: 
  - name: Ensure the folder /tmp/iproute2 is present 
    file: 
      dest: '/tmp/iproute2' 
      state: directory 
    become: True 
  - name: Copy files that start with rt to the tmp folder 
    copy: 
      src: '{{ item }}' 
      dest: '/tmp/iproute2' 
      remote_src: True 
    become: True 
    with_fileglob: 
    - '/etc/iproute2/rt_*' 

我们可以使用以下命令执行:

ansible-playbook -i hosts with_fileglobs.yaml

以接收到类似以下的输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [ws02.fale.io]
ok: [db01.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure the folder /tmp/iproute2 is present] ****************
changed: [db01.fale.io]
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [Copy files that start with rt to the tmp folder] ***********
changed: [db01.fale.io] => (item=/etc/iproute2/rt_dsfield)
changed: [ws02.fale.io] => (item=/etc/iproute2/rt_dsfield)
changed: [ws01.fale.io] => (item=/etc/iproute2/rt_dsfield)
changed: [db01.fale.io] => (item=/etc/iproute2/rt_protos)
changed: [ws01.fale.io] => (item=/etc/iproute2/rt_protos)
changed: [ws02.fale.io] => (item=/etc/iproute2/rt_protos)
changed: [db01.fale.io] => (item=/etc/iproute2/rt_tables)
changed: [ws01.fale.io] => (item=/etc/iproute2/rt_tables)
changed: [ws02.fale.io] => (item=/etc/iproute2/rt_tables)
changed: [db01.fale.io] => (item=/etc/iproute2/rt_scopes)
changed: [ws01.fale.io] => (item=/etc/iproute2/rt_scopes)
changed: [ws02.fale.io] => (item=/etc/iproute2/rt_scopes)
changed: [db01.fale.io] => (item=/etc/iproute2/rt_realms)
changed: [ws01.fale.io] => (item=/etc/iproute2/rt_realms)
changed: [ws02.fale.io] => (item=/etc/iproute2/rt_realms) 
PLAY RECAP *******************************************************
db01.fale.io      : ok=3    changed=2    unreachable=0    failed=0 
ws01.fale.io      : ok=3    changed=2    unreachable=0    failed=0 
ws02.fale.io      : ok=3    changed=2    unreachable=0    failed=0 

整数循环 - with_sequence

许多时候,你需要对整数进行迭代。一个例子可能是创建十个名为 fileXY 的文件夹,其中 XY 是从 110 的顺序数字。为此,我们可以创建一个名为 with_sequence.yaml 的文件,并在其中写入以下代码:

--- 
- hosts: all 
  remote_user: ansible 
  tasks: 
  - name: Create the folders /tmp/dirXY with XY from 1 to 10 
    file: 
      dest: '/tmp/dir{{ item }}' 
      state: directory 
    with_sequence: start=1 end=10 
    become: True 

注意

对于 with_sequence,我们必须使用单行表示法。

然后,我们可以执行以下操作:

ansible-playbook -i hosts with_sequence.yaml

我们将收到:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io]
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Create the folders /tmp/dirXY with XY from 1 to 10] ********
changed: [ws02.fale.io] => (item=1)
changed: [ws01.fale.io] => (item=1)
changed: [db01.fale.io] => (item=1)
changed: [db01.fale.io] => (item=2)
changed: [ws02.fale.io] => (item=2)
changed: [ws01.fale.io] => (item=2)
changed: [db01.fale.io] => (item=3)
changed: [ws01.fale.io] => (item=3)
changed: [ws02.fale.io] => (item=3)
changed: [db01.fale.io] => (item=4)
changed: [ws01.fale.io] => (item=4)
changed: [ws02.fale.io] => (item=4)
changed: [db01.fale.io] => (item=5)
changed: [ws01.fale.io] => (item=5)
changed: [ws02.fale.io] => (item=5)
changed: [db01.fale.io] => (item=6)
changed: [ws01.fale.io] => (item=6)
changed: [ws02.fale.io] => (item=6)
changed: [db01.fale.io] => (item=7)
changed: [ws01.fale.io] => (item=7)
changed: [ws02.fale.io] => (item=7)
changed: [db01.fale.io] => (item=8)
changed: [ws01.fale.io] => (item=8)
changed: [ws02.fale.io] => (item=8)
changed: [db01.fale.io] => (item=9)
changed: [ws01.fale.io] => (item=9)
changed: [ws02.fale.io] => (item=9)
changed: [db01.fale.io] => (item=10)
changed: [ws01.fale.io] => (item=10)
changed: [ws02.fale.io] => (item=10) 
PLAY RECAP *******************************************************
db01.fale.io      : ok=2    changed=1    unreachable=0    failed=0 
ws01.fale.io      : ok=2    changed=1    unreachable=0    failed=0 
ws02.fale.io      : ok=2    changed=1    unreachable=0    failed=0 

Ansible 支持更多种类的循环,但由于它们使用的较少,你可以直接参考官方文档中的循环部分:docs.ansible.com/ansible/playbooks_loops.html

总结

在本章中,我们介绍了许多概念,帮助你将基础设施扩展到单一节点之外。我们从用于指示 Ansible 关于机器的清单文件开始,接着讨论了如何在对多个异构主机运行相同命令时使用主机特定和组特定的变量。然后,我们介绍了动态清单,这些清单是由其他系统(通常是云提供商)直接填充的。最后,我们分析了 Ansible playbook 中的多种迭代方式。

在下一章中,我们将以更合理的方式组织我们的 Ansible 文件,以确保最大程度的可读性。为此,我们引入了角色,这进一步简化了复杂环境的管理。

第四章: 处理复杂的部署

你一定在想,为什么这章会取这样的名字。原因是到目前为止,我们还没有进入可以在生产环境中部署 playbook 的阶段,尤其是在复杂的情况下。复杂情况包括那些你需要与多个(几百或几千)台机器进行交互的情况,其中每组机器可能依赖于另一组或多组机器。这些组之间可能在所有或部分事务上相互依赖,执行安全的复杂数据备份和主从复制。此外,还有一些 Ansible 的有趣且非常吸引人的特性,我们还没有探讨。在本章中,我们将通过示例来讲解这些特性。我们的目标是,在本章结束时,你应该清楚如何编写可以从配置管理角度部署到生产环境中的 playbook。接下来的章节将基于我们学到的内容,进一步增强使用 Ansible 的体验。

为此,我们将从一个可能在某些情况下派上用场的功能开始:local_action

使用 local_action 功能

Ansible 的 local_action 功能非常强大,尤其是在我们考虑编排时。这个功能允许你在运行 Ansible 的机器上本地执行某些任务。

考虑以下情况:

  • 启动一台新机器或创建一个 JIRA 工单

  • 管理你的指挥中心(s),包括安装软件包和配置设置

  • 调用负载均衡器 API 以从负载均衡器中禁用某个 Web 服务器条目

这些任务可以在运行 ansible-playbook 命令的同一机器上执行,而无需登录到远程主机并运行这些命令。

让我们看一个例子。假设你想在本地系统上运行一个 shell 模块,那里正在运行你的 Ansible playbook。在这种情况下,local_action 选项发挥了作用。如果你将模块名称和模块参数传递给 local_action,它将在本地运行该模块。让我们看看这个选项如何与 shell 模块一起工作。考虑以下代码,显示了 local_action 选项的输出:

    --- 
    - hosts: database 
      remote_user: ansible 
      tasks: 
      - name: Count processes running on the remote system 
        shell: ps | wc -l 
        register: remote_processes_number 
      - name: Print remote running processes 
        debug: 
          msg: '{{ remote_processes_number.stdout }}' 
      - name: Count processes running on the local system 
        local_action: shell ps | wc -l 
        register: local_processes_number 
      - name: Print local running processes 
        debug: 
          msg: '{{ local_processes_number.stdout }}' 

现在我们可以将其保存为 local_action.yaml 并用以下命令运行:

ansible-playbook -i hosts local_action.yaml

我们收到以下结果:

PLAY [database] **************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io] 
TASK [Count processes running on the remote system] **************
changed: [db01.fale.io] 
TASK [Print remote running processes] ****************************
ok: [db01.fale.io] => {
 "msg": "7"
} 
TASK [Count processes running on the local system] ***************
changed: [db01.fale.io -> localhost] 
TASK [Print local running processes] *****************************
ok: [db01.fale.io] => {
 "msg": "11"
} 
PLAY RECAP *******************************************************
db01.fale.io      : ok=5    changed=2    unreachable=0    failed=0 

如你所见,提供的两个命令给出了不同的数字,因为它们在不同的主机上执行。你可以使用local_action运行任何模块,Ansible 会确保该模块在运行 ansible-playbook 命令的机器上本地执行。另一个你可以(并且应该!)尝试的简单示例是运行两个任务:

  • 远程机器(如上例中的 db01)上的 uname

  • 本地机器上的 uname,但启用了 local_action

这将进一步明确 local_action 的概念。

Ansible 提供了另一种将某些操作委托给特定(或不同)机器的方法:delegate_to 系统。

委托任务

有时,你可能希望在不同的系统上执行操作。例如,当你在应用服务器节点或本地主机上部署某些内容时,可能需要在数据库节点上执行某个操作。为此,你只需要在任务中添加 delegate_to: HOST 属性,它将会在适当的节点上运行。让我们重新调整前面的示例来实现这一点:

    --- 
    - hosts: database 
      remote_user: ansible 
      tasks: 
      - name: Count processes running on the remote system 
        shell: ps | wc -l 
        register: remote_processes_number 
      - name: Print remote running processes 
        debug: 
          msg: '{{ remote_processes_number.stdout }}' 
      - name: Count processes running on the local system 
        shell: ps | wc -l 
        delegate_to: localhost 
        register: local_processes_number 
      - name: Print local running processes 
        debug: 
          msg: '{{ local_processes_number.stdout }}' 

将其保存为 delegate_to.yaml,然后可以使用以下命令运行:

ansible-playbook -i hosts delegate_to.yaml

我们将得到与之前示例相同的输出:

PLAY [database] **************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io] 
TASK [Count processes running on the remote system] **************
changed: [db01.fale.io] 
TASK [Print remote running processes] ****************************
ok: [db01.fale.io] => {
 "msg": "7"
} 
TASK [Count processes running on the local system] ***************
changed: [db01.fale.io -> localhost] 
TASK [Print local running processes] *****************************
ok: [db01.fale.io] => {
 "msg": "11"
} 
PLAY RECAP *******************************************************
db01.fale.io      : ok=5    changed=2    unreachable=0    failed=0 

使用条件语句

到目前为止,我们只看到了剧本如何工作以及任务如何执行。我们还看到 Ansible 按顺序执行所有任务。然而,这对你在编写包含数十个任务的高级剧本时并没有帮助,因为你可能只希望执行其中的一部分任务。例如,假设你有一个剧本,它将在远程主机上安装 Apache HTTPd 服务器。现在,Apache HTTPd 服务器在基于 Debian 的操作系统中的包名不同,叫做 apache2;而在基于 Red-Hat 的操作系统中,包名是 httpd

在剧本中拥有两个任务,一个用于 httpd 包(适用于基于 Red-Hat 的系统),另一个用于 apache2 包(适用于基于 Debian 的系统),这将导致 Ansible 安装两个包,而执行会失败,因为如果你在基于 Red-Hat 的操作系统上安装,apache2 包将不可用。为了解决这类问题,Ansible 提供了条件语句,帮助在指定条件满足时才执行任务。在这种情况下,我们做的操作类似于以下伪代码:

    If os = "redhat" 
      Install httpd 
    Else if os = "debian" 
      Install apache2 
    End 

在 Red-Hat 系统上安装 httpd 时,我们首先检查远程系统是否运行的是基于 Red-Hat 的操作系统,如果是,我们就安装 httpd 包;否则,跳过此任务。为了不浪费时间,让我们直接进入一个名为 conditional_httpd.yaml 的示例剧本,其内容如下:

    --- 
    - hosts: webserver 
      remote_user: ansible 
      tasks: 
      - name: Print the ansible_os_family value 
        debug: 
          msg: '{{ ansible_os_family }}' 
      - name: Ensure the httpd package is updated 
        yum: 
          name: httpd 
          state: latest 
        become: True 
        when: ansible_os_family == 'RedHat' 
      - name: Ensure the apache2 package is updated 
        apt: 
          name: apache2 
          state: latest 
        become: True 
        when: ansible_os_family == 'Debian' 

使用以下命令运行:

ansible-playbook -i hosts conditional_httpd.yaml

这是结果:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws03.fale.io]
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Print the ansible_os_family value] *************************
ok: [ws01.fale.io] => {
 "msg": "RedHat"
}
ok: [ws02.fale.io] => {
 "msg": "RedHat"
}
ok: [ws03.fale.io] => {
 "msg": "Debian"
} 
TASK [Ensure the httpd package is updated] ***********************
skipping: [ws03.fale.io]
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [Ensure the apache2 package is updated] *********************
skipping: [ws02.fale.io]
skipping: [ws01.fale.io]
changed: [ws03.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=3    changed=1    unreachable=0    failed=0 
ws02.fale.io      : ok=3    changed=1    unreachable=0    failed=0 
ws03.fale.io      : ok=3    changed=1    unreachable=0    failed=0 

如你所见,我为这个例子创建了一个新的服务器(ws03),它是基于 Debian 的。正如预期的那样,httpd 包在两个 CentOS 节点上被安装,而 apache2 包则在 Debian 节点上安装。

提示:

Ansible 只区分少数几个操作系统家族(AIX、Alpine、Altlinux、Archlinux、Darwin、Debian、FreeBSD、Gentoo、HP-UX、Mandrake、Red Hat、Slackware、Solaris 和 Suse,本文写作时如此),因此 CentOS 机器的 ansible_os_family 值为 'RedHat'

同样,你也可以匹配不同的条件。Ansible 支持等于(==)、不等于(!=)、大于(>)、小于(<)、大于或等于(>=)以及小于或等于(<=)等条件。

到目前为止,我们看到的运算符会匹配变量的整个内容,但如果你只想检查一个特定的字符或字符串是否出现在变量中呢?为了执行这些检查,Ansible 提供了 innot 运算符。你还可以使用 ANDOR 运算符匹配多个条件。AND 运算符会确保所有条件都匹配后才执行任务,而 OR 运算符则确保至少有一个条件匹配。例如,你可以使用 foo >= 0foo <= 5

布尔条件语句

除了字符串匹配外,你还可以检查一个变量是否为 True。这种类型的验证在你想要检查变量是否被赋值时非常有用。你甚至可以根据变量的布尔值来执行任务。

例如,假设我们将以下代码放在名为 crontab_backup.yaml 的文件中:

    --- 
    - hosts: all 
      remote_user: ansible 
      vars: 
        backup: True 
      tasks: 
      - name: Copy the crontab in tmp if the backup variable is true 
        copy: 
          src: /etc/crontab 
          dest: /tmp/crontab 
          remote_src: True 
        when: backup 

如果我们使用以下命令执行:

ansible-playbook -i hosts crontab_backup.yaml

我们将获得以下结果:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io]
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Copy the crontab in tmp if the backup variable is true] ****
changed: [ws02.fale.io]
changed: [db01.fale.io]
changed: [ws01.fale.io] 
PLAY RECAP *******************************************************
db01.fale.io      : ok=2    changed=1    unreachable=0    failed=0 
ws01.fale.io      : ok=2    changed=1    unreachable=0    failed=0 
ws02.fale.io      : ok=2    changed=1    unreachable=0    failed=0 

但如果我们稍微修改命令为:

ansible-playbook -i hosts crontab_backup.yaml --extra-vars="backup=False"

我们将收到以下输出:

PLAY [all] *******************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io]
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Copy the crontab in tmp if the backup variable is true] ****
skipping: [ws01.fale.io]
skipping: [ws02.fale.io]
skipping: [db01.fale.io] 
PLAY RECAP *******************************************************
db01.fale.io      : ok=1    changed=0    unreachable=0    failed=0 
ws01.fale.io      : ok=1    changed=0    unreachable=0    failed=0 
ws02.fale.io      : ok=1    changed=0    unreachable=0    failed=0 

如你所见,在第一种情况下,操作已执行,而在第二种情况下,它被跳过了。我们本可以使用配置文件、host 变量或 group 变量来覆盖备份值。

提示

如果以这种方式检查并且变量未设置,Ansible 将假设它为 False

检查变量是否已设置

有时你可能需要在命令中使用一个变量。每次这样做时,你都需要确保变量已设置。这是因为如果使用未设置的变量调用某些命令,可能会导致灾难性后果(例如:如果执行 rm -rf $VAR/*$VAR 未设置或为空,它将摧毁你的机器)。为此,Ansible 提供了一种检查变量是否已定义的方法。

我们可以通过以下方式改进前面的示例:

    --- 
    - hosts: all 
      remote_user: ansible 
      vars: 
        backup: True 
      tasks: 
      - name: Check if the backup_folder is set 
        fail: 
          msg: 'The backup_folder needs to be set' 
        when: backup_folder is not defined 
      - name: Copy the crontab in tmp if the backup variable is true 
        copy: 
          src: /etc/crontab 
          dest: '{{ backup_folder }}/crontab' 
          remote_src: True 
        when: backup 

如你所见,我们使用了 fail 模块,这可以让我们在 backup_folder 变量未设置时将 Ansible 剧本置于失败状态。

使用 include

include 功能有助于减少编写任务时的重复性。这也使我们能够通过使用不要重复自己DRY)原则,将可重用代码包含在独立的任务中,从而拥有更小的剧本。

要触发另一个文件的包含,你需要将以下内容放在任务对象下:

- include: FILENAME.yaml

你还可以将一些变量传递给包含的文件。为此,我们可以以以下方式指定它们:

- include: FILENAME.yaml variable1="value1" variable2="value2"

除了传递变量,你还可以使用条件语句,仅在满足特定条件时包含文件,例如,仅在机器运行 Red Hat 系列操作系统时,使用以下代码包含 redhat.yaml 文件:

 - name: Include the file only for Red Hat OSes
    include: redhat.yaml
    when: ansible_os_family == "RedHat"

与处理程序一起使用

在许多情况下,你会有一个任务或一组任务,改变远程机器上的某些资源,这些任务需要触发一个事件才能生效。例如,当你更改服务配置时,需要重新启动或重新加载该服务。在 Ansible 中,你可以使用 notify 动作触发这个事件。

每个处理任务在收到通知后将在 playbook 执行的最后运行。例如,你多次更改了 HTTPd 服务器的配置,并且你希望重启 HTTPd 服务以应用这些更改。现在,每次做出配置更改时重启 HTTPd 并不是一个好做法;即使配置没有更改,也不应该随便重启服务器。为了解决这种情况,你可以通知 Ansible 在每次配置更改时重启 HTTPd 服务,但 Ansible 会确保无论你多少次通知它重启 HTTPd,它只会在所有其他任务完成后执行该任务一次。我们来稍微修改一下之前章节中创建的 webserver.yaml 文件,修改方式如下:

--- 
- hosts: webserver 
  remote_user: ansible 
  tasks: 
  - name: Ensure the HTTPd package is installed 
    yum: 
      name: httpd 
      state: present 
    become: True 
  - name: Ensure the HTTPd service is enabled and running 
    service: 
      name: httpd 
      state: started 
      enabled: True 
    become: True 
  - name: Ensure HTTP can pass the firewall 
    firewalld: 
      service: http 
      state: enabled 
      permanent: True 
      immediate: True 
    become: True 
  - name: Ensure HTTPd configuration is updated 
    copy: 
      src: website.conf 
      dest: /etc/httpd/conf.d 
    become: True 
    notify: Restart HTTPd 
  handlers: 
  - name: Restart HTTPd 
    service: 
      name: httpd 
      state: restarted 
    become: True 

使用以下命令运行此脚本:

ansible-playbook -i hosts webserver.yaml

我们将得到以下输出:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure the HTTPd package is installed] *********************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure the HTTPd service is enabled and running] ***********
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure HTTP can pass the firewall] *************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure HTTPd configuration is updated] *********************
changed: [ws02.fale.io]
changed: [ws01.fale.io] 
RUNNING HANDLER [Restart HTTPd] **********************************
changed: [ws02.fale.io]
changed: [ws01.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=6    changed=2    unreachable=0    failed=0 
ws02.fale.io      : ok=6    changed=2    unreachable=0    failed=0 

在这种情况下,处理器是通过配置文件的更改被触发的。但是,如果我们第二次运行它,配置不会改变,因此我们将得到以下结果:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure the HTTPd package is installed] *********************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure the HTTPd service is enabled and running] ***********
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Ensure HTTP can pass the firewall] *************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure HTTPd configuration is updated] *********************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=5    changed=0    unreachable=0    failed=0 
ws02.fale.io      : ok=5    changed=0    unreachable=0    failed=0

注意

当使用处理器时,即使它们在 playbook 执行过程中被多次调用,它们也只会触发一次。默认情况下,处理器会在 playbook 执行结束时执行,但你可以使用 meta 任务并加上 flush_handlers 选项来强制它们按需运行,例如:- meta: flush_handlers

使用角色

我们已经看到了如何自动化一些简单的任务,但到目前为止,我们所看到的并不能解决所有问题。这是因为 playbook 非常擅长执行操作,但不适合配置大量机器,因为它们很快就会变得凌乱。为了解决这个问题,Ansible 引入了角色(roles)。

我对角色的定义是:一组 playbook、模板、文件或变量,用于实现特定目标。例如,我们可以有一个数据库角色和一个 Web 服务器角色,这样这些配置就能保持清晰的分离。

在开始查看角色的内部内容之前,我们先来谈谈项目的组织结构。

项目组织结构

在过去几年里,我为多个组织的多个 Ansible 仓库工作过,其中许多都非常混乱。为了确保你的仓库易于管理,我将给你一个我总是使用的模板。

首先,我总是会在 root 文件夹中创建三个文件:

  • ansible.cfg:一个小的配置文件,用来告诉 Ansible 在我们的文件夹结构中哪里可以找到相关文件

  • hosts:我们在之前章节中已经看到的 hosts 文件

  • master.yaml:一个用于对齐整个基础架构的 playbook

除了那三个文件,我还创建了两个文件夹:

  • playbooks:这个文件夹将包含所有的 playbooks 以及一个名为 groups 的文件夹,用于群组管理。

  • roles:这个文件夹将包含我们需要的所有角色

为了澄清这一点,让我们使用 Linux 的 tree 命令,查看一个简单的 Web 应用的 Ansible 仓库结构,该应用需要 Web 服务器和数据库服务器:

 ansible.cfg
    hosts
    master.yaml
    playbooks
        firstrun.yaml
        groups
            database.yaml
            webserver.yaml
    roles
         common
         database
         webserver 

如你所见,我还添加了一个 common 角色。这对于放置每个服务器都必须执行的任务非常有用。通常,我在这个角色中配置 NTP、motd 和其他类似的服务,以及机器的主机名。

现在我们来看一下如何构建一个角色。

角色的结构

角色中的文件夹结构是标准的,你不能做太多改变。

角色中最重要的文件夹是 tasks 文件夹,因为它是唯一一个必需的文件夹。它必须包含一个 main.yaml 文件,其中列出了需要执行的任务。角色中常见的其他文件夹包括 templates 和 files。第一个用于存放 template task 使用的模板,而第二个用于存放 copy task 使用的文件。

将你的 playbooks 转换为一个完整的 Ansible 项目

让我们来看看如何将我们用来设置 Web 基础设施的三个 playbooks(common_tasks.yamlfirstrun.yamlwebserver.yaml)转换为适合这种文件组织方式。我们还需要记住,我们在这些角色中使用了两个文件(index.html.j2motd),所以我们也要正确地放置这些文件。

首先,我们将创建在前一段中看到的文件夹结构。

最容易移植的 playbook 是 firstrun.yaml,因为我们只需要将它复制到 playbooks 文件夹中。这个 playbook 将保持为 playbook,因为它是一组只需要在每台服务器上执行一次的操作。

我们现在进入 common_tasks.yaml playbook,它需要一些修改,以符合角色的范式。

将 playbook 转换为角色

我们需要做的第一件事是创建 roles/common/tasksroles/common/templates 文件夹。在第一个文件夹中,我们将添加以下 main.yaml 文件:

    --- 
    - name: Ensure EPEL is enabled 
      yum: 
        name: epel-release 
        state: present 
      become: True 
    - name: Ensure libselinux-python is present 
      yum: 
        name: libselinux-python 
        state: present 
      become: True 
    - name: Ensure libsemanage-python is present 
      yum: 
        name: libsemanage-python 
        state: present 
      become: True 
    - name: Ensure we have last version of every package 
      yum: 
        name: "*" 
        state: latest 
      become: True 
    - name: Ensure NTP is installed 
      yum: 
        name: ntp 
        state: present 
      become: True 
    - name: Ensure the timezone is set to UTC 
      file: 
        src: /usr/share/zoneinfo/GMT 
        dest: /etc/localtime 
        state: link 
      become: True 
    - name: Ensure the NTP service is running and enabled 
      service: 
        name: ntpd 
        state: started 
        enabled: True 
      become: True 
    - name: Ensure FirewallD is installed 
      yum: 
        name: firewalld 
        state: present 
      become: True 
    - name: Ensure FirewallD is running 
      service: 
        name: firewalld 
        state: started 
        enabled: True 
      become: True 
    - name: Ensure SSH can pass the firewall 
      firewalld: 
        service: ssh 
        state: enabled 
        permanent: True 
        immediate: True 
      become: True 
    - name: Ensure the MOTD file is present and updated 
      template: 
        src: motd 
        dest: /etc/motd 
        owner: root 
        group: root 
        mode: 0644 
      become: True 
    - name: Ensure the hostname is the same of the inventory 
      hostname: 
        name: "{{ inventory_hostname }}" 
      become: True 

如你所见,这与我们的 common_tasks.yaml playbooks 非常相似。事实上,只有两个区别:

  • hostsremote_usertasks(第 2、3、4 行)已经被删除

  • 文件其余部分的缩进已相应修正

在这个角色中,我们使用了模板任务来创建一个包含机器 IP 和其他有用信息的 motd 文件。因此,我们需要创建 roles/common/templates 文件夹,并将 motd 模板放在其中。

到此为止,我们的公共任务将具有以下结构:

    common/ 
        tasks 
            main.yaml 
        templates 
            motd 

现在,我们需要指导 Ansible 在哪些机器上执行common角色中指定的所有任务。为此,我们应该查看playbooks/groups目录。在这个目录中,为每组逻辑上相似的机器(即执行相同操作的机器)创建一个文件是非常方便的。在我们的案例中,分别是数据库和 Web 服务器。

所以,让我们在playbooks/groups中创建一个database.yaml文件,内容如下:

    --- 
    - hosts: database 
      user: ansible 
      roles: 
      - common 

在同一个文件夹中创建一个webserver.yaml文件,内容如下:

    --- 
    - hosts: webserver 
      user: ansible 
      roles: 
      - common 

如你所见,这些文件指定了我们希望操作的主机组、在这些主机上使用的远程用户以及我们希望执行的角色。

辅助文件

当我们在上一章创建hosts文件时,我们注意到它帮助简化了我们的命令行。所以,现在我们开始将之前在 Ansible 仓库的root文件夹中使用的 hosts 文件复制过来。到目前为止,我们总是在命令行中指定这个文件的路径。如果我们创建一个ansible.cfg文件来告诉 Ansible 我们hosts文件的位置,那么就不再需要这样做了。因此,让我们在 Ansible 仓库的根目录下创建一个ansible.cfg文件,内容如下:

    [defaults] 
    hostfile = hosts 
    host_key_checking = False 
    roles_path = roles 

在这个文件中,我们除了之前提到的hostfile变量外,还指定了另外两个变量,它们分别是host_key_checkingroles_path

host_key_checking标志有助于避免要求验证远程系统的 SSH 密钥。这不建议在生产环境中使用,因为建议在此类环境中使用公共密钥传播系统,但在测试环境中非常有用,因为它将帮助你减少 Ansible 等待用户输入的时间。

roles_path用于告诉 Ansible 在哪里查找我们剧本的角色。

我通常会添加一个额外的文件,叫做master.yaml。我发现这个文件非常有用,因为你通常需要将基础设施与 Ansible 代码保持一致。为了通过一个命令完成这件事,你需要一个文件来运行playbooks/groups中的所有文件。因此,让我们在 Ansible 仓库的根文件夹中创建一个master.yaml文件,内容如下:

    --- 
    - include: playbooks/groups/database.yaml 
    - include: playbooks/groups/webserver.yaml 

此时,我们可以执行以下操作:

ansible-playbook master.yaml

结果如下所示:

PLAY [database] **************************************************
TASK [setup] *****************************************************
ok: [db01.fale.io] 
TASK [common : Ensure EPEL is enabled] ***************************
ok: [db01.fale.io] 
TASK [common : Ensure libselinux-python is present] **************
ok: [db01.fale.io] 
TASK [common : Ensure libsemanage-python is present] *************
ok: [db01.fale.io] 
TASK [common : Ensure we have last version of every package] *****
changed: [db01.fale.io] 
TASK [common : Ensure NTP is installed] **************************
ok: [db01.fale.io] 
TASK [common : Ensure the timezone is set to UTC] ****************
ok: [db01.fale.io] 
TASK [common : Ensure the NTP service is running and enabled] ****
ok: [db01.fale.io] 
TASK [common : Ensure FirewallD is installed] ********************
ok: [db01.fale.io] 
TASK [common : Ensure FirewallD is running] **********************
ok: [db01.fale.io] 
TASK [common : Ensure SSH can pass the firewall] *****************
ok: [db01.fale.io] 
TASK [common : Ensure the MOTD file is present and updated] ******
ok: [db01.fale.io] 
TASK [common : Ensure the hostname is the same of the inventory] *
ok: [db01.fale.io] 
PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure EPEL is enabled] ***************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure libselinux-python is present] **************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure libsemanage-python is present] *************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure we have last version of every package] *****
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [common : Ensure NTP is installed] **************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the timezone is set to UTC] ****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the NTP service is running and enabled] ****
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure FirewallD is installed] ********************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure FirewallD is running] **********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure SSH can pass the firewall] *****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the MOTD file is present and updated] ******
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the hostname is the same of the inventory] *
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
PLAY RECAP *******************************************************
db01.fale.io      : ok=13   changed=1    unreachable=0    failed=0 
ws01.fale.io      : ok=13   changed=1    unreachable=0    failed=0 
ws02.fale.io      : ok=13   changed=1    unreachable=0    failed=0

如你所见,common角色中列出的动作首先在database组的节点上执行,然后在webserver组的节点上执行。

转换webserver角色

当我们将common剧本转换为common角色时,我们可以对webserver角色做同样的事情。

在角色中,我们需要有一个webserver文件夹,并在其中创建tasks子文件夹。在这个文件夹里,我们需要放置一个main.yaml文件,其中包含从剧本中复制过来的tasks,它应该如下所示:

    --- 
    - name: Ensure the HTTPd package is installed 
      yum: 
        name: httpd 
        state: present 
      become: True 
    - name: Ensure the HTTPd service is enabled and running 
      service: 
        name: httpd 
        state: started 
        enabled: True 
      become: True 
    - name: Ensure HTTP can pass the firewall 
      firewalld: 
        service: http 
        state: enabled 
        permanent: True 
        immediate: True 
      become: True 
    - name: Ensure HTTPd configuration is updated 
      copy: 
        src: website.conf 
        dest: /etc/httpd/conf.d 
      become: True 
      notify: Restart HTTPd 
    - name: Ensure the website is present and updated 
      template: 
        src: index.html.j2 
        dest: /var/www/html/index.html 
        owner: root 
        group: root 
        mode: 0644 
      become: True 

在这个角色中,我们使用了多个任务,这些任务需要额外的资源才能正常工作,更具体地说,我们需要:

  • website.conf文件放入roles/webserver/files

  • index.html.j2模板放入roles/webserver/templates

  • 创建Restart HTTPd处理程序

前两个应该是相当直接的。事实上,第一个是一个空文件(我们还没有在其中放入任何内容,因为默认配置已经足够适合我们的使用),而index.html.j2文件应包含以下内容:

    <html> 
        <body> 
            <h1>Hello World!</h1> 
            <p>This page was created on {{ ansible_date_time.date }}.</p> 
            <p>This machine can be reached on the following IP addresses</p> 
            <ul> 
    {% for address in ansible_all_ipv4_addresses %} 
                <li>{{ address }}</li> 
    {% endfor %} 
            </ul> 
        </body> 
    </html> 

角色中的处理程序

完成此角色所需的最后一件事是创建Restart HTTPd通知的处理程序。为此,我们需要在roles/webserver/handlers中创建一个main.yaml文件,内容如下:

    --- 
    - name: Restart HTTPd 
      service: 
        name: httpd 
        state: restarted 
      become: True 

如你所注意到的,这与我们在剧本中使用的处理程序非常相似,只是文件位置和缩进不同。

为了使我们的角色适用,我们仍然需要做的唯一事情是添加条目到playbooks/groups/webserver.yaml文件中,这样 Ansible 就知道webserver组中的服务器应该应用webserver角色以及公共角色。我们的playbooks/groups/webserver.yaml文件需要如下所示:

    --- 
    - hosts: webserver 
      user: ansible 
      roles: 
      - common 
      - webserver 

我们现在可以再次执行master.yaml来将webserver角色应用到相关的服务器,但我们也可以只执行playbooks/groups/webserver.yaml,因为我们刚刚做的更改仅与这组服务器相关。为此,我们运行:

    ansible-playbook playbooks/groups/webserver.yaml

我们应该收到类似以下的输出:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure EPEL is enabled] ***************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure libselinux-python is present] **************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure libsemanage-python is present] *************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure we have last version of every package] *****
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure NTP is installed] **************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure the timezone is set to UTC] ****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the NTP service is running and enabled] ****
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure FirewallD is installed] ********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure FirewallD is running] **********************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [common : Ensure SSH can pass the firewall] *****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the MOTD file is present and updated] ******
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the hostname is the same of the inventory] *
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [webserver : Ensure the HTTPd package is installed] *********
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure the HTTPd service is enabled and running]
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure HTTP can pass the firewall] *************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [webserver : Ensure HTTPd configuration is updated] *********
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure the website is present and updated] *****
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=18   changed=1    unreachable=0    failed=0 
ws02.fale.io      : ok=18   changed=1    unreachable=0    failed=0

如你所见,commonwebserver角色已经应用于webserver节点。

应用所有与特定节点相关的角色非常重要,而不仅仅是你更改过的角色,因为通常情况下,当一个或多个节点出现问题,而同一组中的其他节点没有问题时,问题通常是因为组内某些角色的应用不均衡。只有将所有相关角色应用于一个组,才能确保该组中节点的平等性。

执行策略

在 Ansible 2 之前,每个任务都需要在每台机器上执行(并完成)后,Ansible 才会向所有机器发布新任务。这意味着如果你在一百台机器上执行任务,而其中一台机器性能较差,所有机器都会以性能较差的机器的速度运行。

在 Ansible 2 中,执行策略已被模块化,因此你现在可以选择自己偏好的执行策略。你还可以编写自定义的执行策略,但这超出了本书的范围。目前(在 Ansible 2.1 中)只有三种执行策略:线性串行自由

  • 线性执行:此策略与 Ansible 2 之前的行为完全相同。这是默认的执行策略。

  • 串行执行:此策略将选取一部分主机(默认是五台),并对这些主机执行所有任务,完成后再移至下一个子集并从头开始。此类执行策略可以帮助你在有限数量的主机上工作,以便始终保持一些主机可供用户使用。如果你需要这种类型的部署,你将需要在主机前放置负载均衡器,并实时通知负载均衡器哪些节点处于维护状态。

  • 自由执行:此策略将在每个主机完成前一个任务后立即将新任务分配给该主机。这将允许更快的主机在较慢的节点之前完成剧本。如果你选择此执行策略,你必须记住某些任务可能需要所有节点完成前一个任务(例如,集群数据库要求所有数据库节点都安装并运行数据库),在这种情况下,它们可能会失败。

任务块

在 Ansible 2.0 中,任务块已经被引入。任务块允许你以逻辑方式对任务进行分组,并且它们也有助于更好的错误处理。你可以添加到标准任务的多数属性,你也可以将它们添加到任务块中。如果机器是 CentOS,你可能需要执行一个 yum 任务来安装 NTPd 并启用该服务。为此,可以使用以下代码:

    tasks:
    - block:
       - name: Ensure NTPd is present
       yum:
         name: ntpd
         state: present
       - name: Ensure NTPd is running
       service:
         name: ntpd
         state: started
       enabled: True
     when: ansible_distribution == 'CentOS'

如你所见,when子句已应用于任务块,因此只有在when子句为真时,块中的所有任务才会执行。

Ansible 模板 - Jinja 过滤器

我们在第二章中已经看到,模板允许你根据动态数据(如hostgroup变量)动态地完成你的剧本并将文件放置在服务器上。在本节中,我们将继续前进,看看 Jinja2 过滤器如何与 Ansible 一起工作。

Jinja2 过滤器是简单的 Python 函数,它们接收一些参数,处理这些参数并返回结果。例如,考虑以下命令:

{{ myvar | filter }}

在前面的例子中,myvar是一个变量;Ansible 将myvar作为参数传递给 Jinja2 过滤器。然后,Jinja2 过滤器将处理它并返回结果数据。Jinja2 过滤器甚至接受如下的额外参数:

{{ myvar | filter(2) }}

在这个例子中,Ansible 现在将传递两个参数,即myvar2。同样,你可以通过逗号分隔来传递多个参数给过滤器。

Ansible 支持各种各样的 Jinja2 过滤器,我们将看到一些在编写剧本时可能需要使用的重要 Jinja2 过滤器。

使用过滤器格式化数据

Ansible 支持 Jinja2 过滤器将数据格式化为 JSON 或 YAML。你将一个字典变量传递给该过滤器,它将把数据格式化为 JSON 或 YAML。例如,考虑以下命令行:

{{ users | to_nice_json }}

在前面的示例中,users是变量,to_nice_json是 Jinja2 过滤器。正如我们之前看到的,Ansible 会将users作为参数传递给 Jinja2 过滤器to_nice_json。同样,你也可以使用以下命令将数据格式化为 YAML:

{{ users | to_nice_yaml }}

使用带有条件语句的过滤器

你可以使用 Jinja2 过滤器结合条件语句来检查任务的状态是失败、已更改、成功还是跳过。让我们开始在playbooks文件夹中创建一个文件,内容如下:

    --- 
    - hosts: webserver 
      remote_user: ansible 
      tasks: 
      - name: Checking HTTPd service status 
        service: 
          name: httpd 
          state: running 
        register: httpd_result 
        ignore_errors: true 
      - debug: 
          msg: Previous task failed 
        when: httpd_result|failed 

在前面的示例中,我们首先检查了httpd服务是否正在运行,并将该模块的输出存储在httpd_result变量中。然后,我们使用 Jinja2 过滤器httpd_result|failed检查前一个任务是否失败。如果when条件失败,即前一个任务通过,Ansible 将跳过此任务。同样,你可以使用changedsuccessskipped过滤器。

我们现在可以检查之前的playbook是否按预期执行,执行它的方法是:

ansible-playbook playbooks/http_status.yaml

我已经在ws01.fale.io服务器上停止了 HTTPd 服务,命令是systemctl stop httpd,执行它将给我以下结果:

PLAY [webserver] *************************************************
TASK [setup] *****************************************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Checking HTTPd service status] *****************************
ok: [ws02.fale.io]
fatal: [ws01.fale.io]: FAILED! => {"changed": false, "failed": true, "msg": "Failed to start httpd.service: Interactive authentication required.\n"}
...ignoring 
TASK [debug] *****************************************************
skipping: [ws02.fale.io]
ok: [ws01.fale.io] => {
 "msg": "Previous task failed"
} 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=3    changed=0    unreachable=0    failed=0 
ws02.fale.io      : ok=2    changed=0    unreachable=0    failed=0 

默认未定义的变量

我们在前面的章节中看到,在使用变量之前,检查变量是否已定义总是明智的做法。我们可以为变量设置一个default值,这样如果变量未定义,Ansible 将使用该值,而不会失败。要做到这一点,我们使用:

{{ backup_disk | default("/dev/sdf") }}

此过滤器不会将default值赋给变量;它只会将default值传递给当前正在使用它的任务。在关闭这一部分之前,让我们看几个 Jinja 过滤器的更多示例:

使用随机数过滤器:要从列表中查找一个随机的数字、字符或字符串,你可以使用随机过滤器:

  • 执行此命令以从列表中获取一个随机字符:
{{['a', 'b', 'c', 'd'] | random}}

  • 执行此命令以获取 0 到 100 之间的随机数字:
{{100 | random}}

  • 执行此命令以获取 10 到 50 之间步长为 10 的随机数:
{{50 | random(10)}}

  • 执行此命令以获取 20 到 50 之间步长为 10 的随机数字:
{{50 | random(20, 10)}}

使用过滤器将列表连接到字符串:Jinja2 过滤器允许你使用join过滤器将列表连接到字符串。此过滤器需要一个分隔符作为额外的参数。如果你没有指定分隔符,过滤器将会将列表的所有元素组合在一起,没有任何分隔符。考虑以下示例:

{{["This", "is", "a", "string"] | join(" ")}}

  • 上述过滤器将输出一个字符串。你可以指定任何分隔符,而不是空格。

  • 使用过滤器编码或解码数据:你可以使用过滤器来编码或解码数据,如下所示:

  • 使用b64encode过滤器将数据编码为base64

 {{variable | b64encode}}

  • 使用b64decode过滤器解码一个已编码的base64字符串:
      {{"aGFoYWhhaGE=" | b64decode}}

安全管理

本章的最后一节讲的是安全管理。如果您告诉您的系统管理员您想引入一个新功能或工具,他们首先会问的一个问题是:“您的工具有哪些安全功能?”我们将在本节中从 Ansible 的角度尝试回答这些问题。让我们更详细地看一下它们。

使用 Ansible vault

Ansible vault 是 Ansible 的一个令人兴奋的功能,首次出现在 Ansible 版本 1.5 中。它允许您将加密的密码作为源代码的一部分。推荐的做法是不将密码(以及任何其他敏感信息,如私钥、SSL 证书等)以明文形式存储在代码库中,因为任何检出您代码库的人都可以查看您的密码。Ansible vault 可以通过加密和解密内容来帮助您保护机密信息。

Ansible vault 支持交互模式,在该模式下它会要求您输入密码,或者支持非交互模式,您需要指定包含密码的文件,Ansible vault 将直接读取该文件。

在这些示例中,我们将使用密码ansible,所以让我们开始创建一个名为.password的隐藏文件,文件内容为ansible。为此,让我们执行:

echo 'ansible' > .password

我们现在可以在交互模式和非交互模式下创建ansible-vault。如果我们想在交互模式下进行,我们需要执行:

ansible-vault create secret.yaml

Ansible 将要求我们输入 vault 密码并确认。然后,它会打开默认的文本编辑器(在我的情况下是vi),以明文方式添加内容。我使用的密码是ansible,文本内容是“这是一个受密码保护的文件”。我们现在可以保存并关闭编辑器,检查ansible-vault是否加密了我们的内容,事实上,如果我们运行:

cat secret.yaml

这将输出以下内容:

$ANSIBLE_VAULT;1.1;AES256
66346431333933663461383331393763666538373163336536353335646532323135383630646366
3432353561393533623764323961666639326132323331370a636363613032616664333039356565
64643735626162646166313861366532323161646137333634333336393062303461343638333737
6534326135326430390a643739336461616334313833313363343030666662653864353138666233
38386266383866353836373036303339383962363362333364346432613062363830316330653866
6431343764386132663066303761346532643632633432643861

同样,我们可以使用vault-password-file=VAULT_PASSWORD_FILE选项调用ansible-vault命令,来指定我们的.password文件。例如,我们可以使用以下命令编辑我们的secret.yaml文件:

ansible-vault --vault-password-file=.password edit secret.yaml

这将打开您的默认文本编辑器,您可以像编辑普通文件一样修改文件。当您保存文件时,Ansible vault 将在保存之前执行加密,确保您的内容的机密性。

有时您需要查看文件的内容,但又不想在文本编辑器中打开它,所以通常使用cat命令。Ansible vault 有一个类似的功能,叫做view,您可以运行:

ansible-vault --vault-password-file=.password view secret.yaml

Ansible vault 允许您解密一个文件,将其加密内容替换为明文内容。要执行此操作,您可以运行:

ansible-vault --vault-password-file=.password decrypt secret.yaml

此时,我们可以在secret.yaml文件上运行cat命令,结果如下:

This is a password protected file

Ansible vault 还允许你加密已经存在的文件。如果你希望在一个受信任的机器上(例如你自己的本地机器)以明文形式开发所有文件,以提高效率,然后再加密所有敏感文件,这特别有用。为此,你可以执行:

ansible-vault --vault-password-file=.password encrypt secret.yaml

现在你可以检查 secret.yaml 文件是否已经再次加密。

Ansible vault 的最后一个选项非常重要,因为它是一个 rekey 功能。此功能允许你通过一个命令更改加密密钥。你可以通过两个命令执行相同的操作(用 旧密钥 解密 secret.yaml 文件,然后用 新密钥 加密),但是能够在单个步骤中完成这项操作有重大优势,因为在整个过程中,文件的明文形式不会存储在磁盘上。为此,我们需要一个包含新密码的文件(在我们这里,文件名为 .newpassword,内容为字符串 ansible2),然后需要执行以下命令:

ansible-vault --vault-password-file=.password --new-vault-password-file=.newpassword rekey secret.yaml

现在我们可以使用 cat 命令查看 secret.yaml 文件,输出结果如下:

$ANSIBLE_VAULT;1.1;AES256
63313864643434663939333132333537336362313133616430376463613833353366326662303832
6431316131613033343266373137356166383564326234300a386236633635333939333234643435
64353932383930613934343730386635333030373663313631646462613566313362313363393135
3935613661373263330a316634333536653461356535383662376464656466623536363537386462
31636637346538636161616632313866366365666361633138666134303433316665376237326162
3638653738383830323430313161336465323264613634323434

这与我们之前的方法非常不同。

保险库和 playbooks

你也可以在 ansible-playbook 中使用保险库。你需要使用类似以下的命令即时解密文件:

$ ansible-playbook site.yml --vault-password-file .password

还有另一个选项,允许你使用脚本解密文件,然后脚本可以查找其他来源并解密文件。这也可以作为一个有用的选项,以提供更多的安全性。然而,确保 get_password.py 脚本具有可执行权限:

$ ansible-playbook site.yml --vault-password-file ~/.get_password.py

在结束本章之前,我想稍微谈谈密码文件。此文件需要存在于你执行 playbooks 的机器上,并且存放在具有适当权限的位置,以便执行 playbook 的用户可以读取。你可以在启动时创建 .password 文件。.password 文件名中的 . 字符是为了确保文件在查找时默认是隐藏的。这并不直接是一种安全措施,但可以帮助缓解攻击者不清楚自己在寻找什么的情况。

.password 文件的内容应该是一个密码或密钥,确保只有有权限运行 Ansible playbooks 的人员可以访问。

最后,确保你不会加密每一个可用的文件!Ansible vault 应该仅用于需要安全保护的重要信息。

注意

每次保存加密文件时,不管是否已应用更改,文件都会重新加密,因此加密内容会发生变化。这会导致你的 SCM 工具标记该文件为已修改。

加密用户密码

Ansible Vault 负责处理已检查的密码,并帮助你在运行 Ansible 剧本或命令时管理它们。然而,在运行 Ansible play 时,有时你可能需要用户输入密码。你还想确保这些密码不会出现在全面的 Ansible 日志(默认位置:/var/log/ansible.log)或stdout中。

Ansible 使用Passlib,它是一个 Python 的密码哈希库,用于处理提示密码的加密。你可以使用Passlib支持的以下任意算法:

  • des_crypt: DES Crypt

  • bsdi_crypt: BSDi Crypt

  • bigcrypt: BigCrypt

  • crypt16: Crypt16

  • md5_crypt: MD5 Crypt

  • bcrypt: BCrypt

  • sha1_crypt: SHA-1 Crypt

  • sun_md5_crypt: Sun MD5 Crypt

  • sha256_crypt: SHA-256 Crypt

  • sha512_crypt: SHA-512 Crypt

  • apr_md5_crypt: Apache 的 MD5-Crypt 变种

  • phpass: PHPass 的便携式哈希

  • pbkdf2_digest: 通用 PBKDF2 哈希

  • cta_pbkdf2_sha1: Cryptacular 的 PBKDF2 哈希

  • dlitz_pbkdf2_sha1: Dwayne Litzenberger 的 PBKDF2 哈希

  • scram: SCRAM 哈希

  • bsd_nthash: FreeBSD 的 MCF 兼容nthash编码

现在让我们来看一下如何使用变量提示进行加密:

    vars_prompt:
    - name: ssh_password 
      prompt: Enter ssh_password 
      private: True 
      encryption: md5_crypt 
      confirm: True 
      salt_size: 7 

在前面的代码片段中,vars_prompt用于提示用户输入一些数据。vars_prompt不是任务,而是与tasks:同一级别的另一个部分。

name模块表示 Ansible 将存储用户密码的实际变量名称,如下命令所示:

name: ssh_password

我们使用prompt工具提示用户输入密码,如下所示:

prompt: Enter ssh password

我们明确要求 Ansible 通过使用private模块从stdout中隐藏密码;这就像任何 Unix 系统上的密码提示一样。private模块的访问方式如下:

private: True

我们在这里使用md5_crypt算法,并且盐值大小为7

encrypt: md5_crypt
salt_size: 7

此外,Ansible 将提示用户输入两次密码并进行比较:

confirm: True

隐藏密码

默认情况下,Ansible 会过滤包含login_password键、password键以及user:pass格式的输出。例如,如果你通过login_passwordpassword键在模块中传递密码,Ansible 将用VALUE_HIDDEN替换你的密码。现在我们来看一下如何使用password键隐藏密码:

- name: Running a script
 shell: script.sh
 password: my_password

在前面的shell任务中,我们使用password键传递密码。这将允许 Ansible 隐藏密码,不显示在stdout和其日志文件中。

现在,当你在详细模式下运行上述任务时,你应该看不到mypass密码;相反,Ansible 将用VALUE_HIDDEN替代它,如下所示:

REMOTE_MODULE command script.sh password=VALUE_HIDDEN #USE_SHELL

注意

Ansible 会保护你声明为密码的字符串,即使它们在其他上下文中使用。例如,如果你有另一个变量包含字符串my_password,如果你将其打印出来,HIDDEN_VALUE将会出现,即使该特定变量没有被声明为密码。

使用 no_log

Ansible 只会在你使用特定的密钥集时才会隐藏你的密码。然而,这并不总是如此;此外,你可能还希望隐藏一些其他的机密数据。Ansible 的 no_log 功能将隐藏整个任务,不会将其记录到 syslog 文件中。它仍然会在 stdout 上打印你的任务,并将其记录到其他 Ansible 日志文件中。

注意

在编写本书时,Ansible 不支持使用 no_log 隐藏来自 stdout 的任务。

另一种防止 Ansible 记录日志的方法是在 ansible.cfg 文件的 [defaults] 部分设置 log_path/dev/null,这样所有日志都会被保存到 /dev/null,因此会丢失。

现在让我们来看一下如何使用 no_log 隐藏整个任务,如下所示:

    - name: Running a script
      shell: script.sh
        password: my_password
      no_log: True

通过将 no_log: True 传递给任务,Ansible 将防止整个任务被记录到 syslog 中。

总结

在本章中,我们学习了大量的 Ansible 功能。我们从 local_actions 开始,用于在一台机器上执行操作,然后转向委托,在第三台机器上执行任务。接着,我们介绍了条件语句和包含功能,使 playbook 更加灵活。我们了解了角色及其如何帮助你保持系统的一致性,并学习了如何正确组织 Ansible 仓库,最大限度地利用 Ansible 和 Git。随后,我们讨论了执行策略和 Jinja 过滤器,以实现更灵活的执行。

本章最后,我们介绍了 Ansible vault 和其他许多提高 Ansible 执行安全性的技巧。

在下一章,我们将学习如何使用 Ansible 创建基础设施,更具体地说,如何使用云服务提供商 AWS 和 DigitalOcean 来完成这一任务。

第五章:进入云端

在本章中,我们将学习如何使用 Ansible 在几分钟内完成基础设施的配置。在我看来,这是 Ansible 最有趣和最强大的功能之一,因为它使你能够以快速且一致的方式(重新)创建环境。当你有多个环境用于部署管道的各个阶段时,这一点尤其重要。实际上,它使你能够创建相同的环境,并在需要进行更改时保持一致,且不会带来任何痛苦。

让 Ansible 配置你的机器还有其他优点,因此我总是建议做以下事情:

  • 审计日志:近年来,IT 行业吞并了大量其他行业,因此审计过程现在将 IT 视为一个关键部分。当审计员来 IT 部门要求获取一台服务器的历史记录时,从其创建到现在,拥有完整过程的 Ansible playbook 将非常有帮助。

  • 多个预备环境:正如我们之前提到的,如果你有多个环境,使用 Ansible 配置服务器将对你大有帮助。

  • 迁移服务器:当一家公司使用全球云服务提供商(如 AWS 或 DigitalOcean)时,他们通常会选择离他们的办公室或客户最近的区域来创建第一台服务器。这些提供商经常开设新的区域,如果他们的新区域靠近你,你可能会想将你的基础设施迁移或扩展到新区域。如果你手动配置了每个资源,这将是一个噩梦。

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

  • Amazon Web ServicesAWS)中配置机器。

  • 在 DigitalOcean 中配置机器。

  • 配置 Docker 容器。

大多数新机器的创建有两个阶段:

  • 配置一台新机器或一组新机器。

  • 运行 playbook,确保新机器正确配置,以在你的基础设施中发挥作用。

我们在前几章中已经了解了配置管理的相关内容。本章将更侧重于新机器的配置,而在配置管理方面的讨论将相对较少。

在云中配置资源。

有了这些,让我们进入第一个话题。今天,管理基础设施的团队有很多选择来运行他们的构建、测试和部署。像 Amazon、Rackspace 和 DigitalOcean 这样的提供商主要提供 基础设施即服务IaaS)。当我们谈到 IaaS 时,最好谈论资源,而不是虚拟机,原因有很多:

  • 那些公司允许你配置的大多数产品并不是机器,而是其他关键资源,如网络和存储。

  • 最近,许多公司已经开始提供各种不同类型的计算实例,从裸金属机器到容器。

  • 在一些非常简单的环境中,设置没有网络(或存储)的机器可能就足够了,但在生产环境中可能不够用。

这些公司通常提供 API、CLI、GUI 和 SDK 工具,用于创建和管理云资源的整个生命周期。我们更感兴趣的是使用他们的 SDK,因为它将在我们的自动化工作中发挥重要作用。最初,设置新服务器和配置它们可能很有趣,但在某个阶段,它可能会变得乏味,因为这类操作非常重复。每个配置步骤都将涉及多个类似的步骤,以使服务器能够启动并运行。

想象一下,有一天早晨,你收到一封电子邮件,要求为三个新客户进行设置,其中每个客户的设置有三到四个实例,以及一堆服务和依赖项。这对你来说可能是一个简单的任务,但需要多次运行相同的重复命令,随后还要监控服务器启动,确保一切顺利。此外,任何手动操作都有可能引入问题。如果两个客户的设置顺利完成,但由于疲劳,你遗漏了第三个客户的某个步骤,从而引入了问题怎么办?为了解决这种情况,自动化就派上了用场。

云资源自动化配置使得工程师可以尽快地建立一台新服务器,让她能够集中精力处理其他优先事项。通过使用 Ansible,你可以轻松地执行这些操作,并以最小的努力自动化云资源配置。Ansible 为你提供了自动化多种不同云平台的能力,例如亚马逊、Azure、DigitalOcean、谷歌云、Rackspace 等,且在 Ansible 核心或扩展模块包中提供了不同服务的模块。

注意

如前所述,启动新机器并不是最终目的。我们还需要确保我们配置它们以执行所需的角色。

在接下来的章节中,我们将配置我们在前几章中使用的环境(两个 Web 服务器和一个数据库服务器),并在以下环境中进行配置:

  • 简单的亚马逊 Web 服务部署:所有机器将被放置在同一可用区和同一网络中。

  • 复杂的亚马逊 Web 服务部署:机器将分布在多个可用区和网络中。

  • DigitalOcean:DigitalOcean 不允许我们进行很多网络调整,因此它将与第一个类似。

  • Docker:在这种情况下,我们将创建一个简单的部署。

亚马逊 Web 服务

亚马逊 Web 服务是使用最广泛的公共云之一,通常因为其提供的大量服务以及丰富的文档、解答问题和相关文章而被选择,这些都是如此受欢迎的产品所能期待的。

由于 AWS 的目标是成为一个完整的虚拟数据中心提供商(以及更多),我们需要像搭建真实数据中心一样创建和管理我们的网络。显然,由于这是一个虚拟数据中心,我们不需要布线。因此,几行 Ansible 剧本就足够了。

AWS 全球基础设施

亚马逊一直对其云服务由多少数据中心组成、具体位置等信息保持较为低调。截至我写这篇文章时,AWS 拥有 13 个区域(还有 4 个区域已经规划中),共计 35 个 可用区AZ)和超过 50 个边缘位置。亚马逊将一个区域定义为一个物理位置,亚马逊在该位置有多个可用区。根据亚马逊对可用区的定义,一个 AZ 由一个或多个独立的数据中心组成,每个数据中心都有冗余的电力、网络和连接,并位于不同的设施中。至于边缘位置,目前没有官方定义。

如你所见,从现实生活的角度来看,这些定义并不能提供太多帮助。当我尝试解释这些概念时,我通常会使用我自己创建的不同定义:

  • 区域:由物理上相邻的多个可用区组成的群体

  • 可用区:一个区域中的数据中心(亚马逊表示这可能不止一个数据中心,但由于没有文档列出每个 AZ 的具体布局,我假设最坏的情况)

  • 边缘位置:互联网交换点或第三方数据中心,亚马逊在这些地方拥有 S3 和 Route 53 的端点

尽管我尽力让这些定义尽可能简单和有用,但其中一些仍然比较模糊。当我们开始讨论实际的世界差异时,这些定义会立刻变得清晰。例如,从网络速度的角度来看,当你在同一个 AZ 内移动内容时,带宽非常高。当你在同一地区的两个 AZ 之间进行相同的操作时,带宽仍然很高;然而,如果你使用两个来自不同区域的 AZ,带宽将大大降低。此外,价格也有所不同,因为同一地区内的所有流量都是免费的,而不同地区之间的流量则不免费。

AWS 简单存储服务

Amazon S3 是第一个 AWS 服务,也是最著名的 AWS 服务之一。Amazon S3 是一个对象存储服务,具有公共端点和私人端点。它使用桶(bucket)这一概念,允许你存储不同类型的文件,并以简单的方式管理它们。Amazon S3 还提供了更多高级功能,例如使用内置的 Web 服务器来提供桶中的内容。这也是许多人决定将自己网站的内容或图片托管在 Amazon S3 上的原因之一。

S3 的主要优势有:

  • 定价模式:按使用的千兆字节/月和传输的千兆字节计费。

  • 可靠性:亚马逊确认 AWS S3 上的对象在任何一年内生存的概率为 99.999999999%。这个概率比任何硬盘都要高出几个数量级。

  • 工具:由于 S3 已经推出多年,因此有许多工具已经实现以便利用该服务。

AWS 弹性计算云(EC2)

AWS 推出的第二个服务是 EC2 服务。此服务允许您在 AWS 基础设施上启动虚拟机。可以将这些 EC2 实例视为 OpenStack 计算实例或 VMware 虚拟机。最初,这些机器与 VPS 非常相似,但过了一段时间,亚马逊决定对这些机器提供更大的灵活性,推出了非常先进的网络选项。旧款机器仍然可以在最老的数据中心找到,名为 EC2 Classic,而新款机器则是当前的默认版本,简称 EC2

AWS 虚拟专用云(VPC)

VPC 是我们在前文提到的亚马逊的网络实现。VPC 更像是一组工具,而不是单一工具。实际上,它提供的功能曾由经典数据中心中的多个硬件设备提供。通过 VPC,您可以创建的主要内容有:

  • 交换机

  • 路由器

  • DHCP

  • 网关

  • 防火墙

  • 虚拟私人网络

在使用 VPC 时需要理解的一件重要事情是,网络的布局并非完全随意的,因为亚马逊为了简化网络设计,设置了一些限制。基本的限制有:

  • 不能在可用区(AZ)之间创建子网络

  • 不能在不同区域之间创建网络

  • 不能直接在不同区域之间路由网络

对于前两种情况,唯一的解决方案是创建多个网络和子网络;而对于第三种情况,您实际上可以通过 VPN 服务实现一个变通方案,可以是自行提供的,也可以使用官方的 AWS VPN 服务来提供。

我们将主要使用 VPC 的交换和路由功能。

AWS Route 53

和许多其他云服务一样,亚马逊提供了 DNS 即服务DNSaaS)功能,在亚马逊的情况下,它叫做 Route 53。Route 53 是一个分布式的 DNS 服务,全球有超过 50 个端点(Route 53 存在于所有 AWS 边缘位置)。

Route 53 允许您为一个域创建不同的区域,支持分割视图(split-horizon)情况。根据客户端请求 DNS 解析时是否位于 VPC 内部或外部,会返回不同的响应。这在你希望将应用程序轻松地进出 VPC 时非常有用,而不需要更改,同时又希望尽可能让流量保持在私有(虚拟)网络中。

AWS 弹性块存储(EBS)

AWS EBS 是一种块存储服务,允许您的 EC2 实例保存数据,这些数据即使重启也能得以保留,并且非常灵活。从用户角度看,EBS 很像任何其他 SAN 产品,但界面更简洁,因为您只需创建卷并告诉 EBS 需要将其附加到哪个机器,EBS 会自动完成其余操作。您可以将多个卷附加到同一台服务器,但每个卷在任何时刻只能连接到一台服务器。

AWS 身份与访问管理

为了让您管理用户和访问方式,Amazon 提供了 IAM 服务。IAM 服务的主要功能包括:

  • 创建、编辑和删除用户

  • 更改用户密码

  • 创建、编辑和删除组

  • 管理用户和组关联

  • 管理令牌

  • 管理双重身份验证

  • 管理 SSH 密钥

我们将使用此服务来设置用户及其权限。

亚马逊关系型数据库服务

设置和维护关系型数据库是复杂且耗时的。为简化此过程,Amazon 提供了一些广泛使用的数据库即服务(DBaaS),具体包括:

  • Aurora

  • MariaDB

  • MySQL

  • Oracle

  • PostgreSQL

  • SQL Server

对于每种数据库引擎,Amazon 提供不同的功能和定价模型,但每种的具体内容超出了本书的范围。

设置 AWS 账户

在开始使用 Amazon Web Services 之前,首先需要一个账户。创建 Amazon Web Services 账户相当简单,并且有详细的官方文档和多个独立网站的支持,因此本书不会涉及这部分内容。

在创建了 AWS 账户后,您需要进入 AWS 并执行以下操作:

  • 上传您的 SSH 密钥至 EC2 | 密钥对

  • 身份与访问管理 | 用户 | 创建新用户 中创建一个新用户,并在 ~/.aws/credentials 文件中加入以下内容:

    [default] 
    aws_access_key_id = YOUR_ACCESS_KEY 
    aws_secret_access_key = YOUR_SECRET_KEY 

在您创建了 AWS 密钥并上传了 SSH 密钥后,您需要设置 Route53。在 Route53 中,您需要为您的域创建两个区域(如果没有未使用的域名,也可以使用子域):一个 公共 区域和一个 私有 区域。

如果您仅创建公共区域,Route53 会在所有地方传播此区域;但如果创建了公共和私有区域,Route53 会在所有地方提供公共区域,但在创建私有区域时指定的 VPC 中,私有区域将被使用。如果您从该 VPC 内查询这些 DNS 记录,私有区域将被使用。这种方式有多个优点:

  • 仅公开公共机器的 IP 地址

  • 始终使用 DNS 名称而非 IP 地址,即使是内部流量

  • 确保您的内部机器直接通信,避免流量通过公共网络

  • 由于 Amazon Web Services 中的外部 IP 是由 Amazon 管理的虚拟 IP,并通过 NAT 与您的实例关联,这种方式提供了最少的跳数,因此具有较低的延迟。

注意

如果你为公共区域声明了一个条目,但没有在私有区域中声明,VPC 中的机器将无法解析该条目。

在你创建了公共区域之后,Amazon Web Services 会提供几个名称服务器 IP 地址,你需要将这些地址放入你的注册/根区域 DNS 中,以便你可以实际解析这些 DNS。

简单的 AWS 部署

正如我们之前所说的,首先我们需要将网络搭建好。对于这个例子,我们只需要在一个可用区内配置一个网络,所有的机器将都留在这个网络中。

在这一部分,我们将会在 playbooks/aws_simple_provision.yaml 文件中进行操作。

前两行只是用于声明将执行命令的主机(localhost)以及 tasks 部分的开始:

    - hosts: localhost 
      tasks:

在 AWS 中,我们需要有一个 VPC 网络和子网络,但如果你需要的话,你可以按如下方式创建 VPC 网络:

    To create the VPC subnetwork: 
      - name: Ensure the VPC subnetwork is present 
        ec2_vpc_subnet: 
          state: present 
          az: AWS_AZ 
          vpc_id: '{{ aws_simple_net.vpc_id }}' 
          cidr: 10.0.1.0/24 
        register: aws_subnet 

现在我们已经有了网络和子网的所有信息,我们可以继续处理安全组了。我们可以通过 ec2_group 模块来实现这一点。在亚马逊 Web 服务中,安全组用于防火墙。安全组类似于具有相同目标(对于入口规则)或相同目标(对于出口规则)的防火墙规则组。值得一提的是,标准防火墙规则与安全组有三个区别:

  • 可以将多个安全组应用到同一个 EC2 实例。

  • 作为源(对于入口规则)或目的地(对于出口规则),你可以指定以下其中之一:

    • 一个实例 ID

    • 另一个安全组

    • 一个 IP 范围

  • 你不需要在链的末尾指定默认的拒绝规则,因为 AWS 默认会添加它。

      - name: Ensure websg Security Group is present 
        ec2_group: 
          name: web 
          description: Web Security Group 
          region: AWS_AZ 
          vpc_id: VPC_ID 
          rules: 
          - proto: tcp 
            from_port: 80 
            to_port: 80 
            cidr_ip: 0.0.0.0/0 
          - proto: tcp 
            from_port: 443 
            to_port: 443 
            cidr_ip: 0.0.0.0/0 
          rules_egress: 
          - proto: all 
            cidr_ip: 0.0.0.0/0 
        register: aws_simple_websg 

所以,在我的情况下,以下代码将会被添加到 playbooks/aws_simple_provision.yaml

      - name: Ensure wssg Security Group is present 
        ec2_group: 
          name: wssg 
          description: Web Security Group 
          region: eu-west-1 
          vpc_id: '{{ aws_simple_net.vpcs.0.id }}' 
          rules: 
          - proto: tcp 
            from_port: 22 
            to_port: 22 
            cidr_ip: 0.0.0.0/0 
          - proto: tcp 
            from_port: 80 
            to_port: 80 
            cidr_ip: 0.0.0.0/0 
          - proto: tcp 
            from_port: 443 
            to_port: 443 
            cidr_ip: 0.0.0.0/0 
          rules_egress: 
          - proto: all 
            cidr_ip: 0.0.0.0/0 
        register: aws_simple_wssg 

我们现在将为我们的数据库创建另一个安全组。在这种情况下,我们只需要将端口 3036 开放给 Web 安全组中的服务器:

      - name: Ensure dbsg Security Group is present 
        ec2_group: 
          name: dbsg 
          description: DB Security Group 
          region: eu-west-1 
          vpc_id: '{{ aws_simple_net.vpcs.0.id }}' 
          rules: 
          - proto: tcp 
            from_port: 3036 
            to_port: 3036 
            group_id: '{{ aws_simple_wssg.group_id }}' 
          rules_egress: 
          - proto: all 
            cidr_ip: 0.0.0.0/0 
        register: aws_simple_dbsg 

注意

如你所见,我们允许所有出口流量通过。这并不是安全最佳实践所建议的,因此你可能需要对出口流量进行调控。一个常常迫使你调控出口流量的情况是,当你希望目标机器符合 PCI-DSS 标准时。

现在我们已经有了 VPC、子网和所需的安全组,接下来我们可以开始实际创建 EC2 实例了:

      - name: Setup instances 
        ec2: 
          assign_public_ip: '{{ item.assign_public_ip }}' 
          image: ami-7abd0209 
          region: eu-west-1 
          exact_count: 1 
          key_name: fale 
          count_tag: 
            Name: '{{ item.name }}' 
          instance_tags: 
            Name: '{{ item.name }}' 
          instance_type: t2.micro 
          group_id: '{{ item.group_id }}' 
          vpc_subnet_id: '{{ aws_simple_subnet.subnets.0.id }}' 
          volumes: 
            - device_name: /dev/sda1 
              volume_type: gp2 
              volume_size: 10 
              delete_on_termination: True 
        register: aws_simple_instances 
        with_items: 
        - name: ws01.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
        - name: ws02.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
        - name: db01.simple.aws.fale.io 
          group_id: '{{ aws_simple_dbsg.group_id }}' 
          assign_public_ip: False 

注意

当我们创建 db 机器时并未指定 assign_public_ip: True 这一行。在这种情况下,机器将不会获得公共 IP,因此它将无法从我们 VPC 外部访问。由于我们为这台服务器使用了非常严格的安全组,它无论如何都无法从 wssg 外的任何机器访问。

正如你猜测的那样,我们刚刚看到的这段代码将会创建我们的三个实例(两个 Web 服务器和一个数据库服务器)。

我们现在可以将这些新创建的实例添加到我们的 Route 53 账户中,以便解析这些机器的 FQDN。为了与 AWS Route 53 交互,我们将使用 route53 模块,该模块允许我们创建条目、查询条目和删除条目。要创建新的条目,我们将使用以下代码:

      - name: Add route53 entry for server SERVER_NAME 
        route53: 
          command: create 
          zone: ZONE_NAME 
          record: RECORD_TO_ADD 
          type: RECORD_TYPE 
          ttl: TIME_TO_LIVE 
          value: IP_VALUES 
          wait: True 

因此,要为我们的服务器创建条目,我们将添加以下代码:

      - name: Add route53 rules for instances 
        route53: 
          command: create 
          zone: aws.fale.io 
          record: '{{ item.tagged_instances.0.tags.Name }}' 
          type: A 
          ttl: 1 
          value: '{{ item.tagged_instances.0.public_ip }}' 
          wait: True 
        with_items: '{{ aws_simple_instances.results }}' 
        when: item.tagged_instances.0.public_ip 
      - name: Add internal route53 rules for instances 
        route53: 
          command: create 
          zone: aws.fale.io 
          private_zone: True 
          record: '{{ item.tagged_instances.0.tags.Name }}' 
          type: A 
          ttl: 1 
          value: '{{ item.tagged_instances.0.private_ip }}' 
          wait: True 
        with_items: '{{ aws_simple_instances.results }}' 

注意

由于数据库服务器没有公共地址,将这台机器发布到公共区域没有意义,因此我们只在内部区域创建了这台机器的条目。

综合起来,playbooks/aws_simple_provision.yaml 的内容将如下:

    - hosts: localhost 
      tasks: 
      - name: Gather information of the EC2 VPC net in eu-west-1 
        ec2_vpc_net_facts: 
          region: eu-west-1 
        register: aws_simple_net 
      - name: Gather information of the EC2 VPC subnet in eu-west-1 
        ec2_vpc_subnet_facts: 
          region: eu-west-1 
          filters: 
            vpc-id: '{{ aws_simple_net.vpcs.0.id }}' 
        register: aws_simple_subnet 
      - name: Ensure wssg Security Group is present 
        ec2_group: 
          name: wssg 
          description: Web Security Group 
          region: eu-west-1 
          vpc_id: '{{ aws_simple_net.vpcs.0.id }}' 
          rules: 
          - proto: tcp 
            from_port: 22 
            to_port: 22 
            cidr_ip: 0.0.0.0/0 
          - proto: tcp 
            from_port: 80 
            to_port: 80 
            cidr_ip: 0.0.0.0/0 
          - proto: tcp 
            from_port: 443 
            to_port: 443 
            cidr_ip: 0.0.0.0/0 
          rules_egress: 
          - proto: all 
            cidr_ip: 0.0.0.0/0 
        register: aws_simple_wssg 
      - name: Ensure dbsg Security Group is present 
        ec2_group: 
          name: dbsg 
          description: DB Security Group 
          region: eu-west-1 
          vpc_id: '{{ aws_simple_net.vpcs.0.id }}' 
          rules: 
          - proto: tcp 
            from_port: 3036 
            to_port: 3036 
            group_id: '{{ aws_simple_wssg.group_id }}' 
          rules_egress: 
          - proto: all 
            cidr_ip: 0.0.0.0/0 
        register: aws_simple_dbsg 
      - name: Setup instances 
        ec2: 
          assign_public_ip: '{{ item.assign_public_ip }}' 
          image: ami-7abd0209 
          region: eu-west-1 
          exact_count: 1 
          key_name: fale 
          count_tag: 
            Name: '{{ item.name }}' 
          instance_tags: 
            Name: '{{ item.name }}' 
          instance_type: t2.micro 
          group_id: '{{ item.group_id }}' 
          vpc_subnet_id: '{{ aws_simple_subnet.subnets.0.id }}' 
          volumes: 
            - device_name: /dev/sda1 
              volume_type: gp2 
              volume_size: 10 
              delete_on_termination: True 
        register: aws_simple_instances 
        with_items: 
        - name: ws01.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
        - name: ws02.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
        - name: db01.simple.aws.fale.io 
          group_id: '{{ aws_simple_dbsg.group_id }}' 
          assign_public_ip: False 
      - name: Add route53 rules for instances 
        route53: 
          command: create 
          zone: aws.fale.io 
          record: '{{ item.tagged_instances.0.tags.Name }}' 
          type: A 
          ttl: 1 
          value: '{{ item.tagged_instances.0.public_ip }}' 
          wait: True 
        with_items: '{{ aws_simple_instances.results }}' 
        when: item.tagged_instances.0.public_ip 
      - name: Add internal route53 rules for instances 
        route53: 
          command: create 
          zone: aws.fale.io 
          private_zone: True 
          record: '{{ item.tagged_instances.0.tags.Name }}' 
          type: A 
          ttl: 1 
          value: '{{ item.tagged_instances.0.private_ip }}' 
          wait: True 
        with_items: '{{ aws_simple_instances.results }}' 

运行 ansible-playbook playbooks/aws_simple_provision.yaml 后,我们将得到类似于以下的输出:

PLAY [localhost] ***************************************************
TASK [setup] *******************************************************
ok: [localhost] 
TASK [Gather information of the EC2 VPC net in eu-west-1] **********
ok: [localhost] 
TASK [Gather information of the EC2 VPC subnet in eu-west-1] *******
ok: [localhost] 
TASK [Ensure wssg Security Group is present] ***********************
changed: [localhost] 
TASK [Ensure dbsg Security Group is present] ***********************
changed: [localhost] 
TASK [Setup instances] *********************************************
changed: [localhost] => (item={u'group_id': u'sg-950c2cf2', u'name': u'ws01.simple.aws.fale.io', u'assign_public_ip': True})
changed: [localhost] => (item={u'group_id': u'sg-950c2cf2', u'name': u'ws02.simple.aws.fale.io', u'assign_public_ip': True})
changed: [localhost] => (item={u'group_id': u'sg-940c2cf3', u'name': u'db01.simple.aws.fale.io', u'assign_public_ip': False}) 
TASK [Add route53 rules for instances] *****************************
changed: [localhost] =>
    .... 
changed: [localhost] =>
    .... 
skipping: [localhost] =>
    .... 
TASK [Add internal route53 rules for instances] ******************
changed: [localhost] =>
    .... 
changed: [localhost] =>
    .... 
changed: [localhost] =>
    .... 
PLAY RECAP ****************************************************
localhost                  : ok=7    changed=4    unreachable=0    failed=0

复杂的 AWS 部署

在这一段中,我们将略微修改前面的示例,将其中一台 Web 服务器移动到同一区域的另一个 AZ(可用区)。为此,我们将在 playbooks/aws_complex_provision.yaml 中创建一个新文件,该文件与之前的文件非常相似,唯一的区别是在帮助我们配置机器的部分。实际上,我们将使用以下代码,而不是上次运行时使用的代码:

      - name: Setup instances 
        ec2: 
          assign_public_ip: '{{ item.assign_public_ip }}' 
          image: ami-7abd0209 
          region: eu-west-1 
          exact_count: 1 
          key_name: fale 
          count_tag: 
            Name: '{{ item.name }}' 
          instance_tags: 
            Name: '{{ item.name }}' 
          instance_type: t2.micro 
          group_id: '{{ item.group_id }}' 
          vpc_subnet_id: '{{ item.vpc_subnet_id }}' 
          volumes: 
            - device_name: /dev/sda1 
              volume_type: gp2 
              volume_size: 10 
              delete_on_termination: True 
        register: aws_simple_instances 
        with_items: 
        - name: ws01.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
          vpc_subnet_id: '{{ aws_simple_subnet.subnets.0.id }}' 
        - name: ws02.simple.aws.fale.io 
          group_id: '{{ aws_simple_wssg.group_id }}' 
          assign_public_ip: True 
          vpc_subnet_id: '{{ aws_simple_subnet.subnets.1.id }}' 
        - name: db01.simple.aws.fale.io 
          group_id: '{{ aws_simple_dbsg.group_id }}' 
          assign_public_ip: False 
          vpc_subnet_id: '{{ aws_simple_subnet.subnets.0.id }}' 

如您所见,我们已将 vpc_subnet_id 放入变量中,以便可以为 ws02 机器使用不同的子网。由于 AWS 默认提供两个子网(且每个子网绑定到不同的 AZ),因此使用以下 AZ 就足够了。安全组和 Route 53 代码无需更改,因为它们并不是在子网/AZ 层级上工作,而是在 VPC 层级(对于安全组和内部 Route 53 区域)或全局层级(对于公共 Route 53)上工作。

DigitalOcean

与 Amazon Web Services 相比,DigitalOcean 看起来非常不完整。直到几个月前,DigitalOcean 只提供了 droplets、SSH 密钥管理和 DNS 管理。撰写本文时,DigitalOcean 最近刚刚推出了一个额外的块存储服务。与许多竞争对手相比,DigitalOcean 的优势包括:

  • 价格低于 AWS

  • 非常简便的 API

  • 有非常完善的 API 文档

  • Droplets 非常类似于标准虚拟机(它们没有做奇怪的定制化)

  • Droplets 启动和停止的速度非常快

  • 由于 DigitalOcean 的网络架构非常简单,它比 AWS 更加高效

Droplets(云主机)

滴水(droplets)是 DigitalOcean 提供的主要服务,它们是计算实例,类似于 Amazon EC2 经典实例。DigitalOcean 依赖内核虚拟机KVM)来虚拟化机器,确保非常高的性能和安全性。由于它们不会以任何重要方式改变 KVM,且 KVM 是开源的,且可以在任何 Linux 机器上使用,这使得系统管理员能够在私有和公共云中创建相同的环境。DigitalOcean 的滴水(droplets)将拥有一个外部 IP,并且可以最终被添加到一个虚拟网络中,从而允许您的机器使用内部 IP。

与许多其他类似服务不同,DigitalOcean 允许您的滴水(droplets)除了 IPv4 地址外,还可以拥有 IPv6 地址。此服务是免费的。

SSH 密钥管理

每次创建滴水(droplet)时,您必须指定是否希望为root用户分配特定的 SSH 密钥,或者是否希望使用密码(在首次登录时需要更改)。为了能够选择 SSH 密钥,您需要一个界面来上传它。DigitalOcean 允许您通过一个非常简单的界面来执行此操作,该界面可以列出当前的密钥,并且可以创建和删除密钥。

私有网络

如在滴水(droplet)段落中提到的,DigitalOcean 允许我们拥有一个私有网络,在该网络中,我们的机器可以与另一台机器通信。这使得服务(如数据库服务)仅在内部网络上进行隔离,从而提高安全性。由于 MySQL 默认绑定在所有可用接口上,我们需要稍微调整数据库角色,以便只在内部网络上进行绑定。

要区分内部网络与外部网络,可以采用多种方式,这与 DigitalOcean 的一些特性有关:

  • 私有网络始终位于10.0.0.0/8网络中,而公共 IP 则从不位于该网络中。

  • 公共网络始终是eth0,而私有网络始终是eth1

根据您的可移植性需求,您可以使用其中一种策略来理解应该将您的服务绑定到哪里。

在 DigitalOcean 中添加 SSH 密钥

您需要拥有一个设置了信用卡的 DigitalOcean 用户,并获取 API 密钥。要执行这些操作,您可以使用 DigitalOcean 的 Web 界面。我们现在可以开始使用 Ansible 将我们的 SSH 密钥添加到 DigitalOcean 云中。为此,我们需要创建一个名为playbooks/do_provision.yaml的文件,结构如下:

    - hosts: localhost 
      tasks: 
      - name: Add the SSH Key to Digital Ocean 
        digital_ocean: 
          state: present 
          command: ssh 
          name: SSH_KEY_NAME 
          ssh_pub_key: 'ssh-rsa AAAA...' 
          api_token: XXX 
        register: ssh_key 

在我的例子中,这是我的文件内容:

    - hosts: localhost 
      tasks: 
      - name: Add the SSH Key to Digital Ocean 
        digital_ocean: 
          state: present 
          command: ssh 
          name: faleKey 
          ssh_pub_key: 'ssh-rsa AAAA...==' 
          api_token: 259...b3b 
    register: ssh_key 

然后我们可以通过以下命令执行它:

    ansible-playbook -i localhost, playbooks/do_provision.yaml

您将得到类似以下的结果:

PLAY [localhost] **************************************************
TASK [setup] ******************************************************
ok: [localhost] 
TASK [Add the SSH Key to Digital Ocean] ***************************
changed: [localhost] 
PLAY RECAP ********************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0

这个任务是幂等的,因此我们可以多次执行它。如果密钥已经上传,SSH 密钥 ID 将在每次运行时返回。

在 DigitalOcean 中的部署

在写作时,创建一个 droplet 的唯一方法是使用 digital_ocean 模块,但该模块很快可能会被弃用,因为许多功能现在已通过其他模块以更好的、更清晰的方式完成,并且在 Ansible 的 bug 跟踪系统中已经有一个 bug,用于跟踪其完整重写和可能的弃用。我猜测新模块可能会叫做 digital_ocean_droplet,并且会有类似的语法,但目前没有相关代码,所以这只是我的猜测。

为了创建 droplet,我们将必须使用 digital_ocean 模块,语法类似于以下内容:

      - name: Ensure the ws and db servers are present 
        digital_ocean: 
          state: present 
          ssh_key_ids: KEY_ID 
          name: '{{ item }}' 
          api_token: DIGITAL_OCEAN_KEY 
          size_id: 512mb 
          region_id: lon1 
          image_id: centos-7-0-x64 
          unique_name: True 
        with_items: 
        - WEBSERVER 1 
        - WEBSERVER 2 
        - DBSERVER 1 

为了确保我们的配置完全且以合理的方式完成,我总是建议为整个基础设施创建一个单一的配置文件。所以,在我的案例中,我将以下任务添加到 playbooks/do_provision.yaml 文件中:

      - name: Ensure the ws and db servers are present 
        digital_ocean: 
          state: present 
          ssh_key_ids: '{{ ssh_key.ssh_key.id }}' 
          name: '{{ item }}' 
          api_token: 259...b3b 
          size_id: 512mb 
          region_id: lon1 
          image_id: centos-7-0-x64 
          unique_name: True 
        with_items: 
        - ws01.do.fale.io 
        - ws02.do.fale.io 
        - db01.do.fale.io 
        register: droplets 

之后,我们可以使用 digital_ocean_domain 模块添加域名:

      - name: Ensure domain resolve properly
        digital_ocean_domain:
          api_token: 259...b3b
          state: present
          name: '{{ item.droplet.name }}'
          ip: '{{ item.droplet.ip_address }}'
        with_items: '{{ droplets.results }}'

所以,将所有内容整合起来,我们的 playbooks/do_provision.yaml 文件将如下所示:

    - hosts: localhost 
      tasks: 
      - name: Add the SSH Key to Digital Ocean 
        digital_ocean: 
          state: present 
          command: ssh 
          name: faleKey 
          ssh_pub_key: 'ssh-rsa AAAA...==' 
          api_token: 7e7...f6f 
        register: ssh_key 
      - name: Ensure the ws and db servers are present 
        digital_ocean: 
          state: present 
          ssh_key_ids: '{{ ssh_key.ssh_key.id }}' 
          name: '{{ item }}' 
          api_token: 259...b3b 
          size_id: 512mb 
          region_id: lon1 
          image_id: centos-7-0-x64 
          unique_name: True 
        with_items: 
        - ws01.do.fale.io 
        - ws02.do.fale.io 
        - db01.do.fale.io 
        register: droplets 
      - name: Ensure domain resolve properly 
        digital_ocean_domain: 
          api_token: 259...b3b 
          state: present 
          name: '{{ item.droplet.name }}' 
          ip: '{{ item.droplet.ip_address }}' 
        with_items: '{{ droplets.results }}' 

现在我们可以通过以下命令运行它:

ansible-playbook -i localhost, playbooks/do_provision.yaml

我们将看到类似于以下的结果:

PLAY [localhost] **************************************************
TASK [setup] ******************************************************
ok: [localhost] 
TASK [Add the SSH Key to Digital Ocean] ***************************
changed: [localhost] 
TASK [Ensure the ws and db servers are present] *******************
changed: [localhost] => (item=ws01.do.fale.io)
changed: [localhost] => (item=ws02.do.fale.io)
changed: [localhost] => (item=db01.do.fale.io) 
TASK [Ensure domain resolve properly] *****************************
changed: [localhost] =>
    .... 
changed: [localhost] =>
    .... 
changed: [localhost] =>
    .... 
PLAY RECAP ************************************************************
localhost                  : ok=4    changed=3    unreachable=0    failed=0

总结

在这一章中,我们已经看到如何在 AWS 云和 DigitalOcean 云中配置我们的机器。在 AWS 云的案例中,我们看到了两个不同的例子,一个非常简单,另一个稍微复杂一些。

在下一章中,我们将讨论如何在 Ansible 运行出错时收到通知。

第六章:从 Ansible 获取通知

与 bash 脚本相比,Ansible 的一个显著优势是能够在同一系统上多次运行,确保一切井然有序。这是一个非常好的功能,不仅可以确保没有改变服务器上的配置,还可以在短时间内应用新配置。

由于这些原因,许多人每天运行他们的master.yaml。当您这样做时(也许您应该!),您希望 Ansible 本身发送某种反馈给您。还有许多其他情况,您可能希望 Ansible 向您或您的团队发送消息。例如,如果您使用 Ansible 部署应用程序,您可能希望向开发团队频道发送 IRC 消息(或其他类型的群聊消息),以便他们了解系统状态。

其他时候,您希望 Ansible 通知 Nagios 即将破坏某些内容,以便 Nagios 不会担心并开始向系统管理员发送电子邮件和消息。

在本章中,我们将探讨以下主题:

  • 邮件通知

  • Ansible XMPP/Jabber

  • Slack 和 Rocket Chat

  • 向 IRC 频道发送消息(社区信息和贡献)

  • Amazon 简单通知服务

  • Nagios

电子邮件

提醒人们的最简单和最常见方法是发送电子邮件。Ansible 允许您在播放本之间使用mail模块发送电子邮件。您可以在任何任务之间使用此模块,并在需要时通知用户。此外,在某些情况下,您无法自动化每一件事,因为要么您缺乏权限,要么需要一些手动检查和确认。如果是这种情况,您可以通知负责人员 Ansible 已经完成了工作,现在是他/她执行其职责的时候了。让我们看看如何使用名为uptime_and_email.yaml的非常简单的播放本来使用mail模块通知您的用户:

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: uptime -p 
        register: uptime 
      - name: Send the uptime via e-mail 
        mail: 
          host: mail.fale.io 
          username: ansible@fale.io 
          password: PASSWORD 
          to: me@fale.io 
          subject: Ansible-report 
          body: 'Local system uptime is {{ uptime.stdout }}.' 

在前面的手册中,我们将首先读取当前机器的运行时间,然后通过电子邮件发送给某人。这个示例非常简单,将使我们的示例保持简短,但显然你也可以在非常长和复杂的手册中以类似的方式生成电子邮件。如果我们稍微关注一下mail任务,我们可以看到我们正在以下数据一起使用它:

  • 要用来发送电子邮件的电子邮件服务器(还包括登录信息,这是该服务器所需的)

  • 接收者电子邮件地址

  • 电子邮件主题

  • 电子邮件正文

mail模块支持的其他有趣参数包括:

  • attach参数:用于向将生成的电子邮件添加附件。当您希望通过电子邮件发送日志时,这非常有用。

  • port参数:用于指定电子邮件服务器使用的端口。

关于此模块的一个有趣之处是,唯一强制的字段是subject,而不是像许多人期望的那样是主体。

现在我们可以继续执行脚本以验证其功能,方法如下:

ansible-playbook -i localhost, uptime_and_email.yaml

我们将得到类似于以下的结果:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Read the machine uptime] ***********************************
changed: [localhost] 
TASK [Send the uptime via e-mail] ********************************
changed: [localhost] 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=2    unreachable=0    failed=0

同样,正如预期的那样,Ansible 已经向我发送了一封包含以下内容的电子邮件:

    Local system uptime is up 38 min. 

这个模块可以用多种方式。一个我见过的实际案例是,一个剧本被创建来自动化一个非常长的程序中的某个部分,程序历史上是通过电子邮件交换所有者的,每个参与程序的人在收到前一部分所有者的电子邮件后,会完成自己的部分,并在结束时发送电子邮件给下一个所有者。当我们开始自动化这个程序时,我们只自动化了其中的一部分,而没有人注意到这一部分已经被自动化。这不是处理程序的最佳方式,但它在组织中广泛使用,而且你通常无法改变它。

XMPP

电子邮件速度慢、不可靠,而且人们往往不会立即对其作出反应。有时你希望实时向某个用户发送消息。许多组织依赖 XMPP/Jabber 作为内部聊天系统,而最棒的是,Ansible 能够直接向 XMPP/Jabber 用户和会议室发送消息。

让我们调整一下之前的示例,将运行时间信息发送给文件uptime_and_xmpp_user.yaml中的用户:

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: 'uptime -p' 
        register: uptime 
      - name: Send the uptime to user 
        jabber: 
          user: ansible@fale.io 
          password: PASSWORD 
          to: me@fale.io 
          msg: 'Local system uptime is {{ uptime.stdout }}.' 

注意

如果你想使用 Ansible 的jabber任务,你需要在执行任务的系统上安装xmpppy库。

正如你所看到的,jabber模块与mail模块非常相似,并且需要类似的参数。在 XMPP 的情况下,我们不需要指定服务器主机和端口,因为这些信息会通过 DNS 由 XMPP 自动获取。在需要使用不同服务器主机或端口的情况下,我们可以分别使用hostport参数。

现在我们可以继续执行脚本以验证其功能,方法如下:

ansible-playbook -i localhost, uptime_and_xmpp_user.yaml

我们将得到类似于以下的结果:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Read the machine uptime] ***********************************
changed: [localhost] 
TASK [Send the uptime to user] ***********************************
changed: [localhost] 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=2    unreachable=0    failed=0

在需要向会议室而非单个用户发送消息的情况下,只需更改to参数,添加适当的目标即可。

    to=sysop@conference.fale.io (mailto:sysop@conference.fale.io)/ansiblebot 

Slack

在过去几年里,许多新的聊天和协作平台出现了。其中最常用的平台之一是 Slack。Slack 是一款基于云的团队协作工具,这使得它与 Ansible 的集成变得更加便捷。

让我们把以下几行放入文件uptime_and_slack.yaml中:

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: 'uptime -p' 
        register: uptime 
      - name: Send the uptime to slack channel 
        slack: 
          token: TOKEN 
          channel: '#ansible' 
          msg: 'Local system uptime is {{ uptime.stdout }}.' 

如我们所讨论的,这个模块的语法比 XMPP 模块更简洁,事实上,它只需要知道令牌(你可以在 Slack 网站上生成)、要发送消息的频道以及消息内容本身。

注意

自 Ansible 1.8 版本以来,Slack 令牌的新版已经被要求,例如:G522SJP14/D563DW213/7Qws484asdWD4w12Md3avf4FeD

使用以下命令运行剧本:

ansible-playbook -i localhost, uptime_and_slack.yaml

这将产生如下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Read the machine uptime] ***********************************
changed: [localhost] 
TASK [Send the uptime to slack channel] **************************
changed: [localhost] 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=2    unreachable=0    failed=0

由于 Slack 的目标是提高沟通效率,它允许我们调整消息的多个方面。从我的角度来看,最有趣的几点如下:

  • color:这允许你指定一个颜色条,在消息开头显示,以识别以下状态:

    • :绿色条

    • 正常:无条

    • 警告:黄色条

    • 危险:红色条

  • icon_url:这允许你更改该消息的用户头像

火箭聊天

许多公司喜欢 Slack 的功能,但在权衡 Slack 功能和本地部署服务所带来的隐私保护时遇到问题。Rocket Chat 是开源软件,实施了 Slack 的大多数功能以及它的大部分界面。作为开源软件,每个公司都可以在本地安装它,并以符合其 IT 规则的方式进行管理。

由于 Rocket Chat 的目标是作为 Slack 的替代品,从我们的角度来看,几乎不需要做任何更改,实际上,我们可以创建一个名为uptime_and_rocket.yaml的文件,并包含以下内容:

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: 'uptime -p' 
        register: uptime 
      - name: Send the uptime to rocketchat channel 
        rocketchat: 
          token: TOKEN 
          domain: chat.example.com 
          channel: '#ansible' 
          msg: 'Local system uptime is {{ uptime.stdout }}.' 

如你所见,唯一发生变化的行是第 6 行和第 7 行,其中slack被替换成了rocketchat。另外,我们需要添加域名字段,指定我们安装的 Rocket Chat 所在位置。

运行以下代码:

ansible-playbook -i localhost, uptime_and_rocketchat.yaml

这将产生以下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Read the machine uptime] ***********************************
changed: [localhost] 
TASK [Send the uptime to rocketchat channel] *********************
changed: [localhost] 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=2    unreachable=0    failed=0

互联网中继聊天(IRC)

IRC 可能是 1990 年代最著名且广泛使用的聊天协议,今天仍在使用,主要得益于它在开源社区中的应用以及其简洁性。从 Ansible 的角度来看,IRC 是一个相当直接的模块,我们可以按照以下示例使用它(将其放入uptime_and_irc.yaml文件中):

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: 'uptime -p' 
        register: uptime 
      - name: Send the uptime to IRC channel 
        irc: 
          port: 6669 
          server: irc.example.net 
          channel: #desired_channel 
          msg: 'Local system uptime is {{ uptime.stdout }}.' 
          color: green 

注意

你需要安装socket Python 库才能使用 Ansible 的 IRC 模块。

在 IRC 模块中,以下字段是必需的:

  • channel:这是指定你的消息将发送到的频道

  • msg:这是你要发送的消息

你通常需要指定的其他配置包括:

  • server:选择连接的server,如果不是localhost

  • port:选择连接的port,如果不是6667

  • color:如果不是black,这用于指定消息的color

  • nick:这是指定发送消息的nick,如果不是ansible

  • use_ssl:使用 SSL 和 TLS 安全

  • style:如果你希望以粗体、斜体、下划线或反向样式发送消息

运行以下代码:

ansible-playbook uptime_and_irc.yaml

这将产生以下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Read the machine uptime] ***********************************
changed: [localhost] 
TASK [Send the uptime to IRC channel] ****************************
changed: [localhost] 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=2    unreachable=0    failed=0

亚马逊简单通知服务

有时,你希望你的剧本在接收警报的方式上是无关的。这有几个优点,主要体现在灵活性方面。实际上,在这种模式下,Ansible 将消息传递给通知服务,然后由通知服务负责交付。亚马逊简单通知服务SNS)并不是唯一可用的通知服务,但它可能是使用最多的。SNS 具有以下组件:

  • 消息:由发布者生成的消息,具有唯一的 UUID 标识

  • 发布者:生成消息的程序

  • 主题:消息的命名组,可以类似于聊天室或讨论组来理解

  • 订阅者:将接收他们订阅的主题中发布的所有消息的客户端

所以在我们的例子中,具体来说,我们将有:

  • 消息:Ansible 通知

  • 发布者:Ansible 本身

  • 主题:可能是不同的主题,用于根据系统和/或通知类型(例如存储、网络、计算)对消息进行分组

  • 订阅者:需要接收通知的团队成员

正如我们所说,SNS 的一个主要优势是你可以解耦 Ansible 发送消息的方式(SNS API)和用户接收消息的方式。实际上,你可以为每个用户和每个主题规则选择不同的传递系统,并且最终可以动态更改这些系统,以确保消息以最佳方式发送,以适应任何情况。目前,SNS 发送消息的五种方式是:

  • 亚马逊 Lambda 函数(使用 Python、Java 和 JavaScript 编写的无服务器函数)

  • 亚马逊 简单队列服务 (SQS)(一个消息队列系统)

  • 电子邮件

  • HTTP(S) 调用

  • 短信

让我们来看一下如何使用 Ansible 发送 SNS 消息。为此,我们可以创建一个名为 uptime_and_sns.yaml 的文件,内容如下:

    - hosts: localhost 
      tasks: 
      - name: Read the machine uptime 
        command: 'uptime -p' 
        register: uptime 
      - name: Send the uptime to SNS 
        sns: 
          msg: 'Local system uptime is {{ uptime.stdout }}.' 
          subject: "System uptime" 
          topic: "uptime" 

在这个例子中,我们使用 msg 键来设置将要发送的消息,使用 topic 来选择最合适的主题,使用 subject 作为电子邮件通知的主题。你可以设置许多其他选项。主要是,它们用于通过不同的传送方式发送不同的消息。例如,通过短信发送简短的消息是有意义的(毕竟,短信中的第一个 S 代表 简短),而更长且更详细的消息则通过电子邮件发送。为此,SNS 模块为我们提供了以下特定传送选项:

  • 电子邮件

  • HTTP

  • HTTPS

  • 短信

  • SQS

此模块还允许我们设置三个 AWS 特定的参数,我没有指定这些参数,因为我有一个配置文件来存储 AWS 凭证和选项:

  • aws_access_key:AWS 访问密钥,如果未指定,则会使用环境变量 aws_access_key~/.aws/credentials 文件中的内容

  • aws_secret_key:AWS 秘密密钥,如果未指定,则会使用环境变量 aws_secret_key~/.aws/credentials 文件中的内容

  • region:要使用的 AWS 区域,如果未指定,则会使用环境变量 ec2_region~/.aws/config 文件中的内容

使用以下命令运行代码:

ansible-playbook uptime_and_sns.yaml

这将导致以下输出:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Read the machine uptime] *********************************** 
    changed: [localhost] 

    TASK [Send the uptime to SNS] ************************************ 
    changed: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=3    changed=2    unreachable=0    failed=0

Nagios

Nagios 是最常用的服务和服务器状态监控工具之一。Nagios 能定期审核服务器和服务的状态,并在发生问题时通知用户。如果你的环境中有 Nagios,你需要在管理机器时非常小心,因为当 Nagios 发现服务器或服务处于不健康状态时,它会开始向你的整个团队发送电子邮件、短信和电话。当你对被 Nagios 控制的节点运行 Ansible 脚本时,你必须更加小心,因为这可能会在夜间或其他不合适的时间触发电子邮件、短信和电话。为了避免这种情况,Ansible 可以提前通知 Nagios,这样即使某些服务因为重启等原因处于停机状态,或其他检查失败,Nagios 也不会在该时间窗口内发送通知。

在这个例子中,我们将停止一个服务,等待 5 分钟,然后重新启动它,因为这实际上会在大多数配置中触发一个 Nagios 故障。实际上,通常情况下,Nagios 被配置为接受最多两次连续的故障(通常每分钟执行一次),在触发关键状态之前将服务置于警告状态。我们将创建一个文件 long_restart_service.yaml,它将触发 Nagios 关键状态:

    - hosts: ws01.fale.io 
      tasks: 
      - name: Stop the HTTPd service 
        service: 
          name: httpd 
          state: stopped 
      - name: Wait for 5 minutes 
        pause: 
          minutes: 5 
      - name: Start the HTTPd service 
        service: 
          name: httpd 
          state: stopped 

使用以下命令运行代码:

ansible-playbook long_restart_service.yaml

这应该会触发一个 Nagios 警报,并产生以下输出:

    PLAY [ws01.fale.io] ********************************************** 

    TASK [setup] ***************************************************** 
    ok: [ws01.fale.io] 

    TASK [Stop the HTTpd service] ************************************ 
    changed: [ws01.fale.io] 

    TASK [Wait for 5 minutes] **************************************** 
    changed: [ws01.fale.io] 

    TASK [Start the HTTpd service] *********************************** 
    changed: [ws01.fale.io] 

    PLAY RECAP ******************************************************* 
    ws01.fale.io      : ok=4    changed=3    unreachable=0    failed=0

注意

如果没有触发 Nagios 警报,可能是你的 Nagios 安装没有跟踪该服务,或者 5 分钟不足以让它触发关键状态。

现在我们可以创建一个非常相似的 playbook,确保 Nagios 不会发送任何警报。我们将创建一个名为 long_restart_service_no_alert.yaml 的文件,内容如下:

    - hosts: ws01.fale.io 
      tasks: 
      - name: Silence Nagios 
    nagios: 
      action: disable_alerts 
      service: httpd 
      host: '{{ inventory_hostname }}' 
    delegate_to: nagios.fale.io 
  - name: Stop the HTTPd service 
    service: 
      name: httpd 
      state: stopped 
  - name: Wait for 5 minutes 
    pause: 
      minutes: 5 
  - name: Start the HTTPd service 
    service: 
      name: httpd 
      state: stopped 
  - name: Desilence Nagios 
    nagios: 
      action: enable_alerts 
      service: httpd 
      host: '{{ inventory_hostname }}' 
    delegate_to: nagios.fale.io 

如你所见,我们添加了两个任务。第一个任务是通知 Nagios 在指定主机上不发送 HTTPd 服务的警报,第二个任务是通知 Nagios 重新开始发送该服务的警报。即使你没有指定具体的服务,从而导致该主机上的所有警报都被静音,我的建议是仅禁用你将要中断的警报,以便 Nagios 仍能在大部分基础设施上正常工作。

注意

如果 playbook 执行失败并未达到重新启用警报的步骤,那么你的警报将保持 禁用 状态。

该模块的目标是切换 Nagios 警报并安排停机时间,从 Ansible 2.2 开始,该模块还可以取消安排的停机时间。

使用以下命令运行代码:

ansible-playbook long_restart_service_no_alert.yaml

这应该会触发一个 Nagios 警报,并产生以下输出:

PLAY [ws01.fale.io] **********************************************
TASK [setup] *****************************************************
ok: [ws01.fale.io] 
TASK [Silence Nagios] ********************************************
changed: [nagios.fale.io] 
TASK [Stop the HTTpd service] ************************************
changed: [ws01.fale.io] 
TASK [Wait for 5 minutes] ****************************************
changed: [ws01.fale.io] 
TASK [Start the HTTpd service] ***********************************
changed: [ws01.fale.io] 
TASK [Desilence Nagios] ******************************************
changed: [nagios.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=4    changed=3    unreachable=0    failed=0
nagios.fale.io    : ok=2    changed=2    unreachable=0    failed=0

注意

要使用 Nagios 模块,你需要将操作委托给你的 Nagios 服务器。

有时候,您希望通过 Nagios 集成实现的目标恰恰相反,实际上,您并不想使其静默,而是希望 Nagios 处理您的测试结果。一个常见的情况是,您希望利用 Nagios 配置来通知管理员某个任务的输出。为此,我们可以使用 Nagios 的nsca工具,并将其集成到我们的 playbook 中。Ansible 目前还没有专门管理它的模块,但您始终可以使用命令模块来运行它,借助 send_nsca CLI 程序。

摘要

在本章中,我们已经了解了如何让 Ansible 向其他系统和/或人员发送通知。

在下一章,我们将学习如何创建一个模块,以便您可以扩展 Ansible 执行任何任务。

第七章:创建自定义模块

本章将重点介绍如何编写和测试自定义模块。我们已经讨论了模块是如何工作的以及如何在任务中使用它们。简单回顾一下,Ansible 中的模块是一段代码,每次运行 Ansible 任务时,它都会被传输并在远程主机上执行(如果你使用了 local_action,它也可以在本地执行)。

根据我的经验,每当需要将某些功能暴露为一等任务时,我看到过自定义模块的编写。虽然没有模块也能实现相同的功能,但这通常需要通过一系列现有模块的任务来完成目标,而且通常还需要命令和 shell 模块。例如,假设你想通过预启动执行环境PXE)来配置一个服务器。如果没有自定义模块,你可能会使用一些 shell 或命令任务来完成同样的事情。然而,使用自定义模块,你只需将所需参数传递给它,业务逻辑将嵌入到自定义模块中以执行 PXE 启动。这使得你能够编写更易读的 playbook,并且代码的重用性更高,因为你只需要创建一次模块,就可以在角色和 playbook 中随处使用它。

传递给模块的参数,只要它们是键值格式,就会与模块一起转发到一个单独的文件中。Ansible 期望在模块的输出中至少有两个变量(即模块执行结果),无论是成功还是失败,还需要包含一个给用户的消息,这两个变量必须采用 JSON 格式。如果你遵循这个简单的规则,你就可以根据需要进行定制!

本章将涵盖以下主题:

  • Python 模块

  • Bash 模块

  • Ruby 模块

  • 测试模块

当你选择一种特定的技术或工具时,通常会从它所提供的功能开始。你慢慢理解构建该工具背后的理念,以及它能帮助你解决什么问题。然而,只有当你深入理解它的工作原理时,你才会真正感到舒适并掌控全局。在某个阶段,为了充分利用工具的所有功能,你将不得不以适应自己需求的方式和手段来定制它。随着时间的推移,能够轻松插入新功能的工具会留下来,而不能做到这一点的工具则会消失在市场上。Ansible 也有类似的故事。Ansible playbook 中的所有任务都是某种类型的模块,它自带数百个模块。你几乎可以找到满足你所有需求的模块。然而,总是会有一些例外,这时扩展它的能力就显得尤为重要。

Chef 提供了轻量级资源和提供者LWRPs)来执行此活动,而 Ansible 允许你使用自定义模块扩展其功能。然而,显著的区别在于,你可以用任何你选择的语言编写模块(只要你有该语言的解释器),而在 Chef 中,模块必须用 Ruby 编写。Ansible 开发者推荐对任何复杂模块使用 Python,因为它支持开箱即用的参数解析;几乎所有*nix系统都默认安装 Python,而 Ansible 本身就是用 Python 编写的。为了完整起见,本章还将介绍如何用其他语言编写模块。

若要使你的自定义模块对 Ansible 可用,你可以执行以下操作之一:

  • 在环境变量ANSIBLE_LIBRARY中指定自定义模块的路径

  • 使用--module-path命令行选项

  • 将模块放入 Ansible 顶级目录中的library目录

有了这些背景信息,接下来我们来看一些代码!

使用 Python 模块

Ansible 旨在允许用户用任何语言编写模块。然而,使用 Python 编写模块有其独特的优势。你可以利用 Ansible 的库来简化代码,这是其他语言模块无法享有的优势。借助 Ansible 库,解析用户参数、处理错误和返回所需值变得更加容易。我们将通过两个自定义 Python 模块的例子来展示模块的工作方式,一个使用了 Ansible 库,另一个没有使用。请确保在创建模块之前,按前一节提到的方式组织好目录结构。第一个例子创建了一个名为check_user的模块。为此,我们需要在 Ansible 顶级目录中的library文件夹中创建check_user文件,内容如下:

    #!/usr/bin/env python 

    import pwd 
    import sys 
    import shlex 
    import json 

    def main(): 
        # Parsing argument file 
        args = {} 
        args_file = sys.argv[1] 
        args_data = file(args_file).read() 
        arguments = shlex.split(args_data) 
        for arg in arguments: 
            if '=' in arg: 
                (key, value) = arg.split('=') 
                args[key] = value 
        user = args['user'] 

        # Check if user exists 
        try: 
            pwd.getpwnam(user) 
            success = True 
            ret_msg = 'User %s exists' % user 
        except KeyError: 
            success = False 
            ret_msg = 'User %s does not exists' % user 

        # Error handling and JSON return 
        if success: 
            print json.dumps({ 
                'msg': ret_msg 
            }) 
            sys.exit(0) 
        else: 
            print json.dumps({ 
                'failed': True, 
                'msg': ret_msg 
            }) 
            sys.exit(1) 
    main() 

前面的自定义模块check_user将检查主机上是否存在某个用户。该模块期望 Ansible 传入一个用户参数。让我们分解前面的模块,看看它是如何工作的。我们首先声明解释器(Python),并导入解析参数所需的库:

    #!/usr/bin/env python 

    import pwd 
    import sys 
    import shlex 
    import json 

使用sys库,我们然后解析由 Ansible 在文件中传递的参数。参数的格式为param1=value1 param2=value2,其中param1param2是参数,value1value2是参数的值。有多种方法可以分割参数并创建字典,我们选择了一种简单的方式来执行操作。我们首先通过空白字符分割参数创建参数列表,然后通过=字符分割参数并将其赋值给 Python 字典中的键和值。例如,如果您有一个字符串如user=foo gid=1000,那么您首先创建一个列表,看起来像["user=foo", "gid=1000"],然后循环遍历此列表以创建一个字典。此字典将看起来像{"user": "foo", "gid": 1000}。这是通过以下行执行的:

    def main(): 
        # Parsing argument file 
        args = {} 
        args_file = sys.argv[1] 
        args_data = file(args_file).read() 
        arguments = shlex.split(args_data) 
        for arg in arguments: 
            if '=' in arg: 
                (key, value) = arg.split('=') 
                args[key] = value 
        user = args['user'] 

注意

我们基于空白字符分隔参数,因为这是核心 Ansible 模块遵循的标准。您可以使用任何分隔符代替空格,但我们建议保持统一性。

一旦我们有了用户参数,我们就检查该用户是否存在于主机上,如下所示:

    # Check if user exists 
    try: 
        pwd.getpwnam(user) 
        success = True 
        ret_msg = 'User %s exists' % user 
    except KeyError: 
        success = False 
        ret_msg = 'User %s does not exists' % user 

我们使用pwd库来检查用户的passwd文件。为简单起见,我们使用两个变量:一个用于存储成功或失败消息,另一个用于存储用户的消息。最后,我们使用在try-catch块中创建的变量来检查模块是否成功或失败,正如您在这个片段中所看到的:

    # Error handling and JSON return 
    if success: 
        print json.dumps({ 
            'msg': ret_msg 
        }) 
        sys.exit(0) 
    else: 
        print json.dumps({ 
            'failed': True, 
            'msg': ret_msg 
        }) 
        sys.exit(1) 

如果模块成功,则将以退出码 0 退出执行[exit(0)];否则,将以非零代码退出。Ansible 将查找失败变量,如果设置为True,则将退出,除非您显式要求 Ansible 使用ignore_errors参数忽略错误。您可以像使用 Ansible 的任何其他核心模块一样使用自定义模块。要测试自定义模块,我们将需要一个 playbook,所以让我们创建文件playbooks/check_user.yaml,内容如下:

    - hosts: localhost 
      vars: 
        user_ok: root 
        user_ko: this_user_does_not_exists 
      tasks: 
      - name: 'Check if user {{ user_ok }} exists' 
        check_user: 
          user: '{{ user_ok }}' 
      - name: 'Check if user {{ user_ko }} exists' 
        check_user: 
          user: '{{ user_ko }}' 

正如您所看到的,我们像任何其他核心模块一样使用了check_user模块。Ansible 将通过将模块和参数复制到远程主机上执行此模块,并存储在一个单独的文件中。让我们看看以下 playbook 是如何运行的:

ansible-playbook playbooks/check_user.yaml

我们应该收到以下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Check if user root exists] *********************************
ok: [localhost] 
TASK [Check if user this_user_does_not_exists exists] ************
fatal: [localhost]: FAILED! => {"changed": false, "failed": true, "msg": "User this_user_does_not_exists does not exists"} 
NO MORE HOSTS LEFT ***********************************************
 to retry, use: --limit @playbooks/check_user.retry 
PLAY RECAP *******************************************************
localhost         : ok=2    changed=0    unreachable=0    failed=1

正如预期的那样,由于我们有root用户,但没有this_user_does_not_exists,它通过了第一个检查,但在第二个检查时失败了。

Ansible 还提供了一个 Python 库来解析用户参数并处理错误和返回。现在是时候看看 Ansible Python 库如何帮助您使代码更短、更快且更不容易出错了。为此,让我们创建一个名为library/check_user_py2.py的文件,内容如下:

    #!/usr/bin/env python 

    import pwd 
    from ansible.module_utils.basic import AnsibleModule 

    def main(): 
        # Parsing argument file 
        module = AnsibleModule( 
            argument_spec = dict( 
                user = dict(required=True) 
            ) 
        ) 
        user = module.params.get('user') 

        # Check if user exists 
        try: 
            pwd.getpwnam(user) 
            success = True 
            ret_msg = 'User %s exists' % user 
        except KeyError: 
            success = False 
            ret_msg = 'User %s does not exists' % user 

        # Error handling and JSON return 
        if success: 
            module.exit_json(msg=ret_msg) 
        else: 
            module.fail_json(msg=ret_msg) 

    if __name__ == "__main__": 
        main() 

让我们分解上述模块并看看它是如何工作的,如下所示:

    #!/usr/bin/env python 

    import pwd 
    from ansible.module_utils.basic import AnsibleModule 

如你所见,我们没有导入sysshlexjson;我们不再使用它们,因为所有需要它们的操作现在都由 Ansible 的module_utils完成。

    # Parsing argument file 
    module = AnsibleModule( 
        argument_spec = dict( 
            user = dict(required=True) 
        ) 
    ) 
    user = module.params.get('user') 

之前,我们对参数文件进行了大量处理以获取最终的用户参数。Ansible 通过提供一个AnsibleModule类使这一过程变得简单,它会自动处理所有操作并提供最终的参数。required=True参数表示该参数是必需的,如果没有传递该参数,执行将失败。required的默认值是False,这意味着用户可以跳过该参数。然后,你可以通过调用module.params字典上的get方法来访问这些参数的值。远程主机上的用户检查逻辑将保持不变,但错误处理和返回部分将如下所示:

    # Error handling and JSON return 
    if success: 
        module.exit_json(msg=ret_msg) 
    else: 
        module.fail_json(msg=ret_msg) 

使用AnsibleModule对象的一个优点是,它提供了非常好的功能来处理返回值到 playbook。我们将在下一节深入探讨这一点。

注意

我们本可以将检查用户的逻辑和返回部分合并,但为了可读性,我们保持了它们分开。

为了验证一切是否按预期工作,我们可以在playbooks/check_user_py2.yaml中创建一个新的 playbook,内容如下:

    - hosts: localhost 
      vars: 
        user_ok: root 
        user_ko: this_user_does_not_exists 
      tasks: 
      - name: 'Check if user {{ user_ok }} exists' 
        check_user_py2: 
          user: '{{ user_ok }}' 
      - name: 'Check if user {{ user_ko }} exists' 
        check_user_py2: 
          user: '{{ user_ko }}' 

使用以下命令运行:

ansible-playbook playbooks/check_user.yaml

我们应该收到以下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Check if user root exists] *********************************
ok: [localhost] 
TASK [Check if user this_user_does_not_exists exists] ************
fatal: [localhost]: FAILED! => {"changed": false, "failed": true, "msg": "User this_user_does_not_exists does not exists"} 
NO MORE HOSTS LEFT ***********************************************
 to retry, use: --limit @playbooks/check_user_py2.retry 
PLAY RECAP *******************************************************
localhost         : ok=2    changed=0    unreachable=0    failed=1

这与我们的预期一致。

使用exit_jsonfail_json处理

Ansible 通过提供exit_jsonfail_json方法,提供了一种更简便的方式来处理成功和失败。你可以直接将消息传递给这些方法,Ansible 会处理其余部分。你还可以向这些方法传递额外的变量,Ansible 会将这些变量打印到stdout。例如,除了消息外,你可能还想打印用户的uidgid参数。你可以通过将这些变量与逗号分隔后传递给exit_json方法来实现。

让我们看看如何将多个值返回到stdout,这在以下代码中得以演示,该代码位于library/check_user_id.py中:

    #!/usr/bin/env python 

    import pwd 
    from ansible.module_utils.basic import AnsibleModule 

    class CheckUser: 
        def __init__(self, user): 
            self.user = user 

        # Check if user exists 
        def check_user(self): 
            uid = '' 
            gid = '' 
            try: 
                user = pwd.getpwnam(self.user) 
                success = True 
                ret_msg = 'User %s exists' % self.user 
                uid = user.pw_uid 
                gid = user.pw_gid 
            except KeyError: 
                success = False 
                ret_msg = 'User %s does not exists' % self.user 
            return success, ret_msg, uid, gid 

    def main(): 
        # Parsing argument file 
        module = AnsibleModule( 
            argument_spec = dict( 
                user = dict(required=True) 
            ) 
        ) 
        user = module.params.get('user') 

        chkusr = CheckUser(user) 
        success, ret_msg, uid, gid = chkusr.check_user() 

        # Error handling and JSON return 
        if success: 
            module.exit_json(msg=ret_msg, uid=uid, gid=gid) 
        else: 
            module.fail_json(msg=ret_msg) 

    if __name__ == "__main__": 
        main() 

如你所见,我们返回了用户的uidgid以及消息msg。你可以有多个值,Ansible 会以字典格式打印所有这些值。我们可以在playbooks/check_user_id.yaml中创建一个 playbook,内容如下:

    - hosts: localhost 
      vars: 
        user: root 
      tasks: 
      - name: 'Retrive {{ user }} data if it exists' 
        check_user_id: 
          user: '{{ user }}' 
        register: user_data 
      - name: 'Print user {{ user }} data' 
        debug: 
          msg: '{{ user_data }}' 

使用以下命令运行:

ansible-playbook playbooks/check_user.yaml

我们应该收到以下输出:

PLAY [localhost] *************************************************
TASK [setup] *****************************************************
ok: [localhost] 
TASK [Retrieve fale data if it exists] ****************************
ok: [localhost] 
TASK [Print user fale data] **************************************
ok: [localhost] => {
 "msg": {
 "changed": false,
 "gid": 1000,
 "msg": "User root exists",
 "uid": 1000
 }
} 
PLAY RECAP *******************************************************
localhost         : ok=3    changed=0    unreachable=0    failed=0

测试 Python 模块

正如我们所看到的,你可以通过创建非常简单的 playbook 来测试你的模块。你也可以通过更直接的方式来测试你的模块。为此,我们需要克隆 Ansible 的官方仓库(如果你还没有克隆的话):

git clone git://github.com/ansible/ansible.git --recursive

引入环境文件:

source ansible/hacking/env-setup

我们现在可以使用test-module工具运行脚本,并将文件名作为命令行参数传递:

ansible/hacking/test-module -m library/check_user_id.py -a "user=root"

结果将类似于以下内容:

    * including generated source, if any, saving to: /home/fale/.ansible_module_generated 
    * ansiballz module detected; extracted module source to: /home/fale/debug_dir 
    *********************************** 
    RAW OUTPUT 

    {"msg": "User root exists", "invocation": {"module_args": {"user": "root"}}, "gid":     0, "uid": 0, "changed": false} 

    *********************************** 
    PARSED OUTPUT 
    { 
        "changed": false, 
        "gid": 0, 
        "invocation": { 
            "module_args": { 
                "user": "root" 
            } 
        }, 
        "msg": "User root exists", 
        "uid": 0 
    }

注意

如果你没有使用AnsibleModule,直接执行脚本也很简单,这主要是因为该模块需要大量的 Ansible 特定变量,因此模拟 Ansible 的运行比直接运行 Ansible 本身要复杂。

使用 Bash 模块

Ansible 中的 Bash 模块与其他 Bash 脚本没有什么不同,区别仅在于它打印数据的方式,即打印到stdout。Bash 模块可以非常简单,比如检查远程主机上是否有进程在运行,也可以运行一些复杂的命令。

注意

如前所述,一般建议使用 Python 编写模块。在我看来,第二个最佳选择(仅适用于非常简单的模块)是bash模块,因为它简单且有广泛的用户基础。

让我们创建一个名为library/kill_java.sh的文件,内容如下:

    #!/bin/bash 
    source $1 

    SERVICE=$service_name 

    JAVA_PIDS=$(/usr/java/default/bin/jps | grep ${SERVICE} | awk '{print $1}') 

    if [ ${JAVA_PIDS} ]; then 
        for JAVA_PID in ${JAVA_PIDS}; do 
            /usr/bin/kill -9 ${JAVA_PID} 
        done 
        echo "failed=False msg="Killed all the orphaned processes for ${SERVICE}"" 
        exit 0 
    else 
        echo "failed=False msg="No orphaned processes to kill for ${SERVICE}"" 
        exit 0 
    fi 

上面的bash模块将获取service_name参数,并强制终止属于该服务的所有 Java 进程。正如你所知道的,Ansible 将参数文件传递给模块。然后,我们使用source $1来加载参数文件,这实际上会将名为service_name的环境变量设置好。接着,我们通过$service_name来访问该变量,如下所示:

    source $1 

    SERVICE=$service_name 

然后我们检查是否获得了任何该服务的 PID,并对其进行循环操作,强制终止所有与service_name匹配的 Java 进程。进程结束后,我们以failed=False和退出码0退出模块,并附带一条消息,正如你在这里看到的:

    if [ ${JAVA_PIDS} ]; then 
        for JAVA_PID in ${JAVA_PIDS}; do 
            /usr/bin/kill -9 ${JAVA_PID} 
        done 
        echo "failed=False msg="Killed all the orphaned processes for ${SERVICE}"" 
        exit 0 

如果我们没有找到任何正在运行的服务进程,我们仍然会以退出码0退出模块,因为终止 Ansible 的运行可能没有意义;这一部分代码如下:

    else 
        echo "failed=False msg="No orphaned processes to kill for ${SERVICE}"" 
        exit 0 
    fi 

注意

你还可以通过打印failed=True并退出码为1来终止 Ansible 的运行。

Ansible 允许你返回键值输出,即使语言本身不支持 JSON。这使得 Ansible 对开发者和系统管理员更友好,并允许用任何自己选择的语言编写自定义模块。让我们通过将参数文件传递给模块来测试bash模块。现在,我们可以在/tmp/arguments目录下创建一个参数文件,并将service_name参数设置为 Jenkins,如下所示:

    service_name=jenkins 

现在,你可以像运行其他 Bash 脚本一样运行该模块。让我们看看当我们运行它时会发生什么:

bash library/kill_java.sh /tmp/arguments

我们应该收到以下输出:

failed=False msg="No orphaned processes to kill for jenkins"

正如预期的那样,即使本地主机上没有 Jenkins 进程运行,模块也没有失败。

使用 Ruby 模块

在 Ruby 中编写模块和在 Python 或 Bash 中编写模块一样简单。你只需要处理好参数、错误、返回语句,当然,还需要掌握基本的 Ruby!让我们创建一个名为library/rsync.rb的文件,并写入以下代码:

    #!/usr/bin/env ruby 

    require 'rsync' 
    require 'json' 

    src = '' 
    dest = '' 
    ret_msg = '' 
    SUCCESS = '' 

    def print_message(state, mdg, key='Failed') 
        message = { 
            key => state, 
            "msg" => msg 
        } 
        print message.to_json 
        exit 1 if state == false 
        exit 0 
    end 

    args_file = ARGV[0] 
    data = File.read(args_file) 
    arguments = data.split(" ") 
    arguments.each do |argument| 
        print_message(false, "Argument should be name-value pairs. Example name=foo") if not argument.include("=") 
        field.value = argument.split("=") 
        if field == "src" 
            src = value 
        elseif field == "dest" 
            dest = value 
        else print_message(false, "Invalid argument provided. Valid arguments are src and dest.") 
        end 
    end 

    result - Rsync.run("#{src}", "#{dest}") 
    if result.success? 
        success = true 
        ret_msg = "Copied file successfully" 
    else 
        success = false 
        ret_msg = result.error 
    end 

    if success 
        print_message(false, "#{ret_msg}") 
    else 
        print_message(true, "#{ret_msg}") 
    end 

在前面的模块中,我们首先处理用户的参数,然后使用rsync库复制文件,最后返回输出。让我们分解前面的代码,看看它是如何工作的。

我们首先编写了一个方法 print_message,该方法将输出以 JSON 格式打印。通过这样做,我们可以在多个地方重用相同的代码。记住,如果你希望 Ansible 运行失败,你的模块输出应包含 failed=true;否则,Ansible 会认为模块成功,并继续执行下一个任务。得到的输出如下:

    #!/usr/bin/env ruby 

    require 'rsync' 
    require 'json' 

    src = '' 
    dest = '' 
    ret_msg = '' 
    SUCCESS = '' 

    def print_message(state, mdg, key='Failed') 
        message = { 
            key => state, 
            "msg" => msg 
        } 
        print message.to_json 
        exit 1 if state == false 
        exit 0 
    end 

接下来我们处理参数文件,该文件包含由空格分隔的键值对。这与我们之前使用 Python 模块的方式类似,我们也要解析出这些参数。同时,我们进行一些检查,确保用户没有遗漏任何必需的参数。在此例中,我们检查是否已指定 srcdest 参数,并在参数未提供时打印消息。进一步的检查可以包括参数的格式和类型。你可以添加这些检查以及任何你认为重要的检查。例如,如果某个参数是 date,你可能希望验证输入的确是正确的日期。考虑以下代码片段,它展示了讨论过的参数:

    args_file = ARGV[0] 
    data = File.read(args_file) 
    arguments = data.split(" ") 
    arguments.each do |argument| 
        print_message(false, "Argument should be name-value pairs. Example name=foo") if not argument.include("=") 
        field.value = argument.split("=") 
        if field == "src" 
            src = value 
        elseif field == "dest" 
            dest = value 
        else print_message(false, "Invalid argument provided. Valid arguments are src and dest.") 
        end 
    end 

一旦我们获取到所需的参数,我们将使用 rsync 库来复制文件,如下所示:

    result - Rsync.run("#{src}", "#{dest}") 
    if result.success? 
        success = true 
        ret_msg = "Copied file successfully" 
    else 
        success = false 
        ret_msg = result.error 
    end 

最后,我们检查 rsync 任务是通过还是失败,并调用 print_message 函数将输出打印到 stdout,如下所示:

    if success 
        print_message(false, "#{ret_msg}") 
    else 
        print_message(true, "#{ret_msg}") 
    end 

你可以通过简单地将参数文件传递给模块来测试 Ruby 模块。为此,我们可以创建一个名为 /tmp/arguments 的文件,内容如下:

    src=/var/log/ansible.log dest=/tmp/ansible_backup.log 

现在让我们运行该模块,如下所示:

ruby library/rsync.rb /tmp/arguments

我们将收到以下输出:

    {"failed":false,"msg":"Copied file successfully"} 

我们将留给你完成 serverspec 测试部分。

测试模块

测试往往被低估,因为对其目的和带来的好处缺乏理解。测试模块和测试 Ansible 剧本的其他部分一样重要,因为模块中的一个小改动可能会破坏整个剧本。我们将以本章第一部分编写的 Python 模块为例,使用 Python 的 nose 测试框架编写集成测试。单元测试也是被推荐的,但对于我们的场景——检查某个用户是否存在远程主机,集成测试更加合适。

注释

nose 是一个 Python 测试框架。更多信息,请访问 nose.readthedocs.org/en/latest/

为了 test 模块,我们将之前的模块转换为 Python 类,以便我们可以在测试中直接导入该类,只运行模块的主要逻辑。以下代码展示了重新结构化后的 library/check_user_py3.py 模块,它将检查远程主机上是否存在某个用户:

    #!/usr/bin/env python 

    import pwd 
    from ansible.module_utils.basic import AnsibleModule 

    class User: 
        def __init__(self, user): 
            self.user = user 

        # Check if user exists 
        def check_if_user_exists(self): 
            try: 
                user = pwd.getpwnam(self.user) 
                success = True 
                ret_msg = 'User %s exists' % self.user 
            except KeyError: 
                success = False 
                ret_msg = 'User %s does not exists' % self.user 
            return success, ret_msg 

    def main(): 
        # Parsing argument file 
        module = AnsibleModule( 
            argument_spec = dict( 
                user = dict(required=True) 
            ) 
        ) 
        user = module.params.get('user') 

        chkusr = User(user) 
        success, ret_msg = chkusr.check_if_user_exists() 

        # Error handling and JSON return 
        if success: 
            module.exit_json(msg=ret_msg, uid=uid, gid=gid) 
        else: 
            module.fail_json(msg=ret_msg) 

    if __name__ == "__main__": 
        main() 

如你在上面的代码中看到的,我们创建了一个名为 User 的类。我们实例化了该类,并调用 check_if_user_exists 方法来检查用户是否实际存在于远程机器上。现在是时候编写集成测试了。我们假设你已经在系统上安装了 nose 包。如果没有,不用担心!你仍然可以通过以下命令安装该包:

pip install nose

让我们现在编写集成测试文件 library/test_check_user_py3.py,内容如下:

    from nose.tools import assert_equals, assert_false, assert_true 
    import imp 
    imp.load_source("check_user","check_user_py3.py") 
    from check_user import User 

    def test_check_user_positive(): 
        chkusr = User("root") 
        success, ret_msg = chkusr.check_if_user_exists() 
        assert_true(success) 
        assert_equals('User root exists', ret_msg) 

    def test_check_user_negative(): 
        chkusr = User("this_user_does_not_exists") 
        success, ret_msg = chkusr.check_if_user_exists() 
        assert_false(success) 
        assert_equals('User this_user_does_not_exists does not exists', ret_msg) 

在上面的集成测试中,我们导入了 nose 包和我们的模块 check_user。我们通过传入要检查的用户来调用 User 类。然后,我们通过调用 check_if_user_exists() 方法来检查用户是否存在于远程主机上。nose 的方法 assert_trueassert_falseassert_equals 可以用于将预期值与实际值进行比较。只有当断言方法通过时,测试才会通过。你可以在同一个文件中编写多个测试方法,只需让这些方法的名称以 test_ 开头,例如 test_check_user_positive()test_check_user_negative() 方法。Nose 测试会执行所有以 test_ 开头的方法。

注意

如你所见,我们实际上为一个函数创建了两个测试。这是测试的一个关键部分。始终尝试测试你知道肯定能成功的情况,但也不要忘记测试那些你预期会失败的情况。

现在我们可以通过以下方式运行 nose 来测试它是否有效:

cd library
nosetests -v test_check_users_py3.py

你应该会收到类似下面的输出:

test_check_user_py3.test_check_user_positive ... ok
test_check_user_py3.test_check_user_negative ... ok
---------------------------------------------------
Ran 2 tests in 0.001s
OK

如你所见,测试通过了,因为 root 用户存在于主机上,而 this_user_does_not_exists 用户则不存在。

注意

我们在 nose 测试中使用了 -v 选项来开启 详细模式

对于更复杂的模块,我们建议你编写单元测试和集成测试。你可能会想,为什么我们不使用 serverspec 来测试这个模块。

我们仍然建议在 playbooks 中作为功能测试运行 serverspec 测试,但对于单元和集成测试,建议使用知名的框架。同样,如果你编写 Ruby 模块,我们建议你使用诸如 rspec 的框架来为它们编写测试。如果你的自定义 Ansible 模块有多个参数和多个组合,那么你将需要编写更多的测试来测试每种场景。最后,我们建议你将所有这些测试作为 CI 系统的一部分来运行,无论是 Jenkins、Travis 还是任何其他系统。

问题

本节给出了一些问题供你思考:

  • 你能想到你每天执行的常见任务以及如何为这些任务编写 Ansible 模块吗?请按你如何在 playbook 中调用模块的方式列出这些任务。

  • 你认为你的团队会使用哪种语言来编写模块?

  • 你能回顾一下你在第三章《扩展到多个主机》中可能编写的角色,并查看哪些可以潜在地转换为自定义模块吗?

总结

通过这部分内容,我们来到了这个较小但重要的章节的结束,这一章的重点是如何通过编写自定义模块来扩展 Ansible。你学习了如何使用 Python、Bash 和 Ruby 来编写你的模块。我们还看到了如何为模块编写集成测试,以便它们能够集成到你的 CI 系统中。未来,扩展你的 Ansible 功能通过模块应该会变得更加简单!

接下来,我们将进入配置、部署和编排的世界,看看 Ansible 如何解决我们在配置新实例或想要将软件更新部署到我们环境中各个实例时遇到的基础设施问题。我们保证这段旅程会很有趣!

第八章:调试与错误处理

就像软件代码一样,测试基础设施代码是一项至关重要的任务。理想情况下,生产环境中不应该存在未经测试的代码,尤其是当你有严格的客户 SLA 需要满足时,这对于基础设施同样适用。本章中,我们将讨论语法检查、在不将代码应用到机器上的情况下进行测试(无操作模式)以及功能测试,这些都是 Ansible 的核心内容,并触发你想要在远程主机上执行的各种任务。建议将这些集成到你的持续集成CI)系统中,以便更好地测试你的 playbook。我们将关注以下几点:

  • 语法检查

  • 使用和不使用 diff 的检查模式

  • 功能测试

作为功能测试的一部分,我们将关注以下内容:

  • 对系统最终状态的断言

  • 使用标签进行测试

  • Serverspec(一个不同的工具,但可以与 Ansible 很好地配合使用)

  • 使用--syntax-check选项

每当你运行一个 playbook 时,Ansible 首先会检查 playbook 文件的语法。如果遇到错误,Ansible 会提示语法错误并停止执行,除非你修复该错误。此语法检查仅在你运行ansible-playbook命令时进行。当编写大型 playbook 或包含任务文件时,可能很难修复所有错误,这可能会浪费更多时间。为了解决这种情况,Ansible 提供了一种方法,可以在你继续编写 playbook 时检查 YAML 语法。对于这个示例,我们需要创建文件playbooks/setup_apache.yaml,其内容如下:

    - hosts: localhost 
      tasks: 
      - name: Install Apache 
        yum: 
          name: httpd 
          state: present 
      - name: Enable Apache 
      service: 
          name: httpd 
          state: running 
          enabled: True 

现在我们有了示例文件,我们需要使用--syntax-check参数来运行它,因此你将通过以下方式调用 Ansible:

ansible-playbook playbooks/setup_apache.yaml --syntax-check

ansible-playbook命令检查了setup_apache.yml playbook 的 YAML 语法,并显示该 playbook 的语法是正确的。让我们来看看由于 playbook 中无效语法导致的错误:

    ERROR! Syntax Error while loading YAML. 
    The error appears to have been in '~/08_code/playbooks/setup_apache.yaml': line 9, column 4, but may 
    be elsewhere in the file depending on the exact syntax problem. 

    The offending line appears to be: 

      - name: Enable Apache 
      service: 
      ^ here 

错误显示在Enable Apache任务中存在缩进错误。Ansible 还会给出出错的行号、列号和文件名(即使这并不能保证错误的准确位置)。这应该绝对是你在 Ansible 的持续集成中应该运行的基础测试之一。

检查模式

检查模式(也称为干运行无操作模式)将在没有执行任何操作的情况下运行你的剧本,即它不会对远程主机应用任何更改;相反,它只会显示任务运行时将引入的更改。是否启用检查模式取决于每个任务。你可能会对以下命令感兴趣。所有这些模块必须在/usr/lib/python2.7/site-packages/ansible/modules或你 Ansible 模块文件夹所在的路径中运行(不同的路径可能根据你使用的操作系统及 Ansible 的安装方式而有所不同)。

要统计安装中可用的模块数量,你可以执行以下命令:

find . -type f | grep '.py$' | grep -v '__init__' | wc -l

使用 Ansible 2.1.1 时,这个命令的结果是569,因为 Ansible 有这么多模块。

如果你想查看有多少模块支持检查模式,你可以运行:

grep -r 'supports_check_mode=True' | awk -F: '{print $1}' | sort | uniq | wc -l

使用 Ansible 2.1.1 时,这个命令的结果是242

你可能还会发现以下命令在列出所有支持检查模式的模块时很有用:

grep -r 'supports_check_mode=True' | awk -F: '{print $1}' | sort | uniq

这有助于你测试剧本的行为,并在将其运行在生产服务器之前检查是否可能出现任何失败。你只需将--check选项传递给ansible-playbook命令,就可以在检查模式下运行剧本。让我们看看检查模式如何与setup_apache.yml剧本一起工作,如下所示:

PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Install Apache] ******************************************** 
    ok: [localhost] 

    TASK [Enable Apache] ********************************************* 
    changed: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=3    changed=1    unreachable=0    failed=0 

在之前的运行中,Ansible 没有在目标主机上做出更改,而是突出显示了实际运行时将发生的所有更改。从前一次的运行中,你可以发现httpd服务已经安装在目标主机上,因此该任务的 Ansible 退出消息为“ok”。

    TASK [Install Apache] ******************************************** 
    ok: [localhost]

然而,第二个任务发现目标主机上的httpd服务没有运行:

    TASK [Enable Apache] ********************************************* 
    changed: [localhost]

当你再次运行前面的剧本时,如果没有启用检查模式,Ansible 将确保服务状态为运行中。

使用--diff指示文件之间的差异

在检查模式下,你可以使用--diff选项来显示将应用于文件的更改。为了能够看到--diff选项的使用,我们需要将playbooks/setup_apache.yaml剧本修改为如下所示:

    - hosts: localhost 
      tasks: 
      - name: Ensure Apache is installed 
        yum: 
          name: httpd 
          state: present 
      - name: Ensure Apache in enabled 
        service: 
          name: httpd 
          state: running 
          enabled: True 
      - name: Ensure Apache userdirs are properly configured 
        template: 
          src: '../templates/userdir.conf' 
          dest: '/etc/httpd/conf.d/userdir.conf' 

如你所见,我们添加了一个任务,它将确保/etc/httpd/conf.d/userdir.conf文件的某个特定状态。

我们还需要创建一个模板文件,并将其放在templates/userdir.conf中,内容如下:

    # UserDir: The name of the directory that is appended onto a user's home 
    # directory if a ~user request is received. 
    # The path to the end user account 'public_html' directory must be 
    # accessible to the webserver userid.  This usually means that ~userid 
    # must have permissions of 711, ~userid/public_html must have permissions 
    # of 755, and documents contained therein must be world-readable. 
    # Otherwise, the client will only receive a "403 Forbidden" message. 
    # 
    <IfModule mod_userdir.c> 
        # 
        # UserDir is disabled by default since it can confirm the presence 
        # of a username on the system (depending on home directory 
        # permissions). 
        # 
        UserDir enabled 

        # 
        # To enable requests to /~user/ to serve the user's public_html 
        # directory, remove the "UserDir disabled" line above, and uncomment 
        # the following line instead: 
        # 
        #UserDir public_html 
    </IfModule> 

    # 
    # Control access to UserDir directories.  The following is an example 
    # for a site where these directories are restricted to read-only. 
    # 
    <Directory "/home/*/public_html"> 
        AllowOverride FileInfo AuthConfig Limit Indexes 
        Options MultiViews Indexes SymLinksIfOwnerMatch IncludesNoExec 
        Require method GET POST OPTIONS 
    </Directory> 

在这个模板中,我们只更改了UserDir enabled这一行,默认情况下它是UserDir disabled

注意

--diff选项无法与文件模块一起使用;你只能使用模板模块。

我们现在可以通过以下命令测试这个结果:

ansible-playbook playbooks/setup_apache.yaml --diff --check

如你所见,我们使用了--check参数,这将确保这是一个干运行。我们将收到以下输出:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Ensure Apache is installed] ******************************** 
    ok: [localhost] 

    TASK [Ensure Apache in enabled] ********************************** 
    changed: [localhost] 

    TASK [Ensure Apache userdirs are properly configured] ************ 
    changed: [localhost] 
    --- before: /etc/httpd/conf.d/userdir.conf 
    +++ after: dynamically generated 
    @@ -14,7 +14,7 @@ 
        # of a username on the system (depending on home directory 
        # permissions). 
        # 
    -    UserDir disabled 
    +    UserDir enabled 

        # 
        # To enable requests to /~user/ to serve the user's public_html 
    @@ -33,4 +33,3 @@ 
        Options MultiViews Indexes SymLinksIfOwnerMatch IncludesNoExec 
        Require method GET POST OPTIONS 
    </Directory> 
    - 

    PLAY RECAP ******************************************************* 
    localhost         : ok=4    changed=2    unreachable=0    failed=0

如我们所见,Ansible 会将远程主机的当前文件与源文件进行比较;以+开头的行表示文件中添加了一行内容,而-表示删除了一行。

注意

你还可以在没有--check选项的情况下使用--diff,这将允许 Ansible 执行指定的更改并显示两个文件之间的差异。

--diff--check模式一起使用是一个测试步骤,可能作为你 CI 测试的一部分,用来验证在运行过程中有多少步骤发生了变化。另一个可以将这两个功能一起使用的场景是部署过程的一部分,检查在你运行 Ansible 时到底会发生什么变化。

也有一些情况——虽然不该发生,但有时会发生——你可能很长时间没在某台机器上运行 playbook,担心再次运行会破坏某些东西。将这些选项一起使用应能帮助你了解是你过度担心,还是确实存在实际风险。

Ansible 中的功能测试

Wikipedia 表示功能测试是一个质量保证(QA)过程,是一种黑盒测试类型,其测试用例基于被测试软件组件的规格。通过给功能输入数据并检查输出结果来进行测试;通常不考虑内部程序结构。功能测试在基础设施中与代码同样重要。

从基础设施的角度来看,在功能测试方面,我们测试的是 Ansible 运行在实际机器上的输出。Ansible 提供了多种方式来执行 playbook 的功能测试;让我们来看一些最常用的方法。

使用 assert 进行功能测试

检查模式只会在你想检查某个任务是否会对主机做出更改时起作用。如果你想检查模块的输出是否符合预期,这种模式将无效。例如,假设你编写了一个模块来检查端口是否开启。为了测试这个,你可能需要检查模块的输出,看看它是否与预期结果匹配。为了执行这样的测试,Ansible 提供了一种直接比较模块输出与期望输出的方式。

让我们通过创建文件playbooks/assert_ls.yaml并加入以下内容来看这个如何运作:

    - hosts: localhost 
      tasks: 
      - name: List files in /tmp 
        command: ls /tmp 
        register: list_files 
      - name: Check if file testfile.txt exists 
        assert: 
          that: 
          - "'testfile.txt' in list_files.stdout_lines" 

在上述 playbook 中,我们在目标主机上运行ls命令,并将该命令的输出结果注册到list_files变量中。此外,我们要求 Ansible 检查ls命令的输出是否符合预期结果。我们使用assert模块来完成此操作,该模块通过一些条件检查来验证任务的stdout值是否符合用户的预期输出。让我们运行上述 playbook 来查看 Ansible 使用该命令返回的输出:

ansible-playbook playbooks/assert_ls.yaml

由于我们没有这个文件,我们将收到以下输出:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [List files in /tmp] **************************************** 
    changed: [localhost] 

    TASK [Check if file testfile.txt exists] ************************* 
    fatal: [localhost]: FAILED! => {"assertion": "'testfile.txt' in list_files.stdout_lines", "changed":     false, "evaluated_to": false, "failed": true} 

    NO MORE HOSTS LEFT *********************************************** 
        to retry, use: --limit @playbooks/assert_ls.retry 

    PLAY RECAP ******************************************************* 
    localhost         : ok=2    changed=1    unreachable=0    failed=1

如果在创建预期文件后重新运行剧本,它将不会失败,因此结果将是:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [List files in /tmp] **************************************** 
    changed: [localhost] 

    TASK [Check if file testfile.txt exists] ************************* 
    ok: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=3    changed=1    unreachable=0    failed=0

这次,任务通过了,并且显示了testfile.txtlist_files变量中存在的消息。类似地,你可以使用andor操作符在变量中或多个变量中匹配多个字符串。断言功能非常强大,曾经在项目中编写过单元测试或集成测试的用户会对这个功能非常高兴!

使用标签进行测试

标签是测试一堆任务而不运行整个剧本的好方法。我们可以使用标签对节点执行实际测试,以验证用户意图中的状态,即剧本。我们可以把这当作另一种在实际机器上运行 Ansible 的集成测试方式。标签方法可以在实际运行 Ansible 的机器上进行测试,并且它也可以在部署期间主要用于测试最终系统的状态。在本节中,我们将首先了解如何一般性地使用标签,它的功能如何帮助我们,不仅仅是用于测试,甚至在其他方面也有帮助,最后是用于测试目的。

要在剧本中添加标签,使用tags参数,后跟一个或多个标签名称,标签名称之间用逗号分隔。让我们在playbooks/tags_example.yaml中创建一个简单的剧本,查看标签如何工作,内容如下:

    - hosts: localhost 
      tasks: 
      - name: Ensure the file /tmp/ok exists 
        file: 
          name: /tmp/ok 
          state: touch 
        tags: 
        - file_present 
      - name: Ensure the file /tmp/ok does not exists 
        file: 
          name: /tmp/ok 
          state: absent 
        tags: 
        - file_absent 

如果我们现在运行剧本,文件将被创建和销毁。我们可以使用以下命令查看它的运行情况:

ansible-playbook playbooks/tags_example.yaml

它将给我们这个输出:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Ensure the file /tmp/ok exists] **************************** 
    changed: [localhost] 

    TASK [Ensure the file /tmp/ok does not exists] ******************* 
    changed: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=3    changed=2    unreachable=0    failed=0

由于这不是一个幂等的剧本,如果我们反复运行它,我们将始终看到相同的结果,因为剧本每次都会创建和删除该文件。

现在,你可以简单地传递file_present标签或file_absent标签,只执行其中一个动作,像下面的示例一样:

ansible-playbook playbooks/tags_example.yaml -t file_present

由于-t file_present部分,只有带有file_present标签的任务会被执行,实际上这是输出结果:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Ensure the file /tmp/ok exists] **************************** 
    changed: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=2    changed=1    unreachable=0    failed=0

你还可以使用标签在远程主机上执行一组任务,就像从负载均衡器中移除服务器并重新加入负载均衡器一样。

你还可以将--check选项与标签一起使用。通过这样做,你可以在不实际在主机上运行任务的情况下测试任务。这使得你可以直接测试一堆独立的任务,而不需要将任务复制到一个临时的剧本中并从那里运行。

--skip-tags

Ansible 还提供了一种在剧本中跳过某些标签的方法。如果你有一个包含多个标签(如 10 个标签)的长剧本,并且希望执行其中所有标签,除了一个,那么传递九个标签给 Ansible 并不是一个好主意。如果你忘记传递某个标签,并且ansible-playbook命令失败,那情况会变得更加复杂。为了解决这种情况,Ansible 提供了一种跳过某些标签的方法,而不是传递多个标签。其功能非常直接,可以通过以下方式触发:

ansible-playbook playbooks/tags_example.yaml --skip-tags file_present

输出将类似于:

    PLAY [localhost] ************************************************* 

    TASK [setup] ***************************************************** 
    ok: [localhost] 

    TASK [Ensure the file /tmp/ok does not exists] ******************* 
    ok: [localhost] 

    PLAY RECAP ******************************************************* 
    localhost         : ok=2    changed=1    unreachable=0    failed=0

如你所见,所有任务都已执行,除了带有 file_present 标签的任务。

管理异常

有许多情况下,由于某些原因,你希望在一个或多个任务失败的情况下,继续执行你的 playbook 和角色。一个典型的例子可能是,你想检查软件是否已经安装。让我们看看以下安装 Java 的例子。在roles/java/tasks/main.ymal文件中,我们将写入以下代码:

    - name: Verify if the current version of Java is installed 
      command: rpm -q jdk1.8.0_91-1.8.0_91-fcs 
      register: java 
      ignore_errors: True 
      changed_when: java|failed 

    - name: Ensure that JavaSE is download 
      uri: 
        url: 'http://download.oracle.com/otn-pub/java/jdk/8u91-b14/jdk-8u91-linux-x64.rpm' 
        method: GET 
        HEADER_Cookie: 'gpw_e24=http%3A%2F%2Fwww.oracle.com%2F; oraclelicense=accept-securebackup-cookie' 
        dest: /tmp 
        creates: /tmp/jdk-8u91-linux-x64.rpm 
      when: java|failed 

    - name: Ensure JavaSE is installed 
      dnf: 
        name: /tmp/jdk-8u91-linux-x64.rpm 
        state: present 
      when: java|failed 

    - name: Set alternatives for java 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/jre/bin/java 
        name: java 
        link: /usr/bin/java 
      when: java|failed 

    - name: Set alternatives for javac 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/bin/javac 
        name: javac 
        link: /usr/bin/javac 
      when: java|failed 

    - name: Set alternatives for javaws 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/bin/javaws 
        name: javaws 
        link: /usr/bin/javaws 
      when: java|failed 

在继续执行其他需要的部分之前,我想花点时间讲解这个角色任务列表的各个部分,因为这里有很多新的内容:

    - name: Verify if the current version of Java is installed 
      command: rpm -q jdk1.8.0_91-1.8.0_91-fcs 
      register: java 
      ignore_errors: True 
      changed_when: java|failed 

在这个任务中,我们执行一个rpm命令,该命令可能有两种不同的输出:

  • 失败

  • 返回 JDK 包的完整名称

由于我们只想检查包是否存在,然后继续前进,我们注册输出(第三行)并忽略可能的失败(第四行):

    - name: Ensure that JavaSE is download 
      uri: 
        url: 'http://download.oracle.com/otn-pub/java/jdk/8u91-b14/jdk-8u91-linux-x64.rpm' 
        method: GET 
        HEADER_Cookie: 'gpw_e24=http%3A%2F%2Fwww.oracle.com%2F; oraclelicense=accept-securebackup-cookie' 
        dest: /tmp 
        creates: /tmp/jdk-8u91-linux-x64.rpm 
      when: java|failed 

在这一部分中,我们使用 uri 模块,它允许我们使用 HTTP 请求访问远程 URI。这个模块非常好,因为它允许你使用所有 HTTP 方法,并自定义 HTTP 头部。这样使得该模块非常灵活。由于在最后一行我们有 when: java|failed,这将仅在 Java 未安装时执行:

    - name: Ensure JavaSE is installed 
      dnf: 
        name: /tmp/jdk-8u91-linux-x64.rpm 
        state: present 
      when: java|failed 

在这里我们使用 dnf 安装 Java 包。由于在最后一行我们有 when: java|failed,这将仅在 Java 未安装时执行:

    - name: Set alternatives for java 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/jre/bin/java 
        name: java 
        link: /usr/bin/java 
      when: java|failed 

    - name: Set alternatives for javac 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/bin/javac 
        name: javac 
        link: /usr/bin/javac 
      when: java|failed 

    - name: Set alternatives for javaws 
      alternatives: 
        path: /usr/java/jdk1.8.0_91/bin/javaws 
        name: javaws 
        link: /usr/bin/javaws 
      when: java|failed 

在这里,我们将设置新的 alternatives,以防我们正在安装 Java。alternatives是一个 Ansible 模块,它允许我们管理 Linux alternatives 程序的配置。这个程序常用于管理在默认情况下如果安装了多个版本时,哪个版本的程序应该被默认运行。

在我们创建角色之后,我们需要一个包含主机机器的 hosts 文件,在我的例子中:

    j01.fale.io 

以及一个应用该角色的 playbook,放在 playbooks/hosts/j01.fale.io.yaml 中,内容如下:

    - hosts: j01.fale.io 
      user: root 
      roles: 
      - java 

现在我们可以用以下命令执行它:

ansible-playbook playbooks/hosts/j01.fale.io.yaml

我们将得到以下结果:

    PLAY [j01.fale.io] *********************************************** 

    TASK [setup] ***************************************************** 
    ok: [j01.fale.io] 

    TASK [java : Verify if the current version of Java is installed] * 
    fatal: [j01.fale.io]: FAILED! => {"changed": true, "cmd": ["rpm", "-q", "jdk1.8.0_91-1.8.0_91-fcs"],         "delta": "0:00:00.009788", "end": "2016-09-27 11:04:56.185618", "failed": true, "rc": 1, "start":         "2016-    09-27 11:04:56.175830", "stderr": ``, "stdout": "package jdk1.8.0_91-1.8.0_91-fcs is not         installed", "stdout_lines": ["package jdk1.8.0_91-1.8.0_91-fcs is not installed"], "warnings":             ["Consider using yum, dnf or zypper module rather than running rpm"]} 
      ...ignoring 

    TASK [java : Ensure that JavaSE is download] ********************* 
    changed: [j01.fale.io] 

    TASK [java : Ensure JavaSE is installed] ************************* 
    changed: [j01.fale.io] 

    TASK [java : Set alternatives for java] ************************** 
    ok: [j01.fale.io] 

    TASK [java : Set alternatives for javac] ************************* 
    ok: [j01.fale.io] 

    TASK [java : Set alternatives for javaws] ************************ 
    ok: [j01.fale.io] 

    PLAY RECAP ******************************************************* 
    j01.fale.io       : ok=7    changed=2    unreachable=0    failed=0

如你所见,安装检查失败,因为 Java 没有安装在机器上,因此所有其他任务按预期执行。

触发失败

有时你可能希望直接触发失败。这可能由于多种原因发生,尽管这样做有一定的弊端,因为触发失败时,playbook 会被强行中断,如果不小心,可能会让你的机器处于不一致的状态。一个我看到非常有效的情况是,当你运行一个非幂等的 playbook(例如构建新版应用程序)时,你需要设置一个变量(例如:要部署的版本/分支)。在这种情况下,你可以在开始执行操作之前检查预期的变量是否正确配置,以确保后续一切按预期进行。

让我们将以下代码放入 playbooks/maven_build.yaml 文件中:

    - hosts: j01.fale.io 
      tasks: 
      - name: Ensure the tag variable is properly set 
        fail: 'The version needs to be defined. To do so, please add: --extra-vars                                 "version=$[TAG/BRANCH]"' 
        when: version is not defined 
      - name: Get last Project version 
        git: 
          repo: https://github.com/org/project.git 
          dest: "/tmp" 
          version: '{{ version }}' 
      - name: Maven clean install 
        shell: "cd /tmp/project && mvn clean install" 

如您所见,我们希望用户在脚本中添加 --extra-vars "version=$[TAG/BRANCH]" 来调用命令。我们本可以默认使用某个分支,但这样做风险太大,因为用户可能会失去注意力,忘记自己添加正确的分支名称,这将导致编译(和部署)错误版本的应用程序。fail 模块还允许我们指定一条消息,该消息将显示给用户。

注意

我认为 fail 任务在手动运行的 playbook 中更为有用,因为当 playbook 被自动运行时,处理异常往往比直接失败更好。

概述

在本章中,我们已经了解了如何使用多种技术调试 Ansible playbook。接着我们讨论了失败管理,最后我们看到了如何故意触发失败。

在下一章中,我们将讨论多层环境以及部署方法。

第九章. 复杂环境

到目前为止,我们已经看到如何开发剧本并进行测试。最后一个方面是如何将剧本发布到生产环境。在大多数情况下,在剧本发布到生产环境之前,你将需要处理多个环境。这类似于你开发人员编写的软件。许多公司有多个环境,通常你的剧本将遵循以下步骤:

  • 开发环境

  • 测试环境

  • 阶段环境

  • 生产环境

一些公司以不同的方式命名这些环境,还有一些公司有额外的环境,如认证环境,在生产环境之前,所有软件必须先通过认证。

当你编写剧本并设置角色时,我们强烈建议从一开始就考虑到环境的概念。与软件和运维团队沟通,搞清楚你的系统需要支持多少个环境可能是值得的。

我们将列出几种方法和示例,你可以在你的环境中使用:

基于 Git 分支的代码

假设你需要处理四个环境,如下所示:

  • 开发环境

  • 测试环境

  • 阶段

  • 生产环境

在基于 Git 分支的方法中,你将为每个分支拥有一个环境。你将始终首先在开发环境中进行更改,然后将这些更改推广到测试环境(合并或挑选提交并在 Git 中标记),阶段环境生产环境。在这种方法中,你将拥有一个单一的清单文件、一组变量文件,以及每个分支专用的角色和剧本文件夹。

一个稳定的主分支,配有多个文件夹

在这种方法中,你将始终保持 dev 和 master 分支。初始代码提交到 dev 分支,一旦稳定,你会将其推送到 master 分支。master 分支中存在的相同角色和剧本将在所有环境中运行。另一方面,你将为每个环境保持独立的文件夹。我们来看一个例子。我们将展示如何为两个环境——阶段和生产——设置单独的配置和清单。你可以根据你的场景扩展,适应所有使用的环境。首先,我们来看一下playbooks/variables.yaml中的剧本,它将在这些多个环境中运行,内容如下:

- hosts: web 
  user: root 
  tasks: 
  - name: Print environment name 
    debug: 
      var: env 
  - name: Print db server url 
    debug: 
      var: db_url 
  - name: Print domain url 
    debug: 
      var: domain 
- hosts: db 
  user: root 
  tasks: 
  - name: Print environment name 
    debug: 
      var: env 
  - name: Print database username 
    debug: 
      var: db_user 
  - name: Print database password 
    debug: 
      var: db_pass 

如你所见,剧本中有两组任务:

  • 运行于数据库服务器的任务

  • 运行于 Web 服务器的任务

还有一个额外的任务,用于打印特定环境中所有服务器的环境名称。我们还会有两个不同的清单文件。

第一个将被称为inventory/production,其内容如下:

[web] 
ws01.fale.io 
ws02.fale.io 

[db] 
db01.fale.io 

[production:children] 
db 
web 

第二个将被称为inventory/staging,其内容如下:

[web] 
ws01.stage.fale.io 
ws02.stage.fale.io 

[db] 
db01.stage.fale.io 

[staging:children] 
db 
web 

如你所见,在每个环境中,我们为web部分配有两台机器,为db部分配有一台机器。此外,我们为阶段和生产环境配置了不同的机器。额外的部分[ENVIRONMENT:children]允许你创建一个组的组。这意味着在ENVIRONMENT部分中定义的任何变量将应用于dbweb组,除非它们在各自的独立部分中被覆盖。下一个有趣的部分是查看每个环境中变量的值,并查看它们在每个环境中的分离方式。

让我们从所有环境通用的变量开始,它们位于inventory/group_vars/all中:

db_user: mysqluser 

唯一在两个环境中相同的变量是db_user

现在我们可以查看生产环境特定的变量,它们位于inventory/group_vars/production中:

env: production 
domain: fale.io 
db_url: db.fale.io 
db_pass: this_is_a_safe_password 

如果我们现在查看位于inventory/group_vars/staging中的阶段特定变量,我们会发现它们与生产环境中的变量相同,但值不同:

env: staging 
domain: stage.fale.io 
db_url: db.stage.fale.io 
db_pass: this_is_an_unsafe_password 

现在我们可以验证是否收到了预期的结果。首先,我们将在暂存环境中运行:

ansible-playbook -i staging playbooks/variables.yaml

我们应该收到类似于以下内容的输出:

PLAY [web] *******************************************************

TASK [setup] ***************************************************** 
ok: [ws02.stage.fale.io] 
ok: [ws01.stage.fale.io] 

TASK [Print environment name] ************************************ 
ok: [ws01.stage.fale.io] => { 
    "env": "staging" 
} 
ok: [ws02.stage.fale.io] => { 
    "env": "staging" 
} 

TASK [Print db server url] *************************************** 
ok: [ws01.stage.fale.io] => { 
    "db_url": "db.stage.fale.io" 
} 
ok: [ws02.stage.fale.io] => { 
    "db_url": "db.stage.fale.io" 
} 

TASK [Print domain url] ****************************************** 
ok: [ws01.stage.fale.io] => { 
    "domain": "stage.fale.io" 
} 
ok: [ws02.stage.fale.io] => { 
    "domain": "stage.fale.io" 
} 

PLAY [db] ******************************************************** 

TASK [setup] ***************************************************** 
ok: [db01.stage.fale.io] 

TASK [Print environment name] ************************************ 
ok: [db01.stage.fale.io] => { 
    "env": "staging" 
} 

TASK [Print database username] *********************************** 
ok: [db01.stage.fale.io] => { 
    "db_user": "mysqluser" 
} 

TASK [Print database password] ********************************** 
ok: [db01.stage.fale.io] => { 
    "db_pass": "this_is_an_unsafe_password" 
} 

PLAY RECAP ******************************************************* 
db01.stage.fale.io: ok=4    changed=0    unreachable=0    failed=0 
ws01.stage.fale.io: ok=4    changed=0    unreachable=0    failed=0 
ws02.stage.fale.io: ok=4    changed=0    unreachable=0    failed=0

现在我们可以在生产环境中运行:

ansible-playbook -i production playbooks/variables.yaml

我们将收到以下结果:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws02.fale.io]
ok: [ws01.fale.io] 
TASK [Print environment name] ************************************
ok: [ws01.fale.io] => {
 "env": "production"
}
ok: [ws02.fale.io] => {
 "env": "production"
} 
TASK [Print db server url] ***************************************
ok: [ws01.fale.io] => {
 "db_url": "db.fale.io"
}
ok: [ws02.fale.io] => {
 "db_url": "db.fale.io"
} 
TASK [Print domain url] ******************************************
ok: [ws01.fale.io] => {
 "domain": "fale.io"
}
ok: [ws02.fale.io] => {
 "domain": "fale.io"
} 
PLAY [db] ******************************************************** 
TASK [setup] *****************************************************
ok: [db01.fale.io] 
TASK [Print environment name] ************************************
ok: [db01.fale.io] => {
 "env": "production"
} 
TASK [Print database username] ***********************************
ok: [db01.fale.io] => {
 "db_user": "mysqluser"
} 
TASK [Parint database password] **********************************
ok: [db01.fale.io] => {
 "db_pass": "this_is_a_safe_password"
} 
PLAY RECAP *******************************************************
db01.fale.io      : ok=4    changed=0    unreachable=0    failed=0
ws01.fale.io      : ok=4    changed=0    unreachable=0    failed=0
ws02.fale.io      : ok=4    changed=0    unreachable=0    failed=0

你可以看到,Ansible 运行已经获取了为暂存环境定义的所有相关变量。

如果你正在使用这种方法来为多个环境获取稳定的主分支,最好结合使用特定于环境的目录、group_vars和清单组来解决这个问题。

软件分发策略

部署应用程序可能是信息与通信技术ICT)领域最复杂的任务之一。其主要原因是它通常需要更改大多数在某种程度上参与该应用程序的机器的状态。事实上,通常你会发现自己不得不在部署过程中同时更改负载均衡器、分发服务器、应用服务器和数据库服务器的状态。像容器这样的新技术正在努力简化这些操作,但将传统应用程序迁移到容器中往往并不容易或可行。

接下来我们将看到各种软件分发策略,以及 Ansible 如何帮助实现每一种策略。

从本地机器复制文件

这可能是分发软件的最古老策略。其思想是将文件放在本地机器上(通常用于开发代码),一旦做出更改,就将文件复制到服务器上(通常通过 FTP)。这种部署代码的方式通常用于 Web 开发,其中代码(通常是 PHP)不需要任何编译。

由于其多种问题,这种分发策略应该避免使用。

  • 很难回滚

  • 无法跟踪不同部署的变化

  • 没有部署历史

  • 在部署过程中容易出错

尽管这种分发策略可以通过 Ansible 轻松自动化,但我强烈建议你立即转向一种允许你有更安全分发策略的其他方法。

带分支的版本控制系统

许多公司正在使用这种技术来分发他们的软件,主要是针对未编译的软件。这种技术的核心思想是将服务器设置为使用本地代码仓库副本。使用 SVN 时,这种方法是可行的,但管理起来不太容易,而 Git 则使这一技术得到了简化,因而变得非常流行。

这种技术相较于我们刚才看到的方法有许多优势,主要有:

  • 简单的回滚

  • 非常容易获取变更历史

  • 非常简便的部署(主要是使用 Git 时)

另一方面,这种技术仍然有多个缺点:

  • 没有部署历史

  • 对于编译软件来说比较困难

  • 可能的安全问题

我想再谈谈你可能会遇到的安全问题。非常诱人的一种做法是将 Git 仓库直接下载到你用来分发内容的文件夹中,比如如果是一个 Web 服务器,那就是/var/www/文件夹。这有显而易见的优势,因为部署时你只需要执行git pull命令。缺点是 Git 会创建/var/www/.git文件夹,其中包含整个 Git 仓库(包括历史记录),如果没有适当保护,它将会被任何人自由下载。

注意

大约 1%的 Alexa 前百万网站将 Git 文件夹公开访问,因此如果你打算使用这种分发策略,请非常小心。

带标签的版本控制系统

另一种使用版本控制系统的方式稍微复杂一些,但有一些优势,那就是利用标签系统。此方法要求每次部署时都要打标签,然后在服务器上检出特定的标签。

这种方法具备之前方法的所有优势,并且增加了部署历史记录。编译软件问题和可能的安全问题与前述方法相同。

RPM 包

一种非常常见的软件部署方式(主要适用于编译过的应用程序,但对未编译的应用程序也有优势)是使用某种打包系统。一些语言,如 Java,已经包含了一个系统(在 Java 中是 WAR),但也有一些可以用于任何类型应用程序的打包系统,例如 RPM。这些系统的缺点是,它们比之前的方法稍微复杂一些,但它们可以提供更高的安全性和版本控制。此外,这些系统很容易在 CI/CD 流水线中注入,因此实际的复杂性比初看起来要低,因为 CI/CD 流水线会自动处理构建过程。

准备环境

要查看我们如何以我们在前几页中讨论的不同方式部署代码,我们需要一个环境,显然我们将使用 Ansible 来创建它。首先,为了确保我们的角色被正确加载,我们需要ansible.cfg文件,内容如下:

[defaults] 
roles_path = roles 

然后我们需要playbooks/firstrun.yaml来确保我们可以使用基本配置来配置我们的机器,内容如下:

- hosts: all 
  user: root 
  tasks: 
  - name: Ensure ansible user exists 
    user: 
      name: ansible 
      state: present 
      comment: Ansible 
  - name: Ensure ansible user accepts the SSH key 
    authorized_key: 
      user: ansible 
      key: https://github.com/fale.keys 
      state: present 
  - name: Ensure the ansible user is sudoer with no password required 
    lineinfile: 
      dest: /etc/sudoers 
      state: present 
      regexp: '^ansible ALL\=' 
      line: 'ansible ALL=(ALL) NOPASSWD:ALL' 
      validate: 'visudo -cf %s' 

playbooks/groups/web.yaml也需要创建,以便我们能够正确引导我们的 Web 服务器:

- hosts: web 
  user: ansible 
  roles: 
  - common 
  - webserver 

正如你从前面的文件内容中可以想象的那样,我们需要创建角色:commonwebserver,它们与我们在第四章中创建的非常相似,处理复杂的部署。我们从roles/common/tasks/main.yaml文件开始,文件内容如下:

- name: Ensure EPEL is enabled
   yum:
     name: epel-release
     state: present
   become: True
- name: Ensure needed packages are present
   yum:
     name: '{{ item }}'
     state: present
   become: True
   with_items:
   - libsemanage-python
   - libselinux-python
   - ntp
   - firewalld
- name: Ensure we have last version of every package
   yum:
     name: "*"
     state: latest
   become: True
- name: Ensure the timezone is set to UTC
   file:
     src: /usr/share/zoneinfo/GMT
     dest: /etc/localtime
     state: link
   become: True
- name: Ensure the NTP service is running and enabled
   service:
   name: ntpd
   state: started
   enabled: True
 become: True
- name: Ensure FirewallD is running
   service:
     name: firewalld
     state: started
     enabled: True
   become: True
- name: Ensure SSH can pass the firewall
   firewalld:
     service: ssh
     state: enabled
     permanent: True
     immediate: True
   become: True
- name: Ensure the MOTD file is present and updated
   template:
     src: motd
     dest: /etc/motd
     owner: root
     group: root
     mode: 0644
   become: True
- name: Ensure the hostname is the same of the inventory
   hostname:
     name: "{{ inventory_hostname }}"
   become: True

它的motd模板位于roles/common/templates/motd

                This system is managed by Ansible 
  Any change done on this system could be overwritten by Ansible 

OS: {{ ansible_distribution }} {{ ansible_distribution_version }} 
Hostname: {{ inventory_hostname }} 
eth0 address: {{ ansible_eth0.ipv4.address }} 

            All connections are monitored and recorded 
    Disconnect IMMEDIATELY if you are not an authorized user 

现在我们可以继续到webserver角色,更具体地说是到roles/webserver/tasks/main.yaml文件:

- name: Ensure the HTTPd package is installed 
  yum: 
    name: httpd 
    state: present 
  become: True 
- name: Ensure the PHP is installed 
  yum: 
    name: '{{ item }}'
    state: present 
  become: True 
  with_items:
 - git
 - php 
- name: Ensure the HTTPd service is enabled and running 
  service: 
    name: httpd 
    state: started 
    enabled: True 
  become: True 
- name: Ensure HTTP can pass the firewall 
  firewalld: 
    service: http 
    state: enabled 
    permanent: True 
    immediate: True 
  become: True 
- name: Ensure HTTPd configuration is updated 
  copy: 
    src: website.conf 
    dest: /etc/httpd/conf.d 
  become: True 
  notify: Restart HTTPd 

我们还需要在roles/webserver/handlers/main.yaml中创建处理程序,内容如下:

- name: Restart HTTPd 
  service: 
    name: httpd 
    state: restarted 
  become: True 

最后,我们需要修改roles/webserver/files/website.conf文件,暂时保持为空,但该文件必须存在。

现在我们可以配置几台 CentOS 机器(我配置了ws01.fale.iows02.fale.io)并确保库存是正确的。我们还可以运行firstrun.yaml剧本,以确保 Ansible 用户已经存在并且配置正确:

ansible-playbook -i inventory/production playbooks/firstrun.yaml

你应该收到的输出如下:

PLAY [localhost] ************************************************* 
PLAY [all] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure ansible user exists] ********************************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [Ensure ansible user accepts the SSH key] *******************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [Ensure the ansible user is sudoer with no password required]
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=4    changed=3    unreachable=0    failed=0
ws02.fale.io      : ok=4    changed=3    unreachable=0    failed=0

现在我们可以通过运行它们的组剧本来配置这些机器:

ansible-playbook -i inventory/production playbooks/groups/web.yaml

我们将收到以下输出:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io]
 ....
PLAY RECAP *******************************************************
ws01.fale.io      : ok=20   changed=14   unreachable=0    failed=0
ws02.fale.io      : ok=20   changed=14   unreachable=0    failed=0

现在我们可以将浏览器指向我们的节点端口80,检查 HTTPd 页面是否按预期显示。

使用修订控制系统部署 Web 应用程序

对于这个示例,我们将部署一个简单的 PHP 应用程序,它只由一个 PHP 页面组成。源代码可以在以下仓库中找到:https://github.com/Fale/demo-php-app。

为了部署它,我们需要将以下代码放入playbooks/manual/rcs_deploy.yaml中:

- hosts: web 
  user: ansible 
  tasks: 
  - name: Install or update website 
    git: 
      repo: https://github.com/Fale/demo-php-app.git 
      dest: /var/www/application 
    become: True 

现在我们可以使用以下命令运行deployer

ansible-playbook -i inventory/production playbooks/manual/rcs_deploy.yaml

这是预期的结果:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io]
 .... 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=2    changed=1    unreachable=0    failed=0
ws02.fale.io      : ok=2    changed=1    unreachable=0    failed=0

目前,由于我们没有为该文件夹设置 HTTPd 规则,我们的应用程序尚不可访问。为此,我们需要更改roles/webserver/files/website.conf文件,内容如下:

<VirtualHost *:80> 
    ServerName app.fale.io 
    DocumentRoot /var/www/application 
    <Directory /var/www/application> 
        Options None 
    </Directory> 
    <DirectoryMatch ".git*"> 
        Require all denied 
    </DirectoryMatch> 
</VirtualHost> 

如你所见,我们只是将这个应用程序展示给通过app.fale.io URL 访问我们服务器的用户,而不是所有人。这将确保所有用户获得一致的体验。此外,你可以看到我们正在阻止所有访问.git文件夹(以及其中的所有内容)。这对于之前提到的安全原因是必要的。

现在我们可以重新运行 web 剧本,以确保我们的 HTTPd 配置得到了传播,命令如下:

ansible-playbook -i inventory/production playbooks/groups/web.yaml

这是我们将收到的结果:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure EPEL is enabled] ***************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure libselinux-python is present] **************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure libsemanage-python is present] *************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure we have last version of every package] *****
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure NTP is installed] **************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the timezone is set to UTC] ****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the NTP service is running and enabled] ****
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure FirewallD is installed] ********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure FirewallD is running] **********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure SSH can pass the firewall] *****************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the MOTD file is present and updated] ******
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [common : Ensure the hostname is the same of the inventory] *
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure the HTTPd package is installed] *********
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure the PHP is installed] *******************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure git is installed] ***********************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure the HTTPd service is enabled and running]
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure HTTP can pass the firewall] *************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [webserver : Ensure HTTPd configuration is updated] *********
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
RUNNING HANDLER [webserver : Restart HTTPd] **********************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=20   changed=2    unreachable=0    failed=0
ws02.fale.io      : ok=20   changed=2    unreachable=0    failed=0

您现在可以检查并确保一切正常运行。

使用 RPM 包部署 Web 应用

为了部署一个 RPM 包,我们首先需要创建它。为此,我们需要一个 Spec 文件。

创建一个 Spec 文件

首先要做的是创建一个 SpecificsSpec)文件,这是一个指导 rpmbuild 如何实际创建 RPM 包的配方。我们将把 Spec 文件放在 spec/demo-php-app.spec 中,并将以下内容放入其中:

%define debug_package %{nil} 
%global commit0 b49f595e023e07a8345f47a3ad62a6f50f03121e 
%global shortcommit0 %(c=%{commit0}; echo ${c:0:7}) 

Name:       demo-php-app 
Version:    0 
Release:    1%{?dist} 
Summary:    Demo PHP application 

License:    PD 
URL:        https://github.com/Fale/demo-php-app 
Source0:    %{url}/archive/%{commit0}.tar.gz#/%{name}-%{shortcommit0}.tar.gz 

%description 
This is a demo PHP application in RPM format 

%prep 
%autosetup -n %{name}-%{commit0} 

%build 

%install 
mkdir -p %{buildroot}/var/www/application 
ls -alh 
cp index.php %{buildroot}/var/www/application 

%files 
%dir /var/www/application 
/var/www/application/index.php 

%changelog 
* Tue Oct 04 2016 Fabio Alessandro Locati - 0.1 
- Initial packaging 

在继续之前,让我们先了解各部分的作用和含义:

%define debug_package %{nil} 
%global commit0 b49f595e023e07a8345f47a3ad62a6f50f03121e 
%global shortcommit0 %(c=%{commit0}; echo ${c:0:7}) 

这前三行是变量声明。

第一个将禁用调试包的生成。默认情况下,rpmbuild 每次都会创建调试包并包含所有调试符号,但在这种情况下,我们没有任何调试符号,因为我们没有进行任何编译。

第二个将提交的 hash 放入变量 commit0 中。第三个计算 shortcommit0 的值,这是 commit0 字符串的前八个字符:

Name:       demo-php-app 
Version:    0 
Release:    1%{?dist} 
Summary:    Demo PHP application 

License:    PD 
URL:        https://github.com/Fale/demo-php-app 
Source0:    %{url}/archive/%{commit0}.tar.gz#/%{name}-%{shortcommit0}.tar.gz 

在第一行,我们声明名称、版本、发布号和摘要。版本和发布号之间的区别在于,版本是上游版本,而发布号是该上游版本的 Spec 版本。

许可证是源码许可证,而不是 Spec 文件的许可证。URL 用于跟踪上游网站。source0 字段用于 rpmbuild 知道源文件的名称(如果有多个文件,我们可以使用 source1source2 等)。此外,如果源字段是有效的 URI,我们可以使用 spectool 自动下载它们。

%description 
This is a demo PHP application in RPM format 

这是打包在 RPM 包中的软件的 description

%prep 
%autosetup -n %{name}-%{commit0} 

prep 阶段是源码解压缩和可能的补丁应用的阶段。%autosetup 将会解压缩第一个源码,并应用所有补丁。在此部分,您还可以执行其他需要在构建阶段之前执行的操作,目的是为构建阶段准备环境:

%build 

在这里,我们将放置所有 build 阶段的操作。在我们的情况下,我们的源代码不需要编译,因此为空:

%install 
mkdir -p %{buildroot}/var/www/application 
ls -alh 
cp index.php %{buildroot}/var/www/application 

install 阶段,我们将文件放置在 %{buildroot} 文件夹中,该文件夹将模拟目标文件系统。

%files 
%dir /var/www/application 
/var/www/application/index.php 

files 部分用于声明要放入包中的文件。

%changelog 
* Tue Oct 04 2016 Fabio Alessandro Locati - 0.1 
- Initial packaging 

changelog 是用来追踪谁在什么时候发布了新版本以及有哪些变更的。

现在我们有了 Spec 文件,需要构建它。为此,我们可以使用生产机器,但这会增加该机器的攻击面,所以最好避免这种情况。有多种方式可以构建您的 RPM 软件。主要有四种方式:

  • 手动地

  • 使用 Ansible 自动化手动方式

  • Jenkins

  • Koji

让我们简要看一下它们的不同之处。

手动构建 RPM

构建 RPM 包的最简单方法是手动进行。

大的优势是你只需要非常少且易于安装的软件包,正因为如此,许多刚接触 RPM 的人都从这里开始。缺点是过程是手动的,因此人为错误可能会破坏结果,且该过程不容易审计。

要构建 RPM 包,你需要使用 Fedora 或 EL(Red Hat Enterprise Linux,CentOS,Scientific Linux,Oracle Enterprise Linux)系统。如果你使用的是 Fedora,你需要执行以下命令来安装所有必需的软件:

sudo dnf install -y fedora-packager

如果你正在运行 EL 系统,你需要执行的命令是:

sudo yum install -y mock rpm-build spectool

无论哪种情况,你都需要将你将使用的用户添加到 mock 组中,为此你需要执行:

sudo usermod -a -G mock [yourusername]

注意

Linux 在登录时加载用户,因此要应用组的更改,你需要重新启动会话。

此时,我们可以将 Spec 文件复制到文件夹中(通常 $HOME 是一个不错的选择),并执行以下操作:

mkdir -p ~/rpmbuild/SOURCES

这将创建过程所需的 $HOME/rpmbuild/SOURCES 文件夹。-p 选项会自动创建路径中缺失的所有文件夹。

spectool -R -g demo-php-app.spec

我们使用 spectool 下载源文件并将其放置在适当的目录中。spectool 会自动从 Spec 文件获取 URL,这样我们就不必记住它了。

我们现在需要创建一个 src.rpm 文件,为此我们可以使用 rpmbuild

rpmbuild -bs demo-php-app.spec

这个命令将输出类似以下内容:

Wrote: /home/fale/rpmbuild/SRPMS/demo-php-app-0-1.fc24.src.rpm

可能存在一些小的差异,例如你可能会有不同的 $HOME 文件夹,并且如果你使用的不是 Fedora 24 来构建包,可能会有不同的 fc24。此时,我们可以使用以下命令创建二进制文件:

mock -r epel-7-x86_64 /home/fale/rpmbuild/SRPMS/demo-php-app-0-1.fc24.src.rpm

Mock 允许我们在一个干净的环境中构建 RPM 包,而且 thanks to -r 选项,它还允许我们为不同版本的 Fedora、EL 和 Mageia 构建。这个命令将给出非常长的输出,我这里不会列出所有内容,但在最后几行有有用的信息。如果一切构建成功,以下几行应该是你看到的最后内容:

Wrote: /builddir/build/RPMS/demo-php-app-0-1.el7.centos.x86_64.rpm
Executing(%clean): /bin/sh -e /var/tmp/rpm-tmp.d4vPhr
+ umask 022
+ cd /builddir/build/BUILD
+ cd demo-php-app-b49f595e023e07a8345f47a3ad62a6f50f03121e
+ /usr/bin/rm -rf /builddir/build/BUILDROOT/demo-php-app-0-1.el7.centos.x86_64
+ exit 0
Finish: rpmbuild demo-php-app-0-1.fc24.src.rpm
Finish: build phase for demo-php-app-0-1.fc24.src.rpm
INFO: Done(/home/fale/rpmbuild/SRPMS/demo-php-app-0-1.fc24.src.rpm) Config(epel-7-x86_64) 0 minutes 58 seconds
INFO: Results and/or logs in: /var/lib/mock/epel-7-x86_64/result
Finish: run

倒数第二行包含了你可以找到结果的路径。如果你查看该文件夹,你应该能找到以下文件:

drwxrwsr-x. 2 fale mock 4.0K Oct 10 12:26 .
drwxrwsr-x. 4 root mock 4.0K Oct 10 12:25 ..
-rw-rw-r--. 1 fale mock 4.6K Oct 10 12:26 build.log
-rw-rw-r--. 1 fale mock 3.3K Oct 10 12:26 demo-php-app-0-1.el7.centos.src.rpm
-rw-rw-r--. 1 fale mock 3.1K Oct 10 12:26 demo-php-app-0-1.el7.centos.x86_64.rpm
-rw-rw-r--. 1 fale mock 184K Oct 10 12:26 root.log
-rw-rw-r--. 1 fale mock  792 Oct 10 12:26 state.log

这三个日志文件在编译过程中出现问题时非常有用。src.rpm 文件将是我们用第一个命令创建的 src.rpm 文件的副本,而 x86_64.rpm 文件是 mock 创建的文件,也是我们需要安装到机器上的文件。

使用 Ansible 构建 RPM 包

由于手动执行所有这些步骤可能很繁琐、无聊且容易出错,我们可以使用 Ansible 自动化这些步骤。生成的 playbook 可能不是最干净的,但它能以可重复的方式执行所有操作。

因此,我们将从头开始构建一台新机器。我将这台机器命名为builder01.fale.io,我们还将更改库存/生产文件以匹配此更改:

[web] 
ws01.fale.io 
ws02.fale.io 

[db] 
db01.fale.io 

[builders] 
builder01.fale.io 

[production:children] 
db 
web 
builders 

在深入了解 builders 角色之前,我们需要对 webserver 角色做一些更改,以启用新的仓库。第一个更改是在文件末尾的 roles/webserver/tasks/main.yaml 中添加一个任务,代码如下:

- name: Install our private repository 
  copy: 
    src: privaterepo.repo 
    dest: /etc/yum.repos.d/privaterepo.repo 
  become: True 

第二个更改是实际创建 roles/webserver/files/privaterepo.repo 文件,内容如下:

[privaterepo] 
name=Private repo that will keep our apps packages 
baseurl=http://repo.fale.io/ 
skip_if_unavailable=True 
gpgcheck=0 
enabled=1 
enabled_metadata=1 

我们现在可以执行 webserver 组的 playbook 以使更改生效,命令如下:

ansible-playbook -i inventory/production playbooks/groups/web.yaml

以下输出应该会出现:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io]
    ....
PLAY RECAP ******************************************************
ws01.fale.io       : ok=20   changed=1    unreachable=0    failed=0
ws02.fale.io       : ok=20   changed=1    unreachable=0    failed=0

如预期的那样,唯一的变化是我们新生成的仓库文件的部署。

我们还需要为 builders 创建一个角色,并在 roles/builder/tasks/main.yaml 中创建一个 tasks 文件,内容如下:

- name: Ensure needed packages are present 
  yum: 
    name: '{{ item }}' 
    state: present 
  become: True 
  with_items: 
  - mock 
  - rpm-build 
  - spectool 
  - createrepo 
  - httpd 

- name: Ensure the user ansible is in the mock group 
  user: 
    name: ansible 
    groups: mock 
    append: True 
  become: True 

- name: Ensure the /var/www/repo folder is present 
  file: 
    name: /var/www/repo 
    state: directory 
    group: ansible 
    owner: ansible 
    mode: 0755 
  become: True 

- name: Ensure the HTTPd zone for the repo is present 
  copy: 
    src: repo.conf 
    dest: /etc/httpd/conf.d/repo.conf 
  become: True 
  notify: Restart HTTPd 

- name: Ensure the HTTPd service is enabled and running 
  service: 
    name: httpd 
    state: started 
    enabled: True 
  become: True 

- name: Ensure HTTP can pass the firewall 
  firewalld: 
    service: http 
    state: enabled 
    permanent: True 
    immediate: True 
  become: True 

另外,作为 builders 角色的一部分,我们需要 roles/builder/handlers/main.yaml 处理器文件,内容如下:

- name: Restart HTTPd 
  service: 
    name: httpd 
    state: restarted 
  become: True 

正如你从任务文件中可以猜到的那样,我们还需要 roles/builder/files/repo.conf 文件,内容如下:

<VirtualHost *:80> 
    ServerName repo.fale.io 
    DocumentRoot /var/www/repo 
    <Directory /var/www/repo> 
        Options Indexes FollowSymLinks 
    </Directory> 
</VirtualHost> 

我们还需要在 playbooks/groups/builders.yaml 中创建一个新的 group playbook,内容如下:

- hosts: builders 
  user: ansible 
  roles: 
  - common 
  - builder 

我们现在可以执行 firstrun playbook,命令如下:

ansible-playbook -i inventory/production playbooks/firstrun.yaml -lbuilder01.fale.io

我们将收到如下输出:

PLAY [all] ******************************************************* 
TASK [setup] *****************************************************
ok: [builder01.fale.io] 
TASK [Ensure ansible user exists] ********************************
changed: [builder01.fale.io] 
TASK [Ensure ansible user accepts the SSH key] *******************
changed: [builder01.fale.io] 
TASK [Ensure the ansible user is sudoer with no password required]
changed: [builder01.fale.io] 
PLAY RECAP *******************************************************
builder01.fale.io : ok=4    changed=3    unreachable=0    failed=0

我们现在可以继续创建主机,命令如下:

ansible-playbook -i inventory/production playbooks/groups/builders.yaml

我们预期的结果类似于:

PLAY [builders] ************************************************** 
TASK [setup] *****************************************************
ok: [builder01.fale.io]
 .... 
PLAY RECAP *******************************************************
builder01.fale.io : ok=23   changed=5    unreachable=0    failed=0

现在所有基础设施部分都已准备好,我们可以创建 playbooks/manual/rpm_deploy.yaml,内容如下:

- hosts: builders 
  user: ansible 
  tasks: 
  - name: Copy Spec file to user folder 
    copy: 
      src: ../../spec/demo-php-app.spec 
      dest: /home/ansible 
  - name: Ensure rpmbuild exists 
    file: 
      name: ~/rpmbuild 
      state: directory 
  - name: Ensure rpmbuild/SOURCES exists 
    file: 
      name: ~/rpmbuild/SOURCES 
      state: directory 
  - name: Download the sources 
    command: spectool -R -g demo-php-app.spec 
  - name: Ensure no SRPM files are present 
    command: rm -f ~/rpmbuild/SRPMS/* 
  - name: Build the SRPM file 
    command: rpmbuild -bs demo-php-app.spec 
  - name: Execute mock 
    shell: mock ~/rpmbuild/SRPMS/* 
  - name: Copy the arch binaries in the repo path 
    shell: cp -f /var/lib/mock/epel-7-x86_64/result/*.x86_64.rpm /var/www/repo 
  - name: Recreate the repo metadata 
    command: createrepo --database /var/www/repo 
- hosts: web 
  user: ansible 
  tasks: 
  - name: Ensure last version of demo-php-app is present 
    yum: 
      state: latest 
      update_cache: True 
      disable_gpg_check: True 
      name: demo-php-app 
    become: True 

如前所述,这个 playbook 包含了很多命令和 shell,可能会显得不太整洁。未来可能会有机会写出一个功能相同但使用模块的 playbook。大部分操作和我们在前一节中讨论的相同。新的操作是在后面,实际上,在这个案例中,我们将生成的 RPM 文件复制到一个特定文件夹中,使用 createrepo 在该文件夹中生成一个仓库,然后强制所有 Web 服务器更新到最新版本的生成包。

注意

为了确保应用程序的安全,重要的是仓库只能在内部访问,而不能公开访问。

我们现在可以使用以下命令运行 playbook:

ansible-playbook -i inventory/production playbooks/manual/rpm_deploy.yaml

我们预期的结果如下所示:

PLAY [builders] ************************************************** 
TASK [setup] *****************************************************
ok: [builder01.fale.io] 
TASK [Copy SPEC file to user folder] *****************************
changed: [builder01.fale.io] 
TASK [Ensure rpmbuild exists] ************************************
changed: [builder01.fale.io] 
TASK [Ensure rpmbuild/SOURCES exists] ****************************
changed: [builder01.fale.io] 
TASK [Download the sources] **************************************
changed: [builder01.fale.io] 
TASK [Ensure no SRPM files are present] **************************
changed: [builder01.fale.io] 
TASK [Build the SRPM file] ***************************************
changed: [builder01.fale.io] 
TASK [Execute mock] **********************************************
changed: [builder01.fale.io] 
TASK [Copy the arch binaries in the repo path] *******************
changed: [builder01.fale.io] 
TASK [Recreate the repo metadata] ********************************
changed: [builder01.fale.io] 
PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Update all packages] ***************************************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
builder01.fale.io : ok=10   changed=9    unreachable=0    failed=0
ws01.fale.io      : ok=2    changed=1    unreachable=0    failed=0
ws02.fale.io      : ok=2    changed=1    unreachable=0    failed=0

使用 CI/CD 管道构建 RPM 包

尽管本书未涉及这一部分,但在更复杂的情况下,你可能希望使用 CI/CD 管道来创建和管理 RPM 包。主要的两种管道基于两个不同的软件:Jenkins 和 Koji。

Koji 软件由 Fedora 社区和 Red Hat 开发。它根据 LGPL 2.1 许可协议发布。这是目前 Fedora、CentOS 以及许多其他公司和社区用来创建所有 RPM 包的管道(无论是官方版本还是测试版——即临时构建)。Koji 默认不会通过提交触发,而是需要用户通过 web 界面或命令行界面“手动”调用。Koji 将自动下载最新版本的 Spec Git,从侧缓存中下载源代码(这一步是可选的,但推荐)或从原始位置下载,并触发 mock 构建。由于 mock 是唯一允许一致和可重复构建的系统,Koji 仅支持 mock。Koji 可以根据配置永久或在有限时间内存储所有输出工件。这是为了确保很高的审计能力。

Jenkins 是最常用的 CI/CD 管理工具之一,也可以用于 RPM 管道。它的一个大缺点是需要从头开始配置,这意味着需要更多时间,但也因此具有更多的灵活性。Jenkins 的另一个大优势是许多公司已经有了 Jenkins 实例,这使得搭建和维护基础设施变得更加容易,因为您可以重用已有的安装,整体上减少了系统管理的工作。

使用 RPM 打包构建已编译的软件

RPM 打包对于非二进制应用程序非常有用,对于二进制应用程序几乎是必需的。这也是因为非二进制和二进制的复杂性差异非常小。事实上,构建和安装的方式完全相同。唯一的区别是 Spec 文件。

让我们来看一个例子,查看需要编译和打包一个简单的用 C 编写的 Hello World! 应用程序所需的 Spec 文件:

%global commit0 7c288b9d80a6ef525c0cca8a744b32e018eaa386 
%global shortcommit0 %(c=%{commit0}; echo ${c:0:7}) 

Name:           hello-world 
Version:        1.0 
Release:        1%{?dist} 
Summary:        Hello World example implemented in C 

License:        GPLv3+ 
URL:            https://github.com/Fale/hello-world 
Source0:        %{url}/archive/%{commit0}.tar.gz#/%{name}-%{shortcommit0}.tar.gz 

BuildRequires:  gcc 
BuildRequires:  make 

%description 
The description for our Hello World Example implemented in C 

%prep 
%autosetup -n %{name}-%{commit0} 

%build 
make %{?_smp_mflags} 

%install 
%make_install 

%files 
%license LICENSE 
%{_bindir}/hello 

%changelog 
* Tue Oct 11 2016 Fabio Alessandro Locati - 1.0-1 
- Initial packaging 

如您所见,它与我们为 PHP 演示应用程序看到的非常相似。让我们看看其中的区别。

%global commit0 7c288b9d80a6ef525c0cca8a744b32e018eaa386 
%global shortcommit0 %(c=%{commit0}; echo ${c:0:7}) 

如您所见,我们没有禁用调试包的行。每次打包一个已编译的应用程序时,您应该让 rpm 创建调试符号包,这样在发生崩溃时,调试和理解问题会更容易。

Name:           hello-world 
Version:        1.0 
Release:        1%{?dist} 
Summary:        Hello World example implemented in C 

License:        GPLv3+ 
URL:            https://github.com/Fale/hello-world 
Source0:        %{url}/archive/%{commit0}.tar.gz#/%{name}-%{shortcommit0}.tar.gz 

如您所见,本节的变化仅仅是由于新包的名称和 URL 不同,但与是否是可编译的应用程序无关。

BuildRequires:  gcc 
BuildRequires:  make 

在非编译应用程序中,我们在构建时不需要任何包,而在这种情况下,我们将需要 make 和 gcc(编译器)等应用程序。不同的应用程序可能需要不同的工具和/或库在构建时存在于系统中。

%description 
The description for our Hello World Example implemented in C 

%prep 
%autosetup -n %{name}-%{commit0} 

%build 
make %{?_smp_mflags} 

description 是特定于包的,并不受包编译的影响。类似地,%prep 阶段也是如此。

%build阶段,我们现在有了make %{?_smp_mflags}。这是为了告诉rpmbuild实际运行 make 来构建我们的应用程序。_smp_mflags变量将包含一组参数,用于优化编译以支持多线程。

%install 
%make_install 

%install阶段,我们将执行%make_install命令。这个宏将使用一组附加参数调用%make_install,以确保库文件位于正确的文件夹中,二进制文件等也会被正确处理。

%files 
%license LICENSE 
%{_bindir}/hello 

在这种情况下,我们只需要将hello二进制文件放在%install阶段位于buildroot的正确文件夹中,并添加包含许可证的LICENSE文件。

%changelog 
* Tue Oct 11 2016 Fabio Alessandro Locati - 1.0-1 
- Initial packaging 

%changelog与我们之前看到的其他 Spec 文件非常相似,因为它不受编译过程的影响。

完成后,你可以将其放入spec/hello-world.spec中,并调整playbooks/manual/rpm_deploy.yaml,将其保存为playbooks/manual/hello_deploy.yaml,内容如下:

- hosts: builders 
  user: ansible 
  tasks: 
  - name: Copy Spec file to user folder 
    copy: 
      src: ../../spec/hello-world.spec 
      dest: /home/ansible 
  - name: Ensure rpmbuild exists 
    file: 
      name: ~/rpmbuild 
      state: directory 
  - name: Ensure rpmbuild/SOURCES exists 
    file: 
      name: ~/rpmbuild/SOURCES 
      state: directory 
  - name: Download the sources 
    command: spectool -R -g hello-world.spec 
  - name: Ensure no SRPM files are present 
    command: rm -f ~/rpmbuild/SRPMS/* 
  - name: Build the SRPM file 
    command: rpmbuild -bs hello-world.spec 
  - name: Execute mock 
    shell: mock ~/rpmbuild/SRPMS/* 
  - name: Copy the arch binaries in the repo path 
    shell: cp -f /var/lib/mock/epel-7-x86_64/result/*.x86_64.rpm /var/www/repo 
  - name: Recreate the repo metadata 
    command: createrepo --database /var/www/repo 
- hosts: web 
  user: ansible 
  tasks: 
  - name: Ensure last version of hello-world is present 
    yum: 
      state: latest 
      update_cache: True 
      disable_gpg_check: True 
      name: hello-world 
    become: True 

如你所见,我们唯一改变的就是所有对demo-php-app的引用都被替换为hello-world。运行它时:

ansible-playbook -i inventory/production playbooks/manual/hello_deploy.yaml
We are going to have the following result:
PLAY [builders] ************************************************** 
TASK [setup] *****************************************************
ok: [builder01.fale.io] 
TASK [Copy SPEC file to user folder] *****************************
changed: [builder01.fale.io] 
TASK [Ensure rpmbuild exists] ************************************
ok: [builder01.fale.io] 
TASK [Ensure rpmbuild/SOURCES exists] ****************************
ok: [builder01.fale.io] 
TASK [Download the sources] **************************************
changed: [builder01.fale.io] 
TASK [Ensure no SRPM files are present] **************************
changed: [builder01.fale.io] 
TASK [Build the SRPM file] ***************************************
changed: [builder01.fale.io] 
TASK [Execute mock] **********************************************
changed: [builder01.fale.io] 
TASK [Copy the arch binaries in the repo path] *******************
changed: [builder01.fale.io] 
TASK [Recreate the repo metadata] ********************************
changed: [builder01.fale.io] 
PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [Ensure last version of hello-world is present] *************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
builder01.fale.io : ok=10   changed=7    unreachable=0    failed=0
ws01.fale.io      : ok=2    changed=1    unreachable=0    failed=0
ws02.fale.io      : ok=2    changed=1    unreachable=0    failed=0

注意

你最终可以创建一个接受要构建的包名称作为参数的剧本,这样你就不需要为每个包创建不同的剧本了。

部署策略

我们已经看到如何在你的环境中分发软件,现在让我们谈谈部署策略;也就是说,如何在不影响服务的情况下升级你的应用程序。

在更新过程中,你可能会遇到三种不同的问题:

  • 更新发布期间的停机时间

  • 新版本存在问题

  • 新版本似乎能正常工作,直到它失败。

第一个问题是每个系统管理员都知道的。在更新过程中,你可能会重启一些服务,在服务停止和启动的这段时间内,你的应用程序将无法在该机器上使用。避免这种情况意味着你的应用程序根本无法使用;你需要至少有一些机器上能使用应用程序,并在前面放置一个智能负载均衡器,负责移除(并在合适时重新添加)所有无法正常工作的节点。

第二个问题可以通过多种方式来防止。最干净的方法是通过 CI/CD 管道进行测试。事实上,这类问题通过简单的测试就很容易发现。接下来我们将看到的方法也能防止这种情况发生。

第三个问题是迄今为止最复杂的。很多大的故障都由这种问题引起。通常,问题是新版本有一些性能问题或内存泄漏。由于大多数部署是在服务器负载最轻的时段进行的,一旦负载增加,性能问题或内存泄漏可能会导致服务器崩溃。

注意

为了能够正确使用这些方法,你必须确保软件能够接受回滚。有些情况可能无法做到(例如,更新中删除了数据库表),但应尽量避免。我们不会讨论如何避免这些问题,因为它是开发策略的一部分,与 Ansible 无关。

金丝雀发布

金丝雀发布是一种技术,涉及将少量机器(通常是 5%)更新到新版本,并指示负载均衡器只将相应数量的流量发送给它。这有几个优点:

  • 在更新期间,你的容量永远不会低于 95%。

  • 如果新版本完全失败,你将失去 5% 的容量

  • 由于负载均衡器将流量分配到新旧版本之间,如果新版本出现问题,只有 5% 的用户会遇到问题。

  • 你只需要比预期负载多 5% 的容量。

金丝雀发布能够以非常小的开销(5%)和低成本(5%)防止我们提到的三种问题。如果回滚发生时,这一成本也是很低的。正因如此,大公司常常采用这种技术;逐步发布。为了确保地理上靠近的用户有类似的用户体验,通常会使用地理位置来决定用户是访问旧版本还是新版本。

当测试显示成功时,可以逐步增加百分比,直到达到 100%。

在 Ansible 中,可以通过多种方式实现金丝雀发布。我建议的方式是最简洁的;使用清单文件,更具体地说,像下面这样:

[web-main] 
ws[00:94].fale.io 

[web-canary] 
ws[95:99].fale.io 

[web:children] 
web-main 
web-canary 

通过这种方式,你可以在 Web 组上设置所有变量(这些变量在版本间应该是相同的,或者至少应该是),但是你可以轻松地对金丝雀组、主组或两个组同时运行 playbook。另一个选择是创建两个不同的清单文件,一个用于金丝雀组,另一个用于主组,这样两个组的名字相同,以便共享变量。

蓝绿发布

蓝绿发布与金丝雀发布有很大的不同,且各自有优缺点。主要的优点包括:

  • 更容易实现

  • 允许更快速的迭代

  • 所有用户同时迁移

  • 回滚不会导致性能下降。

缺点中,主要的问题是你需要比应用程序要求的机器数多出一倍。这一缺点如果应用程序运行在云环境(无论是私有云、公共云还是混合云)上,通过扩展应用资源以进行部署,然后再缩减,就可以轻松缓解。

在 Ansible 中实现蓝绿部署非常简单。最简单的方法是创建两个不同的清单(一个用于蓝色环境,一个用于绿色环境),然后像管理不同的环境(如生产环境、预生产环境、开发环境等)一样简单地管理你的基础设施。

优化

有时,Ansible 可能感觉很慢,尤其是当你有一个非常长的任务列表需要执行,和/或有大量的机器时。其实这种感觉不仅仅是一种感觉,背后有多种原因,我们将探讨其中的三种以及如何避免它们。

管道化

Ansible 默认较慢的原因之一是,对于每个模块执行和每个主机,Ansible 会执行以下操作:

  • SSH 握手

  • 执行任务

  • 关闭 SSH 连接

正如你所看到的,这意味着如果你有 10 个任务需要在单个远程服务器上执行,Ansible 会打开(并关闭)连接 10 次。由于 SSH 协议是加密协议,这使得 SSH 握手过程变得更加漫长,因为每次都需要重新协商加密算法。

Ansible 通过在 playbook 开始时初始化连接并保持连接在整个执行过程中的活跃,从而大幅度减少了执行时间,这样就不需要在每个任务时重新打开连接。在 Ansible 的发展过程中,这个功能的名称已经多次更改,以及启用它的方式也发生了变化。从 1.5 版本开始,它被称为 管道化(pipelining),启用它的方法是向 ansible.cfg 文件中添加以下行:

pipelining=True 

这个功能默认未启用的原因是,许多发行版在 sudo 中启用了 requiretty 选项。Ansible 中的管道化模式和 sudo 中的 requiretty 选项存在冲突,会导致 playbook 执行失败。

提示

如果你想启用管道模式,请确保目标机器上禁用了 sudo requiretty 模式。

使用 with_items 进行优化

如果你想执行相似的操作多次,可以使用不同的参数重复相同的任务,或者使用 with_items 选项。除了 with_items 让你的代码更易读、更易理解之外,它还可能提升你的性能。例如,在安装软件包时(即:aptdnfyumpackage 模块),如果你使用 with_items,Ansible 会执行一个命令,而如果不使用,它会为每个包执行一个命令。正如你所想象的那样,这可以帮助提升性能。

理解任务执行时发生的事情

即使你已经实现了我们刚才讨论的加速 playbook 执行的方法,你仍然可能发现某些任务需要非常长的时间。这对于某些任务来说是很常见的,即使许多其他模块是可能加速的。通常会出现这个问题的模块有以下几种:

  • 包管理(即:aptdnfyumpackage

  • 云机器创建(例如:DigitalOcean,EC2)

这种慢速的原因通常不是 Ansible 特有的。一个例子是,如果你使用包管理模块来更新机器。这需要在每台机器上下载数十或数百兆字节的数据,并安装大量的软件。加速这种操作的方法是,在数据中心建立一个本地仓库,并让所有机器指向它,而不是指向分发仓库。这样可以让你的机器以更快的速度下载,而且不会使用常常带宽受限或按流量计费的公共连接。

注意

理解模块在后台执行的操作通常很重要,这有助于优化 playbook 的执行。

在云机器创建的案例中,Ansible 仅执行一次 API 调用到选择的云服务提供商,并等待机器准备好。DigitalOcean 的机器创建可能需要最长一分钟(其他云平台可能更长),因此 Ansible 会等待这一时间。一些模块具有异步模式以避免等待时间,但你必须确保机器准备好才能使用它,否则使用该机器的模块将会失败。

总结

在本章中,我们已经看到如何使用 Ansible 部署应用程序,以及可以使用的各种分发和部署策略。我们还了解了如何通过 Ansible 创建 RPM 包,并通过不同的方法优化 Ansible 的性能。

在最后一章,我们将讨论 Ansible 在 Windows 上的使用以及网络设备的管理。此外,还会讨论一些 Ansible Tower 的概念。

第十章. 介绍企业版 Ansible

在本章中,我们将讨论 Ansible 在不同操作系统上的状态。我们还将看看 Ansible Galaxy 和 Ansible Tower。

我们将探讨以下主题:

  • Ansible 在 Windows 上

  • Ansible 用于网络设备

  • Ansible Galaxy

  • Ansible Tower

Ansible 在 Windows 上

Ansible 版本 1.7 开始能够通过一些基本模块管理 Windows 机器。在 Red Hat 收购 Ansible 后,微软和其他许多公司及个人为这个任务投入了大量精力。到 2.1 版本发布时,Ansible 管理 Windows 机器的能力接近完成。某些模块已扩展为可以在 Unix 和 Windows 上无缝工作,而在其他情况下,Windows 的逻辑与 Unix 差异如此之大,以至于需要创建新的模块。

注意

目前,使用 Windows 作为控制机并不被支持,尽管一些用户已通过调整代码和环境使其能够工作。

控制机到 Windows 机器的连接不是通过 SSH 进行的,而是通过Windows 远程管理WinRM)进行的。你可以访问微软的网站,获取详细的解释和实现:msdn.microsoft.com/en-us/library/aa384426(v=vs.85).aspx

在控制机上,安装了 Ansible 后,重要的是你需要安装 WinRM。你可以通过pip命令安装:

pip install "pywinrm>=0.1.1"

注意

你可能需要使用sudoroot账户来执行此命令。

在每台远程 Windows 机器上,你需要安装 PowerShell 3.0 或更高版本。Ansible 提供了一些有用的脚本来帮助设置:

你还需要通过防火墙允许端口5986,因为这是默认的 WinRM 连接端口,并确保从命令中心可以访问该端口。

为了确保你可以远程访问服务,运行一个curl命令:

curl -vk -d `` -u "$USER:$PASSWORD" "https://<IP>:5986/wsman". 

如果基本身份验证工作正常,你就可以开始运行命令了。设置完成后,你就可以开始运行 Ansible 了!让我们通过运行win_ping来执行 Ansible 中的 Windows 版本的Hello, world!程序。为了做到这一点,让我们设置我们的凭证文件。

这可以通过 Ansible vault 来完成,如下所示:

$ ansible-vault create group_vars/windows.yml

正如我们所见,Ansible vault 将交互式地要求设置密码:

Vault password:
Confirm Vault password:

此时,我们可以添加所需的变量:

    ansible_ssh_user: Administrator 
    ansible_ssh_pass: <password> 
    ansible_ssh_port: 5986 
    ansible_connection: winrm 

让我们设置我们的inventory文件,如下所示:

    [windows] 
    174.129.181.242 

接下来,我们运行win_ping

ansible windows -i inventory -m win_ping --ask-vault-pass

Ansible 将要求我们输入 vault 密码,然后打印运行结果,如下所示:

    Vault password: 
    174.129.181.242 | success >> { 
        "changed": false, 
        "ping": "pong" 
    } 

Ansible 用于网络设备

自版本 2.1 以来,我们看到许多新的模块用于网络设备和软件的管理。许多这些模块是由创建设备(或软件)的公司直接贡献的。这样做的一个巨大优势是,基于软件定义网络SDN)的理念,Ansible 可以管理您的所有网络基础设施,从而使您能够在 Ansible 中完全管理整个数据中心。这意味着,所有 IT 组件和所有人员使用相同的语言,这将有助于人们更好地理解公司 IT 的运作方式,并促进团队之间的紧密合作。

Ansible Galaxy

Ansible Galaxy 是一个免费的站点,您可以在此下载由社区开发的 Ansible 角色,并在几分钟内启动自动化。您可以分享或审核社区角色,以便其他人能够轻松找到 Ansible Galaxy 上最受信任的角色。您可以通过简单地使用 Twitter、Google 或 GitHub 等社交媒体应用程序注册,或者在 Ansible Galaxy 网站上创建一个新账户,网址为galaxy.ansible.com/,并使用ansible-galaxy命令下载所需的角色,该命令随 Ansible 版本 1.4.2 及更高版本一起提供。

注意

如果您想托管自己的本地 Ansible Galaxy 实例,可以通过从github.com/ansible/galaxy获取代码来实现。

要从 Ansible Galaxy 下载 Ansible 角色,请使用以下语法:

ansible-galaxy install username.rolename

您还可以通过如下方式指定版本:

ansible-galaxy install username.rolename[,version]

如果不指定版本,ansible-galaxy命令将下载最新的可用版本。您可以通过两种方式安装多个角色;首先,通过传递多个角色名称并用空格分隔,如下所示:

ansible-galaxy install username.rolename[,version] username.rolename[,version]

其次,您可以通过在文件中指定角色名称并将该文件名传递给-r/--role-file选项来执行此操作。例如,您可以创建requirements.txt文件,内容如下:

    user1.rolename,v1.0.0 
    user2.rolename,v1.1.0 
    user3.rolename,v1.2.1 

然后,您可以通过将文件名传递给ansible-galaxy命令来安装角色,如下所示:

ansible-galaxy install -r requirements

让我们看看如何使用ansible-galaxy下载 Apache HTTPd 角色:

sudo ansible-galaxy install geerlingguy.apache

您将看到如下输出:

- downloading role 'apache', owned by geerlingguy
- downloading role from https://github.com/geerlingguy/ansible-role-apache/archive/1.7.3.tar.gz
- extracting geerlingguy.apache to /etc/ansible/roles/geerlingguy.apache
- geerlingguy.apache was installed successfully

上述ansible-galaxy命令将把 Apache HTTPd 角色下载到/etc/ansible/roles目录中。现在,您可以在剧本中直接使用上述角色,创建playbooks/galaxy.yaml文件,内容如下:

    - hosts: web 
      user: ansible 
      become: True 
      roles: 
      - geerlingguy.apache 

如您所见,我们创建了一个简单的剧本,并使用了geerlingguy.apache角色。现在我们可以进行测试:

ansible-playbook playbooks/galaxy.yaml

这将给我们如下输出:

PLAY [web] ******************************************************* 
TASK [setup] *****************************************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : Include OS-specific variables.] *******
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : Define apache_packages.] **************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : Ensure Apache is installed on RHEL.] **
changed: [ws01.fale.io] => (item=[u'httpd', u'httpd-devel', u'mod_ssl', u'openssh'])
changed: [ws02.fale.io] => (item=[u'httpd', u'httpd-devel', u'mod_ssl', u'openssh']) 
TASK [geerlingguy.apache : Ensure Apache is installed on Suse.] **
skipping: [ws01.fale.io] => (item=[])
skipping: [ws02.fale.io] => (item=[]) 
TASK [geerlingguy.apache : Update apt cache.] ********************
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Ensure Apache is installed on Debian.]
skipping: [ws01.fale.io] => (item=[])
skipping: [ws02.fale.io] => (item=[]) 
TASK [geerlingguy.apache : Ensure Apache is installed on Solaris.]
skipping: [ws01.fale.io] => (item=httpd)
skipping: [ws02.fale.io] => (item=httpd)
skipping: [ws01.fale.io] => (item=httpd-devel)
skipping: [ws02.fale.io] => (item=httpd-devel)
skipping: [ws02.fale.io] => (item=mod_ssl)
skipping: [ws01.fale.io] => (item=mod_ssl)
skipping: [ws02.fale.io] => (item=openssh)
skipping: [ws01.fale.io] => (item=openssh) 
TASK [geerlingguy.apache : Get installed version of Apache.] *****
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : Create apache_version variable.] ******
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : include_vars] *************************
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : include_vars] *************************
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
TASK [geerlingguy.apache : Configure Apache.] ********************
ok: [ws01.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'})
ok: [ws02.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'}) 
TASK [geerlingguy.apache : Check whether certificates defined in vhosts exist.] 
TASK [geerlingguy.apache : Add apache vhosts configuration.] *****
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
TASK [geerlingguy.apache : Configure Apache.] ********************
skipping: [ws01.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'})
skipping: [ws02.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'}) 
TASK [geerlingguy.apache : Check whether certificates defined in vhosts exist.] 
TASK [geerlingguy.apache : Add apache vhosts configuration.] *****
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Configure Apache.] ********************
skipping: [ws01.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'})
skipping: [ws02.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'}) 
TASK [geerlingguy.apache : Enable Apache mods.] ******************
skipping: [ws01.fale.io] => (item=rewrite.load)
skipping: [ws02.fale.io] => (item=rewrite.load)
skipping: [ws01.fale.io] => (item=ssl.load)
skipping: [ws02.fale.io] => (item=ssl.load) 
TASK [geerlingguy.apache : Disable Apache mods.] ***************** 
TASK [geerlingguy.apache : Check whether certificates defined in vhosts exist.] 
TASK [geerlingguy.apache : Add apache vhosts configuration.] *****
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Add vhost symlink in sites-enabled.] **
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Remove default vhost in sites-enabled.]
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Configure Apache.] ********************
skipping: [ws01.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'})
skipping: [ws02.fale.io] => (item={u'regexp': u'^Listen ', u'line': u'Listen 80'}) 
TASK [geerlingguy.apache : Add apache vhosts configuration.] *****
skipping: [ws01.fale.io]
skipping: [ws02.fale.io] 
TASK [geerlingguy.apache : Ensure Apache has selected state and enabled on boot.]
ok: [ws01.fale.io]
ok: [ws02.fale.io] 
RUNNING HANDLER [geerlingguy.apache : restart apache] ************
changed: [ws01.fale.io]
changed: [ws02.fale.io] 
PLAY RECAP *******************************************************
ws01.fale.io      : ok=11   changed=3    unreachable=0    failed=0
ws02.fale.io      : ok=11   changed=3    unreachable=0    failed=0

注意

正如您可能已经注意到的,由于该角色设计用于在许多不同的 Linux 发行版上运行,因此许多步骤被跳过了。

Ansible Tower

Ansible Tower 是红帽开发的一个基于 Web 的图形用户界面(GUI)。Ansible Tower 提供了一个易于使用的仪表盘,让您管理节点,并通过基于角色的身份验证控制对 Ansible Tower 仪表盘的访问。Ansible Tower 的主要特点如下:

  • LDAP/AD 集成:您可以根据 Ansible Tower 在您的 LDAP/AD 服务器上执行的 LDAP/AD 查询结果导入(并赋予权限)用户。

  • 基于角色的访问控制:限制用户仅运行他们被授权运行的 playbook,并/或仅针对有限数量的主机。

  • REST API:所有 Ansible Tower 的功能都通过 REST API 暴露。

  • 作业调度:Ansible Tower 允许我们调度作业(playbook 执行)。

  • 图形化库存管理:Ansible Tower 以比 Ansible 更动态的方式管理库存。

  • 仪表盘:Ansible Tower 允许我们查看所有当前和过去作业执行的情况。

  • 日志记录:Ansible Tower 记录每个作业执行的所有结果,以便在需要时回溯检查。

尽管红帽(Red Hat)承诺很快将 Ansible Tower 开源,但目前它并非免费提供,您需要根据要管理的节点数量支付费用。

在撰写本文时,红帽为 10 个节点提供了免费的 Ansible Tower 副本。有关更多详情,请访问 Ansible Tower 网站:www.ansible.com/tower;用户指南可在 docs.ansible.com/ansible-tower/ 中找到。

总结

在本章中,我们已经看到了一些 Ansible 及其生态系统提供的选项。本章还希望教会你如何在 Ansible 文档中查找那些不那么规范的内容,因为 Ansible 可能具有这样的功能。此外,正如你可能已经注意到的,本章涵盖的许多主题在 2.1 版本(发布于本书第二版出版前不到 6 个月)中发生了重大变化,并且这些领域仍在积极开发中,因此查看官方文档是了解这些主题当前状态的最佳途径。

posted @ 2025-07-02 17:45  绝不原创的飞龙  阅读(57)  评论(0)    收藏  举报