非得用 shell 的话,你要自己扛下什么
目标
把同样的命令分别用 command 和 shell 抛出去,用数字测量有什么分歧,并亲手练习在必须使用 shell 时,自己建立报告标准、失败标准和管道退出码。最后要创建一个审计工具,找出没有 guard 就交给 shell 的 task。
为什么重要
第一次使用 Ansible,playbook 很容易变成通过 SSH 执行的 shell 脚本。它确实能跑,但对“现在已经是那个状态了吗”“这次改变了什么”“是失败还是成功”这些问题,什么都回答不了。模块就是为回答这三个问题而创造的,所以如果有做同样事情的模块,就应该优先用它。话虽如此,也不可能永远不用 shell——总会剩下没有模块可用的事。重要的是,要知道一旦交给 shell,Ansible 原本替你做的判断就全部消失了,并把这些判断亲手重新写进去。本实验会把这三项判断逐一建立起来。
步骤
- 创建
/root/ansmod/hosts.ini——在[web]组中放入web1和web2,两者都带有ansible_host=127.0.0.1和ansible_port=2222,并通过[all:vars]设置ansible_user=root。然后把ansible-doc -s ansible.builtin.command的输出保存到/root/ansmod/out/doc-command.txt,把ansible-doc -s ansible.builtin.shell的输出保存到/root/ansmod/out/doc-shell.txt。 - 在
/root/ansmod/files/中创建三个空文件a.txt、b.txt、c.txt。创建/root/ansmod/boundary.yml,在web1上运行四个 task——用command运行一次ls /root/ansmod/files/*.txt(即使失败也继续),用shell运行一次ls /root/ansmod/files/*.txt | wc -l,用command运行一次echo one two three | wc -w,再用shell运行一次同样的命令。把四个结果恰好写成四行,保存到/root/ansmod/out/boundary.txt:glob command rc=<값>/glob shell stdout=<값>/pipe command stdout=<값>/pipe shell stdout=<값>(占位符均为值)。 - 创建
/root/ansmod/report.yml。在web1上用ansible.builtin.command运行id -un,以who进行register,并从其返回值中只取出四个字段,以 JSON 保存到/root/ansmod/out/result.json——rc、stdout、changed直接使用返回值,cmd是把返回值的参数列表用空格连接起来的字符串。这一步不加changed_when。 - 在
/root/ansmod/report.yml中再加入两个 task。一个用command运行cat /etc/hostname,以hn进行 register,并加上changed_when: false。另一个用shell运行grep -c "^nosuchuser:" /etc/passwd,以hits进行 register,并在加上changed_when: false的同时,加上只有在退出码既不是 0 也不是 1 时才算失败的failed_when。然后在/root/ansmod/out/result.json中再添加三个字段——hostname_changed(hn 的 changed)、grep_rc(hits 的 rc)、grep_failed(hits 的 failed)。playbook 必须一直运行到结束。 - 创建
/root/ansmod/pipe.yml。把同一条管道cat /root/ansmod/missing.txt | wc -l运行两次——一次直接用shell(以bare进行 register),一次在前面加上set -o pipefail并把executable指定为/bin/bash(以guarded进行 register)。两者都加上ignore_errors: true和changed_when: false。把结果以四个字段保存到/root/ansmod/out/pipe.json——bare_rc、bare_failed、guarded_rc、guarded_failed。不要创建missing.txt。 - 创建
/root/ansmod/modernize.yml。一次都不使用command和shell,完成以下事项——以权限0750创建/root/ansmod/app目录,以权限0640在/root/ansmod/app/app.conf中写入一行env=lab,用模块读取这两个路径的状态,并以五个字段保存到/root/ansmod/out/modernize.json——dir_mode、dir_isdir、conf_mode、conf_size、conf_checksum_len(校验和字符串的长度)。 - 创建
/root/ansmod/bootstrap.yml。用ansible.builtin.raw运行command -v python3 || echo NOPYTHON并 register,把去掉行尾空白和 CR 之后的路径,以一行保存到/root/ansmod/out/raw.txt(加上changed_when: false)。并且把到目前为止创建的五个 playbook(boundary.yml、report.yml、pipe.yml、modernize.yml、bootstrap.yml)中的所有 task 整理成使用以ansible.builtin.开头的 FQCN。 - 先创建将作为检查对象的
/root/ansmod/legacy.yml——共有四个 task:一个用command运行ansible --version并加上changed_when: false,一个用shell运行mkdir -p /root/ansmod/legacy/logs,一个用shell运行echo seeded > /root/ansmod/legacy/logs/stamp.txt,一个用command运行ls /root/ansmod/legacy/logs(后三个不加 guard)。然后创建/root/ansmod/shell-audit.sh <플레이북경로>(占位符为 playbook 路径):对于该 playbook 中使用command或shell且没有changed_when的 task,只输出其名称,每行一个,按字典序排列(必须同时识别短名称和 FQCN)。最后把./shell-audit.sh /root/ansmod/legacy.yml的输出保存到/root/ansmod/out/audit.txt。
参考
- 请先在第 1 步创建 inventory。这个 Pod 的 sshd 运行在 127.0.0.1:2222,并且已经完成密钥认证。
- 命令提示:用
ansible-doc -l ansible.builtin | grep -i <낱말>(占位符为词语)查找模块,用ansible-doc -s <모듈>(占位符为模块)查看选项骨架,用ansible-inventory -i hosts.ini --graph查看 inventory 的解析结果。 - 命令提示:
yq -r '.[].tasks[] | keys | .[]' <플레이북>(占位符为 playbook)会输出 task 使用的全部键。用jq . <파일>(占位符为文件)确认生成的 JSON 是真正的 JSON。 - 常见错误:没有给只做查询的
commandtask 加上changed_when: false,导致每次运行都累积 changed。 - 常见错误:把管道交给
shell时漏掉set -o pipefail,导致前面的命令失败了,task 仍然作为成功通过。 - 常见错误:把权限像
mode: 0640这样不加引号地写出来,导致八进制被当作十进制读取。 - 这个 Pod 没有 capability,所以
systemctl、mount、sysctl -w无法运行。因此,本实验只处理文件、目录和查询命令——原理在服务管理中也是一样的。 - command 模块 · shell 模块 · raw 模块 · ansible-doc · 错误处理
写出目标,并在文档中查找模块
创建 /root/ansmod/hosts.ini——在 [web] 组中放入 web1 和 web2,两者都带有 ansible_host=127.0.0.1 和 ansible_port=2222,并通过 [all:vars] 设置 ansible_user=root。然后把 ansible-doc -s ansible.builtin.command 的输出保存到 /root/ansmod/out/doc-command.txt,把 ansible-doc -s ansible.builtin.shell 的输出保存到 /root/ansmod/out/doc-shell.txt。
必须先有 inventory,才能开始任何操作。这个 Pod 的 sshd 运行在 127.0.0.1:2222,即使写两个主机,也都连到同一个 sshd。ansible-doc 不是互联网搜索,而是当前这台机器上已安装内容的清单。-s 会输出可以粘贴进 playbook 的骨架。把两个文件并排放在一起,找出只在一边出现的选项名称——它恰好展示了两个模块在性质上的差别。
把同样的命令分别用 command 和 shell 抛出去,测量差别
在 /root/ansmod/files/ 中创建三个空文件 a.txt、b.txt、c.txt。创建 /root/ansmod/boundary.yml,在 web1 上运行四个 task——用 command 运行一次 ls /root/ansmod/files/*.txt(即使失败也继续),用 shell 运行一次 ls /root/ansmod/files/*.txt | wc -l,用 command 运行一次 echo one two three | wc -w,再用 shell 运行一次同样的命令。把四个结果恰好写成四行,保存到 /root/ansmod/out/boundary.txt:glob command rc=<값> / glob shell stdout=<값> / pipe command stdout=<값> / pipe shell stdout=<값>(占位符均为值)。
command 会把收到的字符串拆成单词,原样交给可执行文件——中间没有 shell,所以 glob 和管道都不会按 shell 语法来解释。glob 会因命令以非 0 的退出码失败而马上被注意到,但管道会假装成功。把这个差别用数字记录下来,就是这一步的全部。要让 playbook 不因失败的 task 而停止,就加上 ignore_errors: true,因为只是查询,所以也同时加上 changed_when: false。
用 register 接收模块返回的 JSON 并读取
创建 /root/ansmod/report.yml。在 web1 上用 ansible.builtin.command 运行 id -un,以 who 进行 register,并从其返回值中只取出四个字段,以 JSON 保存到 /root/ansmod/out/result.json——rc、stdout、changed 直接使用返回值,cmd 是把返回值的参数列表用空格连接起来的字符串。这一步不加 changed_when。
模块的返回值不是字符串,而是带键的 JSON,register 会把这份 JSON 整个放进变量。里面有 rc、stdout、stdout_lines、stderr、changed、failed、cmd——cmd 是实际执行的参数列表,要变成字符串,必须把它连接起来。有一个过滤器可以把字典直接变成 JSON 字符串。另外,请亲眼确认,这个 task 明明什么都没有改变,changed 显示的是什么——这是下一步的出发点。
用 changed_when 和 failed_when 亲自建立报告标准
在 /root/ansmod/report.yml 中再加入两个 task。一个用 command 运行 cat /etc/hostname,以 hn 进行 register,并加上 changed_when: false。另一个用 shell 运行 grep -c "^nosuchuser:" /etc/passwd,以 hits 进行 register,并在加上 changed_when: false 的同时,加上只有在退出码既不是 0 也不是 1 时才算失败的 failed_when。然后在 /root/ansmod/out/result.json 中再添加三个字段——hostname_changed(hn 的 changed)、grep_rc(hits 的 rc)、grep_failed(hits 的 failed)。playbook 必须一直运行到结束。
grep -c 在什么都没找到时会返回退出码 1。那不是错误,而是“0 条”这个答案,但默认判定是不为 0 就失败,所以 playbook 会在那里停下。failed_when 就是由人重新书写失败定义的地方,在条件表达式中可以直接使用刚刚 register 的变量。请比较加了 changed_when: false 的 task 与没加的 task,它们的 changed 值在 result.json 中有什么不同。
用数字确认管道会吞掉失败,并加以防止
创建 /root/ansmod/pipe.yml。把同一条管道 cat /root/ansmod/missing.txt | wc -l 运行两次——一次直接用 shell(以 bare 进行 register),一次在前面加上 set -o pipefail 并把 executable 指定为 /bin/bash(以 guarded 进行 register)。两者都加上 ignore_errors: true 和 changed_when: false。把结果以四个字段保存到 /root/ansmod/out/pipe.json——bare_rc、bare_failed、guarded_rc、guarded_failed。不要创建 missing.txt。
在 shell 中,管道的退出码是最后一条命令的退出码。即使前面的 cat 失败了,只要 wc 以 0 结束,整体就是 0,这个 task 就会以绿色通过。set -o pipefail 会在管道中任何一个命令失败时,让整体失败。不过默认的 shell(/bin/sh)可能不认识这个选项,所以必须明确指定 shell。如果两个 rc 值出现不同,就成功了——这个差别,就是 ansible-lint 的 risky-shell-pipe 规则存在的原因。
把三行 shell 改用 file、copy、stat 模块
创建 /root/ansmod/modernize.yml。一次都不使用 command 和 shell,完成以下事项——以权限 0750 创建 /root/ansmod/app 目录,以权限 0640 在 /root/ansmod/app/app.conf 中写入一行 env=lab,用模块读取这两个路径的状态,并以五个字段保存到 /root/ansmod/out/modernize.json——dir_mode、dir_isdir、conf_mode、conf_size、conf_checksum_len(校验和字符串的长度)。
有一个模块可以一次性代替 mkdir -p 和 chmod,有一个模块可以代替 echo >,还有一个模块可以代替 ls -l 或 stat 命令。如果想不起这三个名称,请用 ansible-doc -l ansible.builtin | grep -i <낱말>(占位符为词语)去找——这就是第 1 步要把文档提取出来的原因。读取状态的模块,其返回值在名为 stat 的键之下包含 mode、isdir、size、checksum。权限是字符串,漏掉引号的话,八进制会被当作十进制读取。
用 raw 查找 Python,并把 playbook 整理为 FQCN
创建 /root/ansmod/bootstrap.yml。用 ansible.builtin.raw 运行 command -v python3 || echo NOPYTHON 并 register,把去掉行尾空白和 CR 之后的路径,以一行保存到 /root/ansmod/out/raw.txt(加上 changed_when: false)。并且把到目前为止创建的五个 playbook(boundary.yml、report.yml、pipe.yml、modernize.yml、bootstrap.yml)中的所有 task 整理成使用以 ansible.builtin. 开头的 FQCN。
raw 通过 SSH 原样抛出字符串,并原样接收输出——所以即使目标上没有 Python 也能运行,但收到的字符串上会原样带着 CR 和换行。有一个 Jinja 过滤器可以去掉两端的空白。文件中是否还残留 CR,可以用 od -c 来确认。整理 FQCN 不是靠手,而是靠眼睛——用 yq -r '.[].tasks[] | keys | .[]' <파일>(占位符为文件)把 task 使用的全部键提取出来,短名称就一目了然。
找出没有 guard 就交给 shell 的 task 的审计工具
先创建将作为检查对象的 /root/ansmod/legacy.yml——共有四个 task:一个用 command 运行 ansible --version 并加上 changed_when: false,一个用 shell 运行 mkdir -p /root/ansmod/legacy/logs,一个用 shell 运行 echo seeded > /root/ansmod/legacy/logs/stamp.txt,一个用 command 运行 ls /root/ansmod/legacy/logs(后三个不加 guard)。然后创建 /root/ansmod/shell-audit.sh <플레이북경로>(占位符为 playbook 路径):对于该 playbook 中使用 command 或 shell 且没有 changed_when 的 task,只输出其名称,每行一个,按字典序排列(必须同时识别短名称和 FQCN)。最后把 ./shell-audit.sh /root/ansmod/legacy.yml 的输出保存到 /root/ansmod/out/audit.txt。
这一步是让工具来追问“这个 shell 真的有必要吗”,而不是每次评审都靠人来问。如果工具因为写法不同就视而不见,那就不是审计——shell: 和 ansible.builtin.shell: 必须被视为同一个东西。镜像中的 yq 是 mikefarah 版,所以用 .[].tasks[] 沿着 play 列表深入,含有点的键要像 .["ansible.builtin.shell"] 这样用方括号书写。询问不存在的键会得到 null,所以用 select(... != null) 过滤即可。