不一样的Ansible——进阶学习
前言
Ansible
是一个极其简单的 IT 自动化引擎,可自动执行云配置、配置管理、应用程序部署、服务内编排和许多其他 IT 需求;基本上每一个运维工程师都会听过或者使用过Ansible
(这是必然的,只要你需要管理超过2位数的服务器,Ansible
基本是一个必备的工具),如果你还不知道Ansible
是怎么用的,一定要找一个环境试试,这是一个真正让运维工作自动化并且事半功倍的工具。
Ansible
不使用任何代理程序,也就是不需要部署agent
,也不使用额外的自定义安全基础设施,因此很容易部署 - 最重要的是,使用yaml
配置就能编写整个自动化流程,不需要学习太多额外的编程知识。
这一部分的内容,不是对Ansible
做基本介绍的,而是对其中一些进阶的或者平时不会知道的东西做一个学习和记录的。比如各种插件的特殊用法、如何在我们自己的Python
程序中去使用Ansible
、以及一些其他的技巧或者知识点。
本系列文章的所有内容基于
Ansible 9
编写,Ansible 9
使用Ansible-core 2.16
正文
概念
Ansible Core
Ansible Core
或ansible-core
是Ansible
的主要构件和架构,包含了许多的核心逻辑,比如:
- CLI 工具,如 ansible-playbook、ansible-doc 等,用于驱动自动化并与之交互。
- Ansible 语言使用 YAML 创建一套用于开发 Ansible Playbook 的规则,包括
conditionals
,blocks
,includes
,loops
以及其他的一些内容。 - 允许通过
Ansible collections
进行扩展的架构框架
总结来说,Ansible Core
提供你目前所熟知的所有Ansible的基础能力,并且允许开发者在现有基础上拓展Ansible的功能。
Plugins和Modules
Ansible
中非常重要的两个概念,插件和模块,那么他们有什么区别,以及怎么在自己实现拓展功能的时候选择使用哪个特性:
Plugins
:扩展了Ansible Core
的功能。大多数插件都在/usr/bin/ansible
进程的控制节点上执行。插件为Ansible
的核心功能提供选项和扩展:转换数据、记录输出、连接Inventory
等Modules
:本质也是一种Plugins
,但是其特点在于能够在远程服务器上执行自动化任务
。模块以独立脚本的形式运行,由Ansible
在控制节点之外的进程中执行。模块与Ansible
的接口主要是json
,接受参数并在退出前通过向stdout
打印json
字符串返回信息。与其他插件(必须用 Python 编写)不同,模块可以用任何语言编写;不过Ansible
只提供Python
和Powershell
版本的模块。
这其实很好理解,Plugins
是用来添加Ansible的核心代码逻辑的,因此需要Ansible
能够把它加载进来,Ansible既然是Python
编写的,自然也要求Plugins
是要用Python
编写,而Modules
是通过ssh分发到受管节点执行的,那么他可以是任意程序,只要远程服务器能够运行它就可以了,默认Ansible
的那些模块其实在受管节点也是调用了Python
解释器来运行的
插件
插件类型
上面已经介绍了插件是什么,在Ansible
中有多种插件类型,下面将官网的:
- Action plugins:
Action
与module
一起执行playbook
任务所需的动作,通常在后台自动运行,在模块实际运行前执行一些前期工作; - Become plugins:用于执行命令时进行提权或者用户切换,我们最常用的就是借助
become
完成sudo
操作; - Cache plugins:
Cache
插件允许Ansible
存储收集到的受管节点信息或者inventory
信息,避免因为从源数据检索而影响性能,这个最直观的体现就是我们执行playbook
时如果没有关闭gather_fact
,可能会等待很长时间,借助cache
可以有效减少重复在受管节点执行操作时的这个步骤耗时; - Callback plugins:回调插件可以在
Ansible
响应事件时添加新的行为。默认情况下,回调插件可控制运行命令行程序时看到的大部分输出,但也可用于添加额外输出、与其他工具集成以及将事件汇聚到存储后端;对于想要对接Ansible
做自动化管理的同学来说,这个插件很重要; - Cliconf plugins:
Cliconf
插件是网络设备CLI
接口的抽象。它们为Ansible
在这些网络设备上执行任务提供了标准接口,主机维护不常用,我也没用过; - Connection plugins:
Connection
插件允许Ansible
使用既定方式连接到目标主机,以便在上面执行任务,Ansible
自身附带了许多连接插件,比如openssh
和paramiko
,但每台主机一次只能使用一个; - Docs fragments:
Docs fragments
可让使用者在一个地方记录多个插件或模块的通用参数; - Filter plugins:
Filter
插件也是一个功能常用的插件,他允许对数据进行特定的序列化或者其他自定义的处理,比如对字符串进行拆分和连接,默认Ansible
使用的是jinja2
提供的标准Filter功能,同时增加了一些特性; - Httpapi plugins:
Httpapi
插件告诉Ansible
如何与远程设备基于HTTP
的API
交互,并在设备上执行任务; - Inventory plugins:
Inventory
插件是一个允许用户指向仓库数据源的插件,也就是我们执行ad-hoc
命令式的-i
参数执行的逻辑; - Lookup plugins:
Lookup
插件是Jinja2
模板语言的Ansible
特定扩展。用户可以使用该插件从外部来源(文件、数据库、key/value存储、API 和其他服务)访问playbook
中的数据。与所有模板一样,Lookup
在Ansible
控制节点上运行。Ansible
使用标准模板系统提供查找插件返回的数据。你可以使用查找插件从外部资源加载变量或模板信息。 - Modules:上文说过,
Modules
也是一种插件,所以这里也有它; - Module utilities:包含多个插件使用的共享代码,最好把它和
Modules
一起理解; - Netconf plugins:网络设备
Netconf
接口抽象,用于在网络设备上执行ansible
任务; - Shell plugins:
Shell
插件的作用是确保Ansible
运行的基本命令格式正确,能够在目标计算机上运行,并允许用户配置与Ansible
执行任务方式相关的某些行为,具体的情况可以跳转到 - Strategy plugins:
Strategy
通过handles
和受管节点的调度来控制playbook
的执行流程,具体的策略可以查看:更改Strategy - Terminal plugins:
Terminal
插件包含有关如何正确初始化特定网络设备的SSH shell
以配合Ansible
使用的信息。这通常包括禁用自动分页、检测输出中的错误,以及在设备支持和需要的情况下启用特权模式。 - Test plugins:
Test
插件可评估模板表达式并返回True
或False
。有了测试插件,用户就可以创建条件(比如when
关键字)来实现tasks、blocks、play、playbooks和roles的逻辑。Ansible
使用作为Jinja
一部分提供的标准测试,并添加了一些专门的测试插件 - Vars plugins:
Vars
插件拓展了一些变量的使用方式
上面所说的插件,其中一部分(Action
、Cache
、Callback
、Connection
、Filter
、Inventory
、Lookup
、Test
、Vars
)支持开发者进行自定义的编写,实现新的功能。
编写自定义插件
基本要求
让我们先来编写一个自定义的插件来为Ansible提供额外的核心能力,编写之前我们要知道一个Ansible
插件必须要满足的条件:
- 使用
Python
编写,这个在上一节已经说明原因 - 对异常进行合适的处理,使用
raise
抛出异常 - 返回值要是
unicode
编码的字符串 - 符合
Ansible
的配置和文档标准
接下来针对上面的要求做一下分别的解释
使用Python编写
使用Python
编写插件,这样它才能被PluginLoader
加载,并作为任何模块都能使用的Python
对象返回。由于插件将在控制节点上执行,因此必须使用兼容的 Python 版本编写插件
使用raise处理异常
在插件执行过程中遇到错误时,应通过引发AnsibleError()
或类似类来返回错误信息。在将其他异常包装成错误信息时,应始终使用to_native
Ansible函数,以确保不同Python
版本的字符串兼容,下面是一个简单的样例:
from ansible.module_utils.common.text.converters import to_native
try:
cause_an_exception()
except Exception as e:
raise AnsibleError('Something happened, this was original exception: %s' % to_native(e))
由于Ansible
仅在需要时才会对变量进行解析,因此filter
插件和test
插件应使用jinja2.exceptions.UndefinedError
和AnsibleUndefinedVariable
异常,以确保未定义变量仅在必要时才引起程序的Fatal
返回值符合unicode编码
这个要求是为了保证过程中的字符串str
能够在Jinja2
中正常运行和解析,Ansible提供了响应的方法进行转换:
from ansible.module_utils.common.text.converters import to_text
result_string = to_text(result_string)
插件选项
为了给我们自己的插件定义可配置选项,我们需要在Python文件的DOCUMENTATION
部分进行描述和声明,这种规范可以确保我们插件响应选项的文档时钟保持正确和最新,以下是格式定义:
options:
option_name:
description: describe this config option
default: default value for this config option
env:
- name: NAME_OF_ENV_VAR
ini:
- section: section_of_ansible.cfg_where_this_config_option_is_defined
key: key_used_in_ansible.cfg
vars:
- name: name_of_ansible_var
- name: name_of_second_var
version_added: X.x
required: True/False
type: boolean/float/integer/list/none/path/pathlist/pathspec/string/tmppath
version_added: X.x
我们来看一个样例,这里以自带的Callback插件中的json.py演示:
# (c) 2016, Matt Martz <matt@sivel.net>
# (c) 2017 Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
# Make coding more python3-ish
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
DOCUMENTATION = '''
callback: json
short_description: Ansible screen output as JSON
version_added: "2.2"
description:
- This callback converts all events into JSON output to stdout
type: stdout
requirements:
- Set as stdout in config
options:
show_custom_stats:
version_added: "2.6"
name: Show custom stats
description: 'This adds the custom stats set via the set_stats plugin to the play recap'
default: False
env:
- name: ANSIBLE_SHOW_CUSTOM_STATS
ini:
- key: show_custom_stats
section: defaults
type: bool
notes:
- When using a strategy such as free, host_pinned, or a custom strategy, host results will
be added to new task results in ``.plays[].tasks[]``. As such, there will exist duplicate
task objects indicated by duplicate task IDs at ``.plays[].tasks[].task.id``, each with an
individual host result for the task.
'''
ok,现在假设我们设定好了自己的配置项,如果要在插件内部使用对应的配置项,一般使用self.get_option(<option_name>)
进行获取,不过有些插件的处理方式会有些不同;
配置部分遵循了Ansible中值的优先原则,当在同一个类别下定义多个值时,最后一个值优先,比如上述样例的这部分:
vars:
- name: name_of_ansible_var
- name: name_of_second_var
此时option_name
选项的值使用name_of_second_var
,如果要对option值进行设置,则要使用self.set_option()
方法;
文档标准
支持嵌入式文档(参见 ansible-doc
列表)的插件应包含格式良好的文档字符串,如果我们继承一个插件,则必须在文档中记录被继承插件的选项;
编写插件
以Action plugin为例,编写一个插件,首先,编写的插件应该继承自对应的Base类,比如:
from ansible.plugins.action import ActionBase
class ActionModule(ActionBase):
pass
允许使用_execute_module
方法来调用内置的Module
并获取返回结果:
module_return = self._execute_module(module_name='<NAME_OF_MODULE>',
module_args=module_args,
task_vars=task_vars, tmp=tmp)
我们以官方文档中的示例为准,进行讲解,这里添加了一个通过setup
模块获取的内容进行服务器时间差异的比对动作的action
:
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
from datetime import datetime
from ansible.plugins.action import ActionBase
class ActionModule(ActionBase):
"""Action"""
def run(self, tmp=None, task_vars=None):
super().run(tmp, task_vars)
module_args = self._task.args.copy()
module_return = self._execute_module(module_name='setup',
module_args=module_args,
task_vars=task_vars, tmp=tmp)
ret = {}
remote_date = None
if not module_return.get('failed'):
for key, value in module_return['ansible_facts'].items():
if key == 'ansible_date_time':
remote_date = value['iso8601']
if remote_date:
remote_date_obj = datetime.strptime(remote_date, '%Y-%m-%dT%H:%M:%SZ')
time_delta = datetime.utcnow() - remote_date_obj
ret['delta_seconds'] = time_delta.seconds
ret['delta_days'] = time_delta.days
ret['delta_microseconds'] = time_delta.microseconds
return {"ansible_facts": dict(ret)}
在上述代码中,调用了内部的setup
模块,拉取受管节点的信息,在其返回值中,有这样的数据结构:
"ansible_date_time": {
"date": "2024-01-08",
"day": "08",
"epoch": "1704704528",
"epoch_int": "1704704528",
"hour": "17",
"iso8601": "2024-01-08T09:02:08Z",
"iso8601_basic": "20240108T170208306063",
"iso8601_basic_short": "20240108T170208",
"iso8601_micro": "2024-01-08T09:02:08.306063Z",
"minute": "02",
"month": "01",
"second": "08",
"time": "17:02:08",
"tz": "CST",
"tz_dst": "CST",
"tz_offset": "+0800",
"weekday": "Monday",
"weekday_number": "1",
"weeknumber": "02",
"year": "2024"
},
随后在自定义插件逻辑中我们比对了iso8061
的值,计算出时间的差异值,然后返回,这个插件的返回值类似以下的格式:
172.18.0.25 | SUCCESS => {
"ansible_facts": {
"delta_days": 0,
"delta_microseconds": 152565,
"delta_seconds": 0
},
"changed": false
}
如果想要进一步修改插件,满足自己的需求,只需要在刚才编写的ActionModule
中进行变更即可。
添加一个本地插件
上面我们已经编写了一个自定义的插件,但是只是把代码写好是没法按照预期的方式通过ad-hoc
的方式使用的,大概率会出现以下的报错:
172.18.0.25 | FAILED! => {
"msg": "The module myaction was not found in configured module paths"
}
而想要直接便利的使用自定义的插件,有以下几种方式进行配置:
注册为内置插件
这种方法不建议,只做介绍,直接将编写好的python
文件拷贝到ansible
的安装目录下,比如我们使用虚拟环境,通过pip
安装的ansible
,那么我们的ansible
安装目录ANSIBLE_INSTALL_DIR
大概是这样的:$ENV_HOME/lib/python3.9/site-packages/ansible
,那么只需要把我们的插件放在$ANSIBLE_INSTALL_DIR/plugins/
目录下对应的插件类型下:
不建议使用这种方法的原因主要是因为我们个人编写的代码在规范性上和官方的多少有些差异,最好还是做一些区分,这样在出问题的时候排查也会变得容易一些
指定插件目录
这个方式是比较规范的,当我们编写的插件是一个提供给全局使用的插件时,我们就这样做就可以了,编辑ansible
的配置文件/etc/ansible/ansible.cfg
,添加action_plugins
配置项:
[defaults]
# ...
# 可以自定义为自己需要的目录
action_plugins = /usr/share/ansible/plugins/action
然后把我们的插件放在上面配置的目录下,就能正常使用了
这种配置便于我们对插件进行管理,且配置一次后,所有人都能使用
除此之外,插件也可以放在Collection
中,这个在讲到Collection
时再单独写
其他一些技巧
更改Strategy
默认情况下,Ansible使用linear strategy
进行任务的调度,除此之外,Ansible还提供了以下集中Strategy plugins
:
- debug strategy – 在
debug
模式下运行Task
. - free strategy – 不等待其他受管节点完成当前状态,直接运行
Task
, - host_pinned strategy – 尽可能快的在每个受管节点执行
play
(按照serial
定义的批次执行,默认为全部),除非一个play
可以在不被其他主机Task中断的情况下完成,否则Ansible
不会给一个新的主机启动play
,即:拥有执行中的play的受管节点数量不超过forks
规定的数量 - linear strategy – 默认的策略,线性执行,这个线性是指
Task
,也是我们熟知的,一个Task
会在所有服务器执行完后再执行下一个Task
假设我们希望每个节点都能速度拉满,我们可以把策略改成free,通过在playbook中指定或者更改配置文件都可以实现,先看更改playbook:
- hosts: all
strategy: free
tasks:
# ...
或者更改ansible.cfg
:
[defaults]
strategy = free
结语
本篇文章主要抛砖引玉,说明了Ansible中模块和插件的区别,以及如何自己编写一个简单的Action Ansible插件,接下来会再多讲解一些其他类型的插件编写方法。