规则、配置档、例外,以及把它们串起来的关卡
一句话总结
--syntax-check 只检查 YAML 是否讲得通,质量由 ansible-lint 来看。而让 lint 长期存活的诀窍不是开启很多规则,而是把例外放在尽可能小的范围内。
为什么需要它
Ansible 之所以危险,是因为写错的 playbook 也能以绿色结束。没有名称的 task、带管道的 shell、没有指定权限的文件写入,全都会以 ok 或 changed 结束。问题在六个月后出现。
- 报告永远是
changed,没人再去读它。真正的变更被淹没了。 - 没有写文件权限,各服务器上
644和664混在一起。是因为目标机器的 umask 不同。 - 失败的 task 名称是
shell,看日志也不知道是什么挂了。 shell: a | b中即使a挂了,退出码也是b的,失败被报告为成功。
lint 把这六个月提前到提交之前。但一开启,团队马上会遇到下一个问题。对旧仓库运行 lint 会冒出几百条指摘,其中有几条确实需要例外。这时例外怎么放,决定了 lint 半年后是否还活着。
工作原理
层次不同
| 工具 | 看什么 | 看不到什么 |
|---|---|---|
--syntax-check |
YAML 结构、play 和 task 的形状、模块名称是否真实存在 | 质量、幂等性、值是否合理 |
ansible-lint |
名称、幂等性、权限、FQCN 这类规范 | 运行时的值 |
assert task |
值是否合理(端口范围、环境名称) | 静态规范 |
| molecule | 真正启动 role,做收敛、幂等性、验证场景 | 不能替代上面三者 |
越往下越贵。所以关卡要先运行便宜的。
规则 id 与 profile
ansible-lint 的指摘总会附上规则 id。也有像 name[play] 这样用方括号区分细项的。下面是本课程实验镜像中的版本(6.17.2)实际会出现的规则。
| 规则 id | 抓什么 |
|---|---|
name[play] |
没有名称的 play |
name[missing] |
没有名称的 task |
name[casing] |
名称以小写字母开头 |
no-free-form |
像 copy: src=a dest=b 这样写成一行的调用 |
no-changed-when |
可能改变状态的命令没有报告标准 |
risky-shell-pipe |
使用管道却没有设置 pipefail |
risky-file-permissions |
创建文件时没有指定 mode |
command-instead-of-module |
有专用模块的命令却用 shell 调用 |
fqcn[action-core] |
短模块名称 |
规则被 profile 捆绑在一起。按 min、basic、moderate、safety、shared、production 的顺序,越往上越严格,上面的 profile 包含下面所有的规则。所以把 lint 引入旧仓库时,不会一步到 production。先用 basic 让 CI 变绿,下个月再升到 moderate。profile 存在的理由就是这条迁移路径。
版本不同,规则名称也不同。所以一定要先用 ansible-lint -L 查看自己版本的列表。
放置例外的两个位置
- name: Pack the release bundle # noqa: command-instead-of-module
ansible.builtin.command: tar -czf /tmp/rel.tgz -C /srv app.conf
changed_when: false
# noqa: <규칙id>(占位符为规则 id)只把那个 task 从该规则中排除。它就留在旁边,所以评审时可以问“为什么排除”,理由消失后也可以删除。
# .ansible-lint
profile: production
exclude_paths:
- legacy/
skip_list:
- name[casing]
skip_list 会在整个仓库中关掉该规则。只用于团队达成共识的规范(例如:允许以产品名称小写字母开头的 task 名称)。如果因为指摘太多就把规则一股脑塞进这里,剩下的就只有“lint 是开着的”这一错觉。
exclude_paths 又不同。它不是关掉规则,而是完全不扫描那个路径。放进去的是别人写的代码,或为了教学而保留的坏例子。不过,如果直接把文件名作为参数给出,这个列表就会被忽略——排除是“扫描时”的规则。
设置文件存在的真正理由不是方便,而是让人手和 CI 用同一套规则运行。如果只在 CI 中给出 --profile production,开发者以为通过了就推送,然后在 CI 里失败。
lint 看不到的位置——assert
lint 看的是静态规范。但事故也会出在值上。端口里传进来 80,环境名称有错别字,副本数变成 0。这些只有在运行时才能知道,所以用 ansible.builtin.assert 让 playbook 自己去问。
- name: Assert that the port is usable
ansible.builtin.assert:
that:
- app_port is integer
- app_port >= 1024
fail_msg: "app_port must be an integer of 1024 or above, got {{ app_port }}"
有两点重要。第一,要在改变任何东西之前就问。如果部署了一半才停下,回滚的代价要高得多。第二,一定要写 fail_msg。没有它,失败消息会以条件式原文的形式出现,收到的人不知道该改什么。另外,用 -e 传入的值如果不另外指定,就是字符串——is integer 为什么为假,就在这里分晓。
molecule 还能多做什么
Molecule 是测试 role 的框架。按场景启动目标(Docker、Podman、云),应用 role,再应用一次看是不是 changed=0(幂等性),用验证 playbook 确认结果,然后清理掉。它能自动看到 lint 看不到的“是否真的收敛”。
本实验镜像中没有 molecule。 实验 Pod 没有互联网,无法安装,启动容器也被禁止。所以本模块的实验去掉 molecule,只搭建它的前一段(语法、lint、前置条件)。即使在使用 molecule 的团队,这前一段也照样需要——因为 molecule 要花几分钟,而语法检查连 1 秒都不到。
在现场相遇的样子
第一,引入总是从“不要更糟”开始,而不是“全部修好”。 用较低的 profile 让 CI 变绿,只对新代码应用高标准,旧路径先放进 exclude_paths,动到的时候再取出来。
第二,no-changed-when 指摘的一半是真正的缺陷。 给查询命令加上 changed_when: false,不是在凑格式,而是让报告变得诚实。永远是 changed 的 playbook,会把真正有东西发生变化的那一天藏起来。
第三,关卡脚本一定要确认到它真的能拦住。 只检查、却永远以 0 结束的脚本其实很常见。这样的关卡比没有还糟——因为它制造了“正在检查”的错觉。做出来的当天就故意喂给它坏输入,看它是否以非 0 值结束,是那个脚本的第一项测试。
第四,例外是有期限的。 加 # noqa 时,如果在同一行或正上方用一行写明为什么加,半年后理由消失时就可以删掉。没有写明理由的例外会永远留着。
参考文档
- ansible-lint 规则列表:https://ansible.readthedocs.io/projects/lint/rules/
- profile:https://ansible.readthedocs.io/projects/lint/profiles/
- 配置与例外处理:https://ansible.readthedocs.io/projects/lint/configuring/
- assert 模块:https://docs.ansible.com/ansible/latest/collections/ansible/builtin/assert_module.html
- Molecule:https://ansible.readthedocs.io/projects/molecule/
下一项实验要做什么
故意写一个能通过语法检查的坏 playbook,把 lint 抓到的规则 id 提取出来留成列表。把做同样事情的干净 playbook 提升到 basic profile,再加上 FQCN 和 mode 提升到 production。对一个没有对应模块的命令,用 # noqa 只在一行上设置例外,并在 .ansible-lint 中写明 profile、排除路径和要跳过的规则,让整个仓库一次通过。用 assert 拦住前置条件,最后做一个一次性检查语法、lint 和前置条件的关卡脚本,并确认喂给它坏目录时它真的会拦住。