file·copy·blockinfile — 声明这条路径该是什么样
一句话总结
文件模块是用来写“这条路径应该是什么状态”,而不是“执行这条命令”的地方,state 这一个词就是那个状态机的全部。其余的都是依附在这个状态上的权限、备份、验证和标记问题。
为什么需要它
在手工操作服务器的年代,部署配置是四行命令。mkdir -p /etc/app、cp app.conf /etc/app/、chmod 640 /etc/app/app.conf、sed -i 's/8080/9090/' /etc/app/app.conf。如果把这四行原样搬到 shell task 里,看起来像是实现了自动化,但实际上有三件事同时崩塌。
第一,第二次运行和第一次不同。sed -i 即使对已经是 9090 的文件再次运行,也会被报告为 changed,而且如果模式出现两次,就会改两次。第二,无法知道改变了什么。cp 覆盖之后就结束了,之前有什么,哪里也没有留下。当某个配置上传错误而出了故障时,就没有了回退的依据。第三,损坏的配置原样上线。无论是语法错误的 JSON 还是 nginx 配置,cp 都不问就放上去。服务会在下一次重启时死掉,而那时离部署已经过去很久了。
Ansible 的文件模块用一个参数分别回答这三个问题。幂等性由 state 和模块本身负责,回退的依据由 backup 负责,拦截损坏的配置由 validate 负责。把四行 shell 换成模块,不是文风的问题,而是能否得到这三样东西的问题。
工作原理
state——文件模块的状态机
ansible.builtin.file 看起来参数很多,但骨架只有一个 state。
| state | 含义 | 不存在时 | 已经是该状态时 |
|---|---|---|---|
directory |
必须是目录 | 连中间路径一起创建 | ok |
file |
只调整已存在文件的属性 | 失败(不会创建) | 只比较属性 |
touch |
不存在就创建空文件 | 创建 | 更新 mtime,所以总是 changed |
link |
必须是符号链接 | 创建 | 目标相同则 ok |
hard |
必须是硬链接 | 创建 | inode 相同则 ok |
absent |
必须不存在 | ok | 删除(目录会整个删除) |
这里有两格会坑到人。state: file 不会创建文件——只想修改权限的 task 会以“路径不存在”而失败。还有 state: touch 不是幂等的。即使文件已经存在,它也会碰一下 mtime,每次都产生 changed。如果想让第二次运行的 changed 为 0,要么不用 touch,要用的话就同时给出 modification_time: preserve 和 access_time: preserve。
state: absent 会递归删除目录。这一行曾经多次因为路径变量的一个笔误而把不相干的目录整个删掉。在删除的 task 中,最好不要用变量拼装路径,如果非拼装不可,就在前面放一个用 assert 确认前缀的 task。
mode——一个引号就会改变权限
这是最常发生的事故,而且悄无声息。
- ansible.builtin.copy: {dest: /root/demo/a, content: "x\n", mode: "0640"} # → 0640
- ansible.builtin.copy: {dest: /root/demo/b, content: "x\n", mode: 0644} # → 0644
- ansible.builtin.copy: {dest: /root/demo/c, content: "x\n", mode: 644} # → 1204
第三行就是问题。YAML 会把前面没有 0 的 644 读作十进制的 644,而模块会把这个整数原样当作权限位。十进制 644 用八进制表示是 1204,最前面的 1 是 sticky 位。结果是在 --w----r-- 上带着 sticky 的、没有人想要过的权限。没有错误,也没有警告。所以规则只有一条——mode 始终写成带引号的字符串。像 "0640" 这样。像 u=rw,g=r,o= 这样的符号表示法也是字符串,所以是安全的,对人来说读起来反而更好。
file 模块的 mode 中也可以使用大写 X。u=rwX,g=rX,o=rX 的意思是“只给目录,或者已经有人拥有执行权限的文件设置执行位”,所以很适合用 recurse: true 一次性应用到目录树上。
owner 和 group 如果给出名称,会在目标主机上解析。经常踩到的地方是:必须是目标上的用户,而不是控制节点上的用户,并且如果目标上没有这个用户,task 就会失败。
copy——src 与 content,以及 backup 和 validate
copy 接收两种输入。src 发送控制节点上的文件,content 则把字符串当场作为内容写入。两者不能同时使用。较短的配置用 content 读起来更好,较长的文件或二进制文件则适合 src。如果需要填入值,那就是该转向 template 的地方,而不是往 content 里硬塞很长的 Jinja。
如果指定 backup: true,会在覆盖之前把内容留在同一个目录中。名称的形式是 app.conf.416.2026-09-17@05:16:32 后面再附加一个波浪号,所以原文件和备份会并排显示。返回值的 backup_file 中包含该路径,所以用 register 接收下来,就可以在回滚 task 中直接使用。要记住,备份是留在目标主机上的——如果想拿回控制节点,后面还需要另外用 fetch。
validate 是“只有通过检查的才放到位”的契约。字符串中的 %s 位置会填入临时文件路径,只有该命令以 0 结束,才会被移到目标路径。如果没通过,task 就失败,目标路径上不会生成任何文件。如果原来有文件,那个文件就原样保留。visudo -cf %s、nginx -t -c %s、python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s 是常见的形式。这里容易出错的有两点——漏掉 %s,检查命令就会去看别的文件;检查命令如果带有 sudo 或重启服务之类的副作用,失败的部署就只会留下副作用。
blockinfile——标记是幂等性的关键
有时需要把多行的区块放进别人的配置文件中。比如往 /etc/hosts 中加几行内部主机,往 sshd_config 中加几行我们的策略。这时如果按行数重复使用 lineinfile,会有三件事崩塌。行与行之间的顺序和相邻性得不到保证,以后没有办法把区块整个删除,而如果某一行已经处在别的上下文中,就会匹配到莫名其妙的地方。
blockinfile 通过在托管区块的前后留下标记,解决了这个问题。
- ansible.builtin.blockinfile:
path: /etc/hosts
marker: "# {mark} ANSIBLE MANAGED BLOCK: internal pool"
block: |
10.10.0.11 web1
10.10.0.12 web2
{mark} 的位置分别填入 BEGIN 和 END。下一次运行时,模块只把标记之间视为自己的领域,并把其中的内容整个换掉。外面不会碰。如果指定 state: absent,会把标记和它们之间的内容一起删除。
这里最常见的事故是之后修改标记字符串。标记一变,模块就认不出旧的区块是自己的,会再创建一个新的区块。文件里留下两份相同的内容,其中一份永远不会再被管理。在一个文件里放入两个以上的区块时,必须给 marker 起不同的名字,原因也是一样——默认标记只有一个,所以后面的 task 会覆盖前面的区块。
边界——什么时候用什么
| 情形 | 使用 |
|---|---|
| 整个文件都是我们的 | copy 或 template |
| 在别人的文件里加一行 键=值 | lineinfile |
| 在别人的文件里加多行区块 | blockinfile |
| 替换文件内的所有匹配模式 | replace |
| 只确认是否存在、权限、哈希 | stat |
| 把目标上的文件取回控制节点 | fetch |
stat 什么都不改变,只返回事实。用 register 接收后,可以看到 .stat.exists、.stat.mode、.stat.size、.stat.isdir、.stat.islnk,以及指定了 checksum_algorithm 时的 .stat.checksum。作为条件分支的依据使用时,像 when: st.stat.exists 这样先看 exists 的习惯很重要。路径不存在时,连 mode 这样的键本身都不存在,所以一访问就会出现未定义变量的错误。
fetch 是 copy 的反方向。它把目标主机的文件取回控制节点。默认行为很特别,会让人吃惊一次——它会在 dest 之下创建主机名目录,并原样重现原文件的完整路径后放入。如果 dest: /root/backup/,就是 /root/backup/web1/etc/app/app.conf。这是为了从多台机器收取同一个文件时不会混在一起而设计的。如果只有一台,或者想自己指定名称,就指定 flat: true,并把 dest 写成文件路径。
链接与 follow
state: link 创建符号链接,state: hard 创建硬链接。两者的差别在实际工作中显现的地方,是替换原文件的时候。copy 不会就地修改文件——它先写入临时文件,再替换名称。所以用 copy 重新放置原文件,inode 会重新生成,而硬链接仍然留在旧 inode 上。下一次运行时,state: hard 的 task 会以“目标上已经有文件”而失败。符号链接指向的是路径,所以没有这个问题。部署中常用的 current 符号链接模式之所以不是硬链接,原因就在这里。
follow 决定“路径是符号链接时,看链接本身,还是看它末端的文件”。file 模块默认是 follow: true,所以给链接设置 mode 时,改变的不是链接,而是原文件的权限。在 Linux 上,符号链接本身的权限没有意义,所以通常这样是对的,但“只想重新设置链接,结果原文件变了”的事故就出在这里。stat 则相反,默认是 follow: false,看的是链接本身——只要记住,同名的参数在不同模块中默认值不同就行了。
在现场相遇的样子
案例 1——权限 640 变成 1204 的那天。在部署密钥的 role 中,mode: 600 被写成了没有引号的形式。实际留下的权限是 1170,并且对组开放了读取权限。没有人发现,原因很简单——部署成功了,应用以 root 运行,读取文件没有任何问题。半年后在安全检查中才被发现。从那天起,CI 里加入了一条 lint 规则。如果 mode: 后面的值没有用引号括起来,就让构建失败。
案例 2——区块变成两份的配置。在往 /etc/hosts 中放入内部主机的 role 里,提交了把标记文字从“ANSIBLE MANAGED BLOCK”改为“MANAGED BY PLATFORM”的改动。下一次部署时,所有服务器的 /etc/hosts 中都出现了两份相同的行。旧区块因为标记不同,没有人再管理了,后来某台主机的 IP 变化时,只有新区块得到更新,旧的行反而获胜。标记是留在文件中的接口。要改的话,先用旧标记运行一次 state: absent 删掉,然后再改。
案例 3——没有加 validate 的代价。配置模板中有一个变量为空,渲染结果的 JSON 损坏了,但还是被部署了。部署是绿灯,服务运行正常——因为读取那个配置的时刻是下一次重启。八个小时后遇到节点重启,一半服务死掉了。如果有 validate 那一行,部署会在当场失败,本来一条失败的流水线就能了结的事,变成了故障。
下一项实验要做什么
用 file 的 state 声明目录树,亲自测量没有引号的 mode 留下的权限并用眼睛确认。给 copy 加上 backup 和 validate,留下回退的依据,并拦截损坏的配置。用 blockinfile 创建带标记的区块,确认运行两次也只有一个区块,创建符号链接和硬链接,看看 follow 改变了什么。最后用 stat 和 fetch 收取状态并生成报告,再亲手编写一个接收路径列表、判定每条路径当前是什么状态的检查脚本。