插件:在哪里运行,就是设计的一半
一句话总结
扩展 Ansible 的位置有好几个,但划分的标准只有一条——filter、test、lookup 在运行 playbook 的控制节点上运行,而模块会被复制到目标主机并在那里运行。 把什么放在哪个位置,几乎都能从这一句话得出。
为什么需要它
playbook 一长大,哪个团队都会出现同样形状的位置。把服务名整理成可以用作文件名的形式,把端口号划分成等级,从公司内部某处的表里查出值。如果标准 filter 做不到,人们就会采取两种做法之一。
第一种是硬把 Jinja2 写长。会出现 {{ name | lower | replace(' ', '-') | replace('.', '-') | replace('_', '-') | trim('-') }} 这样的一行,六个月后没有人敢去改这一行。也没法测试——这段逻辑只存在于 playbook 里,没有单独调用它的办法。
第二种是退回到 shell。写成 shell: echo {{ name }} | tr 'A-Z' 'a-z' | sed 's/[^a-z0-9]/-/g',再用 register 接收结果。这一行把 Ansible 原本提供的东西全都丢掉了。没有幂等性(永远是 changed),在检查模式下会被跳过,产生“目标主机上有 tr 和 sed”的假设,往返还多了一次。等于为了计算一个值而去花一次 SSH 往返。
插件就是为这个位置准备的。计算值的工作在控制节点上用 Python 完成,不去碰目标主机。 这样这段逻辑就集中到一个文件里,有了名字,也可以单独测试。
工作原理
Ansible 的插件类型有十几种,但编写 playbook 的人自己会去做的通常是这四种。
| 类型 | 调用方式 | 运行位置 | 返回什么 |
|---|---|---|---|
| filter | 값 | 이름(占位符依次为值、名称) |
控制节点 | 任意值 |
| test | 값 is 이름(占位符依次为值、名称) |
控制节点 | 真或假 |
| lookup | lookup('이름', 인자)(占位符依次为名称、参数) |
控制节点 | 列表 |
| module | task 的键 | 目标主机 | JSON(含 changed) |
前三种是在模板引擎生成值时被调用的。所以不需要在目标主机上安装任何东西,SSH 往返也不会增加。作为交换,它们看不到目标主机的状态——目标主机的磁盘还剩多少,filter 是无从得知的。那是 fact 或模块的事。
模块则相反。文件被复制到目标主机,用目标主机的 Python 运行。所以它可以改变状态,而能改变就意味着必须自己负责幂等性和检查模式。
目录与类的约定
Ansible 不要求在配置文件中注册,而是靠位置和名称来查找。
플레이북 옆:
filter_plugins/ → class FilterModule 의 filters() 가 돌려주는 사전
test_plugins/ → class TestModule 의 tests() 가 돌려주는 사전
lookup_plugins/ → class LookupModule(LookupBase) 의 run()
library/ → 모듈. 파일 이름이 곧 모듈 이름
컬렉션 안:
plugins/filter/ plugins/test/ plugins/lookup/ plugins/modules/
该代码块中的韩文依次说明:playbook 旁边的各目录——filter_plugins/ 对应 FilterModule 类的 filters() 所返回的字典,test_plugins/ 对应 TestModule 类的 tests() 所返回的字典,lookup_plugins/ 对应 LookupModule 类的 run(),library/ 存放模块(文件名就是模块名);以及 collection 内部的目录。
这里有两点经常坑人。一是文件名可以随意,但类名是约定好的。如果不是 FilterModule,不会有任何错误,只是找不到。二是“playbook 旁边”是字面意思。在 ad-hoc 命令(ansible -m debug -a ...)中不会触发这种查找,所以拿新 filter 用 ad-hoc 试了一下,得出“不行啊”的结论,这种事很常见。
放进 collection 里,位置就变了。不是 filter_plugins/ 而是 plugins/filter/,调用时使用 네임스페이스.이름.필터(占位符依次为命名空间、名称、filter)这样的 FQCN。collection 必须位于 <검색경로>/ansible_collections/<네임스페이스>/<컬렉션이름>/(占位符依次为搜索路径、命名空间、collection 名称)这三层目录之下,错开一层同样会悄悄地找不到。
filter 必须遵守的
filter 是 Jinja2 生成值时调用的函数。所以必须是纯函数——相同输入永远给出相同的值,且不触碰外部世界。理由不是性能,而是可预测性。模板会在什么时候、被求值几次,并没有规定。在带条件的 task 中也许根本不会被求值,在多台主机上也许会各自求值。如果 filter 去写文件或读取时间,结果每次运行都会不同,那个 task 就永远不可能幂等。
遇到错误输入时,用 AnsibleFilterError 中断。悄悄返回空字符串的 filter 更糟——错误的值原样进入配置文件,也没有人报告。
lookup 造成的事故
lookup 在控制节点上运行。所以 lookup('file', '/etc/app/secret') 读取的是控制节点上的那个文件,而不是目标主机上的那个文件。文档把这一点用粗体写了出来,它仍然是最常被绊倒的地方。在开发者的笔记本上好好的,到了 CI runner 上因文件不存在而失败,或者反过来,CI runner 上的文件被部署到了目标主机上,都是从这里产生的。
lookup 一定会返回列表。因为 with_ 循环原本就是建立在 lookup 之上的功能。如果只需要一个值,就加上 | first,或者用 lookup() 代替 query()。
什么时候用模块而不是 filter
判断标准很简单。
- 是否需要读取目标主机的状态 → 模块(或 fact)。filter 看不到。
- 是否需要改变目标主机的状态 → 模块。filter 一旦写文件,就不再是纯函数。
- 只是变换一个值吗 → filter。
- 一个真假就能得出答案吗 → test。可以直接用在
when:和select/reject中。 - 需要去取控制节点一侧的资料吗 → lookup。
在现场相遇的样子
第一,一个 filter 就能成为团队的标准。 做一个把服务名变成 slug 的 filter,之后 Kubernetes 标签、文件名、日志标签都会用同一套规则生成。规则集中在代码的一处,这才是这件事真正的价值。需要改规则时,要改的地方只有一个。
第二,变得可以测试。 插件只是 Python 文件,可以直接用 Python 测试工具调用。playbook 里的一行 Jinja2 做不到这一点。逻辑越复杂,这个差别越大。
第三,迁入 collection 时名称会变。 起初放在 filter_plugins/,等到多个仓库都要用,就会迁入 collection,这时所有调用处都会变成 FQCN。如果一开始就在名称上加团队前缀,迁移时的冲突会少一些。
第四,自定义模块并不像想象的那么常需要。 如果是调用内部 API,用 uri 模块通常就够了。真正值得自己写模块的,是处理“即使调用多次也只能改变一次的复杂状态”的时候。这时一定要声明 supports_check_mode——不声明的话,在 --check 中那个 task 不是失败,而是悄无声息地被跳过。为了看计划而运行检查模式的人,永远不会知道那个 task 会做什么。
参考文档
- 插件开发指南:https://docs.ansible.com/ansible/latest/dev_guide/developing_plugins.html
- 在本地添加模块和插件:https://docs.ansible.com/ansible/latest/dev_guide/developing_locally.html
- lookup 插件:https://docs.ansible.com/ansible/latest/plugins/lookup.html
- collection 目录结构:https://docs.ansible.com/ansible/latest/dev_guide/developing_collections_structure.html
- 模块开发指南:https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html
下一项实验要做什么
亲手做出四种插件,并在一个 playbook 中串起来。在 filter_plugins/ 中做一个 slug filter,再加一个接收两个边界作为参数的 filter,在 test_plugins/ 中做一个只返回真假的 test,在 lookup_plugins/ 中做一个读取控制节点 JSON 注册表的 lookup。把同一个 filter 迁入 collection 内部(plugins/filter/)并用 FQCN 调用,最后在 library/ 中做一个自定义模块并在目标主机上运行,确认第二次运行是 changed=0,以及在检查模式下不会创建文件。