Ansible 的 playbook 按顺序编排任务。具有状态感知能力的模块可以记录机器应该拥有的状态,在部署中途失败后比较真实现状并补齐差异。收敛来自所选模块与参数,并不是执行引擎保证任何任务重跑都安全。它是一份可以执行的契约:inventory 决定对象,模块实现比对,操作者审阅即将发生的变化。
只写终点,不写每一步
命令式 shell 脚本会说“按顺序执行这些命令”;面向状态的任务则说“这个包应该已安装、这个文件应该是这些字节、这个服务应该启用并运行”。模块在动作前比较目标主机与目标状态,已经满足的任务报告 ok,只有不一致才报告 changed。同一份声明可以用于全新的机器、只配置了一半的机器,也可以用于被人手工改过的机器。
这里的目标不能只是模糊意图。state: present、模板的精确内容、所有者和权限、服务的 enabled/running 都是契约的一部分。把多台机器共用的值放到变量或 role 没问题,但最终值应该对审阅者可见。隐藏的默认值,或者绕过模块直接改变状态的命令,都会让这份契约难以推理。每一个 task 都应回答“运行完成后应该看到什么”,而不是“我习惯先敲哪一条命令”。
模块让重跑逐渐收敛
多数内置模块都围绕幂等性设计。ansible.builtin.copy 会先比较文件内容和元数据,只有不同才替换;ansible.builtin.package 向包管理器询问目标状态;ansible.builtin.service 使用 state: started 时只在运行状态不符时调整;state: restarted 则要求每次运行都重启。第二次运行应该是一份安静的确认,而不是再次重启、追加重复行或再写一条记录。幂等不是“什么都不做”,而是“已经达到目标,所以没有必要再做”。
并非所有动作天然幂等。直接用 command 创建用户、轮换密钥或调用外部 API,若没有条件限制就会重复副作用。优先找专用模块;没有时先 register 查询结果,再用 when 判断是否执行,或者使用精确的 creates/removes 文件条件。changed_when 只决定变更报告和 handler 通知,不控制任务是否执行;设成 false 不能阻止重复变更,还可能隐瞒真实副作用。对外部接口仍要定义超时、重试与部分成功后的恢复方法,不能把网络调用假装成普通文件复制。
Inventory 是地图
playbook 描述结果,inventory 选择主机,变量提供上下文。ansible_user 选择登录身份,SSH agent 或 Vault 提供认证材料,避免将明文秘密提交到仓库。组变量让同一份声明覆盖整支机器队伍,同时保留端口、区域等主机差异。在 CI 中使用显式 inventory,避免“localhost”意外指向执行器本身。执行前打印解析后的主机集合,确认别名、组继承和变量优先级都与审阅时一致。
--check(检查模式)要求模块预测变化但不真正应用,--diff 则为支持它的模块显示内容差异。预测不是证明:不支持检查模式的模块可能跳过工作,任务级的 check_mode: false 还会在 --check 下真正执行。需要审阅这种覆盖设置,秘密也不应出现在 diff。把检查报告当成审阅材料,再用同一份 inventory 运行正常模式,并把结果记录在部署记录中。检查模式发现的差异还需要解释来源,是首次安装、配置漂移,还是模板变量变化。
坏掉的样子
任务调用 shell、模块的 check mode 不完整,或者未锁定版本的包明天解析出另一版,都会让机器继续漂移。某台主机失败可能留下部分已变更的机群;serial 限制批次,却不提供自动回滚。应明确每批通过条件、失败时 handler 是否运行,以及恢复方案。依赖前一任务事实的动作必须显式表达依赖,不能靠偶然的排列顺序。把多个不相关的副作用塞进一个 task,也会让失败后的重试难以判断到底完成了哪一半。
另一个常见故障是范围弄错:localhost, 配合 local 连接会操作执行器本机,而 all 选择实际 inventory 中匹配的全部主机,并不会自动纠正错误对象。语法检查只能发现 YAML 错误,不能发现目标选错。审阅解析后的 inventory 和 --list-hosts,再跑 check mode。幂等性也只覆盖系统边界;数据库迁移和云 API 仍需要各自的事务与重试语义。不要因为第二次运行显示 ok,就推断外部系统一定已经达到相同状态。
还要警惕变量优先级造成的“看起来声明了,实际被覆盖”。命令行变量、组变量、主机变量和 role 默认值有明确的优先级;ansible-inventory --host 只能帮助检查 inventory 变量,仍须结合 playbook 和命令行覆盖理解任务实际收到的值。并行策略会改变多个主机同时失败时的影响面,滚动发布则需要为每一批定义通过条件。把变更摘要、失败主机和未执行任务写入构建产物,下一次重跑才知道哪些状态已经落地。对包含秘密的任务设置 no_log 和 diff: false,避免把秘密放进结果或差异日志。最后,升级 ansible-core 或 collection 后要重新跑同一夹具,因为模块默认值和检查模式支持可能变化。
快速验证
把下面的 YAML 保存为 site.yml,Python 保存为同目录的 verify_ansible.py,再运行 shell 命令。实验使用本地连接,只操作临时文件;它先建立隔离虚拟环境,再执行语法检查、缺失文件的 check mode、正常收敛、漂移预览和修复。
python3 -m venv .venv-ansible
.venv-ansible/bin/python -m pip install ansible-core==2.21.4
.venv-ansible/bin/python verify_ansible.py
- name: Converge a disposable local file
hosts: localhost
connection: local
gather_facts: false
tasks:
- name: Declare the banner content and mode
ansible.builtin.copy:
dest: "{{ fixture }}"
content: "managed by ansible\n"
mode: "0644"
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
playbook = Path(__file__).with_name("site.yml").resolve()
ansible = Path(sys.executable).parent / "ansible-playbook"
print(subprocess.check_output([str(ansible), "--version"], text=True).splitlines()[0])
with tempfile.TemporaryDirectory(prefix="ansible-state-") as directory:
work = Path(directory)
banner = work / "banner"
config = work / "ansible.cfg"
config.write_text("[defaults]\nstdout_callback=default\n", encoding="utf-8")
env = dict(os.environ, ANSIBLE_CONFIG=str(config), ANSIBLE_NOCOLOR="1",
ANSIBLE_LOCAL_TEMP=str(work / "local-tmp"))
variables = {"fixture": str(banner), "ansible_python_interpreter": sys.executable,
"ansible_remote_tmp": str(work / "module-tmp")}
command = [str(ansible), "-i", "localhost,", str(playbook),
"--extra-vars", json.dumps(variables)]
def run(*options):
result = subprocess.run(command + list(options), cwd=work, env=env,
check=True, text=True, capture_output=True, timeout=60)
print(result.stdout)
return result.stdout
def changed(expected, *options):
output = run(*options)
recap = re.search(r"localhost\s+:\s+ok=(\d+)\s+changed=(\d+)\s+"
r"unreachable=(\d+)\s+failed=(\d+)", output)
assert recap and tuple(map(int, recap.groups())) == (1, expected, 0, 0)
return output
run("--syntax-check")
assert "managed by ansible" in changed(1, "--check", "--diff")
assert not banner.exists(), "check mode created the absent file"
changed(1)
assert banner.read_bytes() == b"managed by ansible\n"
assert stat.S_IMODE(banner.stat().st_mode) == 0o644
changed(0)
banner.write_bytes(b"manual drift\n")
before = (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
diff = changed(1, "--check", "--diff")
assert "manual drift" in diff and "managed by ansible" in diff
assert before == (banner.read_bytes(), banner.stat().st_mtime_ns, banner.stat().st_mode)
changed(1)
assert banner.read_bytes() == b"managed by ansible\n"
changed(0)
print("PASS: syntax, absent-file preview, convergence, drift preview, and repair")
在 Ansible Core 2.21.4 中,语法检查成功;缺失文件的 check mode 报告 changed=1 但不创建文件,正常运行创建文件,第二次运行报告 changed=0。手工写入 manual drift 后,--check --diff 显示前后内容而不改变文件字节、权限或修改时间,正常运行修复漂移,随后再次收敛为 changed=0。这些断言证明的是这一项 copy 任务的行为;其他模块和版本升级仍需各自检查,真实发布也仍须审阅主机模式、权限与秘密的 diff 策略。
延伸阅读
来源
- Ansible playbook 入门,playbook 结构、任务与目标状态,已核验 2026-09-09。
- Ansible 检查模式与 diff 模式,预测变化和预览差异,已核验 2026-09-09。
- Ansible 错误处理,变更报告与 handler 通知,已核验 2026-09-10。
- Ansible service 模块,started 与 restarted 的行为差异,已核验 2026-10-15。