为什么模块优先,以及不得不用 shell 的时候
一句话总结
ansible.builtin.command 不经过 shell,而 ansible.builtin.shell 会经过。这一点差别决定了重定向、管道、glob 能不能用;一旦交给 shell,幂等性、报告、失败判定就全都必须由人自己负责。
为什么需要它
第一次使用 Ansible 的人写的 playbook,几乎总是同一个样子。把原来做的事原样搬过来,结果 task 全都是 shell:。
- name: 설정 배포
ansible.builtin.shell: |
mkdir -p /etc/myapp
echo "env=prod" > /etc/myapp/app.conf
chmod 640 /etc/myapp/app.conf
它能跑。问题在于,这个 task 无论问它什么都回答不了。现在已经是那个状态了吗?不知道。这次运行改变了什么?不知道。在改变之前能不能展示将要改变什么?做不到。是失败了还是成功了?它只会说最后一条命令的退出码是 0 就算成功。所以这个 task 每次运行都会报告 changed,即使二十台里有一台悄悄产生了不同的结果,也没有人知道。
同样的事用模块来写,就变成这样。
- name: 설정 자리를 만든다
ansible.builtin.file:
path: /etc/myapp
state: directory
mode: "0755"
- name: 설정을 쓴다
ansible.builtin.copy:
dest: /etc/myapp/app.conf
content: "env=prod\n"
mode: "0640"
行数差不多,性质却不同。模块会先读取目标的当前状态,如果已经是那个状态,就什么都不做,返回 changed: false。在检查模式下,它不会去改变,而是报告将要改变的内容。结果不是字符串,而是带键的 JSON,所以后面的 task 可以直接取用 .mode、.checksum。这三点——状态判定、事先预告、结构化返回——就是“模块优先”的全部原因。
工作原理
command 与 shell 真正的区别只有一个:有没有 shell。command 会把收到的字符串拆成单词,原样交给可执行文件。中间没有 /bin/sh。shell 则把字符串整个交给 shell。所以原本由 shell 完成的事,全都出现了分歧。
| 传入的内容 | command |
shell |
|---|---|---|
ls /srv/app/*.conf |
glob 不会展开,去找名为 *.conf 的文件 |
由 shell 展开 |
echo a b c 뒤에 파이프와 wc -w(韩文,意为“echo a b c 之后接管道和 wc -w”) |
从管道符号开始的全部内容都成为 echo 的参数 | 真的建立管道 |
echo x > /tmp/f |
大于号也是参数。不会生成文件 | 实现重定向 |
; 와 &&(韩文,意为“和”) |
是参数 | 是 shell 运算符 |
这里有一个很多人会混淆的地方。环境变量在 command 中也会被展开。从 ansible-core 2.16 开始,command 模块新增了 expand_argument_vars 选项,默认值为 true,所以 $HOME 即使没有 shell,也由模块自己展开。要关闭,就设置 expand_argument_vars: false。所以,准确的说法不是“不经过 shell 就什么都不会展开”,而是“shell 语法不生效”。
command 悄悄出错的方式也值得了解。glob 没有展开时,命令会以退出码 2 失败,马上就能注意到。但管道就不同了。把 echo one two three | wc -w 交给 command,echo 会原样输出 one two three | wc -w,并以退出码 0 成功。task 是绿色的,只是结果不对。这是比失败更糟的成功。
必须使用 shell 时,要亲自定下三件事。
第一,changed_when。command 和 shell 没有办法知道自己改变了什么,所以一律报告 changed。对于只做查询的 task,必须加上 changed_when: false。不加的话,什么都不改变的 playbook 每天都会累积 changed,而当这个数字失去意义的那一刻,真正的变更也就不显眼了。
第二,failed_when。默认判定是“退出码不为 0 就是失败”。但 grep 在什么都没找到时会返回 1,那不是错误,而是一种答案。这时要像 failed_when: result.rc not in [0, 1] 这样,亲自写出失败的定义。
第三,管道的退出码。在 shell 中,管道的退出码是最后一条命令的退出码。cat 없는파일 | wc -l(韩文,意为“不存在的文件”)即使 cat 失败了,wc 也会以 0 结束,所以整体是 0。task 成功,结果是 0。要避免这种情况,就在前面加上 bash 的 set -o pipefail,并同时指定 executable: /bin/bash(因为默认的 shell 可能不认识 pipefail)。ansible-lint 的 risky-shell-pipe 规则抓的正是这个地方。
- name: 로그에서 오류 줄을 센다
ansible.builtin.shell:
cmd: set -o pipefail; grep ERROR /var/log/app.log | wc -l
executable: /bin/bash
register: errors
changed_when: false
failed_when: errors.rc not in [0, 1]
模块返回的是 JSON。用 register 接收后,里面有 rc、stdout、stdout_lines、stderr、changed、failed、cmd。stdout_lines 已经是行列表,所以没有必要自己去做 split('\n'),cmd 中留有实际执行的参数列表,用于事故调查。每个模块返回的键不同,这份列表写在 ansible-doc 的 RETURN 部分。
ansible-doc 不是搜索引擎,而是已安装内容的清单。ansible-doc -l 会列出这台机器当前实际能用的全部模块。加上 -s 会得到可以粘贴进 playbook 的骨架。用 -t 可以选择插件类型(callback、filter、lookup、connection)。“有没有做这件事的模块”,应该先在这里找,而不是去互联网上找。
raw 是为例外准备的工具。无论是 command 还是 shell,目标上都必须有 Python 才能运行——因为模块代码是 Python。对于还没有 Python 的机器(刚装好的服务器、网络设备、去掉了 Python 的容器),就使用 raw。raw 通过 SSH 原样抛出字符串,并原样接收输出。它很廉价,但什么都不替你做——没有幂等性,没有返回结构,也没有行尾整理,所以收到的字符串上经常带着 CR。只在引导阶段使用,装好 Python 之后就不要再用。
使用 FQCN 的原因。像 copy: 这样写短名称,现在也能运行。但在装了多个 collection 的机器上,同名的模块可能不止一个,这时选中哪个由搜索路径决定。如果一直写到 ansible.builtin.copy,这种模糊性就消失了。读的人也一眼就知道“这是 core 的”。ansible-lint 的 fqcn 规则要求这样做。
在现场相遇的样子
第一,以 shell 起步的 playbook 会退回成 shell 脚本。一个 task 是 shell,下一个 task 也很容易成为 shell。半年之后,playbook 就成了通过 SSH 执行的 shell 脚本,使用 Ansible 的理由荡然无存。所以评审时的第一个问题是“真的没有能代替这个 shell 的模块吗”。
第二,在事故调查中 changed 会说谎。没有给查询 task 加 changed_when: false 的团队,每天有几十条 changed。即使真正的变更混在其中,也没有人能找出来。反过来,如实管理 changed 的团队,可以把“昨天什么都没变”当作证据来说。
第三,管道退出码事故是悄无声息的。备份验证 task 是 tar -tzf backup.tar.gz | wc -l,文件损坏的那天 tar 失败了,但 wc 返回 0,task 成功了。备份损坏这件事,要到需要恢复的那天才会知道。set -o pipefail 这一行就在其间。
第四,这个实验环境的如实局限。实验 Pod 没有 capability,所以 systemctl、mount、sysctl -w 无法运行。因此,像“把原来用 shell 做的服务重启,改用 ansible.builtin.service”这样的例子,在这个环境中无法判定,所以从实验中去掉了。取而代之,只处理文件、目录、查询命令这样在这个 Pod 中真正能运行的东西。原理是一样的。
参考文档
- ansible.builtin.command 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.html
- ansible.builtin.shell 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/shell_module.html
- ansible.builtin.raw 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/raw_module.html
- ansible-doc 命令:https://docs.ansible.com/ansible/latest/cli/ansible-doc.html
- 错误处理(failed_when、changed_when):https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_error_handling.html
下一项实验要做什么
把同样的命令分别用 command 和 shell 抛出去,亲自测量 glob 和管道上出现了什么分歧,并把这些数字保存为文件。取出用 register 接收的 JSON 中的 rc、stdout、changed、cmd,给查询 task 加上 changed_when: false,给退出码 1 属于正常情况的 task 加上 failed_when,校正标准。用数字确认没有 pipefail 的管道会吞掉失败,再把它修好,把三行 shell 改用 file、copy、stat 模块,只保留结构化的返回值。最后用 raw 去查找 Python,把整个 playbook 整理为 FQCN,再亲手编写一个能找出没有 guard 就交给 shell 的 task 的审计脚本。