检查模式能模拟什么,不能模拟什么
一句话总结
--check 是用来询问“现在运行会改变什么”的模式,而它给出答案的准确度因模块而异。让检查模式变得可信,不是加上一个标志就行,而是要为每个 task 规定它在检查模式下如何行动。
为什么需要它
第一次把 playbook 运行到生产服务器上的那天,恐惧是具体的。没有人知道这个 playbook 会对二十台机器做什么。可能有人手工改过的配置,也可能有半年前的人加入的 task 现在会做出莫名其妙的事。可是又不能一台一台用眼睛看着过去。
--check 就是放在这个位置上的工具。官方文档把这个模式称为 dry run,并用一句话定义了它所做的事。不对远程系统做出改变,并且如果看起来会发生变更,就把那个 task 报告为 changed。如果同时加上 --diff,还会逐行显示具体有什么不同。
然而这个工具会辜负你两次。一次是虚假的安心——检查模式下一片干净,真正运行时却失败。另一次是虚假的失败——检查模式下红色报错,真正运行时却毫无问题。两次辜负的原因是相同的。检查模式是在不实际执行的情况下推测结果的模式,而推测的质量因模块而异。
工作原理
检查模式是逐个 task 分别决定的。如果模块支持检查模式,就会报告“将会改变”,而不是真的去改变。如果不支持,那个 task 就会被跳过(skipping)。command 和 shell 就是典型——Ansible 不知道那条命令会做什么,所以干脆不运行。
第一起事故就出在这里。只做查询的命令 task 一旦被跳过,它的 register 变量就是空的,使用这个值的后续 task 会接连崩溃。所以对只读取的 task,要加上 check_mode: false。这个 task 在检查模式下也会被真正执行。
- name: 현재 설치된 판을 읽는다
ansible.builtin.command: myapp --version
register: current
changed_when: false
check_mode: false
加的标准只有一个。这个 task 会不会改变目标。只有在确信不会改变时才加。如果把 check_mode: false 加到会改变目标的 task 上,那个 task 在 dry run 中也会被真正执行,而在那一刻,dry run 就成了谎言。
也有相反的方向。加了 check_mode: true 的 task,平时也绝不会改变。在实际执行中,它同样只报告“将会改变”。当想把暂时还不能开启的 task 预先写好、只看其影响,或者想把危险的 task 锁住、直到有人确认之前不执行时,就使用它。
虚假的失败是由顺序造成的。想象一个 playbook:前一个 task 创建文件,后一个 task 修改那个文件。在检查模式下,前一个 task 并没有真的创建文件,所以后一个 task 会因为要去修改一个不存在的文件而失败。在这个实验环境中,lineinfile 恰好会以 Destination ... does not exist ! 失败。并不是 playbook 错了,而是检查模式的局限。
修复方法有三种,价值各不相同。第一,给后一个 task 加上 when: not ansible_check_mode,在检查模式下跳过——这是最常见也最诚实的做法。代价是那个 task 会改变什么,就从计划中消失了。第二,给前一个 task 加上 check_mode: false,让它真的创建——这样就不再是 dry run,所以很难说值得推荐。第三,从一开始就改变设计,让一个模块整体管理那个文件——这是最好的,但也是最大的改动。
--diff 的格式是固定的。在 --- before 和 +++ after 之下,变化的行以 - 和 + 显示。如果改变的不是内容而只是属性,显示的就不是文件内容,而是属性 JSON 的 diff(比如目录的 state: absent 变成 state: directory 这样)。--diff 并不只限检查模式——加在真正的执行上,就会在改变的同时显示改变了什么。在事故调查中,这一种往往更有用。
对于包含机密的文件,要同时加上 no_log: true。否则 diff 会把那个值原样打印到日志里。
三个工具的位置各不相同。
| 工具 | 做什么 | 会连接目标吗 | 会展开模板吗 |
|---|---|---|---|
--syntax-check |
读取 YAML 和 playbook 的结构 | 不会 | 不会 |
--list-tasks |
只列出本次选择会运行什么 | 不会 | 不会 |
--check |
以检查模式真正执行 task | 会 | 会 |
实测得到的差别把这一点说得很清楚。使用未定义变量的 playbook,会以退出码 0 通过 --syntax-check 和 --list-tasks。那个错误要到 --check 才会第一次暴露,退出码为 2。反过来,模块名拼写错误或缩进事故,三个工具都会以退出码 4 抓到。所以 CI 会按顺序设置这三个——从成本低的开始。
把检查模式用作审批流程时,需要人可以阅读的产出。屏幕上滚过的绿色、黄色文字不会作为审批依据保留下来。如果把 ansible.posix.json 回调指定为 stdout 回调,整个运行会输出为一个 JSON,从中只提取“将会改变的 task 名称列表”,就可以附到变更请求上。
ANSIBLE_STDOUT_CALLBACK=ansible.posix.json \
ansible-playbook -i hosts.ini site.yml --check --diff > plan.json
再进一步,就成了门禁。在收敛结束之后,再用检查模式运行一次,只要有一个将会改变的项,就以失败结束——把这样的脚本挂到 CI 上,被人手工改过的服务器,在下一次部署之前就会暴露出来。这就是检查模式从“观察的工具”变成“守护的工具”的地方。
在现场相遇的样子
第一,检查模式一片干净,真正执行却失败。多半是因为 shell task 被跳过了。如果检查模式下有很多 skipped,说明那个 playbook 的 dry run 就少看了这么多。养成把 PLAY RECAP 中的 skipped 数字当作计划可信度来读的习惯,会有帮助。
第二,检查模式是红色的,实际上却毫无问题。刚开始引入的团队,最多就是在这里放弃。他们得出“我们的 playbook 不能用 --check”的结论,从此完全不用 dry run。实际上,只要给两三个 task 加上 guard 就解决了。
第三,diff 把机密泄漏到日志里。用模板部署证书密钥或数据库密码的同时加上 --diff,CI 日志里就会原样留下这些值。最好从一开始就确立加上 no_log: true 的规则。
第四,审批是靠屏幕截图来回传递的。把 dry run 结果作为图片贴进去的团队,半年后找不到“当时批准了什么”。只要有一份 JSON 计划产出,审批历史就会以可搜索的文本形式保留下来。
第五,这个实验环境的如实局限。这个 Pod 中没有审计日志,也没有外部审批系统。所以“审批流程”只讲到把计划产出保存为文件为止,把这个文件贴到哪里,只在阅读中讨论。服务重启之类的 task 也因为没有 capability 而无法处理,所以检查模式的对象限定为文件和目录。
参考文档
- 用检查模式验证:https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_checkmode.html
- ansible-playbook 命令行选项:https://docs.ansible.com/ansible/latest/cli/ansible-playbook.html
- ansible.builtin.lineinfile 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/lineinfile_module.html
- ansible.builtin.copy 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/copy_module.html
- 条件判断(when 与 ansible_check_mode):https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_conditionals.html
下一项实验要做什么
刚创建好 playbook,就先用 --check 运行,确认什么都没有生成,收敛一次之后,改动三个值,用 --check --diff 取出内容、权限、单行这三种 diff。看到只读命令 task 在检查模式下被跳过,就用 check_mode: false 让它恢复;反过来,用 check_mode: true 创建一个即使在实际执行中也绝不改变的 task。故意制造检查模式的虚假失败,并用 ansible_check_mode guard 修复。对使用未定义变量的 playbook 依次运行 --syntax-check、--list-tasks、--check,把退出码最早在哪里出现分歧整理成表,用 JSON 回调生成用于审批的计划产出,然后亲手编写在有将会改变的项时以 1 结束的漂移门禁脚本。