4123 字
21 分钟
Ansible 幂等性

本文行文:幂等性到底是什么(以及与收敛、可重入的区别)→ 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 次,系统最终状态相同,且第二次之后不再产生额外副作用。

拆开看是两条要求:

  1. 结果一致——重复执行不会累积效果。缺少它就会重复追加配置行、重复建用户、端口冲突、数据被覆盖;
  2. 副作用可忽略——第二次执行不再触发任何变更。缺少它就会反复重启服务、反复刷审计日志、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,以及目标文件属性
templateJinja2 渲染后的内容 checksum,以及文件属性
package(yum/apt/dnf)已安装版本与 state(present/latest/absent)
service / systemd服务是否运行、是否开机自启
user / group账户是否存在及其属性(uid、shell、附加组等)
lineinfile / blockinfile目标行/块是否存在(依赖 regexp 是否精确)
mountfstab 条目内容与当前挂载状态
croncrontab 中是否存在同名条目

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/app

creates / removes 是文件级判定,简单有效;一旦判断条件涉及”内容”(例如配置文件里有没有某一行),就只能退回 register + when。

2. when + register#

总有些操作无法用声明式表达:自定义安装脚本、一次性数据迁移、数据库初始化、节点加入集群。这类操作的统一解法是三段式:

flowchart LR
    A["检查任务:只读探测"] --> B{"期望状态已满足?"}
    B -- 是 --> C["ok:不执行变更"]
    B -- 否 --> D["变更任务:执行"]
    D --> E["changed:必要时 notify handler"]

写成任务就是”检查任务 + register + 变更任务 + when”,要点有三个:

  1. 检查任务必须只读且无副作用,并显式写 changed_when: false。否则每次运行都报 changed,还会误触发 handler;
  2. 检查任务要能区分”尚未初始化”和”真失败”。很多检查脚本用非 0 返回码表示”还没初始化”,直接用默认失败判定会让 playbook 在第一次运行时中断,需要用 failed_when 放行预期内的返回码;
  3. 检查本身也有成本。如果检查要连远程服务、扫大表,注意加超时与重试,别把一个只读探测做成新的故障点。

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,且被判定为 changedfile: 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每次执行都 changedsystemd: daemon_reload=true,或放进 handler
命令输出里带时间戳、PID、随机串输出每次都不同,永远 changed固定输出,或用 changed_when 按真实条件判断
模板里塞 ansible_date_time渲染结果每次都不同换成稳定变量,或仅在确需时间戳时使用
到处写 changed_when: false掩盖真实变更,handler 不再触发只对只读任务使用
到处写 failed_when: false真正的失败被静默吞掉精确列出允许的返回码 not in [0, 1]

如何验证幂等性#

连跑两次,看第二次的 recap#

最直接、也最应该固化成习惯的做法:

Terminal window
# 第一次:预期有变更
ansible-playbook -i inventory site.yml
# 第二次:预期 changed=0
ansible-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 预览#

正式执行前的”演练”,会由模块自己判断”当前是否需要变更”:

Terminal window
ansible-playbook -i inventory site.yml --check --diff

但 check 模式的保真度取决于模块对 check_mode 的支持情况:command/shell 在 check 模式下默认不会真正执行(显示为 skipped),需要显式写 check_mode: false 才会照常运行。这个特性对”先检查,后执行”的写法影响很大——检查任务若用的是 command,dry run 时它根本没跑,条件判断也就无从谈起。

定位到具体任务#

排查非幂等任务时,缩小范围比全量跑更快:

Terminal window
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 假装无事发生),幂等就只剩下一份好看的输出。

Ansible 幂等性
https://blog-l7wd3.pages.dev/posts/iac/ansible-idempotence/
作者
L7WD3-Xiao
发布于
2026-09-08
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取