Ansible-实践指南第二版-全-
Ansible 实践指南第二版(全)
原文:
annas-archive.org/md5/98ae402a677174ded07211c9c3ed7f5e
译者:飞龙
前言
欢迎来到 Practical Ansible – 第二版,这本书将指导您从一个初学者成长为熟练的 Ansible 自动化工程师,只需几个章节。这本书将为您提供执行首次安装和自动化任务所需的知识和技能,带领您从执行简单的单行自动化命令开始,这些命令能够完成单一任务,直到编写您自己的复杂自定义代码以扩展 Ansible 的功能,甚至自动化云和容器基础设施。在全书中,您将看到实际的示例,不仅仅是阅读关于 Ansible 自动化的内容,您还可以亲自实践并理解代码的工作原理。通过这些学习,您将能够使用 Ansible 自动化您的基础设施,并确保其具备可扩展性、可重复性和可靠性。
本书适合人群
本书适合任何有 IT 任务需要自动化的人,从日常琐碎的家务任务到复杂的基于基础设施即代码的部署。它旨在吸引那些有 Linux 环境经验的人,他们希望快速掌握 Ansible 自动化,并适合各类人员,包括系统管理员、DevOps 工程师、以及考虑整体自动化策略的架构师,甚至是爱好者。假设读者已经具备一定的 Linux 系统管理和维护基础,但无需提前具备 Ansible 或自动化经验。
本书内容
第一章,开始使用 Ansible,提供了您进行首次安装 Ansible 所需的步骤,并解释了如何快速上手并运行这种强大的自动化工具。
第二章,理解 Ansible 的基础,探讨了 Ansible 框架,帮助您深入理解 Ansible 语言的基础,并解释了如何使用其包含的各种命令行工具。
第三章,定义您的库存,为您提供了关于 Ansible 库存、其目的以及如何创建和使用自己的库存的详细信息。它还探讨了静态库存和动态库存之间的差异,以及在何时使用每种类型。
第四章,Playbooks 和角色,为您提供了如何以 playbooks 形式创建自己的 Ansible 自动化代码的深入介绍,以及如何通过角色实现该代码的有效重用。
第五章,创建和使用 模块,向您介绍了 Ansible 模块及其用途,并为您提供了编写自己模块所需的步骤,甚至包括如何将其提交到 Ansible 项目中以供包含。
第六章,创建与使用 集合,探讨了 Ansible Collections,涵盖了它们的设计、意图以及它们为什么对 Ansible 的未来至关重要。随后,我们将引导你如何创建和使用自己的集合,让你获得第一手经验。
第七章,创建与使用 插件,解释了 Ansible 插件的用途,涵盖了 Ansible 使用的各种插件类型。然后解释了如何编写自己的插件,并说明如何将代码提交到 Ansible 项目。
第八章,编码最佳实践,深入探讨了你在编写 Ansible 自动化代码时应遵循的最佳实践,以确保你的解决方案可管理、易于维护和扩展。
第九章,高级 Ansible 主题,探讨了一些更高级的 Ansible 选项和语言指令,这些选项在执行大规模、高可用集群的部署时非常有价值。它还解释了如何通过跳板机在安全网络上自动化任务,以及如何加密静态的变量数据。
第十章,使用 Ansible 进行网络自动化,详细探讨了网络自动化的重要性,解释了为什么 Ansible 特别适合此任务,并通过实际示例向你展示如何使用 Ansible 连接到各种网络设备。
第十一章,容器与云管理,探讨了 Ansible 如何支持与云平台和容器平台的协同工作,并教授如何使用 Ansible 构建容器,以及如何在云环境中使用 Ansible 部署基础设施作为代码的方法。
第十二章,故障排除与测试策略,教你如何测试和调试你的 Ansible 代码,并为你提供强大的策略,以应对无论是 Playbook 还是 Ansible 依赖的无代理连接中的错误和意外失败。
第十三章,Ansible 自动化控制器入门,介绍了 Ansible 自动化控制器及其上游的开源版本 AWX,演示了这个强大工具如何为 Ansible 提供有价值的补充,尤其是在大型、多用户的企业环境中。
第十四章,执行环境,介绍了执行环境,演示了如何创建、共享它们,以及如何在命令行和 Ansible 自动化控制器中使用它们。
若想充分利用本书的内容
本书的所有章节假设你至少有一台运行较新版本 Linux 发行版的 Linux 机器。所有示例都在 Fedora 38 和 Ubuntu Server 22.04 上进行了测试,但也应该能在其他主流发行版上正常运行。你还需要在至少一台测试机器上安装 Ansible 2.15,安装步骤将在第一章中详细介绍。后续版本的 Ansible 也应该可以使用,尽管可能会有一些细微差别,若有不同,你应参考发行说明和迁移指南。最后两章还会介绍 AWX 的安装过程,但前提是你的 Linux 服务器已安装了 Ansible。本书的大多数示例演示了跨多个主机的自动化,如果你有更多的 Linux 主机可用,你将能从示例中获得更多的体验;不过,你也可以根据需要扩展或缩减规模。拥有更多主机并非强制性的,但它能帮助你更好地理解书中的内容。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
至少一台 Linux 服务器(虚拟机或物理机)。 | Fedora 38 或 Ubuntu Server 22.04,尽管其他主流发行版(包括这些操作系统的更新版本)也应该能正常工作。 |
Ansible 8.0 | 如上所述。 |
AWX 版本 22.4.0 或更高版本 | 如上所述。 |
如果你使用的是本书的数字版,建议你自己输入代码,或从本书的 GitHub 仓库访问代码(链接在下一节提供)。这样做可以帮助你避免与代码复制粘贴相关的潜在错误 。
下载示例代码文件
你可以从 GitHub 上下载本书的示例代码文件,链接为github.com/PacktPublishing/Practical-Ansible-Second-Edition
。如果代码有更新,它将会在 GitHub 仓库中更新。
我们还提供了其他代码包,可以从我们丰富的书籍和视频目录中获取,访问链接:github.com/PacktPublishing/
。快去看看吧!
使用的约定
本书中使用了一些文本约定。
文本中的代码
:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。例如:“第一个叫做ansible-core
,它包含了 Ansible 的运行时代码(例如稍后我们会看到的ansible-playbook
命令),以及一些内置的功能,这些功能是所有 playbook 和角色的核心。”
代码块如下所示:
tasks:
- name: Install/Update to the latest of Apache Web Server
ansible.builtin.apt:
name: apache2
state: latest
当我们希望将你注意力集中到代码块的某个特定部分时,相关的行或项目将以粗体显示:
handlers:
- name: Restart the Apache Web Server
ansible.builtin.service:
name: apache2
state: restarted
任何命令行输入或输出的格式如下:
$ python3 --version
Python 3.10.6
粗体:表示一个新术语、一个重要的词汇,或你在屏幕上看到的文字。例如,菜单或对话框中的词汇会显示为粗体。例如:“从管理面板中选择系统信息。”
提示或重要说明
显示如下。
与我们联系
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请通过 customercare@packtpub.com 向我们发送电子邮件,并在邮件的主题中提及书名。
勘误:虽然我们已经尽力确保内容的准确性,但难免会有错误。如果你在本书中发现错误,我们将非常感激你能将其报告给我们。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果你在互联网上发现我们作品的任何非法复制形式,我们将非常感激你能提供其位置或网站名称。请通过 copyright@packt.com 联系我们,并附上材料的链接。
如果你有兴趣成为作者:如果你在某个话题上具有专业知识,并且有兴趣撰写或参与书籍的创作,请访问 authors.packtpub.com。
分享你的想法
阅读完 Practical Ansible - 第二版 后,我们希望听到你的想法!请 点击这里直接进入 Amazon 评论页面 并分享你的反馈。
你的评价对我们和技术社区都非常重要,它将帮助我们确保提供优质的内容。
下载本书的免费 PDF 副本
感谢你购买本书!
你喜欢在路上阅读,但又无法随身携带纸质书籍吗?
你的电子书购买是否与你选择的设备不兼容?
不用担心,现在购买每本 Packt 书籍时,你都会免费获得该书的无 DRM PDF 版本。
在任何地方、任何设备上随时阅读。直接从你最喜欢的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
好处还不止于此,你还可以独家获取折扣、时事通讯和每天发送到你邮箱的精彩免费内容。
按照以下简单步骤获得好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781805129974
-
提交你的购买证明
-
就是这样!我们将直接把你的免费 PDF 和其他好处发送到你的邮箱。
第一部分:学习 Ansible 的基础知识
在本节中,我们将介绍 Ansible 的基础知识。我们将从安装 Ansible 的过程开始,然后学习基本概念,包括语言基础和临时命令。接着,我们将探索 Ansible 的清单,最后编写我们的第一个 playbooks 和角色,完成多阶段的自动化任务。
本节包含以下章节:
-
第一章,Ansible 入门
-
第二章,理解 Ansible 的基础
-
第三章,定义您的清单
-
第四章,Playbooks 与角色
第一章:开始使用 Ansible
Ansible 使你能够轻松地使用原生通信协议(如 SSH 和 WinRM)一致且可重复地部署应用程序和系统。因此,Ansible 是无代理的,不需要在被管理系统上安装任何东西(除了 Python,现如今大多数系统上都有 Python)。因此,它使你能够为你的环境构建一个简单而强大的自动化平台。
Ansible 安装简便,并且已经为许多现代系统打包。它的架构是无服务器和无代理的,因此占用资源最少。你可以选择从中央服务器或自己的笔记本电脑运行它——完全由你决定。你可以从一个 Ansible 控制机管理从单一主机到数十万个远程主机的所有机器。所有远程机器都可以通过 Ansible 进行管理,并且通过创建足够的剧本,你可能再也不需要单独登录到这些机器中去。
本章将开始教授你实际操作技能,涵盖 Ansible 的基本知识,从如何在多种操作系统上安装 Ansible 开始。然后我们将探讨如何配置 Windows 主机,以使它们能够通过 Ansible 自动化进行管理,接着深入了解 Ansible 如何与目标主机连接。接下来,我们将讨论节点要求和如何验证你的 Ansible 安装,最后,我们将探讨如何获取并运行最新的 Ansible 源代码,如果你希望为其开发做出贡献或访问最新功能。
本章将涵盖以下主题:
-
安装和配置 Ansible
-
了解你的 Ansible 安装
-
被管理节点的要求
技术要求
Ansible 对系统的要求相对较低,因此,如果你的机器(无论是笔记本电脑、服务器,还是 虚拟机(VM))能够运行 Python,你就能在其上运行 Ansible。本章后续部分将演示如何在多种操作系统上安装 Ansible——因此,最终选择哪些操作系统适合你将由你决定。
对于前述声明的唯一例外是 Microsoft Windows——尽管 Windows 上有可用的 Python 环境,但目前没有适用于 Windows 的 Ansible 原生版本。使用较新版本 Windows 的读者可以通过 Windows 子系统 Linux(以下简称 WSL)安装 Ansible,只需按照后面为他们选择的 WSL 环境所列的步骤进行操作(例如,如果你在 WSL 上安装 Ubuntu,只需按照本章中为 Ubuntu 安装 Ansible 的说明进行操作)。
安装和配置 Ansible
Ansible 用 Python 编写,因此可以在广泛的系统上运行。这包括最流行的 Linux、FreeBSD 和 macOS 版本。唯一的例外是 Windows,尽管存在原生 Python 发行版,但目前尚无原生 Ansible 构建版本。因此,在撰写时,您最好的选择是在 WSL 下安装 Ansible,操作方式与在本地 Linux 主机上运行相同。
一旦确定了您希望在其上运行 Ansible 的系统,安装过程通常很简单。在接下来的部分中,我们将讨论如何在各种不同的系统上安装 Ansible,以便大多数读者应该能够在几分钟内开始并运行 Ansible。
理解 Ansible 版本号
在上一版书籍发布时,Ansible 遵循了一个相对简单的版本编号方案。所有的 Ansible 发布版本都包含了所有被接受到 Ansible 发布中的模块、插件和其他代码(在上一版书籍中是版本 2.9)。
这在多年来运行良好,但也为 Ansible 的维护人员创建了问题—随着其流行度和采纳率的增加,模块的数量(这些模块是 Ansible 的命脉,执行您将运行的实际自动化任务)已达数千个。将这些模块与核心 Ansible 软件一起发布意味着,如果在模块中发现了错误或者可能有新功能的新版本可用,则必须在终端用户能够利用之前发布新版本的 Ansible。这不仅减慢了新模块代码的发布速度,还为 Ansible 维护人员创建了大量的工作负担。
作为直接结果,Ansible 被分成两个独立的包。第一个称为ansible-core
,它仅包含 Ansible 运行时代码(如稍后将看到的ansible-playbook
命令)以及一些内置功能,这些功能对所有 playbook 和角色都是核心的。ansible-core
包遵循经典的 Ansible 版本控制方案,因此在此书的先前版本围绕 Ansible 2.9 编写时,此书的这个版本将基于ansible-core
2.15。
所有模块和插件,这些功能都包含在Ansible 社区包发布的 2.9 版本(在实施包拆分之前的最后一个版本)中。这遵循语义化版本控制,这意味着,在撰写时当前版本为 8.0,下一个主要版本将是 9.0。
为了实现 ansible-core
和模块、插件等的独立管理,创建了一种名为 collections 的实现方式。Collections 是独立捆绑的模块、插件和角色集合,旨在实现特定功能;我们将在 第六章《创建和使用 Collections》中详细学习这些内容,所以如果你觉得有些复杂,不必担心——后续内容会逐渐清晰。
每个 Ansible 社区包的发布都依赖于特定版本的 ansible-core
包,我们将在本书中使用的 8.0 版本依赖于 ansible-core
版本 2.15。你可以在这里查看 8.0 版本的变更日志和其他详细信息:github.com/ansible-community/ansible-build-data/blob/8.0.0/8/CHANGELOG-v8.rst#v8-0-0
。
这种代码分离的优点在于:假设你想创建一个基于模块中新功能的 playbook 或角色;你可以安装一个更新后的包含该模块的 collection,而不需要更新整个 Ansible 安装(或者,实际上,等待下一个版本发布,就像在 2.9 版本之前那样)。
了解了这些信息后,我们将继续探索如何在各种系统上安装 Ansible 的具体步骤,但在开始之前很重要的一点是,如果你接触过 Ansible 的任何 2.x 版本,新版本的版本管理方案将会有所不同。
如果你想了解有关新版本管理方案、发布周期及其工作方式的更多细节,可以在这里查看官方 Ansible 文档:docs.ansible.com/ansible/latest/reference_appendices/release_and_maintenance.xhtml
。
在 Linux 和 FreeBSD 上安装 Ansible
Ansible 的发布周期通常为六个月,在这个较短的周期内,通常会有许多变化,从小的 bug 修复到重大的 bug 修复、新功能,甚至有时是语言的根本性变化。在每个发布周期结束时,你应该会看到一个新的 Ansible 社区包版本(例如 8.0),以及相应版本的 ansible-core
包(例如 2.15)。虽然很多操作系统仍然会提供原生包,但这些包的更新频率可能会有所不同,目前推荐的安装 Ansible 方式是使用 Python 的 PIP 包管理器。
当然,这其中会有例外。例如,如果你正在使用商业 Linux 发行版,如Red Hat 企业 Linux(RHEL),尤其是为了执行操作系统供应商支持的工作流,那么你应确保使用该供应商提供的软件包。它们通常也会提供有关如何安装 Ansible 的说明。
这确实带来了一个单独的挑战。当 Ansible 通过操作系统的本地包管理器如yum
、dnf
或apt
安装时,它会和系统一起更新。而使用 PIP 时,你需要单独更新 Ansible(稍后我们会演示如何操作)。
说到升级,绝大多数在 Ansible 2.x 版本下创建的 playbook 至今仍然可以使用,而且你通常会发现升级不会特别引发任何问题。然而,强烈建议你在每个版本的迁移说明中阅读相关内容,以确保你的代码依然按预期工作。因此,Ansible 升级应该是一个计划好的活动,几乎不建议你按照操作系统的升级来修补 Ansible。简而言之,从操作系统本地软件包转向 PIP 包管理不会引发问题,甚至可能带来好处。
假设你要在某个节点上安装 Ansible——以下步骤应该适用于任何版本的 Linux 或 FreeBSD,只要该节点上安装了所需版本的 Python:
-
检查你打算使用的节点是否已安装 Python——从现在开始,我们将使用类似以下命令来称呼它为
PATH
:$ python3 --version Python 3.10.6
在这里,你可以看到 Python 3.10.6 已经安装。Ansible 8.0 的最低要求是 Python 3.9,因此在这种情况下,我们可以继续操作。但是,如果你发现没有安装 Python,请参考操作系统文档获取有关如何安装 Python 的指导。
提示
根据你的系统,Python 可能使用诸如python
、python3
或python3.10
之类的命令运行——再次提醒,请参考操作系统文档了解更多信息。Ansible 在这些情况下都能正常工作,但理解如何在系统上运行 Python 对于完成后续步骤非常重要。这里,我们假设使用python3
命令来执行 Python。
-
一旦确认 Python 已安装,下一步是确保安装了 PIP 包管理器。成功查询的输出应该类似于下面这样:
$ python3 -m pip -V pip 22.0.2 from /usr/lib/python3/dist-packages/pip (python 3.10)
你可能会看到如下输出:
$ python3 -m pip -V
/usr/bin/python3: No module named pip
在这种情况下,你需要安装 PIP。你可以通过运行以下命令来完成安装:
$ curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
$ python3 get-pip.py --user
大多数操作系统也会提供用于安装 PIP 的本地软件包——例如,在 Ubuntu 上,你可以使用以下命令来安装 PIP:
$ apt install python3-pip
安装 PIP 的方式无关紧要——只要它已经安装,你就可以继续下一步。
- 完成这些步骤后,剩下的就是安装 Ansible。现在,这可能看起来与以前安装 Ansible 的方式有所不同,以前大多数主要操作系统都提供了操作系统原生的安装包。但使用 PIP 的好处是,一旦你学会了在一个系统上安装和管理 Ansible,其他所有系统的安装方法都是完全相同的,无论你使用的是 Fedora、Ubuntu、FreeBSD、Debian 还是 Gentoo。
事实上,即使你在 WSL 下使用 Linux,一旦 WSL 启动并运行,安装方法也是完全相同的。完成 Ansible 安装的最简单、最直接的方式就是运行以下命令:
$ python3 -m pip install --user ansible
输出应该类似于下面这样:
图 1.1 – 在 Linux 上使用 PIP 安装 Ansible
从输出中,你可以看到这个命令已经成功安装了 Ansible 8.0.0,以及其依赖包 ansible-core
2.15.0——这些是写作时可用的最新版本。
此外,你会注意到 Ansible 已经安装在 /home/james/.local/bin
目录下,而该目录不在系统路径中,因此你将无法运行像 ansible-playbook
这样的命令,除非你更新路径或指定完整路径。
因此,如果你想运行 ansible-playbook
命令,你可以按照以下方式运行:
$ /home/$USER/.local/bin/ansible-playbook
或者,你也可以更新你的路径,然后你就可以运行命令而无需指定完整路径,具体如下:
$ export PATH=$PATH:/home/$USER/.local/bin
$ ansible-playbook --version
ansible-playbook [core 2.15.0]
…
当然,这个过程只是为当前用户安装 Ansible。对于大多数场景,这通常已经足够,但有时你可能有多个开发人员访问同一个控制节点,在这种情况下,你会希望他们都访问一个集中安装的 Ansible 副本——否则,一个开发人员可能使用的是版本 6.2.0,另一个使用的是 8.0.0,开发过程就会变得不一致。
如果你想为所有用户安装 Ansible,你应该按照前面的步骤完成步骤 2,确保 PIP 已经安装。然后,按照以下步骤操作:
-
以 root 用户身份安装 Ansible,运行以下命令:
$ sudo python3 -m pip install ansible
-
现在,你会发现 Ansible 已经集中安装——在我的 Ubuntu Server 22.04 测试系统上,运行此命令后,我看到如下结果:
$ which ansible-playbook /usr/bin/ansible-playbook $ ansible-playbook --version ansible-playbook [core 2.15.0] …
然而,PIP 还有更多技巧,我们现在将展示其中的另一个——指定安装版本。
到目前为止,我们只指定了安装 ansible
包。如果你总是希望安装最新的版本,这样的做法非常有效,但如果出于开发或一致性的原因,你希望安装特定版本呢?
首先,假设你想安装 Ansible 6.2.0,因为你需要在该版本上进行测试或开发。你可以通过修改你的 install
命令来实现:
$ python3 -m pip install --user ansible==6.2.0
这将安装6.2.0
版本(以及相应版本的ansible-core
,在这个例子中是2.13.8
)到你的本地用户目录,如果你密切关注安装过程,你会看到它会卸载你已经安装的任何版本,即使是更新的版本。如果这不是你想要的结果,请小心操作。
提示
当然,你可以在我们之前给出的任何安装命令后加上==6.2.0
后缀来指定要安装的版本。你也可以将6.2.0
更改为任何有效的版本号。你可以通过浏览以下网址来查看可用的版本:pypi.org/project/ansible/#history
。
每当你想要升级已安装的 Ansible 版本(当它是通过 PIP 安装时),你只需在安装命令中添加--upgrade
标志,如下例所示:
$ python3 -m pip install --user --upgrade ansible
这个示例命令将安装最新版本,因为我们没有显式设置其他版本。
如果你出于某种原因需要从系统中删除 Ansible,PIP 也可以处理这一操作——例如,你可以运行此命令来删除你安装的 Ansible 和ansible-core
版本(注意,你必须指定两者——依赖项不会自动删除):
$ python3 -m pip uninstall ansible ansible-core
一旦你掌握了这些命令,你将能够在所有控制节点上安装、维护和卸载 Ansible。
使用虚拟环境
Python 的另一个非常有用且强大的功能是虚拟环境(以下简称venvs)。Venvs 是与系统安装的 Python(及其库)和所有其他虚拟环境分开的独立 Python 环境。这对 Ansible 开发非常有用。例如,假设你有一套在 Ansible 2.7.18 下开发的 Playbook 和角色,这些都已经过测试并且能够正常工作。你想升级到 Ansible 8.0.0,但你不敢冒险让现有代码无法使用,以防万一需要用到它。
使用虚拟环境(venvs),你可以在同一台机器上拥有一个独立的 Ansible 2.7.18 环境和一个独立的 Ansible 8.0.0 环境,并且可以随时在它们之间切换。要开始,按照接下来提供的步骤进行:
-
首先,你需要确保你有一个可操作的 Python 环境和 PIP,正如本章前面所述。一旦这些准备就绪,继续进行步骤 2。如果你需要回退到 Ansible 2.7.18 的版本,你还需要确保它支持你使用的 Python 版本。我的 Ubuntu Server 22.04 上的 Python 环境是 3.10,而 Ansible 2.7.18 不支持该版本。因此,我将安装 Python 2.7,如下所示:
venv installed—this can be installed using PIP, and while most operating systems have a native package for it, Ubuntu Server 22.04 only has it for Python 3\. Thus, I installed it with the following command:
$ sudo pip2 install virtualenv
-
你现在已经准备好创建你的第一个虚拟环境——让我们为 Ansible 2.7.18 创建一个虚拟环境:
$ python2 -m virtualenv ansible-2.7.18
-
这将在当前目录下创建一个包含 Python 2 环境最小文件集的虚拟环境。现在,我们将激活该环境(如果没有这一步,你仍然会使用系统的 Python 环境):
$ . ./ansible-2.7.18/bin/activate --user flag is not required in a venv):
(ansible-2.7.18)$ python -m pip install ansible==2.7.18
-
你会看到通过 PIP 安装 Ansible,如同以前一样。现在,你应该能够查询已安装的 Ansible 版本,如下所示:
(ansible-2.7.18) $ ansible-playbook --version ansible-playbook 2.7.18 …
-
就这样——我们已经在自己的 Python 环境中运行了 Ansible 2.7.18,并且完全与系统及其他环境隔离开来。现在,我们可以对 Ansible 8.0.0 重复这一过程。你在此过程中无疑会收到关于 Python 2.7 已废弃的警告(这是预期中的),所以我们确实想使用系统安装的 Python 3.10 来运行较新版本的 Ansible。因此,我们首先需要停用虚拟环境,以便恢复到系统安装的 Python 库。下面是我们可以怎么做:
(ansible-2.7.18) $ deactivate $
-
请注意你的提示符如何恢复正常,表明虚拟环境不再处于活动状态。如之前所示,我们需要确保安装了 Python venv 库——不过,已经有一个本地包,所以我们可以通过以下命令直接安装:
$ sudo apt install python3-venv
-
现在,你可以为 Ansible 8.0.0 创建一个 Python 3 虚拟环境并使用以下命令激活它:
$ python3 -m venv ansible-8.0.0 $ . ./ansible-8.0.0/bin/activate (ansible-8.0.0) $
-
你现在可以像以前一样通过 PIP 安装 Ansible:
(ansible-8.0.0) $ python3 -m pip install ansible … (ansible-8.0.0) $ ansible-playbook --version ansible-playbook [core 2.15.0] deactivate command first, and then using the appropriate activate command as demonstrated previously.
在这个例子中,我们使用了完全不同版本的 Python 和 Ansible,但它们在系统中依然相互隔离。因此,你可以安全地使用旧版本来运行你的 playbook,同时在更新的版本上测试你的代码。这是一个相当极端的例子,你几乎不需要回到 Python 2.7 和 Ansible 2.7.18,但它展示了如何创建完全独立的 Python 环境来进行开发和测试工作,而不需要多个系统。
从 GitHub 安装
从 GitHub 上运行最新版本的 Ansible 一直是可行的,这一点至今依然如此。然而,现在有一个重要的警告。如果你之前已经这样做过(在 2.x 系列或更早版本的 Ansible 上,这本书的上一版正是基于这个版本),那么你会使用完整的 Ansible 安装,包括你可能想要使用的所有模块。自从引入了 collections(集合)后,你现在只会检出 ansible-core
的代码。因此,如果你想测试包括模块调用在内的代码,你将需要安装或管理 collections 来与 ansible-core
配置一起使用。
我们将在第六章《创建和使用集合》中探讨集合的管理,因此我们不会在此处专门探讨此内容——不过,为了完整性,我们将向你展示如何从代码安装 ansible-core
开发版。
要做到这一点,请按照以下步骤操作:
-
请检查是否已安装 PIP——如果没有,请参考本章前面介绍的内容。安装好 PIP(以及推断出来的工作 Python 3 环境)后,你可以继续进行下一步。
-
像这样克隆 Ansible 的 GitHub 仓库:
$ git clone https://github.com/ansible/ansible.git $ cd ansible
-
使用 PIP 确保你已经安装了所有开发要求:
hacking environment setup—this will set your shell up so that you can access the development version of Ansible you just cloned. You will need to do this every time you open a new shell or reconnect to your development host so that the setup is transient, which is normally perfect for development work:
ansible、ansible-playbook 和 ansible-galaxy。
注意
在 Ubuntu 22.04 等 Linux 发行版上,默认的 Python 安装是 Python 3,执行该 Python 版本的二进制文件名为 python3
。但这在开发安装的 Ansible 中不可行,因为它期待名为 python
的 Python 二进制文件。你可以通过安装一个特殊的元包来解决这个问题。在 Ubuntu 22.04 上,可以运行以下命令:sudo apt install python-is-python3
。或者,你也可以通过运行以下命令创建一个从 python3
到 python
的符号链接:sudo ln -s /usr/bin/python3 /usr/bin/python
。
-
当你运行
env-setup
脚本时,Ansible 会从源代码检出运行,默认清单文件是/etc/ansible/hosts
,该文件并未通过代码检出创建;不过,你可以选择性地指定你机器上任意位置的清单文件(详情请见 如何构建清单,docs.ansible.com/ansible/latest/inventory_guide/intro_inventory.xhtml#inventory-basics-formats-hosts-and-groups
)。以下命令提供了如何执行此操作的示例,但显然,你的文件名和内容几乎肯定会有所不同:$ echo "app01.example.org" > ~/my_ansible_inventory $ export ANSIBLE_INVENTORY=~/my_ansible_inventory
这演示了配置 Ansible 的另一种强大方式——环境变量。ANSIBLE_INVENTORY
变量用于告诉 Ansible 默认查找哪个清单文件——通常是 /etc/ansible/hosts
,但在前面的示例中,我们将其更改为主目录中的 my_ansible_inventory
文件。
提示
你可以在这里了解关于各种 Ansible 配置变量的信息:docs.ansible.com/ansible/latest/reference_appendices/config.xhtml#environment-variables
。
-
完成这些步骤后,你可以像本章中讨论的那样运行 Ansible。
env-setup
脚本会修改你的PATH
变量,这样你就不需要指定检出的 Ansible 仓库的位置。举个例子,如果你按照之前的示范使用环境变量设置了清单,并且克隆了 Ansible 源代码并运行了env-setup
脚本,你可以像我们现在熟悉的那样运行即时命令ansible.builtin.ping
,例如:$ ansible all -m ansible.builtin.ping [WARNING]: You are running the development version of Ansible. You should only run Ansible from "devel" if you are modifying the Ansible engine, or trying out features under development. This is a rapidly changing source of code and can become unstable at any point. app01.example.org | SUCCESS => { "ansible_facts": { "discovered_interpreter_python": "/usr/bin/python3" }, "changed": false, "ping": "pong" }
完成这些设置后,你现在可以像通过其他任何方式安装 Ansible 一样,使用开发分支。
-
当然,Ansible 的源代码树是不断变化的,你不太可能只想一直使用你克隆的副本。当需要更新时,你无需克隆一个新的副本;你可以简单地使用以下命令更新你现有的工作副本:
$ git pull –rebase $ git submodule update --init --recursive
这就是我们在 Linux 上安装 Ansible 的过程,现在我们已经完成了这部分,我们将来看一下在 macOS 上安装 Ansible 的一些具体内容。
在 macOS 上安装 Ansible
在本节中,你将学习如何在 macOS 上安装 Ansible。最简单的安装方法是使用 Homebrew,但你也可以使用 Python 包管理器(PIP)。使用 PIP 的安装步骤与 Linux 相同,因此我们不会在这里再讲解。
相反,让我们从 Homebrew 安装方法开始,这是在 macOS 上最快和最简单的方法。
如果你还没有在 macOS 上安装 Homebrew,可以按照这里的详细说明轻松安装:
-
通常,下面显示的两个命令是安装 Homebrew 在 macOS 上所需的全部:
$ xcode-select –install $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
如果你已经为了其他目的安装了 Xcode 命令行工具,可能会看到以下错误信息:
xcode-select: error: command line tools are already installed, use "Software Update" in System Settings to install updates
你可能需要打开 软件更新 设置,进入 系统设置,检查是否需要更新 Xcode 命令行工具,但只要它们已安装,你的 Homebrew 安装应该能顺利进行。
-
如果你想确认 Homebrew 安装是否成功,可以运行以下命令,这将警告你安装中可能出现的任何问题——例如,下面的输出警告我们,虽然 Homebrew 安装成功,但它不在我们的
PATH
中,因此我们可能无法在不指定绝对路径的情况下运行任何可执行文件:$ brew doctor Please note that these warnings are just used to help the Homebrew maintainers with debugging if you file an issue. If everything you use Homebrew for is working fine: please don't worry or file an issue; just ignore this. Thanks! Warning: Homebrew's sbin was not found in your PATH but you have installed formulae that put executables in /opt/homebrew/sbin. Consider setting the PATH for example like so /usr/bin/python3—this should be the case with almost all modern installations of macOS) or you can install a newer version via Homebrew by running the following command:
$ brew install python3
…
$ which python3
/opt/homebrew/bin/python3
$ python3 --version
Python 3.11.3
完成此步骤后,你现在可以继续安装 Ansible。
-
要通过 Homebrew 安装 Ansible,请运行以下命令:
ansible command as before, and if all has gone according to plan, you will see output similar to the following:
$ ansible --version
ansible [core 2.15.0]
配置文件 = None
配置的模块搜索路径 = ['/Users/james/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python 模块位置 = /opt/homebrew/Cellar/ansible/8.0.0/libexec/lib/python3.11/site-packages/ansible
ansible 集合位置 = /Users/james/.ansible/collections:/usr/share/ansible/collections
可执行文件位置 = /opt/homebrew/bin/ansible
python 版本 = 3.11.3 (main, 2023 年 4 月 7 日,20:13:31) [Clang 14.0.0 (clang-1400.0.29.202)] (/opt/homebrew/Cellar/ansible/8.0.0/libexec/bin/python3.11)
jinja 版本 = 3.1.2
brew 命令,如下所示:
$ brew upgrade ansible
现在你已经学习了如何在 macOS 上安装 Ansible,让我们来看一下如何配置 Windows 主机,以便使用 Ansible 进行自动化。
配置 Windows 主机以供 Ansible 使用
如前所述,Windows 上没有直接的 Ansible 安装方法——建议在有 WSL 的情况下,将其安装并像运行 Linux 一样安装 Ansible,按照本章之前概述的过程进行。
尽管有这一限制,Ansible 并不限于管理仅基于 Linux 和 BSD 的系统——它能够使用本地 WinRM 协议对 Windows 主机进行无代理管理,模块和原始命令利用 PowerShell(在每个现代 Windows 安装中都有)。在本节中,你将学习如何配置 Windows,以启用使用 Ansible 进行任务自动化。
以下是 Ansible 在自动化 Windows 主机时所能做到的一些示例:
-
收集远程主机的事实信息
-
安装和卸载 Windows 功能
-
管理和查询 Windows 服务
-
管理用户账户和用户列表
-
使用 Chocolatey 管理软件包(一个 Windows 的软件仓库和管理工具)
-
执行 Windows 更新
-
从远程机器将多个文件获取到 Windows 主机
-
在目标主机上执行原始 PowerShell 命令和脚本
Ansible 允许你通过连接本地用户或域用户来自动化 Windows 机器上的任务。你可以像在 Linux 发行版中使用 sudo
命令一样,通过 Windows runas
支持以管理员身份运行操作。
此外,作为开源软件(OSS),Ansible 容易通过创建自定义模块或直接发送原始 PowerShell 命令来扩展其功能。例如,信息安全团队可以使用原生的 Ansible 模块和必要时的原始命令,轻松地管理文件系统访问控制列表(ACLs)、配置 Windows 防火墙以及管理主机名和域成员身份。
Windows 主机必须满足以下要求,以便 Ansible 控制机与其通信:
-
Ansible 尝试支持微软当前或扩展支持下的所有 Windows 版本,包括 Windows 8.1、10 和 11 等桌面平台,以及 Windows Server 2012(及 R2)、2016、2019 和 2022 等服务器操作系统。
-
你还需要在 Windows 主机上安装 PowerShell 3.0 或更高版本,并至少安装 .NET 4.0。
-
你需要创建并激活 WinRM 监听器,稍后会详细描述。出于安全原因,默认情况下未启用此功能。
让我们更详细地了解如何准备 Windows 主机,以便让 Ansible 自动化:
-
关于前提条件,您需要确保在 Windows 计算机上安装了 PowerShell 3.0 和 .NET Framework 4.0。在许多现代 Windows 版本中,您会发现这些已经安装好了,但如果您仍在使用旧版本的 PowerShell 或 .NET Framework,您需要升级它们。您可以手动执行此操作,或者以下 PowerShell 脚本可以自动为您处理。您会发现该脚本不是来自官方的 Ansible 仓库——但是,脚本作者是官方 Ansible 仓库的维护者和审阅者。尽管如此,每当下载脚本时,最好检查源代码,确保它符合您的安全协议和要求:
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $url = "https://raw.githubusercontent.com/jborean93/ansible-windows/master/scripts/Upgrade-PowerShell.ps1" $file = "$env:temp\Upgrade-PowerShell.ps1" $username = "Administrator" $password = "Password" (New-Object -TypeName System.Net.WebClient).DownloadFile($url, $file) Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Force &$file -Version 5.1 -Username $username -Password $password -Verbose
这个脚本通过检查需要安装的程序(如 .NET Framework 4.5.2)和所需的 PowerShell 版本(我们在前面的代码片段最后一行指定了 5.1
——有效值为 3.0
、4.0
或 5.1
)以及一个具有管理员权限的账户来工作,因此务必根据您的系统适当设置 $username
和 $password
变量。如果设置了有效的用户名和密码,并且脚本执行完毕后需要重启,脚本将在重启时自动重新启动并登录,以便无需进一步操作,脚本将继续执行,直到 PowerShell 版本与目标版本匹配。
需要注意的是,如果凭据设置为启用自动重启,它们也会以明文形式存储在注册表中,因此在脚本执行后,必须检查是否已将它们清除。以下的 PowerShell 代码段将实现这一点,建议在脚本执行后作为常规操作运行,以确保您的凭据不会泄漏:
$reg_winlogon_path = "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $reg_winlogon_path -Name AutoAdminLogon -Value 0
Remove-ItemProperty -Path $reg_winlogon_path -Name DefaultUserName -ErrorAction SilentlyContinue
username and password parameters aren’t set, the script will ask the user to reboot and log in manually if necessary, and the next time the user logs in, the script will continue at the point where it was interrupted. The process continues until the host meets the requirements for Ansible automation.
Once you have completed the script run, you should set the execution policy back to a more secure value. For Windows servers, this would be achieved using this PowerShell command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force
On Windows clients (for example, Windows 10 or Windows 11), this would be achieved via the following command:
Set-ExecutionPolicy -ExecutionPolicy Restricted -Force
These are the default settings for the aforementioned operating systems, but if you want to check the execution policy setting prior to performing any of the script runs, you can query it before running your scripts with the following command:
Get-ExecutionPolicy
Once you have the required version of PowerShell and the .NET Framework installed, you can move on to the next step.
1. When PowerShell has been upgraded to at least version 3.0, the next step will be to configure the WinRM service so that Ansible can connect to it. WinRM service configuration defines how Ansible can interface with the Windows hosts, including the listener port and protocol.
If you have never set up a WinRM listener before, you have three options to do this, as follows:
* Firstly, you can use `winrm quickconfig` for HTTP and `winrm quickconfig -transport:https` for HTTPS. This is the simplest method to use when you need to run outside of the domain environment and just create a simple listener. This process has the advantage of opening the required port in the Windows Firewall and automatically starting the WinRM service.
Note
More recent versions of Windows (for example, Windows Server 2022) will only let you run `winrm quickconfig -transport:https` if you already have a **Secure Sockets Layer** (**SSL**) certificate installed on your node that matches its hostname and is not self-signed. If this is not the case in your environment, the simplest approach is actually to use the PowerShell commands given next.
* If you are running in a domain environment, I strongly recommend using **group policy objects** (**GPOs**) because if the host is the domain member, then the configuration is done automatically without user input. There are many documented procedures for doing this available, but as this is a very Windows domain-centric task, it is beyond the scope of this book.
* Finally, you can create a listener with a specific configuration by running the following PowerShell commands with administrative privileges:
```
$selector_set = @{
Address = "*"
Transport = "HTTPS"
}
$value_set = @{
CertificateThumbprint = "2c8951160e63b33593e7bbc3a22414a5ab259717"
}
New-WSManInstance -ResourceURI "winrm/config/Listener" -SelectorSet $selector_set -ValueSet $value_set
```
The preceding `CertificateThumbprint` value should match the thumbprint of a valid SSL certificate that you previously created or imported into the Windows Certificate Store. Note that if you use this method to configure your WinRM listener, the Windows Firewall is not automatically configured. You can configure it appropriately to listen on port `5986` (HTTPS) using a PowerShell command such as the following:
New-NetFirewallRule -DisplayName 'WinRM over HTTPS' -Profile 'Any' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5986
In this simple example, we will perform basic authentication against a Windows host—this will only work with local accounts (not domain accounts) but is a quick and easy way to get acquainted with Windows automation. When you are ready to start experimenting with Kerberos authentication, the Ansible documentation provides a valuable reference for you to work from: [`docs.ansible.com/ansible/latest/os_guide/windows_winrm.xhtml`](https://docs.ansible.com/ansible/latest/os_guide/windows_winrm.xhtml).
In the meantime, it is important to note that basic authentication isn’t enabled by default on Windows hosts. To enable it, run the following PowerShell command:
Set-Item -Path "WSMan:\localhost\Service\Auth\Basic" -Value $true
If you are running in PowerShell v3.0, you might face an issue with the WinRM service that limits the amount of memory available. This is a known bug, and a hotfix is available to resolve it. An example process (written in PowerShell) to apply this hotfix is given here:
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$url = "https://raw.githubusercontent.com/jborean93/ansible-windows/master/scripts/Install-WMF3Hotfix.ps1"
\(file = "\)env:temp\Install-WMF3Hotfix.ps1"
(New-Object -TypeName System.Net.WebClient).DownloadFile($url, $file)
powershell.exe -ExecutionPolicy ByPass -File $file -Verbose
Configuring WinRM listeners can be a complex task, so it is important to be able to check the results of your configuration process. The following command (which can be run from Command Prompt) will display the current WinRM listener configuration:
winrm enumerate winrm/config/Listener
If all goes well, you should have output similar to this:
Listener
Address = *
Transport = HTTP
Port = 5985
Hostname
Enabled = true
URLPrefix = wsman
CertificateThumbprint
ListeningOn = 10.0.50.100, 127.0.0.1, ::1, fe80::460:ba22:fac4:71ff%5
Listener
Address = *
Transport = HTTPS
Port = 5986
主机名
Enabled = true
URLPrefix = wsman
CertificateThumbprint = 2c8951160e63b33593e7bbc3a22414a5ab259717
ListeningOn = 10.0.50.100, 127.0.0.1, ::1, fe80::460:ba22:fac4:71ff%5
According to the preceding output, two listeners are active—one to listen on port `5985` over HTTP and the other to listen on port `5986` over HTTPS, providing greater security. By way of additional explanation, the following parameters are also displayed in the preceding output:
* `Transport`: This should be set to either `HTTP` or `HTTPS`, though it is strongly recommended that you use the `HTTPS` listener to ensure your automation commands are not subject to snooping or manipulation.
* `Port`: This is the port on which the listener operates, by default `5985` for `HTTP` or `5986` for `HTTPS`.
* `URLPrefix`: This is the URL prefix to communicate with—by default, `wsman`. If you change it, you must set the `ansible_winrm_path` host on your Ansible control host to the same value.
* `CertificateThumbprint`: If running on an HTTPS listener, this is the certificate thumbprint of the Windows Certificate Store used by the connection.
If you need to debug any connection issues after setting up your WinRM listener, you may find the following commands valuable as they perform WinRM-based connections between Windows hosts without Ansible—hence, you can use them to distinguish whether an issue you might be experiencing is related to your Ansible host or whether there is an issue with the WinRM listener itself:
测试 HTTP
winrs -r:http://
测试 HTTPS(如果证书无法验证将失败)
winrs -r:https://
测试 HTTPS,忽略证书验证
$username = "Username"
$password = ConvertTo-SecureString -String "Password" -AsPlainText -Force
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $username, $password
$session_option = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck
Invoke-Command -ComputerName
If one of the preceding commands fails, you should investigate your WinRM listener setup before attempting to set up or configure your Ansible control host.
At this stage, Windows should be ready to receive communication from Ansible over WinRM. To complete this process, you will need to also perform some additional configuration on your Ansible control host. First of all, you will need to install the `winrm` Python module, which, depending on your control host’s configuration, may or may not have been installed before. The installation method will vary from one operating system to another, but it can generally be installed on most platforms with PIP, as follows:
$ python3 -m pip install pywinrm
Once this is complete, you will need to define some additional inventory variables for your Windows hosts—don’t worry too much about inventories for now as we will cover these later in this book. The following example is just for reference:
[windows]
10.0.50.101
[windows:vars]
ansible_user=administrator
ansible_password=password
ansible_port=5986
ansible_connection=winrm
ansible_winrm_server_cert_validation=ignore
Finally, you should be able to run the Ansible `ansible.windows.win_ping` module to perform an end-to-end connectivity test with a command such as the following (adjust for your inventory):
$ ansible -i inventory -m ansible.windows.win_ping windows
10.0.50.101 | SUCCESS => {
"changed": false,
"ping": "pong"
}
This is just the beginning of Windows automation with Ansible—once you decide on your connectivity strategy and way of configuring (whether it be through Group Policy, PowerShell scripts, or otherwise), you will be able to automate your Windows tasks with as much effectiveness as you automate your Linux tasks.
In Ansible 2.8, support was even added for OpenSSH connectivity to Windows hosts, removing the complexity of WinRM configuration and the various authentication mechanisms. While this looks like a great addition to Ansible’s Windows automation capabilities, it must be stressed that even now (on release 8.0.0) it is marked as experimental and that the future may bring changes that are not backward compatible with the current code. As such, we will not explore the setup here, but if you are interested in experimenting with this Windows support for yourself, you are encouraged to look at this section of the official Ansible documentation: [`docs.ansible.com/ansible/latest/os_guide/windows_setup.xhtml#windows-ssh-setup`](https://docs.ansible.com/ansible/latest/os_guide/windows_setup.xhtml#windows-ssh-setup).
Now that you have learned the fundamental steps to configure Windows hosts for Ansible, let’s see how to manage multiple nodes via Ansible in the next section.
Getting to know your Ansible installation
By this stage in this chapter, regardless of your choice of operating system for your Ansible control machine, you should have a working installation of Ansible with which to begin exploring the world of automation. In this section, we will carry out a practical exploration of the fundamentals of Ansible to help you to understand how to work with it. Once you have mastered these basic skills, you will then have the knowledge required to get the most out of the remainder of this book. Let’s get started with an overview of how Ansible connects to non-Windows hosts.
Understanding how Ansible connects to hosts
With the exception of Windows hosts (as discussed at the end of the previous section), Ansible uses the SSH protocol to communicate with hosts. The reasons for this choice in the Ansible design are many, not least that just about every Linux/FreeBSD/macOS host has it built in, as do many network devices such as switches and routers. The SSH service is normally integrated with the operating system authentication stack, enabling you to take advantage of host-based identity verification, and a variety of authentication mechanisms, including Kerberos, to improve authentication security. Also, features of OpenSSH such as `ControlPersist` are used to increase the performance of automation tasks and SSH jump hosts/bastions for network isolation and security.
Note
`ControlPersist` is enabled by default on most modern Linux distributions as part of the OpenSSH server installation. However, on some older operating systems such as RHEL 6 (and CentOS 6), it is not supported, so you will not be able to use it. Ansible automation is still perfectly possible, but longer playbooks might run slower.
Ansible makes use of the same authentication methods that you will already be familiar with, and SSH keys are normally the easiest way to proceed as they remove the need for users to input the authentication password every time a playbook is run. However, this is by no means mandatory, and Ansible supports password authentication through the use of the `--ask-pass` switch. If you are connecting to an unprivileged account on the managed nodes and need to perform the Ansible equivalent of running commands under `sudo` with a password, you can also add `--ask-become-pass` when you run your playbooks to allow this to be specified at runtime as well.
The goal of automation is to be able to run tasks securely but with minimal user intervention. As a result, it is highly recommended that you use SSH keys for authentication, and if you have several keys to manage, then it is advisable to make use of `ssh-agent`.
Every Ansible task, whether it is run individually or as part of a complex playbook, is run against an inventory. An inventory is, quite simply, a list of hosts that you wish to run automation tasks against. Ansible supports a wide range of inventory formats, including the use of dynamic inventories, which can populate themselves automatically from an orchestration provider (for example, you can generate an Ansible inventory dynamically from your Amazon **Elastic Compute Cloud** (**EC2**) instances, meaning you don’t have to keep up with all of the changes in your cloud infrastructure).
Dynamic inventory plugins have been written for most major cloud providers (for example, Amazon EC2, **Google Cloud Platform** (**GCP**), and Microsoft Azure), as well as on-premises systems such as OpenShift and OpenStack. There are even plugins for Docker. The beauty of OSS is that for most of the major use cases you can dream of, someone has already contributed the code, so you don’t need to figure it out or write it for yourself.
Tip
Ansible’s agentless architecture and the fact that it doesn’t rely on SSL means that you don’t need to worry about DNS not being set up or even time skew problems as a result of the **Network Time Protocol** (**NTP**) not working—these can, in fact, be tasks performed by an Ansible playbook! Ansible really was designed to get your infrastructure running from a virtually bare operating system image.
For now, let’s focus on the INI-formatted inventory. An example is shown here with four servers, each split into two groups. Ansible commands and playbooks can be run against an entire inventory (that is, all four servers), one or more groups (for example, `webservers`), or even a single server:
[webservers]
web01.example.org
web02.example.org
[apservers]
app01.example.org
app02.example.org
Let’s use this inventory file along with the Ansible `ping` module, which is used to test whether Ansible can successfully perform automation tasks on the inventory host in question. The following example assumes you have installed the inventory in the default location, which is normally `/etc/ansible/hosts`. When you run the following `ansible` command against this inventory with the `ping` module, you should see output similar to this:
$ ansible webservers -m anisble.builtin.ping
web01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Notice that the `ansible.builtin.ping` module was only run on the two hosts in the `webservers` group and not the entire inventory—this was by virtue of us specifying this group name in the command-line parameters.
The `ansible.builtin.ping` module is one of many thousands of modules for Ansible, all of which perform a given set of tasks (from copying files between hosts to text substitution to complex network device configuration). Again, as Ansible is OSS, there is a veritable army of coders out there who are writing and contributing modules, which means if you can dream of a task, there’s probably already an Ansible module for it. Even in the instance that no module exists, Ansible supports sending raw shell commands (or PowerShell commands for Windows hosts), and so even in this eventuality, you can complete your desired tasks without having to move away from Ansible.
As long as the Ansible control node can communicate with the managed nodes in your inventory, you can automate your tasks. However, it is worth giving some consideration to where you place your control node. For example, if you are working exclusively with a set of Amazon EC2 machines, it arguably would make more sense for your Ansible control machine to be an EC2 instance—in this way, you are not sending all of your automation commands over the internet. It also means that you don’t need to expose the SSH port of your EC2 hosts to the internet, hence keeping them more secure.
We have so far covered a brief explanation of how Ansible communicates with its target hosts, including what inventories are and the importance of SSH communication to all except Windows hosts. In the next section, we will build on this by looking in greater detail at how to verify your Ansible installation.
Verifying the Ansible installation
In this section, you will learn how you can verify your Ansible installation with simple ad hoc commands.
As discussed previously, Ansible can authenticate with your managed nodes in several ways. In this section, we will assume you want to make use of SSH keys and that you have already generated your public and private key pair and applied your public key to all of the managed nodes that you will be automating tasks on.
Tip
The `ssh-copy-id` utility is incredibly useful for distributing your public SSH key to your target hosts before you proceed any further. An example command might be `ssh-copy-id -i ~/.``ssh/id_rsa ansibleuser@web1.example.com`.
To ensure Ansible can authenticate with your private key, you could make use of `ssh-agent`—the following commands show a simple example of how to start `ssh-agent` and add your private key to it. Naturally, you should replace the path with that to your own private key:
$ eval $(ssh-agent)
Agent pid 3064
$ ssh-add ~/.ssh/id_rsa
Identity added: /home/james/.ssh/id_rsa (james@controlnode)
As we discussed in the previous section, we must also define an inventory for Ansible to run against. Another simple example is shown here:
[webservers]
web01.example.org
web02.example.org
The `ansible` command that we used in the previous section has two important switches that you will almost always use: `-m <MODULE_NAME>` to run a module on the managed nodes from your inventory that you specify and, optionally, the module arguments passed using the `-a OPT_ARGS` switch. Commands run using the `ansible` binary are known as ad hoc commands.
The following are three simple examples that demonstrate ad hoc commands—they are also valuable for verifying both the installation of Ansible on your control machine and the configuration of your target hosts, and they will return an error if there is an issue with any part of the configuration. Here, we are introducing the `-i` switch, which is used to tell Ansible that we are specifying a different inventory file from the default— we’ll be using this frequently throughout the book from here on:
* `ping` on your inventory hosts using the following command:
```
$ ansible webservers -i /etc/ansible/hosts -m ansible.builtin.ping
```
* **Display gathered facts**: You can display gathered facts about your inventory hosts using the following command:
```
$ ansible webservers -i /etc/ansible/hosts -m ansible.builtin.setup | less
```
* **Filter gathered facts**: You can filter gathered facts using the following command:
```
$ ansible webservers -i /etc/ansible/hosts -m ansible.builtin.setup -a "filter=ansible_distribution*"
```
For every ad hoc command you run, you will get a response in JSON format—the following example output results from running the `ansible.builtin.ping` module successfully:
$ ansible webservers -m ansible.builtin.ping
web02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Ansible can also gather and return facts about your target hosts—facts are all manner of useful information about your hosts, from CPU and memory configuration, to network parameters, to disk geometry. These facts are intended to enable you to write intelligent playbooks that perform conditional actions—for example, you might only want to install a given software package on hosts with more than 4 GB of RAM or perhaps perform a specific configuration only on macOS hosts. The following is an example of the filtered facts from a macOS-based host—note the special syntax of the `-i` inventory parameter; by placing a comma after the hostname, we are telling the `ansible` command to read the inventory from the command line, rather than a file (a handy shortcut when you’re testing and developing):
$ ansible -i machost.example.org, -m ansible.builtin.setup -a "filter=ansible_distribution*" all
machost.example.org | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "MacOSX",
"ansible_distribution_major_version": "13",
"ansible_distribution_release": "22.4.0",
"ansible_distribution_version": "13.3.1",
"discovered_interpreter_python": "/opt/homebrew/bin/python3.11"
},
"changed": false
}
Ad hoc commands are incredibly powerful, both for verifying your Ansible installation and for learning Ansible and how to work with modules, as you don’t need to write a whole playbook—you can just run a module with an ad hoc command and learn how it responds. Here are some more ad hoc examples for you to consider:
* Copy a file from the Ansible control node to all managed nodes in the `webservers` group with the following command:
```
webservers inventory group, and create it with specific ownership and permissions, as follows:
```
webservers group with the following command:
```
如果 apache2 软件包尚未安装,则使用 apt 安装;如果已经安装,则不更新。同样,这适用于 webservers 库存组中的所有主机。注意--become 开关的存在——因为我们要安装软件包,所以必须使用 sudo*成为*root 用户。此命令假定托管节点上的用户帐户可以执行无需密码的 sudo 命令:
```
state=present to state=latest causes Ansible to install the (latest version of the) package if it is not present, and update it to the latest version if it is present:
```
$ ansible webservers -m ansible.builtin.apt -a "name=apache2 state=latest" --become
```
```
```
```
```
* Display all facts about all the hosts in your inventory (warning—this will produce a lot of JSON!):
```
$ ansible all -m ansible.builtin.setup
```
Now that you have learned more about verifying your Ansible installation and about how to run ad hoc commands, let’s proceed to look in a bit more detail at the requirements of nodes that are to be managed by Ansible.
Managed node requirements
So far, we have focused almost exclusively on the requirements for the Ansible control host and have assumed that (except for the distribution of the SSH keys) the target hosts will just work. This, of course, is not always the case, and for example, while a modern installation of Linux installed from an ISO will often just work, cloud operating system images are often stripped down to keep them small, and so might lack important packages such as Python, without which Ansible cannot operate.
If your target hosts are lacking Python, it is usually easy to install it through your operating system’s package management system. Ansible requires you to install either Python version 2.7 or 3.5 (and above) on the Ansible-managed nodes but has more stringent requirements for the control node. At the time of writing, `ansible-core` 2.15 (which accompanies Ansible 8.0.0) requires a version of Python between 3.9 and 3.11 to be installed on the control node. This is likely to change with new releases, so you are advised to always consult the official documentation to check the latest requirements: [`docs.ansible.com/ansible/latest/installation_guide/intro_installation.xhtml#managed-node-requirements`](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.xhtml#managed-node-requirements).
Tip
Again, the exception here is Windows, which relies on PowerShell instead of Python.
If you are working with operating system images on managed nodes that lack Python, the following commands provide a quick guide to getting Python installed:
* To install Python using `yum` (on older releases of Fedora and CentOS/RHEL 7 and below), use the following command:
```
使用 dnf 包管理器代替。这里是你需要执行的命令:
```
$ sudo dnf install python
```
```
You might also elect to install a specific version to suit your needs, as in this example:
$ sudo dnf install python39
* On Debian and Ubuntu systems, you would use the `apt` package manager to install Python, again specifying a version if required (the example given here is to install Python 3.10 and would work on Ubuntu 22.04):
```
$ sudo apt update
$ sudo apt install python3.10
```
The `ansible.builtin.ping` module we discussed earlier in this chapter for Ansible not only checks connectivity and authentication with your managed hosts but also uses the managed hosts’ Python environment to perform some basic host checks. As a result, it is a fantastic end-to-end test to give you confidence that your managed nodes are configured correctly, with the connectivity and authentication set up perfectly, and to be safe in the knowledge that if Python is missing, the test would return a `failed` result.
Of course, a perfect question at this stage would be: How can Ansible help if you roll out 100 cloud servers using a stripped-down base image without Python? Does that mean you have to manually go through all 100 nodes and install Python by hand before you can start automating?
Thankfully, Ansible has you covered even in this case, thanks to the `ansible.builtin.raw` module. This module is used to send raw shell commands to the managed nodes—and it works both with SSH-managed hosts and Windows PowerShell-managed hosts. As a result, you can use Ansible to install Python on a whole set of systems from which it is missing, or even run an entire shell script to bootstrap a managed node. Most importantly, the `ansible.builtin.raw` module is one of very few that does not require Python to be installed on the managed node, so it is perfect for our use case where we must roll out Python to enable further automation.
The following are some examples of tasks in an Ansible playbook that you might use to bootstrap a managed node and prepare it for Ansible management:
- name: 在没有安装 python3 的主机上引导启动
ansible.builtin.raw: dnf install -y python3 python3-dnf libselinux-python3
- name: 运行一个使用非 POSIX Shell 特性的命令(在这个例子中,/bin/sh 无法同时处理重定向和通配符,但 bash 可以)
ansible.builtin.raw: cat < /tmp/*txt
args:
executable: /bin/bash
- name: 安全使用模板化变量。始终使用引用过滤器以避免注入问题。
ansible.builtin.raw: "{{package_mgr|quote}} {{pkg_flags|quote}} install {{python|quote}}"
We have now covered the basics of setting up Ansible both on the control host and the managed nodes, and we gave you a brief primer on configuring your first connections. It is hoped that you’ve found this chapter helpful and that it forms the foundation for the rest of your journey through this book.
Summary
Ansible is a powerful and versatile yet simple automation tool, of which the key benefits are its agentless architecture and its simple installation process. Ansible was designed to get you from zero to automation rapidly and with minimal effort, and we have demonstrated the simplicity with which you can get up and running with Ansible in this chapter.
In this chapter, you learned the basics of setting up Ansible—how to install it to control other hosts, and the requirements for nodes being managed by Ansible. You learned about the fundamentals required to set up SSH and WinRM for Ansible automation, as well as how to bootstrap managed nodes to ensure they are suitable for Ansible automation. You also learned about ad hoc commands and their benefits. Finally, you learned how to run the latest version of the code directly from GitHub, which both enables you to contribute directly to the development of Ansible and gives you access to the very latest features should you wish to make use of them on your infrastructure.
In the next chapter, we will learn Ansible language fundamentals to enable you to write your first playbooks and to help you to create templated configurations and start to build up complex automation workflows.
Questions
1. On which operating systems can you set up an Ansible control node? (There are multiple correct answers.)
1. Ubuntu 22.04
2. Fedora 35
3. Windows Server 2022
4. HP-UX
5. Mainframe
2. Which protocol does Ansible use to connect to non-Windows managed nodes for running tasks?
1. HTTP
2. HTTPS
3. SSH
4. TCP
5. UDP
3. To execute a specific module in the Ansible ad hoc command line, you need to use the `-``m` option.
1. True
2. False
Further reading
* For any questions about installation via Ansible’s mailing list on Google Groups, refer to the following URL:
[`groups.google.com/forum/#!forum/ansible-project`](https://groups.google.com/forum/#!forum/ansible-project)
* Information on how to install the latest version of `pip` can be found here:
[`pip.pypa.io/en/stable/installation/`](https://pip.pypa.io/en/stable/installation/)
* Details of specific Windows modules using PowerShell can be found here:
[`docs.ansible.com/ansible/latest/collections/ansible/windows/index.xhtml`](https://docs.ansible.com/ansible/latest/collections/ansible/windows/index.xhtml)
* If you have a GitHub account and want to follow the GitHub project, you can keep tracking issues, bugs, and ideas for Ansible at the following URL:
[`github.com/ansible/ansible`](https://github.com/ansible/ansible)
第二章:理解 Ansible 基础
从本质上讲,Ansible 是一个简单的框架,它将一个叫做 Ansible 模块 的小程序推送到目标节点。模块是 Ansible 的核心,负责执行自动化的所有繁重工作。然而,Ansible 框架不仅仅是如此,它还包括插件和动态清单管理,并且通过 playbook 将这些功能结合起来,用于自动化基础设施供应、配置管理、应用部署、网络自动化等,正如所示:
图 2.1 – Ansible 自动化引擎的典型流程和使用方式
即使在本书的上一版本添加了 Ansible 集合,这一架构仍然保持不变——现在,模块、插件和动态清单脚本通过集合分发,而以前所有内容都是作为 Ansible 发布的一部分进行分发的。
Ansible 只需要在管理节点上安装。从那里,它通过网络的传输层(通常是 SSH 或 WinRM)分发所需的模块来执行任务,并在任务完成后将其删除。通过这种方式,Ansible 保持了其无代理架构,不会在目标节点上留下可能仅为一次性自动化任务所需的代码。
在本章中,您将了解更多关于 Ansible 框架的组成及其各种组件,并学习如何将它们结合使用,在 YAML 语法编写的 playbook 中使用。您将学习如何为 IT 操作任务创建自动化代码,并了解如何通过临时任务和更复杂的 playbook 来应用这些代码。最后,您将学习如何使用 Jinja2 模板重复构建动态配置文件,利用变量和动态表达式。
本章将涵盖以下主题:
-
熟悉 Ansible 框架
-
探索配置文件
-
命令行参数
-
定义变量
-
理解 Jinja2 过滤器
技术要求
本章假设您已经成功安装了最新版本的 Ansible(截至写作时为 8.0 版本,ansible-core
2.15),并且是在 Linux 节点上安装的,正如在 第一章《与 Ansible 入门》中所讨论的内容。还假设您至少有一个其他的 Linux 主机来测试自动化代码。可用的主机越多,您就能在本章中开发更多的示例,并进一步了解 Ansible。本书假定 Linux 主机之间有 SSH 通信,并且您已具备一定的 Linux 使用经验。
本章的代码包可以在 github.com/PacktPublishing/Practical-Ansible-Second-Edition/tree/main/Chapter%202
上获取。
熟悉 Ansible 框架
在本节中,您将学习 Ansible 框架如何适配 IT 操作。我们将解释如何首次运行 Ansible。理解这个框架后,您将准备好学习更高级的概念,比如创建并运行带有您自己清单的 playbook。
为了通过 SSH 连接从您的 Ansible 控制主机到多个远程主机运行 Ansible 的临时命令,您需要确保在控制主机上安装了最新版本的 Ansible。使用以下命令确认安装的是最新版本的 Ansible:
$ ansible --version
ansible [core 2.15.0] (2.15 82b47c8d5c) last updated 2023/05/19 15:21:43 (GMT +000)
config file = None
configured module search path = ['/home/james/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /home/james/ansible-2.15/ansible/lib/ansible
ansible collection location = /home/james/.ansible/collections:/usr/share/ansible/collections
executable location = /home/james/ansible-2.15/ansible/bin/ansible
python version = 3.10.6 (main, Mar 10 2023, 10:55:28) [GCC 11.3.0] (/usr/bin/python)
jinja version = 3.0.3
libyaml = True
您还需要确保与您在清单中定义的每个远程主机具有 SSH 连接性。您可以通过在每个远程主机上进行简单的手动 SSH 连接测试来验证连接性,因为 Ansible 在所有远程 Linux 自动化任务中都会使用 SSH:
$ ssh <username>@web01.example.org
The authenticity of host 'web01.example.org (10.0.50.30)' can't be established.
ED25519 key fingerprint is SHA256:hU+saFERGFDERW453tasdFPAkpVws.
Are you sure you want to continue connecting (yes/no)? yes
password:<Input_Your_Password>
在本节中,我们将带您了解 Ansible 的工作原理,从一些简单的连接性测试开始。通过以下简单的步骤,您可以了解 Ansible 框架如何访问多个主机并执行您的任务:
-
创建或编辑您的默认清单文件,
/etc/ansible/hosts
(您还可以通过传递如–-inventory=/path/inventory_file
等选项指定自定义清单文件的路径)。向您的清单中添加一些示例主机——这些必须是实际机器的 IP 地址或主机名,以便 Ansible 测试。以下是我的网络中的一些示例,但您需要用您自己的设备替换这些。每行添加一个主机名(或 IP 地址):web01.example.org web02.example.org app01.example.org app02.example.org
所有主机应使用可解析的地址指定——即,Ansible 控制节点上的 /etc/hosts
文件)。或者,如果没有设置 DNS 或主机条目,也可以使用 IP 地址。无论您选择何种格式的清单地址,都应该能够成功连接到每个主机。除非您已设置严格的防火墙规则,否则对每个主机进行简单的 ping 测试即可作为验证。以下是一个示例输出:
$ ping web01.example.org
PING web01.example.org (10.0.50.30) 56(84) bytes of data.
64 bytes from web01.example.org (10.0.50.30): icmp_seq=1 ttl=64 time=1.02 ms
64 bytes from web01.example.org (10.0.50.30): icmp_seq=2 ttl=64 time=1.01 ms
64 bytes from web01.example.org (10.0.50.30): icmp_seq=3 ttl=64 time=0.957 ms
64 bytes from web01.example.org (10.0.50.30): icmp_seq=4 ttl=64 time=1.16 ms
-
为了使自动化过程更加流畅,我们将生成一个 SSH 认证密钥对,这样每次运行 playbook 时就不需要输入密码。如果您还没有 SSH 密钥对,可以使用以下命令生成一个:
$ ssh-keygen
当您运行ssh-keygen
工具时,您将看到类似以下的输出。请注意,当提示时,您应该将passphrase
变量留空;否则,每次运行 Ansible 任务时,您都需要输入密码,这样就失去了使用 SSH 密钥认证的便利性(尽管这确实能增强安全性,如果密钥落入错误之手,因此在使用 SSH 密钥对时请考虑这一点):
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/home/james/.ssh/id_rsa): <Enter>
Enter passphrase (empty for no passphrase): <Press Enter>
Enter same passphrase again: <Press Enter>
Your identification has been saved in /home/james/.ssh/id_rsa.
Your public key has been saved in /home/james/.ssh/id_rsa.pub.
The key fingerprint is:
SHA256:1IF0KMMTVAMEQF62kTwcG59okGZLiMmi4Ae/BGBT+24 james@controlnode.example.org
The key's randomart image is:
+---[RSA 2048]----+
|=*=*BB==+oo |
|B=*+*B=.o+ . |
|=+=o=.o+. . |
|...=. . |
| o .. S |
| .. |
| E |
| . |
| |
+----[SHA256]-----+
-
尽管有些条件下您的 SSH 密钥会自动获取,但建议您使用
ssh-agent
,因为这样可以加载多个密钥来对抗各种目标,而无需担心密钥名称和路径是否正确。即使现在用处不大,将来对您也将非常有用。按以下方式启动ssh-agent
,然后添加您的新认证密钥(注意,您需要为每个打开的 shell 执行此操作):$ eval $(ssh-agent) $ ssh-add ~/.ssh/id_rsa
-
在您可以使用目标主机进行基于密钥的身份验证之前,您需要将刚生成的密钥对的公钥应用于每个主机。您可以使用以下命令将密钥逐个复制到每个主机:
$ ssh-copy-id -i ~/.ssh/id_rsa.pub web01.example.org /usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "~/.ssh/id_rsa.pub" /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys james@web01.example.org's password: Number of key(s) added: 1 Now try logging into the machine, with: "ssh 'web01.example.org'" ansible.builtin.ping command on the hosts you put in your inventory file. You will find that you are not prompted for a password at any point as the SSH connections to all the hosts in your inventory are authenticated with your SSH key pair. So, you should see an output similar to the following:
$ ansible -i hosts -m ansible.builtin.ping all
web01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
app02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
app01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
这个示例输出是使用 Ansible 的默认详细级别生成的。如果在此过程中遇到问题,您可以通过在运行 ansible
命令时传递一个或多个 -v
开关来增加 Ansible 的详细级别。对于大多数问题,建议您使用 -vvvv
,这将为您提供丰富的调试信息,包括原始的 SSH 命令及其输出。例如,假设某个主机(如 web02.example.org
)无法连接,并且您收到类似以下错误:
web02.example.org | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: ssh: connect to host web02.example.org port 22: Connection refused",
"unreachable": true
-vvvv flag could potentially produce pages of output and so, to save space, we won’t include an example here—experimenting with the verbosity levels is left as an exercise for you. When you generate output with this highest level of verbosity, you will see that it includes many useful details, such as the raw SSH command that was used to generate the connection to the target host in the inventory, along with any error messages that may have resulted from that call. This can be incredibly useful when debugging connectivity or code issues, although the output might be a little overwhelming at first. However, with some practice, you will quickly learn how to interpret it.
By now, you should have a good idea of how Ansible communicates with its clients over SSH. Let’s proceed to the next section, where we will look in more detail at the various components that make up Ansible, as this will help us understand how to work with it better.
Breaking down the Ansible components
Ansible allows you to define policies, configurations, task sequences, and orchestration steps in playbooks—the limit is really only your imagination. A playbook can be executed to manage your tasks either synchronously or asynchronously on a remote machine, although you will find that just about all examples are synchronous. In this section, you will learn about the main components of Ansible and understand how Ansible employs those components to communicate with remote hosts.
In order to understand the various components, we first need an inventory to work from. Let’s create an example one, ideally with multiple hosts in it—here we will reuse the one we created earlier in this chapter. However, you are free to create your own, and while I have set up name resolution for this example, to give machines more friendly names, remember that you can get started with IP addresses if you don’t want to add name resolution just yet.
To really understand how Ansible—as well as its various components—works, we first need to create an Ansible playbook. While the ad hoc commands that we have experimented with so far are useful in their own right, they are just single **tasks**, whereas playbooks are organized groups of tasks that are (usually) run in sequence. Conditional logic can be applied and in any other programming language, playbooks would be considered as your code. At the head of the playbook, you should specify the name of your **play**—although this is not mandatory, it is good practice to name all your plays and tasks as, without this, it would be quite hard for someone else to interpret what the playbook does, or even for you to do so if you come back to it after some time.
Let’s get started with building our first example playbook:
1. Specify the play name and inventory hosts to run your tasks against at the very top of your playbook. Also, note the use of `---`, which denotes the beginning of a YAML file (all Ansible playbooks that are written in YAML):
```
---
- name: 我的第一个 Ansible playbook
hosts: all
```
2. After this, we will tell Ansible that we want to perform all the tasks in this playbook as a superuser (usually `root`). We do this with the following statement (to aid your memory, think of `become` as shorthand for `become superuser`):
```
become: yes
```
3. After this header, we will specify a task block that will contain one or more tasks to be run in sequence. For now, we will simply create one task to update the version of Apache using the `ansible.builtin.apt` module (because of this, this playbook is only suitable for running against Debian- or Ubuntu-derived hosts). We will also specify a special element of the play called a **handler**. Handlers will be covered in greater detail in *Chapter 4*, *Playbooks and Roles*, so don’t worry too much about them for now. Simply put, a handler is a special type of task that is called only if something changes. So, in this example, the handler code restarts the web server, but only if it is installed or updated, preventing unnecessary restarts if the playbook is run several times and there are no updates for Apache. The following code performs these functions exactly and should form the basis of your first playbook:
```
tasks:
- name: 安装/更新到最新的 Apache Web 服务器
ansible.builtin.apt:
name: apache2
state: latest
notify:
- 重新启动 Apache Web 服务器
handlers:
- name: 重新启动 Apache Web 服务器
ansible.builtin.service:
name: apache2
state: restarted
```
Congratulations, you now have your very first Ansible playbook! If you run this now, you should see it iterate through all the web hosts in your inventory, where it will install or update in the `apache2` package as required, and then only restart the service where the package was installed/updated.
In the following command to run the playbook, we have introduced a new switch, `--limit`, which is used when you want to run a playbook on only part of an inventory. In our example, I have four hosts in my inventory, but I only want to install `apache2` on the ones named `web*`, which I specify using the `--limit web*` option. These, of course, are based on the hostnames I have used in my demo environment for this book, and you should change your limit pattern to match your environment:
$ ansible-playbook -i hosts --limit web* playbook.yml
PLAY [我的第一个 Ansible playbook] **********************************************************
TASK [收集事实] ***************************************************
ok: [web02.example.org]
ok: [web01.example.org]
TASK [安装/更新到最新的 Apache Web 服务器] **********************************************************
changed: [web01.example.org]
changed: [web02.example.org]
RUNNING HANDLER [重新启动 Apache Web 服务器] **********************************************************
changed: [web02.example.org]
changed: [web01.example.org]
PLAY RECAP **********************************************************
web01.example.org : 成功=3 更改=2 无法访问=0 失败=0 跳过=0 救援=0 忽略=0
web02.example.org : 成功=3 更改=2 无法访问=0 失败=0 跳过=0 救援=0 忽略=0
If you examine the output from the playbook, you can see the value in naming not only the play but also each task that is executed, as it makes interpreting the output of the run a very simple task. You will also see that there are multiple possible results from running a task; in the preceding example, we can see two of these results—`ok` and `changed`. Most of these results are fairly self-explanatory, with `ok` meaning the task ran successfully and that nothing changed as a result of the task completing. An example of this in the preceding playbook is the `Gathering Facts` stage, which is a read-only task that gathers information about the target hosts. As a result, it can only ever return `ok` or a failed status, such as `unreachable`, if the host is down. It should never return `changed`.
However, you can see in the preceding output that both hosts needed to install/upgrade their `apache2` package and, as a result of this, the results from the `Install/Update to the latest of Apache Web Server` task are `changed` for all the hosts. This `changed` result means the task ran successfully (as for `ok`) but that a change was made to the managed node. It also means that our `handler` instance is notified and so the web server service is restarted.
If we run the playbook a second time straight away, we know that it is hugely unlikely that the `apache2` package will need upgrading again. Notice how the playbook output differs this time:
执行 [我的第一个 Ansible 剧本] **********************************************************
任务 [收集信息] ************************************************************
成功:[web02.example.org]
成功:[web01.example.org]
任务 [安装/更新到最新版本的 Apache Web Server] ***********************************************************
成功:[web01.example.org]
成功:[web02.example.org]
执行回顾 **********************************************************
web01.example.org : 成功=2 更改=0 无法访问=0 失败=0 跳过=0 救援=0 忽略=0
web02.example.org : 成功=2 更改=0 无法访问=0 失败=0 跳过=0 救援=0 忽略=0
You can see that this time, the output from the `Install/Update to the latest of Apache Web Server` task is `ok` for both hosts, meaning no changes were applied (the package was not updated). As a result of this, our handler is not notified and does not run—you can see that it does not even feature in the preceding playbook output. This distinction is important—the goal of an Ansible playbook (and the modules that underpin Ansible) should be to only make changes when they need to be made. If everything is all up to date, then the target host should not be altered. Unnecessary restarts to services should be avoided, as should unnecessary alterations to files. In short, Ansible playbooks are (and should be) designed to be efficient and to achieve a target machine state.
This has very much been a crash course on writing your first playbook, but hopefully, it has given you a taste of what Ansible can do when you move from single ad hoc commands through to more complex playbooks. Before we explore the Ansible language and components any further, let’s take a more in-depth look at the YAML language that playbooks are written in.
Learning the YAML syntax
In this section, you will learn how to write a YAML file with the correct syntax, and the various constructs you will see time and again on your Ansible automation journey. Ansible uses YAML because it is easier for humans to read and write than other common data formats, such as XML or JSON. There are no commas, curly braces, or tags to worry about, and the enforced indentation in the code ensures that it is tidy and easy on the eye. In addition, there are libraries available in most programming languages for working with YAML.
This reflects one of the core goals of Ansible—to produce easy-to-read (and write) code that describes the target state of a given host. Ansible playbooks are (ideally) supposed to be self-documenting, as documentation is often an afterthought in busy technology environments—so, what better way to document than through the automation system responsible for deploying code?
Before we dive into the YAML structure, a word on the files themselves. Files written in YAML can optionally begin with `---` (as seen in the example playbook in the previous section) and end with `...`. This applies to all files in YAML, regardless of whether they are consumed by Ansible or another system, and indicates that the file is in the YAML language. You will find that most examples of Ansible playbooks (as well as roles and other associated YAML files) start with `---` but do not end with `...`—the header is sufficient to clearly denote that the file uses the YAML format.
Let’s explore the YAML language through the example playbook we created in the preceding section:
1. Lists are an important construct in the YAML language—in fact, although it might not be obvious, the `tasks:` block of the playbook is actually a YAML list. A list in YAML contains all of its items at the same indentation level, with each item in the list preceded by a `-`. For example, we updated the `apache2` package from the preceding playbook using the following code:
```
- 名称:安装/更新到最新版本的 Apache Web Server
ansible.builtin.apt:
名称:apache2
状态:最新
```
However, we could have specified a list of packages to be upgraded as follows:
- 名称:安装/更新到最新版本的 Apache Web Server
ansible.builtin.apt:
名称:
-
apache2
-
apache2-utils
状态:最新
Now, rather than passing a single value to the `name:` key as a string, we pass a YAML-formatted list containing the names of two packages to be installed/updated. Only certain modules support this so do refer to the documentation to establish which ones you can pass lists to.
1. Dictionaries are another important concept in YAML—they are represented by a `key: value` format, as we have already extensively seen, but all of the items in the dictionary are indented by one more level. This is easiest explained by an example, so consider the following code from our example playbook:
```
ansible.builtin.service:
名称:apache2
状态:重启
```
In this example (from `handler`), the `ansible.builtin.service` definition is actually a dictionary and both the `name` and `state` keys are indented with two more spaces than the `ansible.builtin.service` key. This higher level of indentation means that the `name` and `state` keys are associated with the `ansible.builtin.service` key, therefore, in this case, telling the `ansible.builtin.service` module which service to operate on (`apache2`) and what to do with it (restart it).
Using these examples, we can see that you can produce quite complicated data structures by mixing lists and dictionaries.
1. As you become more proficient at playbook design (as you progress through the book, you will certainly become more proficient), you may very well start to produce quite complicated variable structures that you will put into their own separate files to keep your playbook code readable. The following is an example of a `variables` file that provides the details of two employees of a company:
```
---
员工:
- 名称:daniel
全名:Daniel Oh
角色:DevOps 布道者
水平:专家
技能:
- Kubernetes
- 微服务
- Ansible
- Linux 容器
- 名称:michael
全名:Michael Smith
角色:企业架构师
水平:高级
技能:
- 云
- 中间件
- Windows
- 存储
```
In this example, you can see that we have a dictionary containing the details of each employee. The employees themselves are list items (you can spot this because the lines start with `-`) and, equally, the employee skills are denoted as list items. You will notice the `fullname`, `role`, `level`, and `skills` keys are at the same indentation level as `name` but do not feature `-` before them. This tells you that they are in the dictionary with the list item itself, and so they represent the details of the employee.
1. YAML is very literal when it comes to parsing the language and a new line always represents a new line of code. What if you actually need to add a block of text (for example, to a variable)? In this case, you can use a literal block scalar, `|`, to write multiple lines and YAML will faithfully preserve the new lines, carriage returns, and all the whitespace that follows each line (note, however, that the indentation at the beginning of each line is part of the YAML syntax):
```
专业特长:|
敏捷方法论
云原生应用开发实践
高级企业 DevOps 实践
```
So, if we were to get Ansible to print the preceding content to the screen, it would display as follows (note that the preceding two spaces have gone—they were interpreted correctly as part of the YAML language and not printed):
敏捷方法论
云原生应用开发实践
高级企业 DevOps 实践
Similar to the preceding is the folded block scalar, `>`, which does the same as the literal block scalar but does not preserve line endings. This is useful for very long strings that you want to print on a single line, but also want to wrap across multiple lines in your code for the purpose of readability. Take the following variation of our example:
专业特长:>
敏捷方法论
云原生应用开发实践
高级企业 DevOps 实践
Now, if we were to print this, we would see the following:
敏捷方法论云原生应用开发实践高级企业 DevOps 实践
We could add trailing spaces to the preceding example to stop the words from running into each other, but I have not done this here as I wanted to provide you with an easy-to-interpret example.
As you review playbooks, variable files, and so on, you will see these structures used over and over again. Although simple in definition, they are very important—a missed level of indentation or a missing `-` instance at the start of a list item can cause your entire playbook to fail to run. As we discovered, you can put all of these various constructs together. One additional example is provided in the following code block of a `variables` file for you to consider, which shows the various examples we have covered all in one place:
服务器:
-
前端
-
后端
-
数据库
-
缓存
员工:
- 名称:daniel
全名:Daniel Oh
角色:DevOps 布道者
水平:专家
技能:
-
Kubernetes
-
微服务
-
Ansible
-
Linux 容器
-
名称:michael
全名:Michael Smiths
角色:企业架构师
水平:高级
技能:
-
云
-
中间件
-
Windows
-
存储
专业特长:|
敏捷方法论
云原生应用开发实践
高级企业 DevOps 实践
You can also express both dictionaries and lists in an abbreviated form, known as `employees` variable file:
员工:[{"全名": "Daniel Oh","水平": "专家","名称": "daniel","角色": "DevOps 布道者","技能": ["Kubernetes","微服务","Ansible","Linux 容器"]},{"全名": "Michael Smiths","水平": "高级","名称": "michael","角色": "企业架构师","技能":["云","中间件","Windows","存储"]}]
Although this displays exactly the same data structure, you can see how difficult it is to read with the naked eye. Flow collections are not used extensively in YAML and I would not recommend you make use of them yourself, but it is important to understand them in case you come across them. You will also notice that although we’ve started talking about variables in YAML, we haven’t expressed any variable types. YAML tries to make assumptions about variable types based on the data they contain, so if you want to assign `1.0` to a variable, YAML will assume it is a floating-point number. If you need to express it as a string (perhaps because it is a version number), you need to put quotation marks around it, which causes the YAML parser to interpret it as a string instead, such as in the following example:
版本: "2.0"
This completes our look at the YAML language syntax. Now that’s complete, in the next section, let’s take a look at ways that you can organize your automation code to keep it manageable and tidy.
Organizing your automation code
As you can imagine, if you were to write all of your required Ansible tasks in one massive playbook, it would quickly become unmanageable—that is to say, it would be difficult to read, difficult for someone else to pick up and understand, and—most of all—difficult to debug when things go wrong. Ansible provides a number of ways for you to divide your code into manageable chunks; perhaps the most important of these is the use of **roles**. Roles (for the sake of a simple analogy) behave like a library in a conventional high-level programming language. We will go into more detail about roles in *Chapter 4*, *Playbooks* *and Roles*.
There are, however, other ways that Ansible supports splitting your code into manageable chunks, which we will explore briefly in this section as a precursor to the more in-depth exploration of roles later in this book.
Let’s build up a practical example. To start, we know that we need to create an inventory for Ansible to run against. In this instance, we’ll create four notional groups of servers, with each group containing two servers. Our hypothetical example will contain a frontend server and application servers for a fictional application, located in two different geographic locations. Our inventory file will be called `production-inventory` and the example contents are as follows:
[frontends_na_zone]
frontend1-na.example.com
frontend2-na.example.com
[frontends_emea_zone]
frontend1-emea.example.com
frontend2-emea.example.com
[appservers_na_zone]
appserver1-na.example.com
appserver2-na.example.com
[appservers_emea_zone]
appserver1-emea.example.com
appserver2-emea.example.com
Now, obviously, we could just write one massive playbook to address the required tasks on these different hosts, but as we have already discussed, this would be cumbersome and inefficient.
Let’s instead break the task of automating these different hosts down into smaller playbooks:
1. Create a playbook to run a connection test on a specific host group, such as `frontends_na_zone`. Put the following contents into the playbook:
```
---
- hosts: frontends_na_zone
remote_user: james
gather_facts: no
任务:
- name: simple connection test
ansible.builtin.ping:
```
2. Now, try running this playbook against the hosts. (Note that we have configured it to connect to a remote user on the inventory system, called `james`. If you wish to attempt this example yourself, you will need to set up your own user account and change the `remote_user` line of your playbook accordingly. Also remember to set up SSH keys and authentication as we demonstrated earlier in this chapter.) When you run the playbook after setting up the authentication, you should see output like the following:
```
$ ansible-playbook -i production-inventory frontends-na.yml
执行 [frontends_na_zone] ******************************************************
任务 [简单连接测试] *****************************************************
ok: [frontend2-na.example.com]
ok: [frontend1-na.example.com]
执行回顾 **********************************************
frontend1-na.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
使用 ansible.builtin.ping 模块进行连接测试,但在实际情况中,您将执行更复杂的任务,例如安装软件包或修改文件。指定此 playbook 针对 appservers_emea_zone 清单中的主机组运行。将以下内容添加到 playbook 中:
```
---
- hosts: appservers_emea_zone
remote_user: james
gather_facts: no
tasks:
- name: simple connection test
ansible.builtin.ping:
```
```
As before, you need to ensure you can access these servers, so either create the `james` user and set up authentication to that account or change the `remote_user` line in the example playbook. Once you have done this, you should be able to run the playbook and you will see output similar to this:
$ ansible-playbook -i production-inventory appservers-emea.yml
执行 [appservers_emea_zone] ******************************************************
任务 [简单连接测试] *************************************************************
ok: [appserver1-emea.example.com]
ok: [appserver2-emea.example.com]
执行回顾 *****************************************************
appserver1-emea.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
appserver2-emea.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
1. So far, so good. However, we now have two playbooks that we need to run manually, which only address two of our inventory host groups. If we want to address all four groups, we need to create a total of four playbooks, all of which need to be run manually. This is hardly reflective of best automation practices. What if there was a way to take these individual playbooks and run them together from one top-level playbook? This would enable us to divide our code to keep it manageable but also prevent a lot of manual effort when it comes to running the playbooks. Fortunately, we can do exactly that by taking advantage of the `ansible.builtin.import_playbook` directive in a top-level playbook that we will call `site.yml`:
```
---
- ansible.builtin.import_playbook: frontends-na.yml
- ansible.builtin.import_playbook: appservers-emea.yml
```
Now, when you run this single playbook using the (by now, familiar) `ansible-playbook` command, you will see that the effect is the same as if we had actually run both playbooks back to back. In this way, even before we explore the concept of roles, you can see that Ansible supports splitting up your code into manageable chunks without needing to run each chunk manually:
$ ansible-playbook -i production-inventory site.yml
执行 [frontends_na_zone] **********************************************************
任务 [简单连接测试] **********************************************************
ok: [frontend2-na.example.com]
ok: [frontend1-na.example.com]
执行 [appservers_emea_zone] **********************************************************
任务 [简单连接测试] *************************************************************
ok: [appserver2-emea.example.com]
ok: [appserver1-emea.example.com]
执行回顾 *********************************************************
appserver1-emea.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
appserver2-emea.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
frontend1-na.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
frontend2-na.example.com : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
There’s much more that you can do with geographically diverse environments, such as our simple example here, as we have not even touched on things such as placing variables in your inventory (which, for example, associates different parameters with different environments). We will explore this in more detail in *Chapter 3*, *Defining* *Your Inventory*.
However, hopefully, this has armed you with enough knowledge that you can start making informed choices about how to organize the code for your playbooks. As you complete further chapters of this book, you will be able to establish whether you wish to make use of roles or the `ansible.builtin.import_playbook` directive (or perhaps even both) as part of your playbook organization.
Let’s carry on with our practical introduction to Ansible in the next section, with a look at the configuration file and some of the key directives that you might find valuable.
Exploring the configuration file
Ansible’s behavior is, in part, defined by its configuration file. The central configuration file (which impacts the behavior of Ansible for all users on the system) can be found at `/etc/ansible/ansible.cfg`. However, this is not the only place Ansible will look for its configuration; in fact, it will look in the following locations, from the top to the bottom.
The first instance of the file is the configuration it will use; all of the others are ignored, even if they are present:
* `ANSIBLE_CONFIG`: The file location specified by the value of this environment variable, if set
* `ansible.cfg`: In the current working directory
* `~/.ansible.cfg`: In the home directory of the user
* `/etc/ansible/ansible.cfg`: The central configuration that we previously mentioned
If you installed Ansible through a package manager, such as `dnf`, `yum`, or `apt`, you will almost always find a default configuration file called `ansible.cfg` in `/etc/ansible`. However, if you built Ansible from the source or installed it via `pip`, the central configuration file will not exist, and you will need to create it yourself. A good starting point used to be to reference the example Ansible configuration file that was included with the source code. However, since the release of `ansible-core` 2.12, you can now generate an example configuration file with all the available options present (but commented out) using this command:
$ ansible-config init --disabled > ansible.cfg
As you will almost certainly be working with a version of `ansible-core` newer than this, we’ll assume this for the remainder of this section. Specifically, we will detail how to locate Ansible’s running configuration and how to manipulate it. Most people who install Ansible find that they can get a long way with Ansible before they have to modify the default configuration, as it has been carefully designed to work in a great many scenarios. Indeed, all the example code run so far in this book has been performed on a copy of Ansible installed via PIP, with no configuration changes made.
Nonetheless, it is important to know a little about configuring Ansible in case you come across an issue in your environment that can only be changed by modifying the configuration.
Let’s get started by exploring the default configuration that is provided with Ansible:
1. The command in the following code block lists the current configuration parameters supported by Ansible. It is incredibly useful because it tells you both the environment variable that can be used to change the setting (see the `env` field) as well as the configuration file parameter and section that can be used (see the `ini` field). Other valuable information, including the default configuration values and a description of the configuration, is given (see the `default` and `description` fields, respectively). Run the following command to explore the output:
```
$ ansible-config list
```
The following is an example of the kind of output you will see. There are, of course, many pages to it, but a snippet is shown here as an example:
$ ansible-config list
ACTION_WARNINGS:
默认值: true
描述:
- 默认情况下,Ansible 在接收到任务操作(模块或操作插件)时会发出警告。
或操作插件)
- 可以通过将此设置调整为 False 来消除这些警告。
env:
- name: ANSIBLE_ACTION_WARNINGS
ini:
- key: action_warnings
section: defaults
name: 切换操作警告
type: boolean
version_added: '2.5'
AGNOSTIC_BECOME_PROMPT:
default: true
description: 显示一个无关的变成提示,而不是显示包含提示的变成提示
命令行提供的变成方法
env:
- name: ANSIBLE_AGNOSTIC_BECOME_PROMPT
ini:
- key: agnostic_become_prompt
section: privilege_escalation
name: 显示一个无关的变成提示
type: boolean
version_added: '2.5'
yaml:
key: privilege_escalation.agnostic_become_prompt
1. If you want to see a straightforward display of all the possible configuration parameters, along with their current values (regardless of whether they are configured from environment variables or a configuration file in one of the previously listed locations), you can run the following command:
```
$ ansible-config dump
```
The output shows all the configuration parameters (in an environment variable format), along with the current settings. If the parameter is configured with its default value, you are told so (see the `(default)` element after each parameter name). The output is also color-coded by default, with parameters at their default displayed in green, while those that have been changed are yellow (just like the color coding from an Ansible playbook run):
$ ansible-config dump
ACTION_WARNINGS(default) = True
AGNOSTIC_BECOME_PROMPT(default) = True
ANSIBLE_CONNECTION_PATH(default) = None
ANSIBLE_COW_ACCEPTLIST(default) = ['bud-frogs', 'bunny', 'cheese', 'daemon', 'default', 'dragon', 'elephant-in-snake', 'elephant', 'eyes', 'hellok>
ANSIBLE_COW_PATH(default) = None
ANSIBLE_COW_SELECTION(default) = default
ANSIBLE_FORCE_COLOR(default) = False
ANSIBLE_HOME(default) = /home/james/.ansible
…
CONFIG_FILE() = None
…
1. Let’s see the effect on this output by editing one of the configuration parameters. Let’s do this by setting an environment variable, as follows (this command has been tested in the `bash` shell, but may differ for other shells):
```
$ export ANSIBLE_FORCE_COLOR=True
```
Now, let’s rerun the `ansible-config` command, but this time get it to tell us only the parameters that have been changed from their default values:
$ ansible-config dump --only-change
ANSIBLE_FORCE_COLOR(env: ANSIBLE_FORCE_COLOR) = True
ansible-config 告诉我们,我们只改变了 ANSIBLE_FORCE_COLOR 的默认值,它被设置为 True,而且我们通过环境变量进行了设置。注意,CONFIG_FILE 也已更改,但没有找到更改的来源——这是因为我通过 PIP 安装了 Ansible,因此 /etc/ansible/ansible.cfg(以及其他有效的配置文件)不存在——因此,这是预期的行为。这是一个非常有价值的工具,尤其是当你需要调试配置问题时。
当处理 Ansible 配置文件时,你会注意到它是 INI 格式的,这意味着它包含诸如[defaults]
这样的部分,key = value
格式的参数,以及以#
或;
开头的注释。你只需要在配置文件中放置你希望更改的默认参数,因此,如果你想创建一个简单的配置来更改默认库存文件的位置,它可能如下所示:
# Set my configuration variables
[defaults]
inventory = /home/james/ansible-hosts ; Here is the path of the inventory file
如前所述,ansible.cfg
配置文件可能的有效位置之一是在当前工作目录中。它很可能位于你的主目录中,因此在多用户系统上,我们强烈建议你将 Ansible 配置文件的访问权限限制为仅限你的用户账户。你应该采取所有常规预防措施来保护多用户系统上的重要配置文件,特别是因为 Ansible 通常用于配置多个远程系统,因此,如果配置文件不小心泄露,可能会造成很大损害!
同样需要注意的是,环境变量和配置文件的行为是递加的——因此,按照我刚才提到的配置文件,当再次运行已更改配置的 dump 时,将会得到以下结果:
$ ansible-config dump --only-change
ANSIBLE_FORCE_COLOR(env: ANSIBLE_FORCE_COLOR) = True
CONFIG_FILE() = /home/james/code/chapter02/ansible.cfg
DEFAULT_HOST_LIST(/home/james/code/chapter02/ansible.cfg) = ['/home/james/ansible-hosts']
当然,Ansible 的行为不仅仅受到配置文件和开关的控制——你传递给各种 Ansible 可执行文件的命令行参数也至关重要。事实上,我们已经使用过其中一些参数。我们已经向你展示了如何使用ansible.cfg
中的inventory
参数来更改 Ansible 查找库存文件的位置。然而,在本书之前的许多例子中,我们在运行 Ansible 时使用-i
开关覆盖了这一设置。那么,让我们继续下一部分,了解在运行 Ansible 时如何使用命令行参数。
命令行参数
在这一部分,你将学习如何使用命令行参数来执行 playbook,以及如何利用一些常用的参数为自己带来便利。我们已经非常熟悉其中一个参数,--version
开关,用来确认 Ansible 是否已安装(以及安装的是哪个版本)。
就像我们能够通过 Ansible 直接了解各种配置参数一样,我们也可以了解命令行参数。几乎所有的 Ansible 可执行文件都有一个--help
选项,你可以运行它来显示有效的命令行参数。现在让我们尝试一下:
-
你可以查看执行
ansible
命令时所有可用的选项和参数。使用以下命令:$ ansible --help
当你运行上面的命令时,你会看到大量有用的输出;以下代码块展示了一部分输出(你可能想将它通过less
等分页工具输出,以便轻松阅读):
图 2.2 – Ansible 内置帮助输出示例
-
我们可以从前面的代码中举一个例子,基于之前使用
ansible
命令的经验进行扩展;到目前为止,我们几乎只使用它来运行带有-m
和-a
参数的临时任务。然而,ansible
还可以执行一些有用的任务,比如告诉我们关于库存中某个组的主机信息。我们可以通过本章早些时候使用的production-inventory
文件来探讨这一点:$ ansible -i production-inventory --list-host appservers_emea_zone
当你运行这个命令时,你应该会看到appservers_emea_zone
库存组的成员列出。虽然这个例子可能有些牵强,但当你开始使用动态库存文件,并且不能再直接通过cat
命令将库存文件内容输出到终端时,这个例子非常有价值:
$ ansible -i production-inventory --list-host appservers_emea_zone
hosts (2):
appserver1-emea.example.com
ansible-playbook executable file, too. We have already seen a few of these in the previous examples of this book and there’s more that we can do. For example, earlier, we discussed the use of ssh-agent to manage multiple SSH authentication keys. While this makes running playbooks simple (as you don’t have to pass any authentication parameters to Ansible), it is not the only way of doing this. You can use one of the command-line arguments for ansible-playbook to specify the private SSH key file, instead, as follows:
$ ansible-playbook -i production-inventory site.yml --private-key ~/keys/id_rsa
Similarly, in the preceding section, we specified the `remote_user` variable for Ansible to connect with in the playbook. However, command-line arguments can also set this parameter for the playbook; so, rather than editing the `remote_user` line in the playbook, we could remove it altogether and instead run it using the following command-line string:
$ ansible-playbook -i production-inventory site.yml --user james
The ultimate aim of Ansible is to make your life simpler and to complete mundane day-to-day tasks for you. As a result, there is no right or wrong way to do this—you can specify your private SSH key using a command-line argument or make it available using `ssh-agent`. Similarly, you can put the `remote_user` line in your playbook or use the `--user` parameter on the command line. Ultimately, the choice is yours, but it is important to consider that if you are distributing a playbook to multiple users and they all have to remember to specify the remote user on the command line, will they actually remember to do it? What will the consequences be if they don’t? If the `remote_user` line is present in the playbook, will that make their lives easier and be less prone to error because the user account has been set in the playbook itself?
As with the configuration of Ansible, you will use a small handful of the command-line arguments frequently and there will be many that you may never touch. The important thing is that you know they are there and how to find out about them, and you can make informed decisions about when to use them. Let’s proceed to the next section, where we will look in more detail at ad hoc commands with Ansible.
Understanding ad hoc commands
We have already seen a handful of ad hoc commands so far in this book, but to recap, they are single tasks you can run with Ansible, making use of Ansible modules without the need to create or save playbooks. They are very useful for performing quick, one-off tasks on a number of remote machines or for testing and understanding the behavior of the Ansible modules that you intend to use in your playbooks. They are both a great learning tool and a quick and dirty (because you never document your work with a playbook) automation solution.
As with every Ansible example, we need an inventory to run against. Let’s reuse our `production-inventory` file from before:
[frontends_na_zone]
frontend1-na.example.com
frontend2-na.example.com
[frontends_emea_zone]
frontend1-emea.example.com
frontend2-emea.example.com
[appservers_na_zone]
appserver1-na.example.com
appserver2-na.example.com
[appservers_emea_zone]
appserver1-emea.example.com
appserver2-emea.example.com
Now, let’s start with perhaps the quickest and dirtiest of ad hoc commands—running a raw shell command on a group of remote machines. Suppose that you want to check that the date and time of all the frontend servers in EMEA are in sync—you could do this by using a monitoring tool or by manually logging in to each server in turn and checking the date and time. However, you can also use an Ansible ad hoc command:
1. Run the following ad hoc command to retrieve the current date and time from all of the `frontends_emea_zone` servers:
```
$ ansible -i production-inventory frontends_emea_zone -a /usr/bin/date
```
You will see that Ansible faithfully logs in to each machine in turn and runs the `date` command, returning the current date and time. Your output will look something like the following:
$ ansible -i production-inventory frontends_emea_zone -a /usr/bin/date
frontend2-emea.example.com | 已更改 | rc=0 >>
星期二 4 月 25 14:26:30 UTC 2023
frontend1-emea.example.com | 已更改 | rc=0 >>
星期二 4 月 25 14:26:30 UTC 2023
1. This command is run with the user account you are logged in to when the command is run. You can use a command-line argument (discussed in the previous section) to run it as a different user:
```
$ ansible -i production-inventory frontends_emea_zone -a /usr/sbin/pvs -u james
frontend2-emea.example.com | 失败 | rc=5 >>
警告:以非根用户身份运行,功能可能不可用。
/run/lock/lvm/P_global:aux: 打开失败:权限被拒绝,非零返回代码
frontend1-emea.example.com | 失败 | rc=5 >>
警告:以非根用户身份运行,功能可能不可用。
james 用户账户没有执行 pvs 命令所需的权限。然而,我们可以通过添加 --become 命令行参数来解决这个问题,告诉 Ansible 在远程系统上以 root 身份执行:
```
$ ansible -i production-inventory frontends_emea_zone -a /usr/sbin/pvs -u james --become
frontend2-emea.example.com | FAILED | rc=-1 >>
Missing sudo password
frontend1-emea.example.com | FAILED | rc=-1 >>
james is in /etc/sudoers, it is not allowed to run commands as root without entering a sudo password. Luckily, there’s a switch to get Ansible to prompt us for this at runtime, meaning we don’t need to edit our /etc/sudoers file:
```
$ ansible -i production-inventory frontends_emea_zone -a /usr/sbin/pvs -u james --become --ask-become-pass
BECOME 密码:
frontend2-emea.example.com | 已更改 | rc=0 >>
PV VG Fmt Attr PSize PFree
/dev/vdb1 vg_data lvm2 a-- <8.00g <8.00g
frontend1-emea.example.com | 已更改 | rc=0 >>
PV VG Fmt Attr PSize PFree
-m 命令行参数,Ansible 假定您要使用 ansible.builtin.command 模块(请参见 https://docs.ansible.com/ansible/latest/modules/command_module.xhtml)。如果您希望使用特定模块,可以在命令行参数中添加 -m 开关,然后在 -a 开关下指定模块参数,如以下示例所示:
```
$ ansible -i production-inventory frontends_emea_zone -m ansible.builtin.copy -a "src=/etc/hosts dest=/tmp/hosts"
frontend2-emea.example.com | CHANGED => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": true,
"checksum": "02b369653b5171fe264d0ac91c331531d04f375d",
"dest": "/tmp/hosts",
"gid": 1001,
"group": "james",
"md5sum": "cd9654dbef8a534df2557531038e03a7",
"mode": "0664",
"owner": "james",
"size": 427,
"src": "/home/james/.ansible/tmp/ansible-tmp-1682433595.1356337-8575-192421314639198/source",
"state": "file",
"uid": 1001
}
frontend1-emea.example.com | CHANGED => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": true,
"checksum": "02b369653b5171fe264d0ac91c331531d04f375d",
"dest": "/tmp/hosts",
"gid": 1001,
"group": "james",
"md5sum": "cd9654dbef8a534df2557531038e03a7",
"mode": "0664",
"owner": "james",
"size": 427,
"src": "/home/james/.ansible/tmp/ansible-tmp-1682433595.1226285-8574-79002739194384/source",
"state": "file",
"uid": 1001
}
```
```
```
```
The preceding output not only shows that the copy was performed successfully to both hosts but also all the output values from the `ansible.builtin.copy` module. This, again, can be very helpful later when you are developing playbooks as it enables you to understand exactly how the module works and what output it produces in cases where you need to perform further work with that output. As we progress through this book, this will make more sense, so don’t worry right now if it doesn’t make sense.
You will also note that all arguments passed to the module must be enclosed in quotation marks (`"`). All arguments are specified as `key=value` pairs and no spaces should be added between `key` and `value` (for example, `key = value` is not acceptable). If you need to place quotation marks around one of your argument values, you can escape them using the backslash character (for example, `-a "src=/etc/yum.conf` `dest=\"/tmp/yum file.conf\""`).
All examples we have performed so far are very quick to execute and run, but this is not always the case with computing tasks. When you have to run an operation for a long time—say, more than two hours—you should consider running it as a background process. In this instance, you can run the command asynchronously and confirm the result of that execution later.
For example, to execute `sleep 2h` asynchronously in the background with a timeout of 7,200 seconds (`-B`) and without polling (`-P`), use this command:
$ ansible -i production-inventory frontends_emea_zone -B 7200 -P 0 -a "sleep 2h"
frontend1-emea.example.com | 已更改 => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"ansible_job_id": "809218129417.1291",
"changed": true,
"finished": 0,
"results_file": "/home/james/.ansible_async/809218129417.1291",
"started": 1
}
frontend2-emea.example.com | 已更改 => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"ansible_job_id": "772164843356.1273",
"changed": true,
"finished": 0,
"results_file": "/home/james/.ansible_async/772164843356.1273",
"started": 1
}
Note that the output from this command gives a unique job ID for each task on each host. Let’s now say that we want to see how this task proceeds on the second frontend server. Simply issue the following command from your Ansible control machine:
$ ansible -i production-inventory frontend2-emea.example.com -m ansible.builtin.async_status -a "jid=772164843356.1273"
frontend2-emea.example.com | 成功 => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"ansible_job_id": "772164843356.1273",
"changed": false,
"finished": 0,
"results_file": "/home/james/.ansible_async/772164843356.1273",
"started": 1,
"stderr": "",
"stderr_lines": [],
"stdout": "",
"stdout_lines": []
}
Here, we can see that the job has started but not finished. If we now kill the `sleep` command that we issued and check on the status again, we can see the following:
$ ansible -i production-inventory frontend2-emea.example.com -a "pkill sleep"
frontend2-emea.example.com | 已更改 | rc=0 >>
james@controlnode:~/code/chapter02$ ansible -i production-inventory frontend2-emea.example.com -m ansible.builtin.async_status -a "jid=772164843356.1273"
frontend2-emea.example.com | 失败! => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"ansible_job_id": "772164843356.1273",
"changed": true,
"cmd": [
"sleep",
"2h"
],
"delta": "0:02:06.526826",
"end": "2023-04-25 14:45:13.024199",
"finished": 1,
"msg": "非零返回码",
"rc": -15,
"results_file": "/home/james/.ansible_async/772164843356.1273",
"start": "2023-04-25 14:43:06.497373",
"started": 1,
"stderr": "",
"stderr_lines": [],
"stdout": "",
"stdout_lines": []
}
Here, we see a `FAILED` status result because the `sleep` command was killed; it did not exit cleanly and returned a `-15` code (see the `rc` parameter). When it was killed, no output was sent to either `stdout` or `stderr`, but if it had been, Ansible would have captured it and displayed it in the preceding code, which would aid you in debugging the failure. Lots of other useful information is included, including how long the task actually ran for, the end time, and so on. Similarly, useful output is also returned when the task exits cleanly.
That concludes our look at ad hoc commands in Ansible. By now, you should have a fairly solid grasp of the fundamentals of Ansible, but there’s one important thing we haven’t looked at yet, even though we briefly touched on it—variables and how to define them. We’ll proceed to look at this in the next section.
Defining variables
In this section, we will explore the topic of variables and how they can be defined in Ansible. You will learn how variables should be defined step by step and understand how to work with them in Ansible.
Although automation removes much of the repetition from previously manual tasks, not every single system is identical. If two systems differ in some minor way, you could write two unique playbooks—one for each system. However, this would be inefficient and wasteful, as well as difficult to manage as time goes on (for example, if the code in one playbook is changed, how can you ensure that it is updated in the second variant?).
Equally, you might need to use a value from one system in another—perhaps you need to obtain the hostname of a database server and make it available to another. All of these issues can be addressed with variables as they allow the same automation code to run with parameter variations, as well as values to pass from one system to another (although this must be handled with some care).
Let’s get started with a practical look at defining variables in Ansible.
Variables in Ansible should have well-formatted names that adhere to the following rules:
* The name of the variable must only include letters, underscores, and numbers. Spaces are not allowed.
* The name of the variable can only begin with a letter—it can contain numbers but cannot start with one.
For example, the following are good variable names:
* `external_svc_port`
* `internal_hostname_ap1`
The following examples are all invalid, however, and cannot be used (if you are familiar with Python, you will know that these restrictions also apply to Python code, which Ansible, being written in Python, inherits this from):
* `appserver-zone-na`
* `cache` `server ip`
* `dbms.server.port`
* `01appserver`
As discussed in the *Learning the YAML syntax* section, variables can be defined in a dictionary structure, such as the following. All values are declared in key-value pairs:
region:
east: app
west: frontend
central: cache
In order to retrieve a specific field from the preceding dictionary structure, you can use either one of the following notations:
括号表示法
region['east']
点符号表示法
region.east
There are some exceptions to this; for example, you should use bracket notation if the variable name starts and ends with two underscores (for example, `__variable__`) or contains known public attributes (or even variables that are generated during the playbook run), such as the following:
* `as_integer_ratio`
* `symmetric_difference`
You can find more information on this at https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.xhtml#creating-valid-variable-names.
This dictionary structure is valuable when defining host variables. Although earlier in this chapter we worked with a fictional set of employee records defined as an Ansible `variables` file, you could use this to specify something, such as some `redis` server parameters:
redis:
- server: cacheserver01.example.com
port: 6379
slaveof: cacheserver02.example.com
These could then be applied through your playbook and one common playbook could be used for all `redis` servers, regardless of their configuration, as changeable parameters such as the `port` and `master` servers are all contained in the variables.
You can also pass set variables directly in a playbook, and even pass them to roles that you call. For example, the following playbook code calls four hypothetical roles and each assigns a different value to the `username` variable for each one. These roles could be used to set up various administration roles on a server (or multiple servers), with each passing a changing list of usernames as people come and go from the company:
roles:
- role: dbms_admin
vars:
username: James
- role: system_admin
vars:
username: John
- role: security_amdin
vars:
username: Rock
- role: app_admin
vars:
username: Daniel
To access variables from within a playbook, you simply place the variable name inside quoted pairs of curly braces. Consider the following example playbook (based loosely on our previous `redis` example):
- name: 显示 redis 变量
hosts: all
vars:
redis:
server: cacheserver01.example.com
port: 6379
slaveof: cacheserver02.example.com
tasks:
- name: 显示 redis 端口
ansible.builtin.debug:
msg: "redis 服务器 {{ redis.server }} 的端口是 {{ redis.port }}"
Here, we define a variable in the playbook itself called `redis`. This variable is a dictionary, containing a number of parameters that might be important for our server. To access the contents of these variables, we use pairs of curly braces around them (as described previously) and the entire string is encased in quotation marks, which means we don’t have to individually quote the variables. If you run the playbook on a local machine, you should see an output that looks as follows:
$ ansible-playbook -i localhost, -c local redis-playbook.yml
PLAY [显示 redis 变量] **********************************************************
TASK [收集事实] **********************************************************
ok: [localhost]
TASK [显示 redis 端口] **********************************************************
ok: [localhost] => {
"msg": "cacheserver01.example.com 的 redis 端口是 6379"
}
PLAY RECAP **********************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Although we are accessing these variables here to print them in a debug message, you could use the same curly brace notation to assign them to module parameters, or for any other purpose that your playbook requires them for.
Also note the special inventory format we used here:
* `-i localhost,` – This tells `ansible-playbook` to read a list of literal hostnames from the command line, removing the need to define an inventory file.
* `-c local` – This replaces the need to set up SSH authentication with the local host, by simply using the `local` connection mechanism. This is incredibly useful when running commands on the local machine itself.
Ansible, just like many languages, has specially reserved variables that take on particular meanings in playbooks. In Ansible, these are known as magic variables and you can find a full list of them at [`docs.ansible.com/ansible/latest/reference_appendices/special_variables.xhtml`](https://docs.ansible.com/ansible/latest/reference_appendices/special_variables.xhtml). Needless to say, you should not attempt to use any magic variable names for your own variables. Some common magic variables you might come across are as follows:
* `inventory_hostname`: The hostname for the current host that is iterated over in the play
* `groups`: A dictionary of the host groups in the inventory, along with the host membership of each group
* `group_names`: A list of the groups the current host (specified by `inventory_hostname`) is a part of
* `hostvars`: A dictionary of all the hosts in the inventory and the variables assigned to each of them
For example, the host variables for all the hosts can be accessed at any point in the playbook using `hostvars`, even if you are only operating on one particular host. Magic variables are surprisingly useful in playbooks and you will rapidly start to find yourself using them, so it is important to be aware of their existence.
You should also note that you can specify Ansible variables in multiple locations. Ansible has a strict order of variable precedence and you can take advantage of this by setting default values for variables in a place that has low precedence and then overriding them later in the play. This is useful for a variety of reasons, especially where an undefined variable could cause havoc when a playbook is run (or even when the playbook would fail as a result of this). We have not yet discussed all of the places that variables can be stored, so the full list of variable precedence order is not given here.
In addition, it can change between Ansible releases, so it is important that you refer to the documentation when working with and understanding variable precedence—go to [`docs.ansible.com/ansible/latest/user_guide/playbooks_variables.xhtml#variable-precedence-where-should-i-put-a-variable`](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.xhtml#variable-precedence-where-should-i-put-a-variable) for more information.
That concludes our brief overview of variables in Ansible, although we will see them used again in later examples in this book. Let’s now round off this chapter with a look at Jinja2 filters, which add a whole world of power to your variable definitions.
Understanding Jinja2 filters
As Ansible is written in Python, it inherits an incredibly useful and powerful templating engine called Jinja2\. We will look at the concept of templating later in this book, so for now, we will focus on one particular aspect of Jinja2 known as filtering. Jinja2 filters provide an incredibly powerful framework that you can use to manipulate and transform your data. Perhaps you have a string that you need to convert into lowercase, for example—you could apply a Jinja2 filter to achieve this. You can also use it to perform pattern matching, search and replace operations, and much more. There are many hundreds of filters for you to work with, and in this section, we hope to empower you with a basic understanding of Jinja2 filters and some practical knowledge about how to apply them, as well as showing you where to get more information about them if you wish to explore the subject further.
It is worth noting that Jinja2 operations are performed on the Ansible control node and only the results of the filter operation are sent to the remote hosts. This is done by design, both for consistency and to reduce the workload on the individual nodes as much as possible.
Let’s explore this through a practical example. Suppose we have a YAML file containing some data that we want to parse. We can quite easily read a file from the machine filesystem and capture the result using the `register` keyword (`register` captures the result of the task and stores it in a variable—in the case of running the `ansible.builtin.shell` module, it captures all the output from the command that was run).
Our YAML data file might look as follows:
tags:
- key: job
value: developer
- key: language
value: java
Now, we could create a playbook to read this file and register the result, but how can we actually turn it into a variable structure that Ansible can understand and work with? Let’s consider the following playbook:
- name: Jinja2 过滤演示 1
hosts: localhost
tasks:
- name: 复制示例数据到 /tmp
ansible.builtin.copy:
src: multiple-document-strings.yaml
dest: /tmp/multiple-document-strings.yaml
- name: 读取示例数据到变量
ansible.builtin.shell: cat /tmp/multiple-document-strings.yaml
register: result
- name: 打印调试信息中的过滤输出
ansible.builtin.debug:
msg: '{{ item }}'
loop: '{{ result.stdout | from_yaml_all | list }}'
The `ansible.builtin.shell` module does not necessarily run from the directory that the playbook is stored in, so we cannot guarantee that it will find our `multiple-document-strings.yaml` file even when it is in the same directory as the playbook. The `ansible.builtin.copy` module does, however, source files from the current (playbook) directory, so it is useful to use it to copy it to a known location (such as `/tmp`) for the `ansible.builtin.shell` module to read the file from. The `ansible.builtin.debug` module is then run in a `loop`. The `loop` is used to iterate over all of the lines of `stdout` from the `ansible.builtin.shell` command, applying two Jinja2 filters—`from_yaml_all` and `list` to the output line by line.
The `from_yaml_all` filter parses the source document lines as YAML and then the `list` filter converts the parsed data into a valid Ansible list. If we run the playbook, we should see Ansible’s representation of the data structure from within our original file:
PLAY [Jinja2 过滤演示 1] ************************************************************
TASK [收集事实] ************************************************************
ok: [localhost]
TASK [复制示例数据到 /tmp] ***********************************************************
changed: [localhost]
TASK [读取示例数据到变量] ***********************************************************
changed: [localhost]
TASK [打印调试信息中的过滤输出] ***********************************************************
ok: [localhost] => (item={'tags': [{'key': 'job', 'value': 'developer'}, {'key': 'language', 'value': 'java'}]}) => {
"msg": {
"tags": [
{
"key": "job",
"value": "developer"
},
{
"key": "language",
"value": "java"
}
]
}
}
执行回顾 ***************************************************
localhost : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
As you can see, we have generated a list of dictionaries that in themselves contain the key-value pairs.
If this data structure was already stored in our playbook, we could take this one step further and use the `items2dict` filter to turn the list into true `key: value` pairs, removing the `key` and `value` items from the data structure. For example, consider this second playbook:
- name: Jinja2 过滤演示 2
hosts: localhost
vars:
tags:
- key: job
value: developer
- key: language
value: java
tasks:
- name: 通过 items2dict 过滤标签变量
ansible.builtin.debug:
msg: '{{ tags | items2dict }}'
Now, if we run this, we can see that our data is converted into a nice, neat set of `key:` `value` pairs:
$ ansible-playbook -i localhost, -c local jinja-filtering2.yml
[警告]: 发现使用保留名称的变量: tags
执行 [Jinja2 过滤演示 2] ***********************************************************
任务 [收集事实] ***************************************************
ok: [localhost]
任务 [通过 items2dict 过滤标签变量] ***********************************************************
ok: [localhost] => {
"msg": {
"job": "developer",
"language": "java"
}
}
执行回顾 ***************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Observe the warning at the top of the playbook. Ansible displays a warning if you attempt to use a reserved name for a variable, as we did here. Normally, you should not create a variable with a reserved name, but the example here demonstrates both how the filter works and how Ansible will attempt to warn you if you do something that might cause problems.
Earlier in this section, we used the `ansible.builtin.shell` module to read a file and used `register` to store the result in a variable. This is perfectly fine, if a little inelegant. Jinja2 contains a series of `lookup` filters that, among other things, can read the contents of a given file. Let’s examine the behavior of the following playbook:
- name: Jinja2 过滤演示 3
hosts: localhost
vars:
ping_value: "{{ lookup('file', '/etc/hosts') }}"
tasks:
- name: 显示从文件查找中获取的值
ansible.builtin.debug:
msg: "ping 值是 {{ ping_value }}"
When we run this, we can see that Ansible has captured the contents of the `/etc/hosts` file for us, without us needing to resort to the `ansible.builtin.copy` and `ansible.builtin.shell` modules as we did earlier:
$ ansible-playbook -i localhost, -c local jinja-filtering3.yml
执行 [Jinja2 过滤演示 3] ************************************************************
任务 [收集事实] ***************************************************
ok: [localhost]
任务 [显示从文件查找中获取的值] ***********************************************************
ok: [localhost] => {
"msg": "ping 值是 127.0.0.1 localhost\n\n# 以下行适用于支持 IPv6 的主机\n::1 ip6-localhost ip6-loopback\nfe00::0 ip6-localnet\nff00::0 ip6-mcastprefix\nff02::1 ip6-allnodes\nff02::2 ip6-allrouters\nff02::3 ip6-allhosts\n\n10.0.50.30 frontend1-na.example.com frontend1-emea.example.com\n10.0.50.31 frontend2-na.example.com frontend2-emea.example.com\n\n10.0.50.40 appserver1-emea.example.com\n10.0.50.41 appserver2-emea.example.com"
}
执行回顾 **********************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
There are many other filters that you might be interested in exploring and a full list can be found in the official Jinja2 documentation ([`jinja.palletsprojects.com/en/3.1.x/`](https://jinja.palletsprojects.com/en/3.1.x/)). The following are a handful of other examples that will give you an idea of the kinds of things that Jinja2 filters can achieve for you, from quoting strings to concatenating lists, to obtaining useful path information for a file:
在 shell 中添加一些引号
- shell: echo {{ string_value | quote }}
将列表连接成特定字符串
{{ list | join("$") }}
获取特定文件路径的文件名
{{ path | basename }}
从完整路径中获取目录
{{ path | dirname }}
获取特定 Windows 路径的目录
{{ path | win_dirname }}
That concludes our look at Jinja2 filtering. It is a massive topic that deserves a book all to itself, but, as ever, I hope that this practical guide has given you some pointers on how to get started and where to find information.
Summary
Ansible is a very powerful and versatile automation engine that can be used for a wide variety of tasks. Understanding the basics of how to work with it is of paramount importance, before addressing the more complex challenges of playbook creation and large-scale automation. Ansible relies on a language called YAML, a simple-to-read (and write) syntax that supports the rapid development of easy-to-read and easy-to-maintain code and inherits a number of valuable features from the Python language that it is written in, including Jinja2 filtering.
In this chapter, you learned the fundamentals of working with various Ansible programs. You then learned about the YAML syntax and the ways that you can break down your code into manageable chunks to make it easier to read and maintain. We explored the use of ad hoc commands in Ansible, variable definition and structure, and how to make use of Jinja2 filters to manipulate the data in your playbooks.
In the next chapter, we will take a more in-depth look at Ansible inventories and explore some of the more advanced concepts of working with them that you may find useful.
Questions
1. Which component of Ansible allows you to define a block to execute task groups as a play?
1. `handler`
2. `service`
3. `hosts`
4. `tasks`
5. `name`
2. Which basic syntax from the YAML format do you use to start a file?
1. `###`
2. `---`
3. `%%%`
4. `===`
5. `***`
3. True or false – in order to interpret and transform output data in Ansible, you need to use Jinja2 templates.
1. True
2. False
Further reading
To find out about more configuration variables, go to [`docs.ansible.com/ansible/latest/reference_appendices/config.xhtml#ansible-configuration-settings`](https://docs.ansible.com/ansible/latest/reference_appendices/config.xhtml#ansible-configuration-settings).
第三章:定义你的清单
正如我们在前两章中讨论的那样,Ansible 在你告诉它负责哪些主机之前,什么也做不了。当然,这是合乎逻辑的——你不会希望任何自动化工具,无论它多么容易使用和设置,都能随便控制你网络中的每一台设备。因此,至少,你必须告诉 Ansible 它将在哪些主机上执行任务,这从最基本的角度来说,就是清单的作用。
然而,清单不仅仅是自动化目标的列表。Ansible 清单可以以多种格式提供;它们可以是静态的,也可以是动态的,并且可以包含定义 Ansible 如何与每个主机(或主机组)交互的重要变量。因此,它们值得单独成章,在本章中,我们将实际探讨清单以及如何在使用 Ansible 自动化基础设施时充分利用它们。
本章将涵盖以下主题:
-
创建清单文件并添加主机
-
生成动态清单文件
-
使用模式进行特殊的主机管理
技术要求
本章假设你已经根据第一章《与 Ansible 入门》的详细说明,设置了你的控制主机,并且你正在使用最新版本——本章中的示例是在 Ansible 8.0 和 ansible-core
2.15.0 上测试的。本章还假设你至少有一个额外的主机来进行测试,这个主机应该是基于 Linux 的。虽然我们将在本章中给出主机名的具体示例,但你可以自由地将它们替换为你自己的主机名和/或 IP 地址,相关的替换方法将在适当的地方提供。
本章的代码包可以在这里找到:github.com/PacktPublishing/Practical-Ansible-Second-Edition/tree/main/Chapter%203
。
创建清单文件并添加主机
每当你在 Ansible 中看到“创建清单”的提法时,你通常可以放心地假设它是一个静态清单。Ansible 支持两种类型的清单——静态清单和动态清单——我们将在本章后面介绍后者。静态清单本质上是静态的;除非人为编辑,否则它们不会改变。当你刚开始使用并测试 Ansible 时,这种方式非常适用,因为它提供了一种非常快速简便的方式让你快速启动并运行。即使在小型封闭环境中,静态清单也是管理环境的一个很好的方式,特别是在基础设施变化不频繁的情况下。
大多数 Ansible 安装会在/etc/ansible/hosts
中寻找默认的库存文件(尽管这个路径可以在 Ansible 配置文件中进行配置,正如在第二章中讨论的,理解 Ansible 基础知识)。你可以选择填充这个文件,或者为每次 playbook 运行提供自己的库存,通常情况下,库存会与 playbook 一起提供——毕竟,当你开始用变量和分组来构建它们时,它们就变成了代码资产,就像你的 playbook 和角色一样,因此将它们与其他自动化代码一起提交到源代码管理中是很有意义的。正如你在本书的前几章中看到的那样,如果不使用默认库存,Ansible 命令使用-i
标志来指定库存文件的位置。我们在本书中已经看到过这些示例,作为复习,它可能看起来像以下示例:
$ ansible -i /home/cloud-user/inventory all -m ansible.builtin.ping
你遇到的大多数静态库存文件都是以 INI 格式创建的,尽管需要注意的是,其他格式也是支持的。你在 INI 格式文件之后最常见的格式是 YAML 格式——关于你可以使用的库存文件类型的更多细节可以在这里找到:docs.ansible.com/ansible/latest/user_guide/intro_inventory.xhtml
。
在本章中,我们将提供一些 INI 和 YAML 格式的库存文件示例供你参考,因为你必须了解这两者。就我个人而言,我已经使用 Ansible 很多年,只与 INI 格式文件或动态库存文件打过交道,但他们说知识就是力量,因此了解这两种格式对你毫无害处。
让我们从创建一个静态库存文件开始。这个库存文件将与默认库存分开。
使用以下 INI 格式的代码在/etc/ansible/my_inventory
中创建一个库存文件:
target1.example.org ansible_host=192.168.81.142 ansible_port=3333
target2.example.org ansible_port=3333 ansible_user=james
target3.example.org ansible_host=192.168.81.143 ansible_port=5555
库存主机之间的空行并非必需——它们只是为了让本书中的库存更加易读而插入的。这个库存文件非常简单,没有包含任何分组;然而,在引用库存时,你仍然可以通过特殊的all
组来引用所有主机,无论你如何格式化和划分库存文件,该组都会被隐式定义。
前述文件中的每一行包含一个库存主机。第一列包含 Ansible 将使用的库存主机名(可以通过我们在第二章中讨论的inventory_hostname
魔法变量访问)。此行之后的所有参数都是分配给该主机的变量。这些变量可以是用户定义的变量或特殊的 Ansible 变量,正如我们在此设置的那样。
有很多这样的变量,但前面的示例特别包含了以下内容:
-
ansible_host
:如果无法直接访问清单中的主机名——例如,因为它不在 DNS 中——这个变量包含 Ansible 将连接的主机名或 IP 地址 -
ansible_port
:默认情况下,Ansible 会尝试通过端口22
进行所有的 SSH 通信——如果你有一个在其他端口上运行的 SSH 守护进程,你可以通过这个变量告诉 Ansible -
ansible_user
:默认情况下,Ansible 将尝试使用你运行 Ansible 命令时的当前用户账户连接到远程主机——你可以通过几种方式覆盖这一点,这就是其中之一
因此,前面的三台主机可以总结如下:
-
target1.example.org
主机应该通过192.168.81.142
IP 地址、端口3333
连接 -
target2.example.org
主机也应该通过端口3333
连接,但这次使用james
用户,而不是运行 Ansible 命令的账户 -
应该通过
192.168.81.143
IP 地址、端口5555
来连接target3.example.org
主机
通过这种方式,即使没有其他的构造,你也能开始看到静态 INI 格式清单的强大功能。
现在,如果你想创建与前面相同的清单,但这次将其格式化为 YAML,你可以按如下方式指定它(我们将此文件命名为my_inventory.yaml
):
---
ungrouped:
hosts:
target1.example.org:
ansible_host: 192.168.81.142
ansible_port: 3333
target2.example.org:
ansible_port: 3333
ansible_user: james
target3.example.org:
ansible_host: 192.168.81.143
ansible_port: 5555
你可能会遇到包含如ansible_ssh_port
、ansible_ssh_host
和ansible_ssh_user
等参数的清单文件示例——这些变量名(以及类似的变量名)是在 Ansible 2.0 版本之前使用的。尽管许多这些参数保持了向后兼容性,但你应该尽量更新它们,因为这种兼容性可能会在未来某个时刻被移除。
现在,如果你使用一个简单的ansible.builtin.shell
命令在 Ansible 中运行前面的清单,结果将如下所示:
$ ansible -i /etc/ansible/my_inventory.yaml all -m ansible.builtin.shell -a 'echo hello-yaml' -f 5
target1.example.org | CHANGED | rc=0 >>
hello-yaml
target2.example.org | CHANGED | rc=0 >>
hello-yaml
target3.example.org | CHANGED | rc=0 >>
hello-yaml
这涵盖了创建一个简单静态清单文件的基础。接下来,让我们通过在这一章节的下一部分中向清单添加主机组来扩展这一点。
使用主机组
很少有一个 playbook 可以适用于整个基础架构,虽然很容易告诉 Ansible 为不同的 playbook 使用一个替代的清单,但这很快就会变得非常混乱,可能会有成百上千个小的清单文件散布在你的网络中。你可以想象,这样会很快变得无法管理,而 Ansible 本应让事情变得更易管理,而不是相反。一个可能的简单解决方案是开始在清单中添加主机组。
假设你有一个简单的三层 Web 架构,每一层有多个主机以实现高可用性和/或负载均衡。这个架构中的三层可能如下:
-
前端服务器
-
应用服务器
-
数据库服务器
在设置好架构之后,我们开始为其创建清单,再次混合使用 YAML 和 INI 格式,以便让你在两者之间获得实践经验。为了保持示例的简洁明了,我们假设你可以通过完全限定域名(FQDNs)访问所有服务器,因此在这些清单文件中不会添加任何主机变量。当然,你完全可以这么做,每个示例都是不同的。
首先,让我们使用 INI 格式为三层前端创建清单。我们将这个文件命名为 hostsgroups-ini
,该文件的内容应该如下所示:
loadbalancer.example.org
[frontends]
web01.example.org
web02.example.org
[apps]
app01.example.org
app02.example.org
[databases]
db01.example.org
db02.example.org
在前面的清单中,我们创建了三个组,分别叫做 frontends
、apps
和 databases
。请注意,在 INI 格式的清单中,组名位于方括号内。每个组名下列出属于该组的服务器名称,因此前面的示例显示了每个组中有两个服务器。请注意最上面的例外,loadbalancer.example.org
——这个主机没有属于任何组。所有未分组的主机必须放在 INI 格式文件的最上方。
在我们进一步讨论之前,值得注意的是,清单还可以包含“组的组”,这对于通过不同部门处理某些任务非常有用。前面的清单是独立存在的,但如果我们的前端服务器运行在 Ubuntu 上,而应用和数据库服务器运行在 Fedora 上呢?在处理这些主机时会有一些根本性的差异——例如,我们可能会使用 ansible.builtin.apt
模块来管理 Ubuntu 上的包,而在 Fedora 上使用 ansible.builtin.dnf
模块。
当然,我们也可以通过收集每个主机的事实来处理这种情况,因为这些事实将包含操作系统的详细信息。我们还可以创建清单的新版本,如下所示:
loadbalancer.example.org
[frontends]
web01.example.org
web02.example.org
[apps]
app01.example.org
app02.example.org
[databases]
db01.example.org
db02.example.org
[fedora:children]
apps
databases
[ubuntu:children]
frontends
在组定义中使用 children
关键字(在方括号内),我们可以创建“组的组”;因此,我们可以进行巧妙的分组,以帮助我们的剧本设计,而无需多次指定每个主机。
这种 INI 格式的结构非常易读,但当它转换为 YAML 格式时,需要一些适应。接下来的代码显示了前面清单的 YAML 版本——就 Ansible 来说,这两者是相同的,但最终由你决定使用哪种格式。
all:
hosts:
loadbalancer.example.org:
children:
fedora:
children:
apps:
hosts:
app01.example.org:
app02.example.org:
databases:
hosts:
db01.example.org:
db02.example.org:
ubuntu:
children:
frontends:
hosts:
web01.example.org:
web02.example.org:
你可以看到,children
关键字仍然在 YAML 格式的清单中使用,但现在结构比 INI 格式更具层次性。缩进可能更容易跟随,但请注意,主机最终是在相当高的缩进级别下定义的——根据你希望的方式,这种格式可能更难扩展。
当你想使用前面清单中的任何组时,你只需在剧本或命令行中引用它。扩展我们之前的示例,我们可以运行以下命令:
$ ansible -i hostgroups-yaml all -m ansible.builtin.shell -a 'echo hello-yaml' -f 5
注意那行中的 all
关键字。那是所有库存中隐含的特殊 all
组,并且在你之前的 YAML 示例中已明确提及。如果我们想运行相同的命令,但这次只对来自之前 YAML 库存的 fedora
组主机运行,我们将运行以下命令的变体:
$ ansible -i hostgroups-yaml fedora -m ansible.builtin.shell -a 'echo hello-yaml' -f 5
app01.example.org | CHANGED | rc=0 >>
hello-yaml
app02.example.org | CHANGED | rc=0 >>
hello-yaml
db01.example.org | CHANGED | rc=0 >>
hello-yaml
db02.example.org | CHANGED | rc=0 >>
hello-yaml
如你所见,这是一种强大的方式,可以管理你的库存,并使得只对你想要的主机运行命令变得轻松。创建多个组的可能性使得管理变得简单,尤其是当你想在不同的服务器组上运行不同的任务时。
顺便提一下,在开发库存时,值得注意的是,有一种快捷的简写表示法可以用来创建多个主机。假设你有 100 台应用服务器,它们的名称按顺序排列,如下所示:
[apps]
app01.example.org
app02.example.org
...
app99.example.org
app100.example.org
这是完全可能的,但手动创建将是繁琐且容易出错的,并且会产生一些非常难以阅读和解释的库存文件。幸运的是,Ansible 提供了一种快捷的简写表示法来实现这一点,以下的库存片段实际上生成了一个与我们手动创建的相同的包含 100 台应用服务器的库存:
[apps]
app[01:100].example.org
也可以使用字母范围和数字范围——扩展我们的示例,添加一些缓存服务器,你可能会有如下所示的内容:
[cache]
cache-[a:e].example.org
这与手动创建以下内容相同:
[cache]
cache-a.example.org
cache-b.example.org
cache-c.example.org
cache-d.example.org
cache-e.example.org
现在我们已经完成了对各种静态库存格式以及如何创建组(甚至子组)的探索,在下一部分,让我们扩展一下之前对主机变量的简要介绍。
向库存添加主机和组变量
我们已经触及了主机变量——我们在本章早些时候看到它们时,用它们覆盖了连接详细信息,如连接的用户账户、连接的地址和使用的端口。但是,使用 Ansible 和库存变量,你可以做的事情远不止这些,值得注意的是,它们不仅可以在主机级别定义,还可以在组级别定义,这再次为你提供了极其强大的方式来有效管理一个集中库存中的基础设施。
让我们在之前的三层示例基础上构建,并假设我们需要为每个前端服务器设置两个变量。这些不是特殊的 Ansible 变量,而是我们完全自己选择的变量,我们将在稍后运行这些变量的 playbook 中使用。假设这些变量如下:
-
https_port
,它定义了前端代理应该监听的端口 -
lb_vip
,它定义了前端服务器前负载均衡器的 FQDN
让我们看看这是如何完成的:
-
我们可以像之前使用 Ansible 连接变量一样,简单地将它们添加到
frontends
部分的每个主机中。在这种情况下,我们的 INI 格式的库存文件的一部分可能如下所示:[frontends] web01.example.org https_port=8443 lb_vip=lb.example.org web02.example.org https_port=8443 lb_vip=lb.example.org
如果我们针对这个库存运行临时命令,我们可以看到这两个变量的内容:
$ ansible -i hostvars1-hostgroups-ini frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\""
web01.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
web02.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
这种方法按预期工作,但效率较低,因为你必须将相同的变量添加到每个主机上。
-
幸运的是,你可以为主机组分配变量,也可以单独为每个主机分配变量。如果我们编辑前面的库存文件以实现这一点,
frontends
部分将如下所示:[frontends] web01.example.org web02.example.org [frontends:vars] https_port=8443 lb_vip=lb.example.org
注意这次的可读性更高了?然而,如果我们再次运行相同的命令,对照我们新整理的库存文件,你会看到结果还是一样的:
$ ansible -i groupvars1-hostgroups-ini frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\""
web01.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
web02.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
-
有时你需要为单个主机处理主机变量,而有时则更倾向于使用组变量。你可以根据自己的场景来判断哪种方式更好;不过,记住,主机变量可以与组变量一起使用。还需要注意的是,主机变量会覆盖组变量,因此,如果我们需要将
web01.example.org
主机的连接端口更改为8444
,我们可以如下操作:[frontends] web01.example.org https_port=8444 web02.example.org [frontends:vars] https_port=8443 lb_vip=lb.example.org
现在,如果我们使用新的库存文件再次运行我们的临时命令,我们可以看到我们已经覆盖了一个主机上的变量:
$ ansible -i groupvars2-hostgroups-ini frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\""
web01.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8444"
}
web02.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
当然,对于只有两个主机的情况,这样做可能显得有些多余,但当你有一个包含数百个主机的库存时,这种覆盖单个主机的方式就会变得非常有价值。
-
完整起见,如果我们将之前定义的主机变量添加到 YAML 格式的库存文件中,那么
frontends
部分将如下所示(其他库存部分已删除以节省空间):frontends: hosts: web01.example.org: https_port: 8444 web02.example.org: vars: https_port: 8443 lb_vip: lb.example.org
运行与之前相同的临时命令,你会发现结果与我们使用 INI 格式库存文件时相同:
$ ansible -i groupvars2-hostgroups-yaml frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\""
web01.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8444"
}
web02.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
到目前为止,我们已经介绍了几种为库存提供主机变量和组变量的方法;然而,还有一种方法值得特别提及,当你的库存变得更大更复杂时,它将变得非常有价值。
目前,我们的示例很小且紧凑,只包含少量的组和变量;然而,当你将其扩展到完整的服务器基础架构时,使用单一的平面库存文件可能会变得难以管理。幸运的是,Ansible 也提供了解决方案。如果在 playbook 目录中存在两个特殊命名的目录 host_vars
和 group_vars
,系统会自动搜索这些目录中的适当变量内容。我们可以通过使用这种特殊的目录结构来重新创建前面的前端变量示例,而不是将变量放入库存文件中:
-
让我们首先为此目的创建一个新的目录结构:
$ mkdir vartree $ cd vartree
-
现在,在这个目录下,我们将创建两个更多的目录来存放变量:
host_vars directory, we’ll create a file with the name of our host that needs the proxy setting, with .yml appended to it (that is, web01.example.org.yml). This file should contain the following:
https_port: 8444
-
类似地,在
group_vars
目录下,创建一个以我们要分配变量的组命名的 YAML 文件(即frontends.yml
),并包含以下内容:--- https_port: 8443 lb_vip: lb.example.org
-
最后,我们将像以前一样创建我们的清单文件,只是它不包含任何变量:
loadbalancer.example.org [frontends] web01.example.org web02.example.org [apps] app01.example.org app02.example.org [databases] db01.example.org db02.example.org
为了清晰起见,你最终的目录结构应如下所示:
$ tree
.
├── group_vars
│ └── frontends.yml
├── host_vars
│ └── web01.example.org.yml
└── inventory
2 directories, 3 files
-
现在,让我们尝试运行熟悉的临时命令,看看会发生什么:
$ ansible -i inventory frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\"" web01.example.org | SUCCESS => { "msg": "Connecting to lb.example.org, listening on 8444" } web02.example.org | SUCCESS => { "msg": "Connecting to lb.example.org, listening on 8443" }
正如你所看到的,这一切和之前完全一样,并且在没有进一步指示的情况下,Ansible 已经遍历了目录结构并加载了所有变量文件。
-
如果你有数百个变量(或者需要更加细粒度的方式),你可以用以主机和组命名的目录替换 YAML 文件。让我们重新创建目录结构,但现在使用目录替代:
$ tree . ├── group_vars │ └── frontends │ ├── https_port.yml │ └── lb_vip.yml ├── host_vars │ └── web01.example.org │ └── main.yml └── inventory 4 directories, 4 files
注意我们现在有了以frontends
组和web01.example.org
主机命名的目录吗?在frontends
目录中,我们将变量拆分成了两个文件,这对于逻辑地组织分组变量极其有用,特别是当你的剧本变得更大、更复杂时。虽然我们按变量名称命名了变量文件,这只是为了帮助我们理解代码结构。每个变量文件可以包含任意数量的变量,它们与文件名没有任何关系。
这些文件本身只是我们之前文件的一个改编版本:
$ cat host_vars/web01.example.org/main.yml
---
https_port: 8444
$ cat group_vars/frontends/https_port.yml
---
https_port: 8443
$ cat group_vars/frontends/lb_vip.yml
---
lb_vip: lb.example.org
即使使用了这种更精细化的目录结构,运行临时命令的结果仍然是相同的:
$ ansible -i inventory frontends -m ansible.builtin.debug -a "msg=\"Connecting to {{ lb_vip }}, listening on {{ https_port }}\""
web01.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8444"
}
web02.example.org | SUCCESS => {
"msg": "Connecting to lb.example.org, listening on 8443"
}
-
在我们结束这一部分之前,有一点需要特别注意,如果你在组级别和子组级别都定义了相同的变量,子组级别的变量会优先。这个结论不像看起来那么直观。回想一下我们之前的清单,我们使用子组来区分 Fedora 和 Ubuntu 主机——如果我们将一个同名变量同时添加到
ubuntu
子组和frontends
组(frontends
组是一个ubuntu
组),会发生什么?清单将会如下所示:loadbalancer.example.org [frontends] web01.example.org web02.example.org [frontends:vars] testvar=childgroup [apps] app01.example.org app02.example.org [databases] db01.example.org db02.example.org [fedora:children] apps databases [ubuntu:children] frontends [ubuntu:vars] testvar=group
现在,让我们运行一个临时命令,查看实际设置了哪个 testvar
的值:
$ ansible -i hostgroups-children-vars-ini ubuntu -m ansible.builtin.debug -a "var=testvar"
web01.example.org | SUCCESS => {
"testvar": "childgroup"
}
web02.example.org | SUCCESS => {
"testvar": "childgroup"
frontends group is a child of the ubuntu group in this inventory (hence, the group is listed under [ubuntu:children]), and so the variable value we set at the frontends group level wins as this is the child group in this scenario.
By now, you should have a pretty good idea of how to work with static inventory files. However, no look at Ansible’s inventory capabilities is complete without a look at dynamic inventories, and we shall do exactly this in the next section.
Generating a dynamic inventory file
In these days of cloud computing and infrastructure-as-code, the hosts you may wish to automate could change on a daily, if not hourly, basis! Keeping a static Ansible inventory up to date could become a full-time job, and hence, in many large-scale scenarios, it becomes unrealistic to attempt to use a static inventory on an ongoing basis.
This is where Ansible’s dynamic inventory support comes in. In short, Ansible can gather its inventory data from just about any executable file (though you will find that most dynamic inventories are written in Python)—the only requirement is that the executable returns the inventory data in a specified JSON format. You are free to create your own inventory scripts if you wish, but thankfully, many have been created already for you to use that cover a multitude of potential inventory sources including Amazon EC2, Microsoft Azure, Red Hat Satellite, **Lightweight Directory Access Protocol** (**LDAP**) directories, and many more systems.
When writing a book, it is difficult to know for certain which dynamic inventory script to use as an example, as it is not a given that everyone will have an Amazon EC2 account they can freely use to test against. As a result, we will use the Cobbler provisioning system by way of example, as this is freely available and easy to roll out on a Fedora system. For those interested, Cobbler is a system for dynamically provisioning and building Linux systems, and it can handle all aspects of this, including DNS, DHCP, PXE booting, and so on. Hence, if you were to use this to provision virtual or physical machines in your infrastructure, it would make sense to also use this as your inventory source, as Cobbler was responsible for building the systems in the first place and so knows all the system names.
This example will demonstrate to you the fundamentals of working with a dynamic inventory, which you can then take forward to use the dynamic inventory scripts for other systems. Let’s get started with this process by first installing Cobbler—the process outlined here was tested on Fedora:
1. Your first task is to install the relevant Cobbler packages using `dnf`. Note that, at the time of writing, the SELinux policy provided with Fedora 38 does not support Cobbler’s functionality and blocks some aspects from working. Although this is not something you should do in a production environment, your simplest path to getting this demo up and running is to simply disable SELinux:
```
$ sudo dnf install -y cobbler
cobblerd 服务配置为监听回环地址,具体设置可在 /etc/cobbler/settings 文件中检查,文件的相关片段如下所示,应如下所示:
```
# default, localhost
server: 127.0.0.1
```
```
Note
This is not a public listening address, so please *do not use* `0.0.0.0`. You can also set it to the IP address of the Cobbler server.
1. With this step complete, you can start the `cobblerd` service using `systemctl`:
```
$ sudo systemctl enable --now cobblerd.service
假设你已在 Fedora 38 上安装了 Cobbler,/boot 目录中的内容。在用于此演示的测试系统上,使用了以下命令;但是你必须根据系统的 /boot 目录中的适当版本号,替换 vmlinuz 和 initramfs 文件名中的版本号:
```
$ sudo cobbler distro add --name=Fedora38 --kernel=/boot/vmlinuz-6.2.9-300.fc38.x86_64 --initrd=/boot/initramfs-6.2.9-300.fc38.x86_64.img
$ sudo cobbler profile add --name=webservers --distro=Fedora38
```
```
This definition is quite rudimentary and would not necessarily be able to produce working server images; however, it will suffice for our simple demo as we can add some systems based on this notional Fedora 38-based image. Note that the profile name we are creating, `webservers`, will later become our inventory group name in our dynamic inventory.
1. Let’s now add those systems to Cobbler. The following two commands will add two hosts called `frontend01` and `frontend02` to our Cobbler system, using the `webservers` profile we created previously:
```
$ sudo cobbler system add --name=frontend01 --profile=webservers --dns-name=frontend01.example.org --interface=eth0
$ sudo cobbler system add --name=frontend02 --profile=webservers --dns-name=frontend02.example.org --interface=eth0
```
Note that, for Ansible to work, it must be able to reach the FQDNs specified in the `--dns-name` parameter. To achieve this, I am also adding entries to `/etc/hosts` on the Ansible control node for these two machines to ensure we can reach them later. These entries can point to any two systems of your choosing, as this is just a test.
At this point, you have successfully installed Cobbler, created a profile, and added two hypothetical systems to this profile. The next stage in our process is to download and configure the Ansible dynamic inventory scripts to work with these entries. To achieve this, let’s get started on the process given here:
1. Since Ansible 3.0, most dynamic inventory scripts have been moved into Collections as this is the easiest way to distribute and update them while decoupling them from the `ansible-core` distribution. The Cobbler dynamic inventory script is included as part of the `community.general` collection, which you should find was installed as part of the Ansible 8.0 package you installed previously. You can verify this with the following command:
```
$ ansible-galaxy collection list | grep community.general
community.general 6.5.0
```
Here, we can see that the `community.general` collection is installed and is at version 6.5.0 (collection versioning is independent of Ansible versioning).
If you have worked with dynamic inventory scripts in versions of Ansible before 2.9 (before Collections became mainstream), you would almost certainly have located the dynamic inventory script and made it executable, even executing it directly. This is no longer necessary in the new Collections- and Plugins-based architecture, which makes your life much easier, as you shall see shortly.
1. Referring to the documentation for the Cobbler dynamic inventory plugin ([`docs.ansible.com/ansible/latest/collections/community/general/cobbler_inventory.xhtml`](https://docs.ansible.com/ansible/latest/collections/community/general/cobbler_inventory.xhtml)), you will see that, to use the plugin, we must create a configuration file as an inventory source. The filename must end in `.cobbler.yml` or `.cobbler.yaml`, and contain a line referencing the plugin. There are also a whole host of configuration options available (which, in our simple demo setup, aren’t necessary), but if you were working with a cloud service provider, you would certainly have to specify your region and credentials in the configuration file. In some cases, you will also have to install additional libraries or software for the inventory plugin to work, and again, the documentation page for the plugin will tell you whether this is a requirement. In my demo environment, the Cobbler server I built is accessible at the address `cobbler.example.org`, so I shall add this to the configuration file, resulting in the following:
```
$ cat my.cobbler.yml
插件:community.general.cobbler
ansible-inventory 命令,你可以用它来验证动态库存的操作:
```
$ ansible-inventory -i my.cobbler.yml --graph
@all:
|--@ungrouped:
|--@cobbler:
| |--frontend01.example.org
| |--frontend02.example.org
|--@cobbler_webservers:
| |--frontend01.example.org
| |--frontend02.example.org
|--@cobbler_:
| |--frontend01.example.org
| |--frontend02.example.org
```
```
This is an incredibly powerful and rapid way to query the dynamic inventory operation and what it is returning. Notice also that all group names have had `cobbler_` placed in front of them by the plugin, so we will need to make use of this when we reference the group names.
1. You can now run an Ansible ad hoc command in the manner you are used to—the only difference this time is that you will specify the filename of the dynamic inventory plugin configuration file rather than the name of the static inventory file. Assuming you have set up hosts at the two addresses we entered into Cobbler earlier, your output should look something like that shown here:
```
$ ansible -i my.cobbler.yml cobbler_webservers -m ansible.builtin.ping
frontend01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
frontend02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
```
That’s it! You have just implemented your first dynamic inventory in Ansible. Of course, we know that many of you won’t be using Cobbler, and some of the other dynamic inventory plugins are a little more complex to get going. For example, the Amazon EC2 dynamic inventory script requires your authentication details for Amazon Web Services (or a suitable IAM account) and the installation of the Python `boto` and `boto3` libraries. How would you know to do all of this? Luckily, it is all documented in the plugin documentation, which you can access via the official Ansible documentation website, or on the command line using a command such as the following:
$ ansible-doc -t inventory community.general.cobbler
An example of the documentation you will see is shown in the following figure:

Figure 3.1 – The Ansible CLI documentation for the community.general.cobbler plugin
The most fundamental piece of advice I can give is this: whenever you install a new collection containing a dynamic inventory plugin, be sure to check out the documentation, as the requirements will have been documented for you.
Before we end this section, let’s have a look at a few other handy hints for working with inventories, starting with the use of multiple inventory sources in the next section.
Using multiple inventory sources in the inventory directories
So far in this book, we have been specifying our inventory file (either static or dynamic) using the `-i` switch in our Ansible commands. What might not be apparent is that you can specify the `-i` switch more than once and so use multiple inventories at the same time. This enables you to perform tasks such as running a playbook (or ad hoc command) across hosts from both static and dynamic inventories at the same time. Ansible will work out what needs to be done—static inventories should not be marked as executable and so will not be processed as such, whereas dynamic inventories will be. This small but clever trick enables you to combine multiple inventory sources with ease. Let’s move on, in the next section, to looking at the use of static inventory groups in combination with dynamic ones, an extension of this multiple-inventory functionality.
Using static groups with dynamic groups
Of course, the possibility of mixing inventories brings with it an interesting question—what happens to the groups from a dynamic inventory and a static inventory if you define both? The answer is that Ansible combines both, and this leads to an interesting possibility. As you will have observed, our Cobbler inventory script produced an Ansible group called `cobbler_webservers` from a Cobbler profile that we called `webservers`. This is common for most dynamic inventory providers; most inventory sources (for example, Cobbler and Amazon EC2) are not Ansible-aware and so do not offer groups that Ansible can directly use—they may also automatically add prefixes or separators to metadata (which may or may not be configurable). As a result, most dynamic inventory scripts will use some facet of information from the inventory source to produce groupings—the Cobbler machine profile being one such example.
Let’s extend our Cobbler example from the preceding section by mixing a static inventory. Suppose that we want to make our `cobbler_webservers` machines a child group of a group called `fedora` so that we can, in the future, group all Fedora machines together. We know that we only have a Cobbler profile called `webservers`, and ideally, we don’t want to start messing with the Cobbler setup to do something solely Ansible-related.
The answer to this is to create a static inventory file with two group definitions. The first must be the same name as the group you are expecting from the dynamic inventory, except that you should leave it blank. When Ansible combines the static and dynamic inventory contents, it will overlap the two groups and so add the hosts from Cobbler to these groups.
The second group definition should state that `cobbler_webservers` is a child group of the `fedora` group. The resulting file should look something like this:
[cobbler_webservers]
[fedora:children]
cobbler_webservers
Now let’s run a simple ad hoc `ansible.builtin.ping` command in Ansible to see how it evaluates the two inventories together. Notice how we will specify the `fedora` group to run `ansible.builtin.ping` against, instead of the `cobbler_webservers` group. We know that Cobbler has no `fedora` group because we never created one, and we know that any hosts in this group must come via the `cobbler_webservers` group when you combine the two inventories, as our static inventory has no hosts in it. The results will look something like this:
$ ansible -i my.cobbler.yml -i static-groups-mix-ini fedora -m ansible.builtin.ping
frontend01.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
frontend02.example.org | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
As you can see from the preceding output, we have referenced two different inventories, one static and the other dynamic. We have combined groups, taking hosts that only exist in one inventory source, and combining them with a group that only exists in another. As you can see, this is an incredibly simple example, and it would be easy to extend this to combine lists of static and dynamic hosts or to add a custom variable to a host that comes from a dynamic inventory.
This is a trick of Ansible that is little known but can be very powerful as your inventories expand and grow. As we have worked through this chapter, you will have observed that we have been very precise about specifying our inventory hosts either individually or by group; for example, we explicitly told the `ansible` command to run the ad hoc command against all hosts in the `cobbler_webservers` group. In the next section, we will build on this to look at how Ansible can manage a set of hosts specified using patterns.
Special host management using patterns
We have already established that you will often want to run either an ad hoc command or a playbook against only a subsection of your inventory. So far, we have been quite precise in doing that, but let’s now expand upon this by looking at how Ansible can work with patterns to figure out which hosts a command (or playbook) should be run against.
As a starting point, let’s again consider an inventory that we defined earlier in this chapter for the purposes of exploring host groups and child groups. For your convenience, the inventory contents are provided again here:
loadbalancer.example.org
[前端]
web01.example.org
web02.example.org
[应用]
app01.example.org
app02.example.org
[数据库]
db01.example.org
db02.example.org
[fedora:children]
应用
数据库
[ubuntu:children]
前端
To demonstrate host/group selection by pattern, we shall use the `--list-hosts` switch with the `ansible` command to see which hosts Ansible would operate on. You are welcome to expand the example to use the `ansible.builtin.ping` module, but we’ll use `--list-hosts` here in the interests of space and keeping the output concise and readable:
1. We have already mentioned the special `all` group to specify all hosts in the inventory:
```
$ ansible -i hostgroups-ini all --list-hosts
主机(7):
loadbalancer.example.org
app01.example.org
app02.example.org
db01.example.org
db02.example.org
web01.example.org
web02.example.org
```
The asterisk character has the same effect as `all`, but needs to be quoted in single quotes for the shell to interpret the command properly:
$ ansible -i hostgroups-ini '*' --list-hosts
主机(7):
loadbalancer.example.org
app01.example.org
app02.example.org
db01.example.org
db02.example.org
web01.example.org
web02.example.org
1. Use `:` to specify a logical `OR`, meaning “*apply to hosts either in this group or that group*,” as in this example:
```
$ ansible -i hostgroups-ini frontends:apps --list-hosts
主机(4):
web01.example.org
web02.example.org
app01.example.org
! 排除特定组——你可以将这个与其他字符组合使用,例如:显示所有主机,但不包括应用组中的主机。再说一次,! 是 shell 中的特殊字符,因此你必须用单引号引用你的模式字符串才能使其工作,如本示例所示:
```
$ ansible -i hostgroups-ini 'all:!apps' --list-hosts
hosts (5):
loadbalancer.example.org
db01.example.org
db02.example.org
web01.example.org
:& to specify a logical AND between two groups, for example, if we want all hosts that are in the fedora group and the apps group (again, you must use single quotes in the shell):
```
$ ansible -i hostgroups-ini 'fedora:&apps' --list-hosts
主机(2):
app01.example.org
* 使用类似于在 shell 中使用的通配符,就像这个示例:
```
$ ansible -i hostgroups-ini 'db*.example.org' --list-hosts
hosts (2):
db01.example.org
db02.example.org
```
```
```
```
Another way you can limit which hosts a command is run on is to use the `--limit` switch with Ansible. This uses exactly the same syntax and pattern notation as in the preceding but has the advantage that you can use it with the `ansible-playbook` command, where specifying a host pattern on the command line is only supported for the `ansible` command itself. Hence, for example, you could run the following:
$ ansible-playbook -i hostgroups-ini site.yml --limit frontends:apps
执行 [一个简单的 playbook 来演示库存模式] *************************************************************************************
任务 [Ping 每个主机] *****************************************************************************************************************************
ok: [web02.example.org]
ok: [web01.example.org]
ok: [app02.example.org]
ok: [app01.example.org]
执行总结 ****************************************************************************************************************************************
app01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
app02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Patterns are a very useful and important part of working with inventories, and something you will no doubt find invaluable going forward. That concludes our chapter on Ansible inventories; however, it is hoped that this has given you everything you need to work confidently with Ansible inventories.
Summary
Creating and managing Ansible inventories is a crucial part of your work with Ansible, and hence we have covered this fundamental concept early in this book. They are vital as, without them, Ansible would have no knowledge of what hosts it is to run automation tasks against, yet they provide so much more than this. They provide an integration point with configuration management systems, they provide a sensible source for host-specific (or group-specific) variables to be stored, and they provide you with a flexible way of running this playbook.
In this chapter, you learned about creating simple static inventory files and adding hosts to them. We then extended this by learning how to add host groups and assign variables to hosts. We also looked at how to organize your inventories and variables when a single flat inventory file becomes too much to handle. We then learned how to make use of dynamic inventory files, before concluding with a look at useful tips and tricks such as combining inventory sources and using patterns to specify hosts, all of which will make how you work with inventories easier and yet simultaneously more powerful.
In the next chapter, we will learn how to develop playbooks and roles to configure, deploy, and manage remote machines using Ansible.
Questions
1. How do you add the `frontends` group variables to your inventory?
1. `[``frontends::]`
2. `[``frontends::values]`
3. `[``frontends:host:vars]`
4. `[``frontends::variables]`
5. `[``frontends:vars]`
2. What enables you to automate Linux tasks such as provisioning DNS, managing DHCP, updating packages, and configuration management?
1. Playbook
2. Yum
3. Cobbler
4. Bash
5. Role
3. Ansible allows you to specify an inventory file location by using the `-i` option on the command line:
1. True
2. False
Further reading
Details on using Ansible inventory plugins are available here: [`docs.ansible.com/ansible/latest/plugins/inventory.xhtml#using-inventory-plugins`](https://docs.ansible.com/ansible/latest/plugins/inventory.xhtml#using-inventory-plugins)
第四章:剧本与角色
到目前为止,本书大部分内容使用的是简单的临时 Ansible 命令,以帮助你理解基础概念。然而,Ansible 的核心无疑是剧本,它是将任务(类似临时命令)按逻辑组织起来的结构,目的是产生有用的结果。比如,它可以部署一个 Web 服务器到一个新建的虚拟机,或者应用安全策略,甚至可能涉及到整个虚拟机的构建过程!可能性是无穷的。正如我们之前讨论的,Ansible 剧本旨在简洁易写且易读——它们旨在自我文档化,因此,它们将成为你 IT 流程中宝贵的一部分。
本章将更深入地探讨剧本,从剧本创建的基础知识到更高级的概念,例如在循环和块中运行任务、执行条件逻辑,以及——可能是剧本组织和代码复用中最重要的概念之一——Ansible 角色。我们稍后会更详细地介绍角色,但请记住,这是你在创建可管理的剧本代码时希望尽可能多使用的功能。
本章将特别讨论以下主题:
-
理解剧本框架
-
理解角色——剧本的组织者
-
在代码中使用条件
-
使用循环重复任务
-
使用块来分组任务
-
通过策略配置剧本执行
-
使用
ansible-pull
技术要求
本章假设你已经按照第一章《与 Ansible 入门》的详细说明设置了控制主机,并且使用的是最新版本——本章中的示例在 Ansible 8.0 和 ansible-core
2.15 上进行了测试。本章还假设你至少有一个额外的主机进行测试,且该主机应为基于 Linux 的。尽管本章将给出特定主机名的示例,你可以根据需要将其替换为自己的主机名和/或 IP 地址,相关的替换方法将在适当的地方提供。
本章的代码包可以在此找到:github.com/PacktPublishing/Practical-Ansible-Second-Edition/tree/main/Chapter%204
。
理解剧本框架
一个 playbook 允许你在许多机器上简单轻松地管理多个配置和复杂的部署。这是使用 Ansible 进行复杂应用交付的关键优势之一。通过 playbook,你可以以逻辑结构组织任务,因为任务(通常)按它们的书写顺序执行,从而让你对自动化过程有较高的控制度。话虽如此,仍然可以异步执行任务,因此如果任务不是按顺序执行的,我们将特别指出这一点。我们的目标是,在完成本章后,你将理解编写自己的 Ansible playbook 的最佳实践。
虽然 YAML 格式易于阅读和编写,但在缩进方面要求非常严格。例如,尽管在屏幕上,制表符和四个空格看起来可能是相同的,但在 YAML 中它们并不相同。我们建议,如果你第一次编写 playbook,可以采用支持 YAML 的编辑器来帮助你,可能是 Vim、Visual Studio Code 或 Eclipse,因为这些编辑器会帮助你确保缩进正确。如果要测试我们在本章中开发的 playbook,我们将创建一个变体的清单,基于第三章,定义你的清单(除非另有说明):
[frontends]
web01.example.org https_port=8443
web02.example.org http_proxy=proxy.example.org
[frontends:vars]
ntp_server=ntp.web.example.org
proxy=proxy.web.example.org
[apps]
app01.example.org
app02.example.org
[webapp:children]
frontends
apps
[webapp:vars]
proxy_server=proxy.webapp.example.org
health_check_retry=3
health_check_interval=60
让我们直接开始编写 playbook。在第二章,理解 Ansible 的基础知识中,我们介绍了创建 playbook 所需的 YAML 语法和结构的基本知识,因此这里不再重复。相反,我们将在此基础上继续,展示 playbook 开发的核心内容:
-
创建一个简单的 playbook(称为
myplaybook.yml
),在我们清单文件中定义的frontends
主机组上的主机上运行。我们可以使用 playbook 中的remote_user
指令设置访问主机的用户,如下所示(你也可以在命令行上使用--user
开关,但因为本章是关于 playbook 开发的,我们暂时忽略这个):--- - hosts: frontends remote_user: james tasks: - name: simple connection test ansible.builtin.ping: remote_user: james
-
在第一个任务下方添加另一个任务,运行
ansible.builtin.shell
模块(该模块将依次在远程主机上运行ls
命令)。我们还将向该任务添加ignore_errors
指令,确保如果ls
命令失败(例如,如果我们尝试列出的目录不存在),playbook 不会失败。注意缩进,确保它与文件的第一部分匹配:- name: run a simple command ansible.builtin.shell: /bin/ls -al /nonexistent ignore_errors: True
-
让我们看看新创建的 playbook 在使用以下命令运行时的表现:
$ ansible-playbook -i hosts myplaybook.yml
该 playbook 运行的输出应类似于以下截图:
图 4.1 – 演示执行一个故意忽略错误的 playbook
从剧本运行的输出中,你可以看到我们的两个任务是按照指定的顺序执行的。我们可以看到ls
命令失败了,因为我们尝试列出一个不存在的目录,但剧本并没有记录任何failed
任务,因为我们为该任务(仅此任务)设置了ignore_errors
为true
。
大多数 Ansible 模块(除了运行用户自定义命令的模块,如ansible.builtin.shell
、ansible.builtin.command
和ansible.builtin.raw
)都被编写为幂等的——也就是说,如果你运行相同的任务两次,结果将是相同的,且该任务不会两次做出相同的更改;如果它检测到请求它执行的操作已经完成,则不会第二次执行。当然,这对前述模块来说不可能,因为它们可以用于执行几乎所有可以想象的任务——因此,模块怎么知道它被执行了两次呢?
每个模块都会返回一组结果,其中包括任务状态。你可以在前面剧本运行的输出底部看到这些结果的总结,它们的含义如下:
-
ok
:任务成功运行且没有进行任何更改 -
changed
:任务成功运行且进行了更改 -
failed
:任务未能成功运行(但主机是可连接的) -
unreachable
:无法连接主机以运行该任务 -
skipped
:此任务被跳过 -
ignored
:此任务被忽略(例如,在ignore_errors
的情况下) -
rescued
:稍后我们将在查看块和恢复任务时看到一个例子
这些状态非常有用——例如,如果我们有一个任务是从模板部署新的 Apache 配置文件,我们知道必须重启 Apache 服务才能使更改生效。然而,我们只希望在文件实际被更改的情况下才重启 Apache——如果没有更改,我们不希望不必要地重启 Apache,因为这会打断可能正在使用该服务的用户。因此,我们可以使用notify
动作,这会告诉 Ansible 在任务结果为changed
时(且仅在此时)调用handler
。简而言之,处理程序是一种特殊类型的任务,它是在notify
的结果下运行的。然而,与 Ansible 剧本任务按顺序执行不同,处理程序会被聚集在一起并在剧本结束时统一执行。此外,处理程序可以被通知多次,但无论如何只会执行一次,从而防止不必要的服务重启。考虑以下剧本:
---
- name: Handler demo 1
hosts: web01.example.org
gather_facts: no
become: yes
tasks:
- name: Update Apache configuration
ansible.builtin.template:
src: template.j2
dest: /etc/apache2/apache2.conf
notify: Restart Apache
handlers:
- name: Restart Apache
ansible.builtin.service:
name: apache2
state: restarted
为了简洁起见,我已关闭了此剧本的事实收集(我们在任何任务中都不会使用事实)。我也再次仅在一台主机上运行,以简化演示,但你可以根据需要扩展演示代码。如果我们第一次运行这个任务,我们将看到以下结果:
$ ansible-playbook -i hosts handlers1.yml
PLAY [Handler demo 1] **********************************************************
TASK [Update Apache configuration] *********************************************
changed: [web01.example.org]
RUNNING HANDLER [Restart Apache] ***********************************************
changed: [web01.example.org]
PLAY RECAP *********************************************************************
web01.example.org : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
注意处理程序是在最后执行的,因为配置文件已被更新。然而,如果我们第二次运行这个 Playbook 而不对模板或配置文件做任何更改,我们将看到如下内容:
$ ansible-playbook -i hosts handlers1.yml
PLAY [Handler demo 1] **********************************************************
TASK [Update Apache configuration] *********************************************
ok: [web01.example.org]
PLAY RECAP *********************************************************************
web01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
这次,处理程序没有被调用,因为配置任务的结果是 ok
。所有处理程序应该具有全局唯一的名称,以便 notify
动作能够调用正确的处理程序。你还可以通过设置公共名称并使用 listen
指令来调用多个处理程序——这样,你可以通过 name
或 listen
字符串来调用处理程序,如以下示例所示:
---
- name: Handler demo 1
hosts: web01.example.org
gather_facts: no
become: yes
handlers:
- name: restart timesyncd
ansible.builtin.service:
name: systemd-timesyncd.service
state: restarted
listen: "restart all services"
- name: restart apache
ansible.builtin.service:
name: apache2.service
state: restarted
listen: "restart all services"
tasks:
- name: restart all services
ansible.builtin.command: echo "this task will restart all services"
notify: "restart all services"
我们在 Playbook 中只有一个任务,但当我们运行它时,两个处理程序都会被调用。此外,记住我们之前说过 ansible.builtin.command
是一组特殊模块中的一部分,因为它们无法检测是否发生了变化——因此,它们始终返回 changed
值,在这个示范 Playbook 中,即使我们第二次运行,处理程序也会始终被通知:
$ ansible-playbook -i hosts handlers2.yml
PLAY [Handler demo 1] **********************************************************
TASK [restart all services] ****************************************************
changed: [web01.example.org]
RUNNING HANDLER [restart timesyncd] ********************************************
changed: [web01.example.org]
RUNNING HANDLER [restart apache] ***********************************************
changed: [web01.example.org]
PLAY RECAP *********************************************************************
web01.example.org : ok=3 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
这些是你开始编写自己 Playbooks 所需了解的一些基础知识。掌握这些后,让我们在下一节中对比临时命令和 Playbooks。
比较 Playbooks 和临时任务
临时命令允许你快速创建并执行一次性命令,而无需保留任何记录(除了可能的 shell 历史)。这些命令具有重要意义,对于快速完成小的变更,以及学习 Ansible 和其模块非常有价值。
相比之下,Playbooks 是逻辑组织的任务集(每个任务都可以是临时命令),按顺序组合在一起,执行一个更大的操作。添加条件逻辑、错误处理等功能意味着,通常情况下,Playbooks 的优势要超过临时命令的实用性。此外,只要保持组织得当,你将拥有所有之前运行过的 Playbooks 的副本,因此你可以回溯(如果有需要的话)查看你曾执行过的操作和时间。
让我们来开发一个实际的例子——假设你想在 Ubuntu Server 上安装 Apache 2.4。即使默认配置足够(尽管这不太可能,但为了简化示例,我们暂时保持这个假设),也需要进行一些步骤。如果你手动进行基本安装,你需要安装软件包,打开防火墙,并确保服务正在运行(并且能够在启动时运行)。
在 shell 中执行这些命令,你可能会这样做:
$ sudo apt -y install apache2
$ sudo ufw allow http
$ sudo ufw allow https
$ sudo systemctl enable apache2.service
$ sudo systemctl restart apache2.service
现在,对于这些命令,每个都可以通过一个等效的临时 Ansible 命令来执行。为了节省空间,我们不在这里详细介绍所有命令;不过,假设你想重启 Apache 服务——如果是这样,你可以运行一个类似以下的临时命令(同样,为了简洁,我们只在一个主机上执行):
$ ansible -i hosts web01* -m ansible.builtin.service -a "name=apache2 state=restarted" --become
成功运行后,你将看到页面上显示着 shell 输出,包含从运行ansible.builtin.service
模块返回的所有变量数据。这里显示了一部分来自运行此临时命令的输出,以供你对照检查——关键在于该命令的执行结果是changed
状态,意味着它成功运行并且服务确实被重启:
web01.example.org | CHANGED => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": true,
"name": "apache2",
"state": "started",
当然,你可以创建并执行一系列临时命令,以重复之前给出的多个 shell 命令,并分别运行它们。稍加巧妙,你可以将四个命令合并为更少的命令(例如,Ansible 的ansible.builtin.service
模块可以在一个临时命令中同时启用服务并重启它)。然而,你最终仍然会得到至少两三个临时命令,如果你希望稍后在另一台服务器上运行这些命令,你将需要查阅你的笔记以了解如何执行的。
因此,playbook 是更有价值的解决方案——它不仅会一次性完成所有步骤,还会为你提供一个记录,供你以后参考。有多种方法可以做到这一点,但以下是一个示例:
---
- name: Install Apache
hosts: web01.example.org
gather_facts: no
become: yes
tasks:
- name: Install Apache package
ansible.builtin.apt:
name: apache2
state: latest
- name: Open firewall for Apache
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "http"
- "https"
- name: Restart and enable the service
ansible.builtin.service:
name: apache2
state: restarted
enabled: yes
现在,当你运行此命令时,你应该会看到我们所有的安装要求都通过一个相当简单且易于阅读的 playbook 完成了。这里有一个新概念——循环(loops),我们尚未讲解,但不用担心——我们会在本章后续部分讲解它:
$ ansible-playbook -i hosts installapache.yml
PLAY [Install Apache] **********************************************************
TASK [Install Apache package] **************************************************
changed: [web01.example.org]
TASK [Open firewall for Apache] ************************************************
changed: [web01.example.org] => (item=http)
changed: [web01.example.org] => (item=https)
TASK [Restart and enable the service] ******************************************
changed: [web01.example.org]
PLAY RECAP *********************************************************************
web01.example.org : ok=3 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
如你所见,这种方式比实际完成的工作更加有效,并且可以以其他人容易理解的格式进行记录。尽管我们会在书中的后面部分讲解循环(loops),但从前面的例子中,你可以很容易地看出它们是如何工作的。明确这一点后,让我们进入下一部分,详细了解我们多次使用的几个术语,确保你能清楚它们的含义——play和task。
定义 plays 和 tasks
到目前为止,当我们处理 playbooks 时,每个 playbook 里我们只创建了一个 play(这在逻辑上是最基本的做法)。然而,你可以在一个 playbook 中拥有多个 play,而在 Ansible 术语中,一个“play”只是与某个主机(或一组主机)关联的一组 tasks(以及角色、处理器和其他 Ansible 相关内容)。一个 task 是一个 play 中最小的元素,它负责运行一个单独的模块,并传递一组参数以实现特定目标。当然,理论上这听起来很复杂,但有了实际的例子,它会变得非常容易理解。
如果我们参考我们的示例清单,它描述了一个简单的两层架构(我们暂时省略了数据库层)。现在,假设我们想编写一个单一的剧本来配置前端服务器和应用服务器。我们可以使用两个单独的剧本来配置前端和应用服务器,但这有可能使代码碎片化并且难以组织。然而,前端服务器和应用服务器(本质上)是完全不同的,因此不太可能使用相同的任务集进行配置。
解决这个问题的方法是创建一个包含两个任务的单一剧本。每个任务的开始可以通过最低缩进的行来识别(即前面没有空格的行)。
让我们开始构建我们的剧本吧:
-
将第一个任务添加到剧本中,并定义一些简单的任务以在前端设置 Apache 服务器,如下所示:
--- - name: Play 1 - configure the frontend servers hosts: frontends become: yes tasks: - name: Install the Apache package ansible.builtin.apt: name: apache2 state: latest - name: Start the Apache server ansible.builtin.service: name: apache2 state: started
-
在此下方的同一个文件中,添加第二个任务,用于配置应用层服务器:
- name: Play 2 - configure the application servers hosts: apps become: true tasks: - name: Install Tomcat ansible.builtin.apt: name: tomcat9 state: latest - name: Start the Tomcat server ansible.builtin.service: name: tomcat9 state: started
现在,您有两个任务——一个用于在frontends
组中安装 Web 服务器,另一个用于在apps
组中安装应用服务器,所有这些都结合成一个简单的剧本。
当我们运行这个剧本时,我们会看到两个任务按顺序执行,顺序与它们在剧本中出现的顺序一致。请注意PLAY
关键字的存在,它表示每个任务的开始。使用以下命令来运行剧本:
$ ansible-playbook -i hosts playandtask.yml
您的剧本运行输出应该类似于以下截图:
图 4.2 – 演示包含两个任务的剧本执行
这样,我们就有了一个剧本,但其中包含两个在提供的清单中操作不同主机集的任务。这非常强大,尤其是当它与角色结合使用时(将在本书后面介绍)。当然,您的剧本中可以只有一个任务——不一定要有多个任务,但能够开发多任务剧本是很重要的,因为随着环境的复杂性增加,您几乎肯定会发现它们非常有用。
剧本是 Ansible 自动化的生命线——它们将自动化从单一任务/命令(本身非常强大)扩展到一系列逻辑组织的任务。然而,随着您扩展剧本库,如何保持工作有序呢?如何高效地重用相同的代码块?在前面的示例中,我们安装了 Apache,而这可能是多个服务器的要求。然而,您是否应该尝试从一个剧本管理所有这些服务器?还是应该不断地复制和粘贴相同的代码块?有一种更好的方法,在 Ansible 术语中,我们需要开始看角色,我们将在下一个部分讨论这个问题。
理解角色——剧本组织者
角色的设计旨在使你能够高效且有效地重用 Ansible 代码。它们总是遵循已知的结构,并且通常会包含合理的默认值,比如变量、错误处理、处理程序等。在上一章的 Apache 安装示例中,我们知道这是我们可能想要反复执行的操作,可能每次使用不同的配置文件,并且可能在每个服务器(或每个库存组)上进行一些其他调整。在 Ansible 中,支持以这种方式重用代码的最有效方法是将其创建为角色。
创建角色的过程其实非常简单——Ansible 默认会在运行 playbook 的同一目录中查找roles/
目录,然后在其中为每个角色创建一个子目录。角色名称来源于子目录名称。不需要创建复杂的元数据或其他任何东西——就是这么简单。在每个子目录中会有一个固定的目录结构,告诉 Ansible 每个角色的任务、默认变量、处理程序等内容。
注意
roles/
目录并不是 Ansible 查找角色的唯一地方——这是它首先会查找的目录,但它随后会在/etc/ansible/roles
中查找任何额外的角色。通过 Ansible 配置文件,这个位置还可以进一步自定义,正如在第二章中所讨论的,理解 Ansible 基础知识。
让我们更详细地探讨一下这个问题。考虑以下目录结构:
site.yml
frontends.yml
dbservers.yml
roles/
installapache/
tasks/
handlers/
templates/
vars/
defaults/
installtomcat/
tasks/
meta/
上面的目录结构展示了我们假设的 playbook 目录中定义的两个角色,分别是installapache
和installtomcat
。在这些目录中的每一个,你会看到一系列的子目录。这些子目录不一定要存在(稍后会详细说明它们的意义,但是例如,如果你的角色没有处理程序,则handlers/
目录不需要创建)。然而,当你需要这样的目录时,你应该在其中放置一个名为main.yml
的 YAML 文件。根据目录的不同,每个main.yml
文件的内容会有所不同。
角色内部可以存在的子目录如下:
-
tasks
:这是角色中最常见的目录,包含角色应执行的所有 Ansible 任务。 -
handlers
:所有在角色中使用的处理程序应放入此目录。 -
defaults
:所有角色的默认变量都放在这里。 -
vars
:这些是其他角色变量——它们会覆盖defaults/
目录中声明的变量,因为它们的优先级更高。 -
files
:角色所需的文件应放在这里,例如需要部署到目标主机的配置文件。 -
templates
:与files/
目录不同,此目录应包含角色使用的所有模板。 -
meta
:所有需要的元数据都应放在这里。例如,角色通常按从父剧本调用的顺序执行;然而,有时一个角色会有依赖的其他角色需要先执行,如果是这种情况,它们可以在此目录中声明。
对于本章中我们要开发的示例,我们需要一个清单,所以我们将重用上一节中使用的清单(为方便起见,附在此处):
[frontends]
web01.example.org https_port=8443
web02.example.org http_proxy=proxy.example.org
[frontends:vars]
ntp_server=ntp.web.example.org
proxy=proxy.web.example.org
[apps]
app01.example.org
app02.example.org
[webapp:children]
frontends
apps
[webapp:vars]
proxy_server=proxy.webapp.example.org
health_check_retry=3
health_check_interval=60
让我们通过一些实际练习来帮助你学习如何创建和使用角色。我们将从创建一个名为installapache
的角色开始,这个角色将处理我们在上一节中看到的 Apache 安装过程。然而,在这里,我们将扩展它,以覆盖在 Fedora 和 Ubuntu 上安装 Apache。这是一个很好的实践,特别是如果你打算将你的角色提交回社区,因为它们越通用(适用的系统范围越广),对大家的帮助就越大。
按照以下步骤创建你的第一个角色:
-
在你选择的剧本目录中为
installapache
角色创建目录结构——这非常简单:main.yml inside the tasks directory we just created. This won’t actually perform the Apache installation – rather, it will call one of two external tasks files, depending on the operating system detected on the target host during the fact-gathering stage. We can use a fact called ansible_distribution, in a when clause, to determine which of the task files to import:
- name: 根据操作系统平台导入任务
import_tasks: fedora.yml
when: ansible_distribution == 'Fedora'
- import_tasks: ubuntu.yml
when: ansible_distribution == 'Ubuntu'
-
在
roles/installapache/tasks
目录下创建fedora.yml
,通过dnf
包管理器在 Fedora 上安装最新版本的 Apache web 服务器。这个文件应包含以下内容:--- - name: Install Apache using dnf ansible.builtin.dnf: name: httpd state: latest - name: Start the Apache server ansible.builtin.service: name: httpd state: started
-
在
roles/installapache/tasks
目录下创建一个名为ubuntu.yml
的文件,通过apt
包管理器在 Ubuntu 上安装最新版本的 Apache web 服务器。注意 Fedora 和 Ubuntu 主机之间内容的差异:--- - name: Install Apache using apt ansible.builtin.apt: name: apache2 state: latest - name: Start the Apache server ansible.builtin.service: name: apache2 state: started
目前,我们将角色的代码保持得非常简单——然而,你可以看到前面的任务文件就像一个 Ansible 剧本,只是缺少了 play 定义。由于它们不属于某个 play,它们的缩进级别也比在剧本中的要低,但除了这个差异,代码对你来说应该非常熟悉。事实上,这就是角色的一个优点——只要你注意缩进级别的正确性,你基本可以在剧本或角色中使用相同的代码。
现在,角色不能直接运行——我们必须创建一个剧本来调用它们,所以让我们编写一个简单的剧本来调用我们新创建的角色。这个剧本有一个像我们之前看到的 play 定义,但与其在 play 中有一个tasks:
部分,我们改为有一个roles:
部分来声明角色。约定俗成地,这个文件被称为site.yml
,但你可以根据需要命名:
---
- name: Install Apache using a role
hosts: frontends
become: true
roles:
- installapache
为了清晰起见,你最终的目录结构应该如下所示:
.
├── hosts
├── roles
│ └── installapache
│ └── tasks
│ ├── fedora.yml
│ ├── main.yml
│ └── ubuntu.yml
└── site.yml
完成这些后,你可以像往常一样使用 ansible-playbook
运行 site.yml
playbook——你应该看到类似以下的输出:
$ ansible-playbook -i hosts site.yml
PLAY [Install Apache using a role] *********************************************
TASK [Gathering Facts] *********************************************************
ok: [web02.example.org]
ok: [web01.example.org]
TASK [installapache : Install Apache using yum] ********************************
skipping: [web01.example.org]
skipping: [web02.example.org]
TASK [installapache : Start the Apache server] *********************************
skipping: [web01.example.org]
skipping: [web02.example.org]
TASK [installapache : Install Apache using apt] ********************************
changed: [web02.example.org]
changed: [web01.example.org]
TASK [installapache : Start the Apache server] *********************************
changed: [web02.example.org]
changed: [web01.example.org]
PLAY RECAP *********************************************************************
web01.example.org : ok=3 changed=2 unreachable=0 failed=0 skipped=2 r escued=0 ignored=0
web02.example.org : ok=3 changed=2 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
就这样——你已经在最简单的层次上创建了你的第一个角色。当然(正如我们之前讨论的那样),角色远不止我们在这里添加的简单任务,我们将在本章的其他部分看到更多扩展的例子。然而,前面的例子旨在向你展示如何快速且轻松地开始使用角色。
在我们查看与角色相关的其他方面之前,先来看看调用角色的其他方式。Ansible 允许你在编写 playbook 时静态导入或动态包含角色。导入或包含角色的语法略有不同,特别是两者都放在 playbook 的 tasks
部分,而不是 roles
部分。以下是一个假设的示例,展示了在一个非常简单的 playbook 中使用这两种方式。roles/
目录结构,包括 common
和 approle
角色,创建方式与前面的例子类似:
---
- hosts: frontends
tasks:
- ansible.builtin.import_role:
name: common
- ansible.builtin.include_role:
name: approle
这些功能在 2.3 版本之前的 Ansible 中是不可用的,并且在 2.4 版本中为了与某些其他 Ansible 功能的工作方式保持一致,使用方式略有变化。我们这里不需要过多关注这些细节,因为核心版本已经更新到 2.15,因此除非你确实需要运行一个更早版本的 Ansible,否则可以假设这两个语句会像我们接下来将概述的那样正常工作。
从根本上讲,ansible.builtin.import_role
语句在解析所有 playbook 代码时执行你指定角色的静态导入。因此,使用 ansible.builtin.import_role
引入的角色在 Ansible 开始解析时就像 play 或 role 中的其他任何代码一样处理。使用 ansible.builtin.import_role
基本上与在 site.yml
中的 roles:
语句后声明角色相同,就像我们在前面的例子中所做的那样。
ansible.builtin.include_role
有一个微妙但根本的不同之处,即你指定的角色不会在初始解析 playbook 时进行评估——而是在 playbook 执行过程中,当遇到 ansible.builtin.include_role
时动态处理。
选择 include
或 import
语句的最根本原因可能是循环——如果你需要在循环中运行角色,则无法使用 import_role
,因此必须使用 include_role
。然而,二者各有优缺点,你需要根据你的场景选择最合适的一个——官方的 Ansible 文档(docs.ansible.com/ansible/latest/user_guide/playbooks_reuse.xhtml#dynamic-vs-static
)将帮助你做出正确的决策。
正如我们在本节中所见,角色非常容易上手,并且提供了一种极其强大的方式来组织和重用你的 Ansible 代码。在下一节中,我们将通过查看如何向代码中添加特定角色的变量和依赖项,来扩展我们的简单任务示例。
设置基于角色的变量和依赖项
变量是使 Ansible playbook 和角色可重用的核心,因为它们允许使用相同的代码,只是值或配置数据略有不同。Ansible 角色目录结构允许在两个位置声明特定于角色的变量。虽然一开始这两个位置之间的区别可能并不明显,但它是至关重要的。
基于角色的变量可以放在两个位置之一:
-
defaults/main.yml
-
vars/main.yml
这两个位置之间的区别在于它们在 Ansible 变量优先级顺序中的位置(docs.ansible.com/ansible/latest/user_guide/playbooks_variables.xhtml#variable-precedence-where-should-i-put-a-variable
)。放在defaults/
目录中的变量在优先级上是最低的,因此很容易被覆盖。这个位置是你放置那些希望容易被覆盖的变量的地方,但你又不希望让一个变量未定义。例如,如果你安装 Apache Tomcat,你可能会构建一个角色来安装特定版本。然而,如果有人忘记设置版本,你不希望角色因错误而退出——相反,你更希望设置一个合理的默认值,比如9.0
,然后可以通过清单变量或命令行(使用-e
或--extra-vars
选项)进行覆盖。这样,即使没有人明确设置这个变量,你也知道角色会正常工作,但如果需要,可以轻松地更改为更新的 Tomcat 版本。
然而,放在vars/
目录中的变量在 Ansible 的变量优先级排序中排得更高。这些变量不会被清单变量覆盖,因此应将其用于需要保持静态的变量数据。当然,这并不是说它们不能被覆盖——-e
或--extra-vars
选项具有最高的优先级,因此可以覆盖你定义的任何其他内容。
大多数时候,你可能只会使用defaults/
目录中的变量,但肯定会有一些情况下,将变量放在更高优先级的位置对自动化变得有价值,因此知道你可以使用这个选项是非常重要的。
除了之前描述的基于角色的变量外,还有一个选项,可以通过meta/
目录向角色添加元数据。像之前一样,要使用这个功能,只需在该目录中添加一个名为main.yml
的文件。为了说明如何使用meta/
目录,让我们构建并运行一个实际示例,展示它是如何使用的。不过,在开始之前,需要注意的是,默认情况下,Ansible 解析器只允许你运行一次角色。
这有点类似于我们之前讨论的处理程序,处理程序可以多次调用,但最终只会在剧本结束时运行一次。角色也是如此,它们可以多次引用,但实际上只会运行一次。对此有两个例外——第一个是如果角色被多次调用,但使用了不同的变量或参数;另一个是如果被调用的角色在其meta/
目录中设置了allow_duplicates
为true
。在构建示例时,我们将看到这两种情况的示例:
-
在我们实际示例的最高层次,我们将有一个与本章中使用的相同的库存副本。我们还将创建一个名为
site.yml
的简单剧本,内容如下:--- - name: Role variables and meta playbook hosts: web01.example.org roles: - platform
请注意,我们在此剧本中只是调用了一个名为platform
的角色——剧本本身没有调用其他任何内容。
-
让我们继续创建
platform
角色——与我们之前的角色不同,它将不包含任何任务,甚至不包含任何变量数据;它只会包含一个meta
目录:$ mkdir -p roles/platform/meta
在此目录中,创建一个名为main.yml
的文件,内容如下:
---
dependencies:
- role: linuxtype
type: "fedora"
- role: linuxtype
type: "ubuntu"
这段代码将告诉 Ansible,platform
角色依赖于linuxtype
角色。请注意,我们指定依赖关系时,重复指定了两次,每次我们都传递了一个名为type
的变量,且值不同——通过这种方式,Ansible 解析器允许我们调用该角色两次,因为每次传递给它的变量值不同。
-
现在,让我们继续创建
linuxtype
角色——同样,它不会包含任务,只会有更多的依赖声明:$ mkdir -p roles/linuxtype/meta/
再次,在meta
目录中创建一个main.yml
文件,但这次内容如下:
---
dependencies:
- role: version
- role: network
再次,我们创建更多的依赖——这次,当调用linuxtype
角色时,它反过来又声明了对version
和network
角色的依赖。
-
让我们先创建
version
角色——它将包含meta
和tasks
两个目录:$ mkdir -p roles/version/meta $ mkdir -p roles/version/tasks
在meta
目录中,我们将创建一个名为main.yml
的文件,内容如下:
---
allow_duplicates: true
这个声明在本示例中非常重要——如前所述,通常情况下,Ansible 只允许角色执行一次,即使它被多次调用。将allow_duplicates
设置为true
告诉 Ansible 允许角色执行多次。这是必需的,因为在platform
角色中,我们通过依赖关系调用了两次linuxtype
角色,这意味着我们也将两次调用version
角色。
我们还将在tasks/
目录中创建一个简单的main.yml
文件,它打印传递给角色的type
变量的值:
---
- name: Print type variable
ansible.builtin.debug:
var: type
-
我们现在将重复
network
角色的处理过程——为了保持示例代码的简单性,我们将其定义为与version
角色相同的内容:$ mkdir -p roles/network/meta $ mkdir -p roles/network/tasks
在meta
目录中,我们将再次创建一个main.yml
文件,内容如下:
---
allow_duplicates: true
同样,我们将在tasks
目录中创建一个简单的main.yml
文件,打印传递给角色的type
变量的值:
---
- name: Print type variable
ansible.builtin.debug:
var: type
在此过程的最后,你的目录结构应如下所示:
.
├── hosts
├── roles
│ ├── linuxtype
│ │ └── meta
│ │ └── main.yml
│ ├── network
│ │ ├── meta
│ │ │ └── main.yml
│ │ └── tasks
│ │ └── main.yml
│ ├── platform
│ │ └── meta
│ │ └── main.yml
│ └── version
│ ├── meta
│ │ └── main.yml
│ └── tasks
│ └── main.yml
└── site.yml
11 directories, 8 files
让我们看看当我们运行这个 playbook 时会发生什么。流程应该是这样的——我们初始的 playbook 静态导入了platform
角色。然后,platform
角色声明它依赖于linuxtype
角色,并且在一个名为type
的变量中声明了两次不同的依赖关系。接着,linuxtype
角色声明它依赖于network
和version
角色,这些角色允许多次执行并打印type
的值。因此,我们看到network
和version
角色被调用两次,第一次调用时打印fedora
,第二次调用时打印ubuntu
(因为这就是我们在platform
角色中最初指定的依赖关系)。你可以使用以下命令来执行 playbook:
$ ansible-playbook -i hosts site.yml
该 playbook 执行后的输出应该类似于以下内容:
图 4.3 – 使用具有依赖关系的角色运行 playbook 的示例
这与早期版本的 Ansible(例如 Ansible 2.7)有显著不同,如果你运行相同的代码,你只会看到屏幕上打印出type
为ubuntu
。这展示了一个关于查看 Ansible/ansible-core
不同版本之间代码迁移指南的重要点,并帮助我们理解行为可能发生的变化。虽然在大多数情况下(除了关于集合的根本变化),你的自动化代码在从一个版本升级到下一个版本时会正常工作,但这并不保证,而且像这样的微小变化可能在生产环境中造成灾难性后果。因此,Ansible 代码应当像代码一样被对待,包括在部署新版本时进行测试。
这是一个相当高级的 Ansible 角色依赖示例,但提供这个示例是为了展示了解变量优先级(即变量的作用范围)以及解析器如何工作的知识有多么重要。如果你写的是简单的、按顺序解析的任务,可能永远不需要了解这些内容,但我建议你广泛使用 debug
语句,并测试你的 playbook 设计,以确保在 playbook 开发过程中不会遇到这些问题。
现在我们已经详细了解了多个角色方面的内容,让我们在下一部分中看看公开的 Ansible 角色集中存储——Ansible Galaxy。
Ansible Galaxy
任何关于 Ansible 角色的部分都不能不提 Ansible Galaxy。Ansible Galaxy 是一个由社区驱动的 Ansible 角色和集合的集合,托管在 Red Hat 的 galaxy.ansible.com/
上。它包含了大量由社区贡献的 Ansible 角色和集合,如果你能想象一个自动化任务,很可能有人已经写了代码来完成你所需要的功能。它非常值得探索,能迅速推动你的自动化项目起步,因为你可以开始使用一套现成的角色。
除了网站,ansible-galaxy
客户端也包含在 Ansible 中,它为你提供了一种快速便捷的方式,将角色下载并部署到你的 playbook 结构中。假设你想更新 arillso.motd
,我们可以使用以下命令将它下载到我们的角色目录中:
$ ansible-galaxy role install -p roles/ arillso.motd
你需要做的就是这些——下载完成后,你可以像我们在本章讨论的手动创建的角色那样,将角色导入或包含到你的 playbook 中。请注意,如果你没有指定 -p roles/
,ansible-galaxy
会将角色安装到 ~/.ansible/roles
,即你用户账户的中央角色目录。当然,这可能是你想要的,但如果你希望角色直接下载到你的 playbook 目录结构中,你需要添加这个参数。
另一个有用的小技巧是使用 ansible-galaxy
为你创建一个空的角色目录结构,供你创建自己的角色——这可以节省我们在本章中进行的所有手动目录和文件创建工作,以下是这个示例:
$ ansible-galaxy role init --init-path roles/ testrole
- Role testrole was created successfully
$ tree roles/testrole/
roles/testrole/
├── README.md
├── defaults
│ └── main.yml
├── files
├── handlers
│ └── main.yml
├── meta
│ └── main.yml
├── tasks
│ └── main.yml
├── templates
├── tests
│ ├── inventory
│ └── test.yml
└── vars
└── main.yml
8 directories, 8 files
这些信息应该足够让你开始进入 Ansible 角色的学习旅程。我不能过分强调开发代码为角色的重要性——一开始可能看起来不重要,但随着自动化用例的扩展,以及对代码重用的需求增加,你会为当初做出的决定而感到欣慰。在下一部分,我们将扩展对 Ansible playbook 的理解,讨论如何在 Ansible 代码中使用条件逻辑。
在你的代码中使用条件
到目前为止,在我们的示例中,我们创建了始终运行的简单任务集。然而,当你生成任务(无论是在角色还是剧本中),并希望将其应用于更多主机时,迟早你会希望执行某种条件操作。这可能是仅在前一个任务的结果基础上执行某个任务,或者仅在从托管节点收集到特定信息时执行任务。在本节中,我们将提供一些实用的条件逻辑示例,以应用于你的 Ansible 任务,并展示如何使用这一功能。
一如既往,我们需要一个清单来开始,并且我们将重用本章中使用过的清单:
[frontends]
web01.example.org https_port=8443
web02.example.org http_proxy=proxy.example.org
[frontends:vars]
ntp_server=ntp.web.example.org
proxy=proxy.web.example.org
[apps]
app01.example.org
app02.example.org
[webapp:children]
frontends
apps
[webapp:vars]
proxy_server=proxy.webapp.example.org
health_check_retry=3
health_check_interval=60
假设你只希望在某些操作系统上执行 Ansible 任务。我们已经讨论过 Ansible 的事实,它们为在剧本中开始探索条件逻辑提供了完美的平台。想象一下——针对所有 Fedora 系统,已经发布了一个紧急补丁,你希望立即应用它。当然,你可以通过创建一个特别的清单(或主机组)来处理 Fedora 主机,但这会是额外的工作,并非必要。
相反,让我们定义一个执行更新的任务,添加一个包含 Jinja2 表达式的 when
子句,使得更新仅在基于 Fedora 的主机上执行:
---
- name: Play to patch only Fedora systems
hosts: all
become: true
tasks:
- name: Patch Fedora systems
ansible.builtin.dnf:
name: httpd
state: latest
when: ansible_facts['distribution'] == "Fedora"
现在,当我们运行这个任务时,如果你的测试系统是基于 Fedora 的(而我的其中一个是),你应该看到类似以下的输出:
$ ansible-playbook -i hosts condition.yml
PLAY [Play to patch only Fedora systems] ***************************************
TASK [Gathering Facts] *********************************************************
ok: [web01.example.org]
ok: [app01.example.org]
ok: [web02.example.org]
ok: [app02.example.org]
TASK [Patch Fedora systems] ****************************************************
skipping: [web02.example.org]
skipping: [app01.example.org]
skipping: [app02.example.org]
ok: [web01.example.org]
PLAY RECAP *********************************************************************
app01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
app02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web01.example.org : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
前面的输出显示,我们的系统中只有一个是基于 Fedora 的,它返回了 ok
状态,表示补丁已经应用。现在,我们可以使逻辑更加精确——也许只有运行 Fedora 35 的遗留系统需要应用补丁。在这种情况下,我们可以扩展剧本中的逻辑,检查发行版和主要版本,如下所示:
---
- name: Play to patch only Fedora systems
hosts: all
become: true
tasks:
- name: Patch Fedora systems
yum:
name: httpd
state: latest
when: (ansible_facts['distribution'] == "Fedora" and ansible_facts['distribution_major_version'] == "35")
现在,如果我们运行修改后的剧本,根据你清单中的系统,可能会看到类似以下的输出。在这种情况下,所有系统都被跳过了,因为它们没有匹配我的逻辑表达式(因此,我可以放心,知道这个清单中没有遗留系统需要担心):
$ ansible-playbook -i hosts condition2.yml
PLAY [Play to patch only Fedora systems] ***************************************
TASK [Gathering Facts] *********************************************************
ok: [app01.example.org]
ok: [web01.example.org]
ok: [app02.example.org]
ok: [web02.example.org]
TASK [Patch Fedora systems] ****************************************************
skipping: [web01.example.org]
skipping: [web02.example.org]
skipping: [app01.example.org]
skipping: [app02.example.org]
PLAY RECAP *********************************************************************
app01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
app02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web01.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web02.example.org : ok=1 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
当然,这种条件逻辑不仅限于 Ansible 的事实,在使用 ansible.builtin.shell
或 ansible.builtin.command
模块时也非常有价值。当你运行 任何 Ansible 模块时,该模块会返回一个数据,详细描述其运行结果。你可以使用 register
关键字将其捕获到 Ansible 变量中,然后在剧本后续中进一步处理。
请考虑以下的 playbook 代码。它包含两个任务,第一个任务是获取当前目录的文件列表,并将 ansible.builtin.shell
模块的输出捕获到名为 shellresult
的变量中。然后,我们打印一个简单的 ansible.builtin.debug
消息,但只有在 ansible.builtin.shell
模块执行的输出中包含 hosts
字符串时才会打印:
---
- name: Play to test for hosts file in directory output
hosts: localhost
tasks:
- name: Gather directory listing from local system
ansible.builtin.shell: "ls -l"
register: shellresult
- name: Alert if we find a hosts file
ansible.builtin.debug:
msg: "Found hosts file!"
when: '"hosts" in shellresult.stdout'
现在,当我们在当前目录中运行这个任务时,如果你从本书随附的 GitHub 仓库工作,当前目录将包含一个名为 hosts
的文件,那么你应该会看到类似于以下的输出:
$ ansible-playbook condition3.yml
PLAY [Play to test for hosts file in directory output] *************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Gather directory listing from local system] ******************************
changed: [localhost]
TASK [Alert if we find a hosts file] *******************************************
ok: [localhost] => {
"msg": "Found hosts file!"
}
PLAY RECAP *********************************************************************
localhost : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
然而,如果文件不存在,你会看到 ansible.builtin.debug
消息被跳过:
$ ansible-playbook condition3.yml
PLAY [Play to test for hosts file in directory output] *************************
TASK [Gathering Facts] *********************************************************
ok: [localhost]
TASK [Gather directory listing from local system] ******************************
changed: [localhost]
TASK [Alert if we find a hosts file] *******************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
你还可以为生产环境中的 IT 运维任务创建复杂的条件;然而,请记住,在 Ansible 中,变量默认不会被转换为特定类型,所以即使一个变量(或事实)的内容看起来像数字,Ansible 也会默认将其视为字符串。如果你需要进行整数比较,必须先将变量转换为整数类型。例如,这里是一个 playbook 的片段,它只会在 Fedora 35 及更新版本上运行任务:
tasks:
- name: Only perform this task on Fedora 35 and later
ansible.builtin.shell: echo "only on Fedora 35 and later"
when: ansible_facts['distribution'] == "Fedora" and ansible_facts['distribution_major_version']|int >= 35
你可以对 Ansible 任务应用许多不同类型的条件,这一节只是简单介绍了其中的一些;然而,它应该为你提供一个坚实的基础,帮助你扩展在 Ansible 中应用条件的知识。不仅可以将条件逻辑应用于 Ansible 任务,还可以将任务在数据集上循环运行,这一点我们将在下一节中进行探讨。
使用循环重复任务
很多时候,我们需要执行一个单独的任务,但希望它能遍历一组数据。也许你正在为不同的团队在服务器上创建 15 个新的用户组。要实现这一目标,如果你需要在一个 Ansible play 中编写 15 个单独的任务,那将是非常低效的——而 Ansible 的核心理念就是提高效率,节省用户时间。为了实现这一效率,Ansible 支持对数据集进行循环,以确保你可以使用简洁的代码执行大规模的操作。在本节中,我们将探讨如何在 Ansible playbook 中实际应用循环。
一如既往,我们必须从一个清单开始工作,我们将使用我们目前已熟悉的清单,这也是本章中一直在使用的清单:
[frontends]
web01.example.org https_port=8443
web02.example.org http_proxy=proxy.example.org
[frontends:vars]
ntp_server=ntp.web.example.org
proxy=proxy.web.example.org
[apps]
app01.example.org
app02.example.org
[webapp:children]
frontends
apps
[webapp:vars]
proxy_server=proxy.webapp.example.org
health_check_retry=3
health_check_interval=60
让我们从一个非常简单的 playbook 开始,向你展示如何在单个任务中遍历一组数据。虽然这是一个非常牵强的例子,但它的目的是简单地展示如何在 Ansible 中使用循环的基本原理。我们将定义一个单一任务,它会在清单中的单台主机上运行 ansible.builtin.command
模块,并使用 ansible.builtin.command
模块在远程系统上依次 echo
数字 1 到 6(稍加想象,这个例子可以很容易扩展为添加用户账户或创建一系列文件)。
请考虑以下代码:
---
- name: Simple loop demo play
hosts: web01.example.org
tasks:
- name: Echo a value from the loop
ansible.builtin.command: echo "{{ item }}"
loop:
- 1
- 2
- 3
- 4
- 5
- 6
loop:
语句定义了循环的开始,循环中的项目被定义为 YAML 列表。还要注意更高的缩进级别,这告诉解析器它们是循环的一部分。当我们处理循环数据时,我们使用一个名为item
的特殊变量,它包含当前循环迭代的值。在运行这个 playbook 时,我们应该看到类似以下的输出:
$ ansible-playbook -i hosts loop1.yml
PLAY [Simple loop demo play] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01.example.org]
TASK [Echo a value from the loop] **********************************************
changed: [web01.example.org] => (item=1)
changed: [web01.example.org] => (item=2)
changed: [web01.example.org] => (item=3)
changed: [web01.example.org] => (item=4)
changed: [web01.example.org] => (item=5)
changed: [web01.example.org] => (item=6)
PLAY RECAP *********************************************************************
web01.example.org : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
你可以将我们在前一节中讨论的条件逻辑与循环结合起来,使循环仅作用于其数据的一个子集。例如,考虑以下的 playbook 迭代:
---
- name: Simple loop demo play
hosts: web01.example.org
tasks:
- name: Echo a value from the loop
ansible.builtin.command: echo "{{ item }}"
loop:
- 1
- 2
- 3
- 4
- 5
- 6
when: item|int > 3
现在,当我们运行这个时,我们可以看到任务会被跳过,直到我们在循环内容中达到整数值4
及更高:
$ ansible-playbook -i hosts loop2.yml
PLAY [Simple loop demo play] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01.example.org]
TASK [Echo a value from the loop] **********************************************
skipping: [web01.example.org] => (item=1)
skipping: [web01.example.org] => (item=2)
skipping: [web01.example.org] => (item=3)
changed: [web01.example.org] => (item=4)
changed: [web01.example.org] => (item=5)
changed: [web01.example.org] => (item=6)
PLAY RECAP *********************************************************************
web01.example.org : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
当然,你可以像我们之前讨论的那样,将其与基于 Ansible facts 和其他变量的条件逻辑结合使用。就像我们之前用register
关键字捕获模块执行结果一样,我们也可以在循环中做到这一点。唯一的不同是,结果现在将存储在一个字典中,每次循环迭代都会有一个字典项,而不是只有一组结果。
因此,让我们来看一下如果我们进一步增强这个 playbook 会发生什么,如下所示:
---
- name: Simple loop demo play
hosts: web01.example.org
tasks:
- name: Echo a value from the loop
ansible.builtin.command: echo "{{ item }}"
loop:
- 1
- 2
- 3
- 4
- 5
- 6
when: item|int > 3
register: loopresult
- name: Print the results from the loop
ansible.builtin.debug:
var: loopresult
现在,当我们运行 playbook 时,你会看到包含loopresult
内容的字典输出的多页内容。以下输出因空间原因进行了截断,但展示了你运行这个 playbook 后应当期待的结果:
$ ansible-playbook -i hosts loop3.yml
PLAY [Simple loop demo play] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01.example.org]
TASK [Echo a value from the loop] **********************************************
skipping: [web01.example.org] => (item=1)
skipping: [web01.example.org] => (item=2)
skipping: [web01.example.org] => (item=3)
changed: [web01.example.org] => (item=4)
changed: [web01.example.org] => (item=5)
changed: [web01.example.org] => (item=6)
TASK [Print the results from the loop] *****************************************
ok: [web01.example.org] => {
"loopresult": {
"changed": true,
"msg": "All items completed",
"results":
{
"ansible_loop_var": "item",
"changed": false,
"item": 1,
"skip_reason": "Conditional result was False",
"skipped": true
},
{
"ansible_loop_var": "item",
"changed": false,
"item": 2,
"skip_reason": "Conditional result was False",
"skipped": true
},
如你所见,输出中的results
部分是一个字典,我们可以清楚地看到列表中的前两个项目被跳过
,因为我们的when
子句(条件)的结果为false
。
因此,到目前为止我们可以看到,循环是容易定义和使用的——但你可能会问,你能创建嵌套循环吗? 答案是可以,但有一个问题——名为item
的特殊变量会发生冲突,因为内外层的循环都会使用相同的变量名。这意味着,最好的情况是你的嵌套循环的结果会是意外的。
幸运的是,有一个名为loop_control
的loop
参数,它允许你将包含当前loop
迭代数据的特殊变量的名称从item
更改为你选择的名称。让我们创建一个嵌套循环来看看它是如何工作的。
首先,我们将按照通常的方式创建一个 playbook,里面包含一个在循环中运行的单个任务。为了生成我们的嵌套循环,我们将使用include_tasks
指令动态地从另一个 YAML 文件中包含一个单一任务,这个文件也将包含一个循环。因为我们打算在嵌套循环中使用这个 playbook,所以我们将使用loop_var
指令将特殊循环内容变量的名称从item
更改为second_item
:
---
- name: Play to demonstrate nested loops
hosts: localhost
tasks:
- name: Outer loop
ansible.builtin.include_tasks: loopsubtask.yml
loop:
- a
- b
- c
loop_control:
loop_var: second_item
然后,我们将创建一个名为 loopsubtask.yml
的第二个文件,它包含内部循环,并被包含在之前的 playbook 中。由于我们已经在外部循环中更改了循环项变量名,因此在此不需要再次更改。请注意,这个文件的结构非常像角色中的任务文件——它不是完整的 playbook,而仅仅是任务的列表:
---
- name: Inner loop
ansible.builtin.debug:
msg: "second item={{ second_item }} first item={{ item }}"
loop:
- 100
- 200
- 300
现在你应该能够运行 playbook,你会看到 Ansible 首先迭代外部循环,然后在外部循环定义的数据上处理内部循环。通过运行以下命令测试此 playbook:
$ ansible-playbook loopmain.yml
由于循环变量名没有冲突,一切如我们预期的那样运行:
![图 4.4 – 演示在 Ansible playbook 中嵌套循环的示例
图 4.4 – 演示在 Ansible playbook 中嵌套循环的示例
循环很容易使用,却非常强大,因为它们允许你轻松地使用一个任务来迭代大量数据集。在下一节中,我们将探讨 Ansible 语言的另一种结构,用于控制 playbook 流程——块。
使用块对任务进行分组
Ansible 中的块允许你将一组任务逻辑地组合在一起,主要有两个目的。一个目的是将条件逻辑应用于整个任务集;在这个示例中,你可以对每个任务应用相同的 when
子句,但这样做既繁琐又低效——将所有任务放在一个块中,并将条件逻辑应用于块本身要更好。这样,逻辑只需要声明一次。当涉及到错误处理,尤其是从错误条件中恢复时,块也非常有价值。我们将在本章中通过简单的实践示例来探讨这两种情况,帮助你快速掌握 Ansible 中的块。
一如既往,我们确保有一个可用的清单来进行操作:
[frontends]
web01.example.org https_port=8443
web02.example.org http_proxy=proxy.example.org
[frontends:vars]
ntp_server=ntp.web.example.org
proxy=proxy.web.example.org
[apps]
app01.example.org
app02.example.org
[webapp:children]
frontends
apps
[webapp:vars]
proxy_server=proxy.webapp.example.org
health_check_retry=3
health_check_interval=60
现在,让我们直接深入,看看如何使用块将条件逻辑应用于一组任务的示例。从高层次来说,假设我们想要在所有 Fedora Linux 主机上执行以下操作:
-
安装 Apache Web 服务器的包
-
安装模板化配置
-
启动适当的服务
我们可以通过三个独立的任务来实现,每个任务都带有一个 when
子句,但块为我们提供了更好的方式。以下示例 playbook 展示了三个任务,它们被包含在一个块中(注意需要额外的缩进级别来表示它们包含在块内):
---
- name: Conditional block play
hosts: all
become: true
tasks:
- name: Install and configure Apache
block:
- name: Install the Apache package
ansible.builtin.dnf:
name: httpd
state: installed
- name: Install the templated configuration to a dummy location
ansible.builtin.template:
src: templates/src.j2
dest: /tmp/my.conf
- name: Start the httpd service
ansible.builtin.service:
name: httpd
state: started
enabled: True
when: ansible_facts['distribution'] == 'Fedora'
使用以下命令运行你的 playbook:
$ ansible-playbook -i hosts blocks.yml
你应该发现,涉及 Apache 的任务只会在你的清单中任何 Fedora 主机上运行;你应该看到这三个任务要么全部执行,要么跳过——这取决于你清单的构成和内容,它可能看起来像这样:
图 4.5 – 展示剧本中使用块来条件执行任务的示例
这非常简单易行,但在控制大量任务流程方面非常强大。
这次,让我们构建一个不同的示例来演示块如何帮助 Ansible 优雅地处理错误条件。到目前为止,您应该已经看到,如果您的剧本遇到任何错误,它们可能会在失败点停止执行。在某些情况下,这远非理想,您可能希望在此事件中执行某种恢复操作,而不只是简单地停止剧本。
让我们创建一个新的剧本,这次内容如下:
---
- name: Play to demonstrate block error handling
hosts: frontends
tasks:
- name: block to handle errors
block:
- name: Perform a successful task
ansible.builtin.debug:
msg: 'Normally executing....'
- name: Deliberately create an error
ansible.builtin.command: /bin/whatever
- name: This task should not run if the previous one results in an error
ansible.builtin.debug:
msg: 'Never print this message if the above command fails!!!!'
rescue:
- name: Catch the error (and perform recovery actions)
ansible.builtin.debug:
msg: 'Caught the error'
- name: Deliberately create another error
ansible.builtin.command: /bin/whatever
- name: This task should not run if the previous one results in an error
ansible.builtin.debug:
msg: 'Do not print this message if the above command fails!!!!'
always:
- name: This task always runs!
ansible.builtin.debug:
msg: "Tasks in this part of the play will be ALWAYS executed!!!!"
注意,在前面的剧本中,我们现在有额外的block
部分 – 除了block
本身的任务外,我们还有两个标记为rescue
和always
的新部分。执行流程如下:
-
所有
block
部分的任务按列出的顺序正常执行。 -
如果
block
中的任务导致错误,则不会继续运行block
中的其他任务:-
rescue
部分的任务按列出的顺序开始运行 -
如果
block
任务未导致错误,则不会运行rescue
部分的任务
-
-
如果在
rescue
部分运行任务时出错,则不会执行更多的rescue
任务,并且执行将转移到always
部分。 -
always
部分的任务始终运行,无论block
或rescue
部分是否存在任何错误。即使遇到没有错误的情况,它们也会运行。
测试你的剧本代码,运行以下命令:
$ ansible-playbook -i hosts blocks-error.yml
考虑到这种执行流程,请在执行此剧本时看到类似以下输出的内容,注意我们已故意创建了两个错误条件来演示流程:
图 4.6 – 演示剧本中错误处理中使用块的情况
Ansible 有两个特殊变量,它们包含在rescue
块中可能对执行恢复操作有用的信息:
-
ansible_failed_task
:这是一个字典,包含导致我们进入rescue
部分的block
失败的任务的详细信息。您可以通过使用ansible.builtin.debug
来显示其内容,例如,可以从ansible_failed_task.name
获取失败任务的名称。 -
ansible_failed_result
:这是失败任务的结果,并且与如果将register
关键字添加到失败任务中相同。如果失败,则无需在每个块中的任务中添加register
,这将节省您的时间。
随着你的 playbook 变得越来越复杂,错误处理变得越来越重要(或者说条件逻辑变得更为关键),block
将成为编写健壮 playbook 的重要工具。接下来的章节我们将继续探讨执行策略,以便更好地控制你的 playbook 运行。
通过策略配置 play 执行
随着你的 playbook 变得越来越复杂,调试可能出现的问题变得越来越重要。例如,是否有办法在执行过程中检查给定变量(或多个变量)的内容,而不需要在整个 playbook 中插入 ansible.builtin.debug
语句?类似地,到目前为止我们已经看到,Ansible 会确保在应用到所有库存主机后,特定任务在开始下一个任务之前完成——是否有办法改变这一点?
当你开始使用 Ansible 时,默认的执行策略(我们到目前为止在每个执行过的 playbook 中都看到了这个策略,尽管我们没有专门提到它)被称为 linear
。它正如其名字所示——每个任务都会依次在所有适用的主机上执行,然后才开始下一个任务。然而,还有另一种不太常用的策略叫做 free
,它允许所有任务在每个主机上尽可能快速地完成,而无需等待其他主机。
然而,当你开始使用 Ansible 时,最有用的策略将是 debug
策略,它使得 Ansible 在 playbook 中发生错误时能够直接将你带入集成的调试环境。我们通过创建一个包含故意错误的 playbook 来演示这一点。注意 play 定义中的 strategy: debug
和 debugger: on_failed
语句:
---
- name: Play to demonstrate the debug strategy
hosts: web01.example.org
strategy: debug
debugger: on_failed
gather_facts: no
vars:
name: james
tasks:
- name: Generate an error by referencing an undefined variable
ansible.builtin.ping: data={{ mobile }}
现在,如果你执行这个 playbook,你应该会看到它开始运行,但当遇到其中故意设置的错误时,它会进入集成调试器。输出的开始部分应该类似于以下内容:
$ ansible-playbook -i hosts debug.yml
[WARNING]: Found variable using reserved name: name
PLAY [Play to demonstrate the debug strategy] **********************************
TASK [Generate an error by referencing an undefined variable] ******************
fatal: [web01.example.org]: FAILED! => {"msg": "The task includes an option with an undefined variable. The error was: 'mobile' is undefined. 'mobile' is undefined\n\nThe error appears to be in '/home/james/Practical-Ansible-Second-Edition/Chapter 4/debug.yml': line 11, column 7, but may\nbe elsewhere in the file depending on the exact syntax problem.\n\nThe offending line appears to be:\n\n tasks:\n - name: Generate an error by referencing an undefined variable\n ^ here\n"}
[web01.example.org] TASK: Generate an error by referencing an undefined variable (debug)> p
***SyntaxError:SyntaxError('invalid syntax', ('<string>', 0, 0, '', 0, 0))
[web01.example.org] TASK: Generate an error by referencing an undefined variable (debug)> p task_vars
{'ansible_check_mode': False,
'ansible_config_file': None,
'ansible_current_hosts': ['web01.example.org'],
'ansible_dependent_role_names': [],
'ansible_diff_mode': False,
'ansible_facts': {},
'ansible_failed_hosts': [],
'ansible_forks': 5,
...
[web01.example.org] TASK: Generate an error by referencing an undefined variable (debug)> quit
User interrupted execution
$
请注意,playbook 开始执行,但由于变量未定义,它在第一个任务上失败。然而,它并没有退出到 shell,而是进入了交互式调试器。我们在前面的输出中展示了如何使用调试器的简单示例,如果你想更全面地了解如何使用它,Ansible 文档提供了完整的命令以及如何使用它们的细节:docs.ansible.com/ansible/latest/user_guide/playbooks_debugger.xhtml
。
为了带你了解一个非常简单的调试示例,在提示符下输入 p task
命令——这将导致 Ansible 调试器打印出失败任务的名称,如果你正在处理一个大的 playbook,这将非常有用:
[web01.example.org] TASK: Generate an error by referencing an undefined variable (debug)> p task
TASK: Generate an error by referencing an undefined variable
[web01.example.org] TASK: Generate an error by referencing an undefined variable
现在,我们知道了剧本失败的原因,因此让我们通过执行p task.args
命令深入挖掘一下,这将显示传递给模块的任务参数:
(debug)> p task.args
{'data': '{{ mobile }}'}
[web01.example.org] TASK: Generate an error by referencing an undefined variable
所以,我们可以看到我们的模块传递了一个名为data
的参数,参数值是一个变量(由成对的大括号表示),名为mobile
。因此,查看任务中可用的变量,看看这个变量是否存在,并且如果存在,值是否合理,可能是合乎逻辑的(使用p task_vars
命令来做这件事):
(debug)> p task_vars
{'ansible_check_mode': False,
'ansible_config_file': None,
'ansible_current_hosts': ['web01.example.org'],
'ansible_dependent_role_names': [],
'ansible_diff_mode': False,
'ansible_facts': {},
'ansible_failed_hosts': [],
'ansible_forks': 5,
上面的输出被截断,你会发现任务中有很多与之相关的变量——这是因为任何收集到的事实和内部的 Ansible 变量都会对任务可用。然而,如果你滚动浏览列表,你将能确认没有名为mobile
的变量。
因此,这些信息应该足够让你修复你的剧本。输入q
退出调试器:
[web01.example.org] TASK: Generate an error by referencing an undefined variable (debug)> q
User interrupted execution
$
Ansible 调试器是一个非常强大的工具,尤其是当你的剧本复杂度增长时,你应该学会有效地使用它。这是我们对剧本设计各个方面的实际讲解的结束——在接下来的章节中,我们将看看如何将 Git 源代码管理集成到你的剧本中。
使用 ansible-pull
当然,与 Ansible 代码的理想工作方式是将其存储在版本控制仓库中。这是一个宝贵的步骤,确保所有更改都被跟踪,并且每个负责自动化的人都使用相同的代码。然而,这也带来了一种低效性——最终用户必须记得检出(或拉取)代码的最新版本,然后执行它,尽管这并不困难,但手动任务既是效率的敌人,也容易导致错误的发生。幸运的是,Ansible 再次通过提供工具来确保能够实现最有效的方法来支持我们,一个名为ansible-pull
的特殊命令可以用来从 Git 仓库中获取最新的代码并执行它,只需一个命令即可完成。这不仅提高了最终用户的效率(并减少了人为错误的机会),还使得自动化任务可以无人值守地运行(例如,使用像cron
这样的调度程序)。
然而,需要注意的是,尽管ansible
和ansible-playbook
命令可以在整个库存上运行,并在一个或多个远程主机上执行剧本,但ansible-pull
命令仅打算在localhost
上运行它从源控制系统获取的剧本。因此,如果你想在整个基础设施中使用ansible-pull
,你必须将其安装到每个需要它的主机上。
尽管如此,让我们看看这如何运作。我们将通过手动运行命令来探索其应用,但实际上,你几乎肯定会将其安装在 crontab
中,这样它就能定期运行,拾取你在版本控制系统中对 playbook 所做的任何更改。
如前所述,ansible-pull
仅用于在本地系统上运行 playbook,因此清单文件有些多余 —— 相反,我们将使用一个很少用到的清单指定方法,你可以在命令行上简单地指定清单主机目录作为以逗号分隔的列表。如果只有一个主机,只需指定其名称并加上逗号。
我们使用 GitHub 上的一个简单的 playbook,根据变量内容设置每日消息。为了做到这一点,我们将运行以下命令(稍后我们将逐步分析):
$ ansible-pull -d /tmp/ansible-set-motd -i ${HOSTNAME}, -U https://github.com/jamesfreeman959/ansible-set-motd.git site.yml -e "ag_motd_content='MOTD generated by ansible-pull'" >> /tmp/ansible-pull.log 2>&1
该命令的拆解如下:
-
-d /tmp/ansible-set-motd
:这设置了包含从 GitHub 上检出的代码的工作目录。确保该目录对运行ansible-pull
命令的用户账户可写。 -
-i ${HOSTNAME},
:这只在当前主机上运行,由其主机名和相应的 shell 变量指定。 -
-U
github.com/jamesfreeman959/ansible-set-motd.git
:我们使用这个 URL 来获取 playbooks。 -
site.yml
:这是从之前指定的 Git 仓库中运行的 playbook 的名称。 -
-e "ag_motd_content='MOTD 由 ansible-pull 生成'"
:这设置了适当的 Ansible 变量来生成 MOTD 内容。 -
>> /tmp/ansible-pull.log 2>&1
:这将命令的输出重定向到日志文件,以便我们以后分析 —— 如果在 cron 作业中运行命令,这尤其有用,因为输出不会打印到用户的终端。
你可以使用以下命令进行测试:
$ ansible-pull -d /tmp/ansible-set-motd -i ${HOSTNAME}, -U https://github.com/jamesfreeman959/ansible-set-motd.git site.yml -e "ag_motd_content='MOTD generated by ansible-pull'"
你应该看到类似于以下的输出(注意,日志重定向已被去除,以便更容易查看输出):
图 4.7 – 演示 ansible-pull 的使用
这个命令可以成为你整体 Ansible 解决方案中的一个非常强大的部分,特别是因为它意味着你不需要太担心集中运行所有 playbooks,或者确保它们在每次运行时都保持最新。将其安排在 cron
中的能力在大规模基础设施中尤其强大,理想情况下,自动化意味着一切应该自行解决。
这就是我们对 playbooks 的实际操作示范,以及如何编写自己的代码 —— 通过对 Ansible 模块进行一些研究,你现在应该能够轻松编写自己的强大 playbooks。
总结
Playbooks 是 Ansible 自动化的核心,提供了一个强大的框架,用于定义逻辑任务集合,并干净、稳健地处理错误情况。在此框架中加入角色,既有助于组织代码,又能在自动化需求增长时支持代码重用。Ansible 的 playbook 为您的技术需求提供了一个真正完整的自动化解决方案。
在本章中,您了解了 playbook 框架以及如何开始构建自己的 playbook。接着,您学习了如何将代码组织成角色,并设计代码以有效和高效地支持重用。然后,我们探讨了一些更高级的 playbook 编写主题,如条件逻辑、块和循环的使用。最后,我们回顾了 playbook 执行策略,特别是如何有效调试我们的 playbook,并总结了如何直接从 GitHub 运行 Ansible playbook。
在下一章中,我们将学习如何使用和创建我们自己的模块,为您提供扩展 Ansible 功能的技能,以适应您定制的环境并为社区做出贡献。
问题
-
如何通过临时命令在
frontends
主机组中重启 Apache web 服务器?-
ansible frontends -i hosts -a "``name=httpd state=restarted"
-
ansible frontends -i hosts -b ansible.builtin.service -a "``name=httpd state=restarted"
-
ansible frontends -i hosts -b -m ansible.builtin.service -a "``name=httpd state=restarted"
-
ansible frontends -i hosts -b -m ansible.builtin.server -a "``name=httpd state=restarted"
-
ansible frontends -i hosts -m restart -``a "name=httpd"
-
-
块允许您将任务逻辑上分组或执行错误处理:
-
正确
-
错误
-
-
默认策略通过 playbook 中的相关模块实现:
-
正确
-
错误
-
进一步阅读
ansible-galaxy
及其文档可以在此找到:galaxy.ansible.com/docs/
。
第二部分:扩展 Ansible 的功能
在本节中,我们将涵盖 Ansible 插件和模块的重要概念。我们将讨论它们的有效使用,以及如何通过编写自己的插件、模块和集合来扩展 Ansible 的功能。我们甚至会看看将模块和插件提交到官方 Ansible 项目的要求。我们还将探讨编程最佳实践,以及一些高级 Ansible 技巧,帮助您在处理集群环境时安全地自动化基础设施。
本节包含以下章节:
-
第五章,创建和使用 模块
-
第六章,创建和使用 集合
-
第七章,创建和使用 插件
-
第八章,编码最佳实践
-
第九章,高级 Ansible 主题
第五章:创建和使用模块
在本书的整个过程中,我们几乎一直在引用并使用 Ansible 模块。我们将这些模块视为黑箱——也就是说,我们只是接受它们的存在,并且相信它们会以某种已记录的方式工作。然而,Ansible 的许多优点之一是它是一个开源项目,因此你不仅可以查看和修改其源代码,还可以开发自己的扩展功能。截止目前,Ansible 已拥有 3300 多个模块,处理从简单的命令(如复制文件和安装包)到配置高度复杂和定制的网络设备等各种任务。这些模块的庞大阵容来源于 Ansible 解决问题的真实需求,而且每次发布新版本时,包含的模块数量都会增加。
迟早,你会遇到一种当前 Ansible 模块中不存在的特定功能。当然,你可以尝试通过编写自己的模块或将某个现有模块的增强功能贡献回 Ansible 项目来填补这个空白,从而让其他人也能受益。在本章中,你将学习创建模块的基础知识,并了解如何将你的代码贡献回上游的 Ansible 项目(如果你愿意的话)。
本章将具体涉及以下主题:
-
使用命令行执行多个模块
-
审查模块索引
-
从命令行访问模块文档
-
模块返回值
-
开发自定义模块
开始吧!
技术要求
本章假设你已经按照第一章《开始使用 Ansible》的详细说明,设置好了控制主机,并且正在使用最新版本的 Ansible——本章的示例是在ansible-core
版本 2.15 下测试的。本章还假设你至少有一个额外的主机进行测试。理想情况下,该主机应为基于 Linux 的。尽管本章中将给出具体的主机名示例,你可以自由地用自己的主机名和/或 IP 地址替换它们。如何进行替换的详细说明将在适当的位置提供。
本章中将涉及的模块开发工作假定你的计算机上已安装 Python 3 开发环境,并且你正在运行 Linux、FreeBSD 或 macOS 之一。如果需要额外的 Python 模块,其安装过程已记录。本章中模块文档的构建任务有一些关于 Python 3.10 或更高版本的特定要求,因此如果你希望尝试此任务,你需要安装合适的 Python 环境。
本章的代码包可以在这里获取:github.com/PacktPublishing/Practical-Ansible-Second-Edition/tree/main/Chapter%205
。
使用命令行执行多个模块
由于本章内容主要围绕模块及其创建方式,因此让我们回顾一下如何使用模块。我们在本书中一直在这样做,但没有特别指出它们的工作方式的某些细节。我们尚未讨论的一个关键点是 Ansible 引擎如何与其模块进行交互,反之亦然,接下来我们就来探索这个问题。
和往常一样,在使用 Ansible 命令时,我们需要一个清单来执行命令。在本章中,由于我们关注的是模块本身,我们将使用一个非常简单且小的清单,如下所示:
[frontends]
frt01.example.com
[appservers]
app01.example.com
现在,在我们的回顾部分,使用临时命令可以非常轻松地运行模块,并使用 -m
选项告诉 Ansible 你要运行哪个模块。因此,你可以运行的最简单命令之一是 Ansible 的 ping
命令,如下所示:
$ ansible -i hosts appservers -m ping
现在,让我们查看前面命令的输出:
$ ansible -i hosts appservers -m ping
app01.example.com | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
你注意到输出的结构了吗——大括号、冒号和逗号?是的,Ansible 使用 JSON 格式的数据与模块进行交互,而模块也以 JSON 格式将数据报告回 Ansible。前面的输出是 ping
模块返回给 Ansible 引擎的 JSON 格式数据的一个子集。
当然,在使用命令行中的 key=value
对或在 playbook 和角色中使用 YAML 与模块交互时,我们永远不需要担心这个问题。因此,JSON 对我们来说是隐藏的,但这是一个重要的事实,在我们进入模块开发的世界时需要牢记。
Ansible 模块就像高级编程语言中的函数一样,它们接受一组定义良好的输入参数,执行功能,然后提供一组输出数据,这些输出数据也是明确定义和记录的。我们将在本章稍后详细讨论这一点。当然,前面的命令没有包括任何参数,因此这是通过 Ansible 调用模块的最简单方式。
现在,让我们运行另一个带有参数并将数据传递给模块的命令:
$ ansible -i hosts appservers -m command -a "/bin/echo 'hello modules'"
在这种情况下,我们向命令模块提供了一个字符串作为参数,Ansible 会将其转换为 JSON,并在调用时传递给命令模块。当你运行这个临时命令时,你会看到类似以下的输出:
$ ansible -i hosts appservers -m command -a "/bin/echo 'hello modules'"
app01.example.com | CHANGED | rc=0 >>
hello modules
在这个例子中,输出数据似乎不是 JSON 格式的;然而,当你运行模块时,Ansible 打印到终端的内容仅是每个模块返回的数据的一个子集——例如,命令的 CHANGED
状态和 rc=0
退出代码都以 JSON 格式的数据结构传回给 Ansible——这些只是对我们隐藏了。
这一点不需要过多解释,但设定背景是很重要的。正是基于这个背景,我们将在本章中不断构建,所以请记住以下关键点:
-
Ansible 和其模块之间的通信是通过 JSON 格式的数据结构进行的
-
模块接受控制其功能的输入数据(参数)
-
模块总是返回数据——至少是模块执行的状态(例如,
changed
、ok
或failed
)。
当然,在开始编码模块之前,首先检查是否已经存在可以执行你所需要的所有(或部分)功能的模块是有意义的。我们将在下一节探讨这一点。
审查模块索引
正如前一节所讨论的,Ansible 提供了成千上万个模块,使得开发 playbooks 并在多个主机上运行它们变得快速而容易。然而,在这么多模块中,如何找到合适的模块开始使用呢?幸运的是,Ansible 文档提供了一个组织良好的、带有索引的模块列表,你可以查阅该列表来找到所需的模块——你可以在这里找到它:docs.ansible.com/ansible/latest/collections/index_module.xhtml
。
假设你想看看是否有一个原生的 Ansible 模块可以帮助你配置和管理你的 Amazon Web Services S3 存储桶。这是一个相当精确、明确的需求,所以让我们按逻辑来处理:
-
按照之前讨论的,首先在你的网页浏览器中打开所有模块的索引:
docs.ansible.com/ansible/latest/collections/index_module.xhtml
。 -
现在,我们知道
amazon.aws
模块肯定在这页的最开始部分已经列出了。 -
这页上仍然列出了成千上万个模块!所以,让我们在浏览器中使用查找功能(Ctrl + F),看看
amazon.aws.s3
这个关键词是否出现:
图 5.1 – Amazon 模块
现在我们已经有了一个模块的短名单——虽然有几个模块,所以我们仍然需要决定哪个(或哪些)模块适合我们的 playbook。正如前面简短描述所示,这将取决于你预期的任务是什么。
- 简短的描述应该足够给你一些线索,判断该模块是否适合你的需求。一旦你有了大致的想法,可以点击相应的文档链接查看该模块的更多详情及如何使用:
图 5.2 – Amazon S3 模块详情
- 如你所见,每个模块的文档页面提供了大量信息,包括更长的描述。如果你向下滚动页面,你会看到可以提供给模块的参数列表,一些实际的使用示例,以及有关模块输出的细节。同时,请注意,如果你在没有安装
boto3
和botocore
模块的 Python 3.6 或更高版本上运行 playbook 时使用aws_s3
模块,你将会收到一个错误。
所有模块在被接受作为 Ansible 项目的一部分之前,必须有这样的文档,因此如果你打算提交自己的模块,必须记住这一点。这也是 Ansible 受欢迎的原因之一——凭借易于维护且文档完善的标准,它是自动化的完美社区平台。然而,官方的 Ansible 网站并不是你唯一可以获取文档的地方,因为它甚至可以在命令行中使用。我们将在下一节中讨论如何通过这种方式检索文档。
从命令行访问模块文档
如前一节所述,Ansible 项目以其文档为荣,确保文档易于访问是项目的一个重要部分。现在,假设你正在处理一个 Ansible 任务(无论是在 playbook、角色中,还是在临时命令中),并且你处于一个数据中心环境,只有你所工作的机器的 shell 可用。你该如何获取 Ansible 文档?
幸运的是,我们尚未讨论的 Ansible 安装的一部分是 ansible-doc
工具,它与熟悉的 ansible
和 ansible-playbook
可执行文件一起作为标准安装。ansible-doc
命令包含了所有随你安装的 Ansible 版本一起发布的模块的完整(基于文本的)文档库。这意味着,无论你身处数据中心并且没有网络连接,你都能触手可及地获取到你需要的模块信息!
以下是一些示例,展示如何使用 ansible-doc
工具:
-
你可以通过简单地执行以下命令,列出 Ansible 控制机上所有有文档的模块:
$ ansible-doc -l
你应该看到类似于以下的输出:
amazon.aws.autoscaling_group Create or delete AWS AutoScaling..
amazon.aws.autoscaling_group_info Gather information about...
amazon.aws.aws_az_info Gather information about availability...
amazon.aws.aws_caller_info Get information about the user...
输出页数众多,这也展示了模块的数量!你甚至可以数一数它们:
$ ansible-doc -l | wc -l
7484
没错——Ansible 2.15 包含了 7,484 个模块!
-
和以前一样,你可以使用你喜欢的 shell 工具来处理索引,查找特定模块;例如,你可以使用
grep
查找s3
,以便找到所有与 S3 相关的模块,正如我们在上一节中在浏览器中进行的交互一样:$ ansible-doc -l | grep s3 amazon.aws.s3_bucket Manage S3 buckets in AWS... amazon.aws.s3_object_info Gather informatio... community.aws.s3_bucket_info Li... aws_s3 module – just as we did on the website, simply run the following:
$ ansible-doc aws_s3
这将生成类似于以下的输出:
$ ansible-doc aws_s3
> AMAZON.AWS.S3_OBJECT (/Users/danieloh/Library/Python/3.11/lib/python/site-packages/ansible_collections/amazon/aws/plugins/modules/s3_object.py)
This module allows the user to manage the objects and directories within S3 buckets. Includes support for creating and deleting objects and directories, retrieving
objects as files or strings, generating download links and copying objects that are already stored in Amazon S3\. Support for creating or deleting S3 buckets with
...
ADDED IN: version 1.0.0 of amazon.aws
* note: This module has a corresponding action plugin.
OPTIONS (= is mandatory):
- access_key
AWS access key ID.
See the AWS documentation for more information about access tokens https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.xhtml#access-keys-and-secret-
ansible-doc tells us about the module, provides a list of all of the arguments (OPTIONS) that we can pass it, and as we scroll down, even gives some working examples and possible return values. We shall explore the topic of return values in the next section as they are important to understand, especially as we approach the topic of developing modules.
Module return values
As we discussed earlier in this chapter, Ansible modules return their results as structured data, formatted behind the scenes in JSON. You came across this return data in the previous example, both in the form of an exit code and where we used the `register` keyword to capture the results of a task in an Ansible variable. In this section, we shall explore how to discover the return values for an Ansible module so that we can work with them later on in a playbook, for example, with conditional processing (see *Chapter 4*, *Playbooks* *and Roles*).
Due to conserving space, we shall choose what is perhaps one of the simplest Ansible modules to work with when it comes to return values – the `ping` module.
Without further ado, let’s use the `ansible-doc` tool that we learned about in the previous section and see what this says about the return values for this module:
$ ansible-doc ping
If you scroll to the bottom of the output from the preceding command, you should see something like this:
$ ansible-doc ping
ANSIBLE.BUILTIN.PING (/home/james/.local/lib/python3.10/site-packages/ansible/modules/ping.py)
...
返回值:
ping:
description: 数据参数提供的值
returned: success
sample: pong
type: str
Hence, we can see that the `ping` module will only return one value, and that is called `ping`. `description` tells us what we should expect this particular return value to contain, while the `returned` field tells us that it will only be returned on `success` (if it were to be returned on other conditions, these would be listed here). The `type` return value is a string (denoted by `str`), and although you can change the value with an argument provided to the `ping` module, the default return value (and hence `sample`) is `pong`.
Now, let’s see what this looks like in practice. For example, there’s nothing contained in those return values that would tell us whether the module ran successfully and whether anything was changed; however, we know that these are fundamental pieces of information about every module run.
Let’s put a very simple playbook together. We’re going to run the `ping` module with no arguments, capture the return values using the `register` keyword, and then use the `debug` module to dump the return values onto the Terminal:
- name: 演示返回值的简单任务
hosts: localhost
tasks:
- name: 执行一个简单的基于模块的任务
ansible.builtin.ping:
register: pingresult
- name: 显示结果
ansible.builtin.debug:
var: pingresult
Now, let’s see what happens when we run this playbook:
$ ansible-playbook retval.yml
[警告]: 提供的主机列表为空,仅 localhost 可用。注意
隐式 localhost 不匹配 'all'
任务 [展示返回值的简单示例] *******************************
任务 [Gathering Facts] *********************************************************
ok: [localhost]
任务 [执行简单的基于模块的任务] **************************************
ok: [localhost]
任务 [显示结果] ******************************************************
ok: [localhost] => {
"pingresult": {
"changed": false,
"failed": false,
"ping": "pong"
}
}
任务总结 *********************************************************************
localhost : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Notice that the `ping` module does indeed return a value called `ping`, which contains the `pong` string (as the ping was successful). However, you can see that there are, in fact, two additional return values that were not listed in the Ansible documentation. These accompany every single task run, and are hence implicit – that is to say, you can assume they will be among the data that’s returned from every module. The `changed` return value will be set to `true` if the module run resulted in a change on the target host, while the `failed` return value will be set to `true` if the module run failed for some reason.
Using the `debug` module to print the output from a module run is an incredibly useful trick if you want to gather more information about a module, how it works, and what sort of data is returned. At this point, we’ve covered just about all of the fundamentals of working with modules, so in the next section, we’ll make a start on developing our very own (simple) module.
Developing custom modules
Now that we’re familiar with modules, how to call them, how to interpret their results, and how to find documentation on them, we can make a start on writing a simple module. Although this will not include the deep and intricate functionality of many of the modules that ship with Ansible, it is hoped that this will give you enough information to proceed with confidence when you build out your own, more complex, ones.
One important point to note is that Ansible is written in Python 3, and as such, so are its modules. As a result, you will need to write your module in Python 3; to get started with developing your own module, you will need to make sure you have Python 3 and a few essential tools installed. If you are already running Ansible on your development machine, you probably have the required packages installed, but if you are starting from scratch, you will need to install Python 3, the Python 3 package manager (`pip3`), and perhaps some other development packages. The exact process will vary widely between operating systems, but here are some examples to get you started:
* On Fedora, you would run the following command to install the required packages:
```
$ sudo dnf install python python-devel
```
* Similarly, on CentOS, you would run the following command to install the required packages:
```
$ sudo yum install python3 python3-devel
```
* On Ubuntu, you would run the following commands to install the packages you need:
```
$ sudo apt-get update
$ sudo apt-get install python3-pip python3-dev build-essential
```
* If you are working on macOS and are using the Homebrew packaging system, the following command will install the packages you need:
```
$ brew install python
```
Once you have the required packages installed, you will need to clone the Ansible Git repository to your local machine as there are some valuable scripts in there that we will need later on in the module development process. Use the following command to clone the Ansible repository to your current directory on your development machine:
$ git clone https://github.com/ansible/ansible.git
Finally (although optionally), it is good practice to develop your Ansible modules in a **virtual environment** (**venv**) as this means any Python packages you need to install go in here, rather than in with your global system Python modules. Installing modules for the entire system in an uncontrolled manner can, at times, cause compatibility issues or even break local tools, so although this is not a required step, it is highly recommended.
The exact command to create a virtual environment for your Python module development work will depend on both the operating system you are running and the version of Python you are using. You should refer to the documentation for your Linux distribution for more information; however, the following commands were tested on CentOS 8 with the default Python 3.11 and higher to create a virtual environment called `moduledev` inside the Ansible source code directory you just cloned from GitHub:
$ cd ansible
$ python -m virtualenv moduledev
新的 Python 可执行文件位于 /home/james/ansible/moduledev/bin/python
安装 setuptools, pip, wheel...完成。
With our development environment set up, let’s start writing our first module. This module will be very simple as it’s beyond the scope of this book to provide an in-depth discussion about how to write large amounts of Python code. However, we will code something that can use a function from a Python library to copy a file locally on the target machine.
This overlaps heavily with existing module functionality, but it will serve as a nice concise example of how to write a simple Python program in a manner that allows Ansible to make use of it as a module. Now, let’s start coding our first module:
1. In your preferred editor, create a new file called (for example) `remote_filecopy.py`:
```
$ vi remote_filecopy.py
```
2. Start with a shebang to indicate that this module should be executed with Python:
```
#!/usr/bin/env python
```
3. Although not mandatory, it is good practice to add copyright information, as well as your details, in the headers of your new module. By doing this, anyone using it will understand the terms under which they can use, modify, or redistribute it. The text given here is merely an example; you should investigate the various appropriate licenses for yourself and determine which is the best for your module:
```
# 版权:(c) 2018, Jesse Keating <jesse.keating@example.org>
# GNU 通用公共许可证 v3.0+(请参阅 COPYING 或 https://www.gnu.org/licenses/gpl-3.0.txt)
```
4. It is also good practice to add an Ansible metadata section that includes `metadata_version`, `status`, and `supported_by` information immediately after the copyright section. Note that the `metadata_version` field represents the Ansible metadata version (which, at the time of writing, should be `1.1`) and is not related to the version of your module or the Ansible version you are using. The values suggested in the following code will be fine for just getting started, but if your module gets accepted into the official Ansible source code, they are likely to change:
```
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
```
5. Remember `ansible-doc` and that excellent documentation that is available on the Ansible documentation website? That all gets automatically generated from special sections you add to this file. Let’s get started by adding the following code to our module:
```
DOCUMENTATION = '''
---
模块:remote_filecopy
version_added: "2.15"
简短描述:在远程主机上复制文件
描述:
- remote_copy 模块将文件从给定的源复制到提供的目标位置。
选项:
source:
描述:
- 远程主机上源文件的路径
必需:True
dest:
描述:
- 远程主机上目标的复制路径
必需:True
作者:
- Jesse Keating (@omgjlk)
'''
```
Pay particular attention to the `author` dictionary – to pass the syntax checks for inclusion in the official Ansible code base, the author’s name should be appended with their GitHub ID in brackets. If you don’t do this, your module will still work, but it won’t pass the test we’ll perform later.
Notice how the documentation is in YAML format, enclosed between triple single quotes?
The fields listed should be common to just about all modules, but naturally, if your module takes different options, you would specify these so that they match your module.
1. The examples that you will find in the documentation are also generated from this file – they have a special documentation section immediately after `DOCUMENTATION` and should provide practical examples of how you might create a task using your module, as shown in the following example:
```
EXAMPLES = '''
# 来自 Ansible Playbooks 的示例
- 名称:备份配置文件
remote_copy:
source: /etc/herp/derp.conf
dest: /root/herp-derp.conf.bak
'''
```
2. The data that’s returned by your module to Ansible should also be documented in its own section. Our example module will return the following values:
```
RETURN = '''
source:
描述:用于复制的源文件
返回:成功
类型:str
sample: "/path/to/file.name"
dest:
描述:复制的目标
返回:成功
类型:str
sample: "/path/to/destination.file"
gid:
描述:目标的组 ID
返回:成功
类型:int
sample: 502
组:
描述:目标的组名
返回:成功
类型:str
sample: "users"
uid:
描述:目标的所有者 ID
返回:成功
类型:int
sample: 502
owner:
描述:目标所有者名称
返回:成功
类型:str
sample: "fred"
模式:
描述:目标的权限
返回:成功
类型:int
sample: 0644
大小:
描述:目标大小
返回:成功
类型:int
sample: 20
状态:
描述:目标的状态
返回:成功
类型:str
sample: "file"
'''
```
3. Immediately after we have finished our documentation section, we should import any Python modules we’re going to use. Here, we will include the `shutil` module, which will be used to perform our file copy:
```
主函数,其中我们将创建一个 AnsibleModule 类型的对象,并使用 argument_spec 字典获取模块调用时的选项:
```
def main():
module = AnsibleModule(
argument_spec = dict(
source=dict(required=True, type='str'),
dest=dict(required=True, type='str')
),
)
```
```
4. At this stage, we have everything we need to write our module’s functional code – even the options that it was called with. Hence, we can use the Python `shutil` module to perform the local file copy, based on the arguments provided:
```
shutil.copy(module.params['source'],
module.params['dest'])
```
5. At this point, we’ve executed the task our module was designed to complete. However, it is fair to say that we’re not done yet – we need to exit the module cleanly and provide our return values to Ansible. Normally, at this point, you would write some conditional logic to detect whether the module was successful and whether it performed a change on the target host or not. However, for simplicity, we’ll simply exit with the `changed` status every time – expanding this logic and making the return status more meaningful is left as an exercise for you:
```
module.exit_json(changed=True)
```
The `module.exit_json` method comes from `AnsibleModule`, which we created earlier – remember, we said it was important to know that data was passed back and forth using JSON!
1. As we approach the end of our module code, we must now tell Python where it can import the `AnsibleModule` object from. This can be done with the following line of code:
```
from ansible.module_utils.basic import *
```
2. Now, let’s look at the final two lines of code for the module – this is where we tell the module that it should be running the `main` function when it starts:
```
if __name__ == '__main__':
main()
```
That’s it – with a series of well-documented steps, you can write your own Ansible modules in Python. The next step is, of course, to test it (and you will need to write formal tests to merge it with Ansible’s GitHub repository). Before we test it in Ansible, let’s see whether we can run it manually in the shell. Of course, to make the module think it is being run within Ansible, we must generate some arguments in – you guessed it – JSON format. Create a file with the following contents to provide the arguments:
{
"ANSIBLE_MODULE_ARGS": {
"source": "/tmp/foo",
"dest": "/tmp/bar"
}
}
Armed with this little snippet of JSON, you can execute your module directly with Python. If you haven’t already done so, you’ll need to set up your Ansible development environment as follows. Note that we also manually create the source file, `/tmp/foo`, so that our module can perform the file copy:
$ touch /tmp/foo
$ . moduledev/bin/activate
(moduledev) $ . hacking/env-setup
正在运行 egg_info
正在创建 lib/ansible_base.egg-info
正在将依赖写入 lib/ansible_base.egg-info/requires.txt
正在写入 lib/ansible_base.egg-info/PKG-INFO
正在将顶层名称写入 lib/ansible_base.egg-info/top_level.txt
正在写入依赖链接到 lib/ansible_base.egg-info/dependency_links.txt
正在写入清单文件 'lib/ansible_base.egg-info/SOURCES.txt'
正在读取清单文件 'lib/ansible_base.egg-info/SOURCES.txt'
正在读取清单模板 'MANIFEST.in'
警告:未找到与 'SYMLINK_CACHE.json' 匹配的文件
警告:未找到与 'docs/docsite/rst_warnings' 匹配的先前包含的文件
警告:在目录 'docs/docsite/_build' 下未找到与 '*' 匹配的先前包含的文件
警告:在目录 'docs/docsite/_extensions' 下未找到与 '*.pyc' 匹配的先前包含的文件
警告:在目录 'docs/docsite/_extensions' 下未找到与 '*.pyo' 匹配的先前包含的文件
警告:在目录 'lib/ansible/modules/windows' 下未找到与 '*.ps1' 匹配的文件
警告:在目录 'test/support' 下未找到与 '*.psm1' 匹配的文件
正在写入清单文件 'lib/ansible_base.egg-info/SOURCES.txt'
正在设置 Ansible 从检出中运行...
PATH=/home/james/ansible/bin:/home/james/ansible/moduledev/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/home/james/bin
PYTHONPATH=/home/james/ansible/lib
MANPATH=/home/james/ansible/docs/man:/usr/local/share/man:/usr/share/man
请记住,您可能希望通过 -i 指定主机文件
完成!
Now, you’re finally ready to run your module for the first time. You can do this as follows:
(moduledev) $ python remote_filecopy.py args.json
{"invocation": {"module_args": {"dest": "/tmp/bar", "source": "/tmp/foo"}}, "changed": true}
(moduledev) $ ls -l /tmp/bar
-rw-r--r-- 1 root root 0 Apr 26 12:35 /tmp/bar
Success! Your module works – and it both ingests and produces JSON data, as we discussed earlier in this chapter. Of course, there’s much more to add to your module – we’ve not addressed `failed` or `ok` returns from the module, nor does it support check mode. However, we’re off to a flying start. If you want to learn more about Ansible modules and fleshing out your functionality, you can find more details here: [`docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.xhtml`](https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.xhtml).
Note that when it comes to testing your module, creating arguments in a JSON file is hardly intuitive, although, as we have seen, it does work well. Luckily for us, it is easy to run our Ansible module in a playbook! By default, Ansible will check the playbook directory for a subdirectory called `library/` and will run referenced modules from here. Hence, we might create the following:
$ cd ~
$ mkdir testplaybook
$ cd testplaybook
$ mkdir library
$ cp ~/ansible/moduledev/remote_filecopy.py library/
Now, create a simple inventory file in this playbook directory, just as we did previously, and add a playbook with the following contents:
- name: 测试自定义模块的 Playbook
hosts: all
tasks:
- name: 测试自定义模块
remote_filecopy:
source: /tmp/foo
dest: /tmp/bar
register: testresult
- name: 打印测试结果数据
ansible.builtin.debug:
var: testresult
For clarity, your final directory structure should look like this:
testplaybook
├── hosts
├── library
│ └── remote_filecopy.py
└── testplaybook.yml
Now, try running the playbook in the usual manner and see what happens:
$ ansible-playbook -i hosts testplaybook.yml
PLAY [测试自定义模块的 Playbook] ******************************************
TASK [Gathering Facts] *********************************************************
ok: [frt01.example.com]
ok: [app01.example.com]
TASK [测试自定义模块] **************************************************
changed: [app01.example.com]
changed: [frt01.example.com]
任务 [打印测试结果数据] **********************************************
ok: [app01.example.com] => {
"testresult": {
"changed": true,
"failed": false
}
}
ok: [frt01.example.com] => {
"testresult": {
"changed": true,
"failed": false
}
}
PLAY RECAP *********************************************************************
app01.example.com : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
frt01.example.com : ok=3 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Success! Not only have you tested your Python code locally, but you have also successfully run it on two remote servers in an Ansible playbook. That was really easy, which proves just how straightforward it is to get started expanding your Ansible modules so that they meet your own bespoke needs.
Despite the success of running this piece of code, we’ve not checked the documentation yet, nor tested its operation from Ansible. Before we address these issues in more detail, in the next section, we’ll take a look at some of the common pitfalls of module development and how to avoid them.
Avoiding common pitfalls
Your modules must be well thought out and handle error conditions gracefully – people are going to rely on your module someday to automate a task on perhaps thousands of servers, so the last thing they want is to spend significant amounts of time debugging errors, especially trivial ones that could have been trapped or handled gracefully. In this section, we’ll look specifically at error handling and ways to do this so that playbooks will still run and exit gracefully.
One piece of overall guidance before we get started is that just like documentation receives a high degree of attention in Ansible, so should your error messages. They should be meaningful and easy to interpret, and you should steer clear of meaningless strings such as `Error!`.
So, right now, if we remove the source file that we’re attempting to copy and then rerun our module with the same arguments, I think you’ll agree that the output is neither pretty nor meaningful unless you happen to be a hardened Python developer:
(moduledev) $ rm -f /tmp/foo
(moduledev) $ python3 remote_filecopy.py args.json
最后一次调用的追踪:
文件 "remote_filecopy.py",第 101 行,在
main()
^^^^^^
文件 "remote_filecopy.py",第 94 行,在 main
shutil.copy(module.params['source'],
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
文件 "/opt/homebrew/Cellar/python@3.11/3.11.4/Frameworks/Python.framework/Versions/3.11/lib/python3.11/shutil.py",第 419 行,在 copy
copyfile(src, dst, follow_symlinks=follow_symlinks)
文件 "/opt/homebrew/Cellar/python@3.11/3.11.4/Frameworks/Python.framework/Versions/3.11/lib/python3.11/shutil.py",第 256 行,在 copyfile
with open(src, 'rb') as fsrc:
^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] 没有那个文件或目录: '/tmp/foo'
We can, without a doubt, do better. Let’s make a copy of our module and add a little code to it. First of all, replace the `shutil.copy` lines of code with the following:
try:
shutil.copy(module.params['source'], module.params['dest'])
except:
module.fail_json(msg="文件复制失败")
This is some incredibly basic exception handling in Python, but what it does is allow the code to try the `shutil.copy` task. However, if this fails and an exception is raised, rather than exiting with a traceback, we exit cleanly using the `module.fail_json` call. This will tell Ansible that the module failed and cleanly send a JSON-formatted error message back. Naturally, we could do a lot to improve the error message; for example, we could obtain the exact error message from the `shutil` module and pass it back to Ansible, but again, this is left as an exercise for you to complete.
Now, when we try and run the module with a non-existent source file, we will see the following cleanly formatted JSON output:
(moduledev) $ rm -f /tmp/foo
(moduledev) $ python3 better_remote_filecopy.py args.json
{"msg": "复制文件失败", "failed": true, "invocation": {"module_args": {"dest": "/tmp/bar", "source": "/tmp/foo"}}}
However, the module still works in the same manner as before if the copy succeeds:
(moduledev) $ touch /tmp/foo
(moduledev) $ python3 better_remote_filecopy.py args.json
{"invocation": {"module_args": {"dest": "/tmp/bar", "source": "/tmp/foo"}}, "changed": true}
With this simple change to our code, we can now cleanly and gracefully handle the failure of the file copy operation and report something more meaningful back to the user rather than using a traceback. Some additional pointers for exception handling and processing in your modules are as follows:
* Fail quickly – don’t attempt to keep processing after an error
* Return the most meaningful possible error messages using the various module JSON return functions
* Never return a traceback if there’s any way you can avoid it
* Try making errors meaningful in the context of the module and what it does (for example, for our module, `File copy error` is more meaningful than `File error` – and I think you’ll easily come up with even better error messages)
* Don’t bombard the user with errors; instead, try to focus on reporting the most meaningful ones, especially when your module code is complex
That completes our brief yet practical look at error handling in Ansible modules. In the next section, we shall return to the documentation we included in our module, including how to build it into HTML documentation so that it can go on the Ansible website (and indeed, if your module gets accepted into the Ansible source code, this is exactly how the web documentation will be generated).
Testing and documenting your module
We have already put a great deal of work into documenting our module, as we discussed earlier in this chapter. However, how can we see it, and how can we check that it compiles correctly into the HTML that would go on the Ansible website if it were accepted as part of the Ansible source code?
Before we get into actually viewing our documentation, we should make use of a tool called `ansible-test`, which was newly added in the 2.15 release. This tool can perform a sanity check on our module code to ensure that our documentation meets all the standards required by the Ansible project team and that the code is structured correctly (for example, the Python `import` statements should always come after the documentation blocks). Let’s get started:
1. To run the sanity tests, assuming you have cloned the official repository, change into this directory and set up your environment. Note that if your standard Python binary isn’t Python 3, the `ansible-test` tool will not run, so you should ensure Python 3 is installed and, if necessary, set up a virtual environment to ensure you are using Python 3\. This can be done as follows:
```
$ cd ansible$ python 3 -m venv venv
$ . venv/bin/activate
(venv) $ source hacking/env-setup
正在运行 egg_info
正在创建 lib/ansible.egg-info
正在写入 lib/ansible.egg-info/PKG-INFO
正在写入依赖链接到 lib/ansible.egg-info/dependency_links.txt
正在将要求写入 lib/ansible.egg-info/requires.txt
正在写入顶级名称到 lib/ansible.egg-info/top_level.txt
正在写入清单文件 'lib/ansible.egg-info/SOURCES.txt'
正在读取清单文件 'lib/ansible.egg-info/SOURCES.txt'
正在读取清单模板 'MANIFEST.in'
警告: 找不到匹配的文件 'SYMLINK_CACHE.json'
正在写入清单文件 'lib/ansible.egg-info/SOURCES.txt'
正在设置 Ansible 以从检出目录运行...
PATH=/home/james/ansible/bin:/home/james/ansible/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/home/james/bin
PYTHONPATH=/home/james/ansible/lib
MANPATH=/home/james/ansible/docs/man:/usr/local/share/man:/usr/share/man
请记住,您可能希望通过 -i 来指定您的主机文件。
使用 pip 安装 Python 依赖,以便您可以运行 ansible-test 工具:
```
(venv) $ pip install -r test/runner/requirements/sanity.txt
```
```
2. Now, provided you have copied your module code into the appropriate location in the source tree (an example copy command is shown here), you can run the sanity tests as follows:
```
(venv) $ cp ~/moduledev/remote_filecopy.py ./lib/ansible/modules/files/
(venv) $ ansible-test sanity --test validate-modules remote_filecopy
使用 validate-modules 进行健康检查
警告:无法对基础分支执行模块比较。运行时未检测到基础分支。
警告:正在查看之前的 1 个警告:
警告:无法对基础分支执行模块比较。运行时未检测到基础分支。
```
From the preceding output, you can see that apart from one warning related to us not having a base branch to compare against, the module code that we developed earlier in this chapter has passed all the tests. If you had an issue with the documentation (for example, the author’s name format was incorrect), this would be given as an error.
Now that we have passed the sanity checks with `ansible-test`, let’s see whether the documentation looks right by using the `ansible-doc` command. This is very easy to do. First of all, exit your virtual environment, if you are still in it, and change to the Ansible source code directory you cloned from GitHub earlier. Now, you can manually tell `ansible-doc` where to look for modules instead of the default path. This means that you could run the following:
$ cd ~/ansible
$ ansible-doc -M moduledev/ remote_filecopy
You should be presented with the textual rendering of the documentation we created earlier – an example of the first page is shown here to give you an idea of how it should look:
REMOTE_FILECOPY (/home/james/ansible/moduledev/remote_filecopy.py)
remote_copy 模块从远程主机复制文件到
将给定的源复制到提供的目标位置。
- 本模块由 Ansible 社区维护
选项(= 为必填项):
= dest
远程主机上的目标路径
= source
远程主机上源文件的路径
Excellent! So, we can already access our module documentation using `ansible-doc` and indeed confirm that it renders correctly in text mode. However, how do we go about building the HTML version? Fortunately, there is a well-defined process for this, which we shall outline here:
1. Under `lib/ansible/modules/`, you will find a series of categorized directories that modules are placed under – ours fits best under the `files` category, so copy it to this location in preparation for the build process to come:
```
docs/docsite/ 目录是文档创建过程中的下一步:
```
$ cd docs/docsite/
```
```
2. Build a documentation-based Python file. Use the following command to do so:
```
$ MODULES=hello_module make webdocs
```
Now, in theory, making the Ansible documentation should be this simple to get the `make webdocs` command to run at all.
Even in this environment, on CentOS 7, the `make webdocs` command fails unless you have some very specific Python 3 requirements in place. These are not well documented, but from testing, I can tell you that Sphinx v2.4.4 works. The version that’s supplied with CentOS 7 is too old and fails, while the newest version available from the Python module repositories is not compatible with the build process and fails.
Once I’d started working from the Ansible source tree, I had to make sure I had removed any preexisting `sphinx` modules from my Python 3 environment (you need Python 3.11 or above to build the documentation locally – if you don’t have this installed on your node, please do this before proceeding) and then ran the following commands:
$ pip3 uninstall sphinx
$ pip3 install sphinx==2.4.4
$ pip3 install sphinx-notfound-page
With this in place, you will be able to successfully run `make webdocs` to build your documentation. You will see pages of output. A successful run should end with something like the output shown here:
正在生成索引... genindex py-modindexdone
写入额外页面... search/home/james/ansible/docs/docsite/_themes/sphinx_rtd_theme/search.xhtml:21: RemovedInSphinx30Warning: 修改主题中的 script_files 已被弃用。请直接在主题中插入