TT Lab
开始
学习 学习路径 课程

Ansible 实战

在运行之前就拦住:语法、lint 与前置条件合成一道关卡

在 TT Lab 中继续学习

目标

从能通过语法检查的坏 playbook 出发,用 ansible-lint 的规则和 profile 一层层提升,学会把例外放在尽可能小的范围内,用 assert 先拦住前置条件,并把这三样整合成一个关卡脚本。

为什么重要

Ansible 的危险之处在于,写错的 playbook 也能顺利运行。没有名称的 task、带管道的 shell、没有指定权限的文件写入,全都以绿色结束。问题在六个月后出现——报告永远是 changed,没人再读;各服务器上的文件权限不一样;失败的 task 名称是 shell,看日志也不知道什么挂了。lint 把这六个月提前到提交之前。不过一开启 lint,团队马上就会遇到下一个问题——冒出几百条指摘,其中有几条确实需要例外。这时如果分不清在整个仓库中关掉规则和只排除一行,lint 一个月就会变成空壳。本实验把这个区分,以及 lint 看不到的东西(值是否合理)用 assert 来拦住的位置,一并搭建起来。

步骤

  1. 把 /root/anslint/ansible.cfg 的默认 inventory 设为 ./inventory/hosts.ini,并在该文件中写入 web 组的 web1、web2(两者都是 ansible_host=127.0.0.1 ansible_port=2222,[all:vars] 的 ansible_user 是 root)。在 /root/anslint/messy.yml 中写一个没有名称的 play,里面有三个 task——没有名称且带管道的 shell task、名称以小写字母开头的 command: mkdir -p task、像 copy: content=... dest=... 这样写成一行的 task。运行 ansible-playbook --syntax-check messy.yml,把输出保存到 /root/anslint/out/syntax.txt。
  2. 把 ansible-lint -f pep8 messy.yml 的输出保存到 /root/anslint/out/lint_before.txt(每行一条指摘的格式)。然后对同一个文件用 JSON 格式运行 lint,只把抓到的规则 id 去重并按字典序每行一个,保存到 /root/anslint/out/rules.txt。
  3. 重新编写 /root/anslint/site.yml——一个有名称的 play,里面有三个有名称的 task。第一个创建 /root/anslint/out/data 目录,第二个向 /root/anslint/out/app.conf 写入 port=8080 一行,第三个向 /root/anslint/out/upper-<호스트이름>.txt(占位符为主机名)把该主机名转成大写写入一行。一条 shell 命令也不使用。ansible-lint --profile basic site.yml 必须通过,并真正运行 playbook,把输出保存到 /root/anslint/out/run.txt。
  4. 把 /root/anslint/site.yml 的所有模块都改成 FQCN(ansible.builtin.<모듈>,占位符为模块名),并为每个创建文件和目录的 task 写上 mode。ansible-lint --profile production site.yml 必须通过,并把输出保存到 /root/anslint/out/lint_production.txt。
  5. 在 /root/anslint/site.yml 中再加第四个 task——用 ansible.builtin.command 执行 tar -czf /root/anslint/out/bundle.tgz -C /root/anslint/out app.conf,并加上 changed_when: false。lint 会把这个 task 以 command-instead-of-module 抓出来,但 unarchive 模块只能解包不能打包,所以这里用 shell 命令是对的。加一条注释,只把这一行从规则中排除,让 --profile production 重新通过,并再次运行 playbook,真正生成压缩包。
  6. 在 /root/anslint/site.yml 中再加第五个 task——名称是以小写字母开头的 nginx health probe,向 /root/anslint/out/health.txt 写入 ok 一行。然后创建 /root/anslint/.ansible-lint,写入 profile: production,在 exclude_paths 中写 messy.yml 和 out/,在 skip_list 中写 name[casing]。不带参数运行 ansible-lint,确认整个目录通过,并把输出保存到 /root/anslint/out/lint_repo.txt。
  7. 创建 /root/anslint/checks.yml——在 localhost 上不收集 fact,用 play 变量设置 app_port: 8080、app_env: staging、allowed_envs: [staging, prod]。第一个 task 检查 app_port 是否为整数且在 1024 以上 65535 以下,第二个 task 检查 app_env 是否在 allowed_envs 之中。两个都用 ansible.builtin.assert,并带上 fail_msg 和 success_msg。把用默认值运行的输出保存到 /root/anslint/out/assert_ok.txt,把用 -e app_port=80 覆盖后运行的输出保存到 /root/anslint/out/assert_fail.txt。
  8. 创建 /root/anslint/gate.sh——切换到作为第一个参数收到的目录(默认是当前目录),依次确认三件事。对该目录正下方的每个 *.yml 做语法检查,输出 OK syntax <파일> 或 FAIL syntax <파일>(占位符均为文件名);不带参数运行 ansible-lint,输出 OK lint 或 FAIL lint;如果有 checks.yml 就运行它,输出 OK assert 或 FAIL assert。只要有一项失败,就以非 0 值结束。在当前目录运行这个关卡,把输出保存到 /root/anslint/out/gate.txt。

