本文行文:幂等性到底是什么(以及与收敛、可重入的区别)→ Ansible 靠什么做到幂等 → 最常见的非幂等写法 → 如何验证幂等。
Ansible 简介
Ansible 是一款无代理(agentless)的配置管理与编排工具:Ansible 使用模块将复杂的操作流程配置成剧本(playbook),控制机通过 SSH 把模块推送到被管节点执行,节点上只需要有 SSH 与 Python,不需要常驻守护进程,也不需要自签证书体系。
它的核心特征决定了它必然要关心幂等性:
| 特征 | 说明 |
|---|---|
| 无代理 | 不安装 agent,靠 SSH 推送;被管节点”掉线”没有额外残留 |
| 声明式 | 你描述期望状态(服务在跑、包已装、文件内容是这些),而不是”执行哪几条命令” |
| 模块化 | 每个模块封装一类资源操作,自带「探测当前状态 → 与期望比较 → 只在需要时变更」的逻辑 |
| 顺序执行 | task 自上而下逐个执行,默认失败即从该主机后续任务中剔除 |
| 事实收集 | setup 收集 facts(OS、网卡、内存等),供条件判断与模板渲染使用 |
一份 playbook 由若干 play 组成,每个 play 把若干 task 映射到一组主机上;变量、模板(Jinja2)、handler、role 是围绕它的配套设施。
- hosts: web become: true tasks: - name: Check nginx installed ansible.builtin.package: name: nginx state: present
- name: Check nginx is running and enabled ansible.builtin.service: name: nginx state: started enabled: true注意这份 playbook 里没有一条”安装 nginx 的 shell 命令”——它只声明了结果。正是这种「描述结果而非步骤」的风格,才让幂等成为可能。
执行模型:每个 task 都是”三段式”
模块内部大致都遵循同一个流程:读取当前状态 → 与期望状态比较 → 不一致才变更。于是 task 的结果被分成几类:
| 结果 | 含义 |
|---|---|
ok | 当前状态已满足期望,什么都没做 |
changed | 当前状态不满足期望,本次做了变更 |
skipped | 被 when 等条件跳过 |
failed | 执行失败(可被 failed_when 重定义) |
unreachable | 连不上目标主机 |
幂等性最终就体现在 recap 里的 changed 计数上:第一次运行应该 changed,第二次(及以后)应该全 ok。
幂等性
定义
数学上,幂等指 f(f(x)) = f(x)。放到运维语境里:同一个操作执行一次和执行 N 次,系统最终状态相同,且第二次之后不再产生额外副作用。
拆开看是两条要求:
- 结果一致——重复执行不会累积效果。缺少它就会重复追加配置行、重复建用户、端口冲突、数据被覆盖;
- 副作用可忽略——第二次执行不再触发任何变更。缺少它就会反复重启服务、反复刷审计日志、CI 里永远显示有 diff。
Ansible 把这两条具象成了可观测的输出:第二次运行 changed=0。这既是幂等的定义落地,也是实践中最常用的验收方式。
与相邻概念的区别
| 概念 | 含义 | 例子 |
|---|---|---|
| 幂等(idempotent) | 同一操作重复执行,结果不变 | mkdir -p /data |
| 收敛(convergent) | 从任意中间状态反复执行,最终稳定在期望状态 | 声明式 playbook、Kubernetes 的 reconcile 循环 |
| 可重入(re-entrant) | 中断后可以安全地从头再来 | 带断点续传的安装脚本 |
| 非幂等 | 每次执行都实实在在改变系统 | echo >> file、date > file、rm -rf |
幂等描述的是”一次调用”,收敛描述的是”反复调用”。Ansible 追求的是收敛,实现手段则是让每个 task 幂等——只要每个 task 幂等,整个 playbook 就收敛。
为什么值得较真
- 可安全重跑:改一个变量就全量重跑,不必担心”跑第二遍会出事”;
- 可做漂移检测:定期跑一次,全
ok说明没人手工改过配置;有changed就说明系统漂移了; - 让预览可信:
--check --diff之所以能当变更评审用,前提是模块自己知道「当前是否已满足」; - 可重建:新机器上线、扩容、灾后重建走同一条路径,而不是靠”祖宗留下的手工步骤文档”;
- 可做 CI 门禁:把”连跑两次、第二次要求 changed=0”做成流水线检查,就能把非幂等写法挡在合并之前。
Ansible 如何实现
- 原生幂等模块:声明式期望状态
- 非幂等模块:先查后改(creates / removes / when+register)
- changed_when/failed_when 修正结果 + handler 去重。
展开如下:
| 机制 | 解决的问题 | 关键点 |
|---|---|---|
| 原生幂等模块 | 能用声明就别写命令 | file/copy/template/package/service/user/... |
| 先检查,后执行 | 无法用声明表达的操作 | 探测 → 判断 → 变更,检查必须只读 |
changed_when / failed_when | 状态报告失真 | 只改判定与 handler 触发,不改行为 |
notify + handler | 变更副作用的去重 | 只在 changed 时触发,且一个 play 只跑一次 |
各个模块作用可参考社区文档 Playbook 关键字 — Ansible 社区文档
原生幂等模块:能声明就不命令
Ansible 里像 file、copy、template、yum、apt、service、user、group、lineinfile、cron、mount 这些模块,天生幂等。
这些模块只描述结果(声明)而非描述步骤(命令)。执行时先探测当前状态,再和期望状态比:满足返回 ok,不满足才改,返回 changed。
例:
service: name=nginx state=started enabled=yes,第二次执行一般就是ok。
不同模块”探测”的东西并不一样,理解这一点才能预判它什么时候会 changed:
| 模块 | 探测/比较的内容 |
|---|---|
file | 路径是否存在、类型、owner/group/mode、state |
copy | 源与目标文件的 checksum,以及目标文件属性 |
template | Jinja2 渲染后的内容 checksum,以及文件属性 |
package(yum/apt/dnf) | 已安装版本与 state(present/latest/absent) |
service / systemd | 服务是否运行、是否开机自启 |
user / group | 账户是否存在及其属性(uid、shell、附加组等) |
lineinfile / blockinfile | 目标行/块是否存在(依赖 regexp 是否精确) |
mount | fstab 条目内容与当前挂载状态 |
cron | crontab 中是否存在同名条目 |
template:天然幂等,内容比对
单独讲一下template:典型幂等模块。Ansible 用 Jinja2 渲染模板,然后和目标文件做 checksum/内容比较:一致就 ok,不一致才复制并 changed。
owner、group、mode 等属性不一致也会触发变更。
变更后通过 notify 通知 handler,handler 只在 changed 时触发,而且同一个 handler 在一个 play 中通常只跑一次,避免重复重启服务。
- name: 部署配置 template: src: app.conf.j2 dest: /etc/app/app.conf owner: root group: root mode: '0644' notify: restart app
handlers: - name: restart app service: name: app state: restarted两点值得记住:
- 模板里不要放天然变化的东西。
ansible_date_time、随机数、递增版本号会让渲染结果每次都不同,于是每次都 changed。需要时间戳就自己定义稳定变量,或在确实需要时才引入; validate能在落地前兜底。例如validate: 'nginx -t -c %s',Ansible 会先把渲染结果写到临时文件校验,失败就不替换——避免把语法错误的配置推上去,再把服务搞挂。
copy 的思路相同,区别只在于它是”源文件原样复制”,不经过 Jinja2 渲染。
非幂等模块:先检查,后执行
1. creates / removes
command、shell、raw 默认非幂等,每次跑都 changed,要用 creates、removes 或 when + register 兜底:
- name: 解压应用包(已解压则跳过) ansible.builtin.command: cmd: tar -xzf /tmp/app.tar.gz -C /opt/app creates: /opt/app/bin/appcreates / removes 是文件级判定,简单有效;一旦判断条件涉及”内容”(例如配置文件里有没有某一行),就只能退回 register + when。
2. when + register
总有些操作无法用声明式表达:自定义安装脚本、一次性数据迁移、数据库初始化、节点加入集群。这类操作的统一解法是三段式:
flowchart LR
A["检查任务:只读探测"] --> B{"期望状态已满足?"}
B -- 是 --> C["ok:不执行变更"]
B -- 否 --> D["变更任务:执行"]
D --> E["changed:必要时 notify handler"]写成任务就是”检查任务 + register + 变更任务 + when”,要点有三个:
- 检查任务必须只读且无副作用,并显式写
changed_when: false。否则每次运行都报changed,还会误触发 handler; - 检查任务要能区分”尚未初始化”和”真失败”。很多检查脚本用非 0 返回码表示”还没初始化”,直接用默认失败判定会让 playbook 在第一次运行时中断,需要用
failed_when放行预期内的返回码; - 检查本身也有成本。如果检查要连远程服务、扫大表,注意加超时与重试,别把一个只读探测做成新的故障点。
register 把任务返回存成变量,里面有 rc、stdout、stderr、changed 等:
| 字段 | 含义 |
|---|---|
rc | 返回码(command/shell 等) |
stdout / stderr | 标准输出 / 标准错误(字符串) |
stdout_lines | 按行切分后的列表,便于 in 判断 |
changed | 本次任务是否被判定为 changed |
failed / skipped | 是否失败 / 是否被跳过 |
results | 使用 loop 时每一项的返回 |
when 根据 facts、变量、register 结果决定要不要执行。
黄金模式:检查任务 + register + changed_when: false,变更任务 + when 条件。
比如:
- name: 检查是否初始化 command: /usr/local/bin/check_init register: init_check changed_when: false failed_when: init_check.rc not in [0, 1]
- name: 初始化 command: /usr/local/bin/init when: init_check.rc == 1这样已初始化就不执行,实现幂等。
when 不只能看 register,也可以直接判断 facts 与变量,把”不该在这台机器上执行”的任务提前摘掉:
- name: 仅在 RedHat 系安装 ansible.builtin.package: name: nginx state: present when: ansible_facts['os_family'] == 'RedHat'状态修正与 Handler
1. changed_when / failed_when:修正状态报告
changed_when 用来告诉 Ansible 这次到底算不算变更。
只读检查命令常写 changed_when: false,避免污染 changed 状态、误触发 handler。
也可以按输出判断:changed_when: "'already' not in result.stdout"。
failed_when 用来自定义失败条件,避免 rc != 0 但其实是正常情况被误判失败。
常见写法:
| 写法 | 用途 |
|---|---|
changed_when: false | 只读检查任务,永不报 changed、不触发 handler |
changed_when: "'already' not in result.stdout" | 按输出内容判定是否真的变更 |
failed_when: result.rc not in [0, 1] | 放行预期内的非 0 返回码 |
failed_when: "'ERROR' in result.stdout" | 按输出内容判定失败(脚本不用返回码时) |
failed_when: false | 永不失败——谨慎使用,会掩盖真实错误 |
注意:这俩只影响结果判定和 handler 触发,不会让非幂等命令本身变幂等,真正幂等还得靠 when、creates/removes 控制执行。换句话说,changed_when: false 是”把报告改好看”,不是”把系统改正确”——它让第二次运行的 changed 消失,却让真正的重复执行继续发生。
2. notify + handler:变更收敛的去重器
handler 是”只在被通知时才执行”的任务,而通知只发生在任务 changed 时。它存在的意义就是让”多个任务改了多个文件”收敛成”一次重启服务”:
handlers: - name: reload nginx service: name: nginx state: reloaded几条关键语义:
- 同一个 handler 在一个 play 中默认只执行一次,无论被 notify 多少次。改了三个配置文件,nginx 也只重启一次;
- handler 默认在该 play 的所有普通任务执行完后统一运行。需要”先重启服务、再执行后续任务”时,用
meta: flush_handlers显式提前触发; - 触发条件是 changed:被
when跳过的任务不会触发,写了changed_when: false的任务也不会触发——这既是保护,也是坑(真变更被标成false时,handler 就哑了); - 执行顺序是定义顺序,不是通知顺序;多个 handler 想响应同一个事件时用
listen订阅同一主题; - handler 里同样要写幂等语义的模块。
service: state=restarted放在 handler 里是合理的(由 changed 驱动),但把command/shell这种每次都 changed 的操作放进 handler,会形成”变更 → 执行命令 → 又变更”的连锁。
关于 handler 的更多细节(通知去重、执行时机、
listen主题),可参考 深入理解 Ansible Handler | Will’s Blog,讲的比较详尽。
常见非幂等陷阱
机制都懂,写出来依然不幂等,多半是以下几类写法:
| 反例 | 为什么非幂等 | 幂等写法 |
|---|---|---|
shell: echo "x" >> /etc/app.conf | 每跑一次追加一行,重启后配置重复 | lineinfile / blockinfile / template |
shell: mkdir /data | 目录已存在时返回非 0,且被判定为 changed | file: state=directory(非要用 shell 就加 creates) |
shell: git clone ... | 第二次因目录非空直接失败 | git 模块,或 creates: /opt/app/.git |
普通任务里写 service: state=restarted | 每次运行都重启服务,本质是”动作”不是”状态” | 移入 handler,或改成 state=started |
lineinfile 缺 regexp 或写得过宽 | 缺 regexp 时按 line 逐字比对,历史上多插过一行就永远清不掉;过宽的 regexp 会改错行 | regexp 锚定到唯一目标行,再配 line 成对声明 |
shell: systemctl daemon-reload | 每次执行都 changed | systemd: daemon_reload=true,或放进 handler |
| 命令输出里带时间戳、PID、随机串 | 输出每次都不同,永远 changed | 固定输出,或用 changed_when 按真实条件判断 |
模板里塞 ansible_date_time | 渲染结果每次都不同 | 换成稳定变量,或仅在确需时间戳时使用 |
到处写 changed_when: false | 掩盖真实变更,handler 不再触发 | 只对只读任务使用 |
到处写 failed_when: false | 真正的失败被静默吞掉 | 精确列出允许的返回码 not in [0, 1] |
如何验证幂等性
连跑两次,看第二次的 recap
最直接、也最应该固化成习惯的做法:
# 第一次:预期有变更ansible-playbook -i inventory site.yml
# 第二次:预期 changed=0ansible-playbook -i inventory site.yml# PLAY RECAP *********************************************************************# web01 : ok=12 changed=0 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0第二次的 changed 不为 0,就把 changed 的任务逐个拎出来看——它们要么缺 creates/when,要么判定条件写得不精确。
—check —diff 预览
正式执行前的”演练”,会由模块自己判断”当前是否需要变更”:
ansible-playbook -i inventory site.yml --check --diff但 check 模式的保真度取决于模块对 check_mode 的支持情况:command/shell 在 check 模式下默认不会真正执行(显示为 skipped),需要显式写 check_mode: false 才会照常运行。这个特性对”先检查,后执行”的写法影响很大——检查任务若用的是 command,dry run 时它根本没跑,条件判断也就无从谈起。
定位到具体任务
排查非幂等任务时,缩小范围比全量跑更快:
ansible-playbook -i inventory site.yml --start-at-task "部署配置"ansible-playbook -i inventory site.yml --tags config --limit web01做成自动化检查
- Molecule:
molecule test会在收敛之后自动再跑一遍,若仍有变更则判失败,等于内置了一次幂等测试; - CI 门禁:在流水线里连跑两次 playbook,解析第二次 recap 的
changed计数,非 0 即失败; - 注意
--syntax-check只是语法检查,与幂等无关;check 模式通过也不等于幂等——例如只读命令忘记标changed_when: false,普通运行时每次报 changed,而 check 模式下它压根没执行,问题被完美隐藏。
小结
- 能声明不命令,能判断不硬跑;
- 检查用 register,控制用 when;
- 只读
changed_when: false,误报用failed_when修; - 模板比对天然幂等,notify handler 收尾;
- 验收标准只有一条:第二次运行的
changed=0。
Ansible 幂等是单模块声明式、条件控制、结果修正、模板比对、handler 去重一起保证的。任何一环被绕过(尤其是用 changed_when: false 假装无事发生),幂等就只剩下一份好看的输出。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
最后更新于 ,距今已过 36 天
部分内容可能已过时