参考

能通过语法检查的坏 playbook

把 /root/anslint/ansible.cfg 的默认 inventory 设为 ./inventory/hosts.ini,并在该文件中写入 web 组的 web1、web2(两者都是 ansible_host=127.0.0.1 ansible_port=2222,[all:vars] 的 ansible_user 是 root)。在 /root/anslint/messy.yml 中写一个没有名称的 play,里面有三个 task——没有名称且带管道的 shell task、名称以小写字母开头的 command: mkdir -p task、像 copy: content=... dest=... 这样写成一行的 task。运行 ansible-playbook --syntax-check messy.yml,把输出保存到 /root/anslint/out/syntax.txt。

--syntax-check 只读取 YAML,看 play 和 task 的结构是否讲得通、模块名称是否真实存在,仅此而已。更多的不会看——不幂等的命令、没有名称的 task、没有指定权限的文件写入,全都能通过。这一步的目的是亲眼看到为什么不能相信语法检查。请故意写得糟一点。

用规则 id 数出 lint 抓到了什么

把 ansible-lint -f pep8 messy.yml 的输出保存到 /root/anslint/out/lint_before.txt(每行一条指摘的格式)。然后对同一个文件用 JSON 格式运行 lint,只把抓到的规则 id 去重并按字典序每行一个,保存到 /root/anslint/out/rules.txt。

ansible-lint 的输出格式用 -f 选择——有供人阅读的默认格式、每行一条的 pep8、供工具读取的 json。JSON 的每一项中,规则 id 的字段名是 check_name。用 jq 和 sort -u 提取即可。规则 id 像 name[play] 这样用方括号区分细项——同样是 name 规则,也能通过 id 看出是哪个位置被抓到。lint 发现违规时会以非 0 值结束,所以保存时请考虑这一点。

提升到 basic profile

重新编写 /root/anslint/site.yml——一个有名称的 play,里面有三个有名称的 task。第一个创建 /root/anslint/out/data 目录,第二个向 /root/anslint/out/app.conf 写入 port=8080 一行,第三个向 /root/anslint/out/upper-<호스트이름>.txt(占位符为主机名)把该主机名转成大写写入一行。一条 shell 命令也不使用。ansible-lint --profile basic site.yml 必须通过,并真正运行 playbook,把输出保存到 /root/anslint/out/run.txt。

profile 是把规则捆绑起来的层级——按 min、basic、moderate、safety、shared、production 的顺序,越往上越严格,上面的 profile 包含下面 profile 的所有规则。不要想着一步到 production,一层层往上提才是实际工作中的顺序。basic 抓的主要是“没有名称”和“写成了一行自由格式”。把 shell 命令换成模块,no-changed-when 也会随之消失。

production profile 额外要求的两点

把 /root/anslint/site.yml 的所有模块都改成 FQCN(ansible.builtin.<모듈>,占位符为模块名),并为每个创建文件和目录的 task 写上 mode。ansible-lint --profile production site.yml 必须通过,并把输出保存到 /root/anslint/out/lint_production.txt。

production profile 额外要求的东西里,最常碰到的就是这两项。FQCN 防止名称冲突——collection 一多,copy 这个名称会出现在多个地方,短名称可能随搜索路径顺序变成不同的模块。要求写 mode 的规则是防止权限靠运气。不写的话由目标主机的 umask 决定,而那个值各服务器不同。哪条规则属于哪个 profile,可以在 lint 输出末尾的汇总表里看到。

保留规则,只排除一行

在 /root/anslint/site.yml 中再加第四个 task——用 ansible.builtin.command 执行 tar -czf /root/anslint/out/bundle.tgz -C /root/anslint/out app.conf,并加上 changed_when: false。lint 会把这个 task 以 command-instead-of-module 抓出来,但 unarchive 模块只能解包不能打包,所以这里用 shell 命令是对的。加一条注释,只把这一行从规则中排除,让 --profile production 重新通过,并再次运行 playbook,真正生成压缩包。

在 task 的任意一行加上 # noqa: <규칙id>(占位符为规则 id),只有那个 task 会从该规则中排除。排除多个规则时用空格列出。这与设置文件中的 skip_list 性质完全不同——noqa 是“只在这里例外”,所以旁边的人在评审时可以问理由;skip_list 是“整个仓库都不看这条规则”,那条规则实际上就没有了。设置例外时,总要选择最小的范围。现在抓到的规则 id,与第 2 步中提取的列表是同一种形式。

把作用于整个仓库的规则写进设置文件

在 /root/anslint/site.yml 中再加第五个 task——名称是以小写字母开头的 nginx health probe,向 /root/anslint/out/health.txt 写入 ok 一行。然后创建 /root/anslint/.ansible-lint,写入 profile: production,在 exclude_paths 中写 messy.yml 和 out/,在 skip_list 中写 name[casing]。不带参数运行 ansible-lint,确认整个目录通过,并把输出保存到 /root/anslint/out/lint_repo.txt。

有了设置文件,就不必每次手动给出 --profile,CI 和人手用同一套规则运行——这才是设置文件存在的真正理由。exclude_paths 是“这个路径根本不要看”。放进去的是为了教学而保留的坏例子或别人写的代码。不过如果直接把文件名作为参数给出,排除列表会被忽略——排除是“扫描时”的规则。放进 skip_list 的必须是团队达成共识的例外。这里可以看作是约定允许使用以产品名称小写字母开头的 task 名称。

让 playbook 自己去问值是否合理

创建 /root/anslint/checks.yml——在 localhost 上不收集 fact,用 play 变量设置 app_port: 8080、app_env: staging、allowed_envs: [staging, prod]。第一个 task 检查 app_port 是否为整数且在 1024 以上 65535 以下,第二个 task 检查 app_env 是否在 allowed_envs 之中。两个都用 ansible.builtin.assert,并带上 fail_msg 和 success_msg。把用默认值运行的输出保存到 /root/anslint/out/assert_ok.txt,把用 -e app_port=80 覆盖后运行的输出保存到 /root/anslint/out/assert_fail.txt。

assert 检查 that 中写的条件是否全部为真。只要有一个为假,那台主机上的 play 就停下——这正是目的。前置条件必须在改变任何东西之前确认。部署到一半才停下,回滚要贵得多。不写 fail_msg,失败消息会以条件式原文的形式出现,收到的人不知道该改什么。用 -e 传入的值如果不另外指定,就是字符串——在这里可以看到 is integer 为什么为假。失败的运行会以非 0 值结束,所以保存输出时请考虑这一点。

把三层合成一个关卡

创建 /root/anslint/gate.sh——切换到作为第一个参数收到的目录(默认是当前目录),依次确认三件事。对该目录正下方的每个 *.yml 做语法检查,输出 OK syntax <파일> 或 FAIL syntax <파일>(占位符均为文件名);不带参数运行 ansible-lint,输出 OK lint 或 FAIL lint;如果有 checks.yml 就运行它,输出 OK assert 或 FAIL assert。只要有一项失败,就以非 0 值结束。在当前目录运行这个关卡,把输出保存到 /root/anslint/out/gate.txt。

关卡的价值在于拦住。只放行、永远以 0 结束的脚本等于没有,反而制造出“正在检查”的错觉,更糟。所以做好之后,一定要喂给它坏输入试一试——在临时目录里故意放一个坏 playbook,把那个目录作为参数给出。三层按这个顺序摆放也是有原因的。语法检查连 1 秒都不用,lint 要几秒,前置条件检查是真正运行 Ansible——先跑便宜的,错误的提交才能更快被退回。