用 stat 归并 failed_when 判空与 changed_when 抑制无效报告的写法

在写 Ansible playbook 时,最常见的一个"报告污染"场景是这样的:先用 stat 判断某个文件或目录是否存在,存在就删掉再创建,不存在就直接创建。逻辑本身没错,但跑完之后你会发现 PLAY RECAP 里那一台机器明明什么都没改,却显示 changed=1;或者更糟,stat 的结果注册成变量后,后续 when 判空没写对,直接抛出 'dic

在写 Ansible playbook 时,最常见的一个”报告污染”场景是这样的:先用 stat 判断某个文件或目录是否存在,存在就删掉再创建,不存在就直接创建。逻辑本身没错,但跑完之后你会发现 PLAY RECAP 里那一台机器明明什么都没改,却显示 changed=1;或者更糟,stat 的结果注册成变量后,后续 when 判空没写对,直接抛出 'dict object' has no attribute 'stat' 之类的报错,任务红一片。

本文要解决的就是这一组耦合问题:用一次 stat 注册的结果,同时驱动 failed_when 的存在性判空、changed_when 的变更报告抑制,以及后续任务的 when 分支。核心思路是把”探测”和”动作”分离,让 stat 只负责产出事实(fact),让 failed_when / changed_when 基于这些事实做判断,而不是让每个 task 各自去 when: xxx is exists 里重复探测。文章会给出可直接运行的完整 playbook,并解释每个参数为什么这么写、常见的报错长什么样、怎么修。

一、先厘清三个指令的执行时机差异

很多人把 when、failed_when、changed_when 混为一谈,其实它们介入的时机完全不同,这决定了你不能用同一种写法处理它们。

指令 执行时机 作用对象 典型误用
when 任务执行之前 是否跳过整个任务 写 when: result.stat.exists,但 result 尚未注册
failed_when 任务执行之后 覆盖模块自带的失败判定 判空写成 result.stat.exists == false,result 是 undefined
changed_when 任务执行之后 覆盖模块自带的 changed 判定 用 false 硬编码,掩盖了真正的变更
register 任务执行之后 把返回值存入变量 以为 register 的变量在 when 里立即可用(同一任务内不可用)

关键点:同一个 task 的 register 变量,不能在它自己的 when 里引用,但可以在它自己的 failed_when / changed_when 里引用。因为 failed_when 和 changed_when 是在模块返回之后求值的,此时 register 已经生效。这条规则是本文所有写法的基石。

常见报错:fatal: [web01]: FAILED! => {"msg": "The conditional check 'st.stat.exists' failed. The error was: error while evaluating conditional (st.stat.exists): 'st' is undefined"}

原因就是 st 是在这个 task 里 register 的,而 when 在 task 执行前求值,此时 st 还不存在。修法:把判断拆到下一个 task,或者把存在性判断挪进 failed_when。

二、stat 注册结果的结构与”判空”的真正含义

要正确地 failed_when 判空,必须先知道 stat 返回的字典长什么样。对不存在的路径,stat 模块不会失败,而是正常返回一个 exists: false 的结果。这是它和 file、command 最大的不同,也是它能安全用于探测的原因。

存在的路径:

{
  "changed": false,
  "stat": {
    "exists": true,
    "path": "/opt/app/conf.d",
    "isdir": true,
    "isreg": false,
    "islnk": false,
    "mode": "0755",
    "uid": 1000,
    "gid": 1000,
    "size": 4096,
    "mtime": 1717036800.0,
    "pw_name": "deploy",
    "gr_name": "deploy"
  }
}

不存在的路径:

{
  "changed": false,
  "stat": {
    "exists": false
  }
}

注意两点:第一,不存在的路径返回的 stat 里只有 exists 一个键,此时如果你去访问 st.stat.isdir,Ansible 会抛出 'dict object' has no attribute 'isdir'。第二,stat 任务本身几乎永远是 changed: false,因为它只读不写。

判空的三种写法及其风险

  • when: st.stat.exists:最常见,但没说清”存在之后要什么”,且当 st 未定义时直接报错。
  • when: st.stat.exists is defined and st.stat.exists:多层防御,冗长但安全。
  • when: st.stat.exists | default(false):推荐,一行搞定且对 undefined 友好。当 st.stat.exists 不存在时,Jinja2 的 default 过滤器会兜底。

但要注意 default(false) 的生效条件是变量整个未定义。如果 st.stat 存在但里面没有 exists 键(极少见,比如某些旧版本或 follow: true 遇到断链),st.stat.exists 会解析失败而不是走 default。所以更稳的写法是 st.stat | default({}) | ...,或者干脆在 failed_when 里用 is not defined 显式判空。

三、failed_when 判空的正确姿势(含幂等创建流程)

假设需求是:”确保 /opt/app/conf.d 存在、属主为 deploy、权限 0755;如果它是文件而不是目录,直接失败并给出明确信息。”这里 failed_when 的作用不是”路径不存在就失败”(不存在是合法的,我们会去创建),而是”路径存在但类型不对就失败”。

---
- name: 归并 stat 判空与变更抑制
  hosts: web
  gather_facts: false
  vars:
    conf_dir: /opt/app/conf.d
    owner: deploy
    group: deploy
    mode: "0755"

  tasks:
    - name: 探测目标路径是否存在及其类型
      ansible.builtin.stat:
        path: "{{ conf_dir }}"
        follow: false
      register: conf_stat
      failed_when:
        - conf_stat.stat.exists | default(false)
        - not (conf_stat.stat.isdir | default(false))

    - name: 路径存在但不是目录时给出可读的失败信息
      ansible.builtin.fail:
        msg: >-
          目标路径 {{ conf_dir }} 已存在,但不是目录
          (isreg={{ conf_stat.stat.isreg | default(false) }},
          islnk={{ conf_stat.stat.islnk | default(false) }})。
          请先人工确认后再执行本 playbook。
      when:
        - conf_stat.stat.exists | default(false)
        - not (conf_stat.stat.isdir | default(false))

    - name: 确保目录存在且属性正确
      ansible.builtin.file:
        path: "{{ conf_dir }}"
        state: directory
        owner: "{{ owner }}"
        group: "{{ group }}"
        mode: "{{ mode }}"
      when: not (conf_stat.stat.exists | default(false)) or not (conf_stat.stat.isdir | default(false))

拆开看几个细节:

  • follow: false:不跟随符号链接。如果目标是个指向目录的软链,follow: false 会看到 islnk: true、isdir: false(在某些 Ansible 版本上),这样我们的判空逻辑能识别出”这是个链接而不是真目录”,避免误判。如果业务上接受软链,就把 follow 改成 true,此时 isdir 反映的是链接目标。
  • | default(false):不管 conf_stat.stat 里有没有 exists,都不会炸。这是判空的兜底层。
  • failed_when 用列表:多个条件之间是 AND 关系,比写成一行 and 更易读、diff 更友好。
  • 为什么要额外加一个 fail 任务:failed_when 触发后,报错信息是 Ansible 自动生成的,通常是 "msg": "One or more items failed" 这种无法定位的文本。用一个显式的 fail 任务给出 isreg/islnk 的取值,运维排障时能一眼看出到底撞到了什么类型的对象。

如果你只想要一个”存在即失败”的简单断言,比如配置文件不允许被手工创建,可以这样写:

      failed_when: conf_stat.stat.exists | default(false)

但请务必带上 | default(false),否则在 conf_stat.stat 结构异常时会报 'dict object' has no attribute 'exists'。

四、changed_when 抑制无效报告:什么时候该抑制,什么时候属于掩盖

changed_when 是双刃剑。用对了,PLAY RECAP 干净可信;用错了,真正的变更被吞掉,CI 里的 --check 检测和审批流全部失效。判断标准只有一条:模块的”变更”是否对系统产生了持久影响。

场景 A:command/shell 做只读探测,必须抑制

command 和 shell 模块无论实际做了什么,默认都返回 changed: true,因为它们无法判断命令的副作用。下面这个任务用于确认目录里是否有残留的锁文件,明显是只读操作,必须抑制:

    - name: 列出目录内容(只读探测)
      ansible.builtin.command:
        cmd: "find {{ conf_dir }} -maxdepth 1 -name '*.lock' -print"
      register: lock_files
      changed_when: false
      failed_when: lock_files.rc not in [0, 1]

    - name: 清理残留锁文件
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop: "{{ lock_files.stdout_lines | default([]) }}"
      when: lock_files.stdout_lines | default([]) | length > 0

这里 failed_when: lock_files.rc not in [0, 1] 也值得一提:find 在目录不存在时返回码是 1,这在我们的流程里是合法的(后面会创建),所以要把 1 从失败里排除掉。真实报错信息长这样,如果你不处理就会红:

fatal: [web01]: FAILED! => {"changed": true, "cmd": ["find", "/opt/app/conf.d", "-maxdepth", "1", "-name", "*.lock", "-print"], "delta": "0:00:00.003112", "end": "2024-05-30 10:12:44.221113", "msg": "non-zero return code", "rc": 1, "start": "2024-05-30 10:12:44.218001", "stderr": "find: '/opt/app/conf.d': No such file or directory", "stderr_lines": ["find: '/opt/app/conf.d': No such file or directory"], "stdout": "", "stdout_lines": []}

场景 B:file 模块建目录,不该抑制

有人喜欢给所有 file 任务加 changed_when: false 来”让报告好看”。这是典型的掩盖:目录权限从 0700 改成 0755 是实实在在的持久变更,你把它标成 unchanged,下次审计就无法发现问题。正确做法是让 file 模块自己报告,它已经足够幂等——第二次运行时它自己会返回 changed: false。

场景 C:幂等创建流程里,用 stat 结果精确控制 changed

当你不信任模块的判定,或者用的是没有幂等能力的模块组合时,可以用 stat 结果手工推导 changed。下面这个任务把”目录已存在且属性一致”视为 unchanged,”新建或修正”视为 changed:

    - name: 确保目录存在(基于 stat 事实精确控制报告)
      ansible.builtin.file:
        path: "{{ conf_dir }}"
        state: directory
        owner: "{{ owner }}"
        group: "{{ group }}"
        mode: "{{ mode }}"
      register: mkdir_result
      changed_when: >-
        not (conf_stat.stat.exists | default(false))
        or (mkdir_result.changed | default(false))
      when: not (conf_stat.stat.exists | default(false)) or not (conf_stat.stat.isdir | default(false))

解释一下这里的逻辑:file 模块在路径已存在时会比对 owner/group/mode,有差异就返回 changed: true;无差异返回 changed: false。所以 mkdir_result.changed 本身就已经是对的。上面这个 changed_when 的作用是把”路径原本不存在时的创建”显式标记为变更(即使某些版本返回了 false 也能兜住),逻辑上等价于 not exists or module_changed。实际生产中,如果只用 file 模块,直接省略 changed_when 即可,因为模块的判定是正确的;只有在你把多个操作合并进一个 task,或者使用了不返回 changed 的模块时,才需要手写。

五、把 stat 事实抽成可复用的单次探测

一个 playbook 里出现三次 stat 是常见的坏味道。更好的做法是:在 pre_tasks 或任务序列开头统一探测一次,把需要的路径一次性注册成一组 fact,后面所有 failed_when / changed_when / when 都引用这组事实。

    - name: 统一探测关键路径的存在性与类型
      ansible.builtin.stat:
        path: "{{ item }}"
      loop:
        - /opt/app
        - /opt/app/conf.d
        - /opt/app/conf.d/app.yml
        - /etc/systemd/system/app.service
      register: paths_stat

    - name: 将探测结果整理为按路径索引的字典
      ansible.builtin.set_fact:
        path_facts: >-
          {{
            dict(
              paths_stat.results
              | map(attribute='item')
              | zip(paths_stat.results | map(attribute='stat') | list)
            )
          }}

zip 和 dict 是 Jinja2 内置过滤器(Ansible 2.11+ 可用),前者把两个列表配对,后者把配对结果转成字典。整理完成后,path_facts['/opt/app/conf.d'].exists 这类访问就变得非常直观,且每个路径只探测一次。注意 paths_stat.results 里每个元素的 stat 键始终存在(不存在时是 {exists: false}),所以这里的 map attribute 不会丢项。

后续任务的判空就统一成:

    - name: 仅当配置文件缺失时才渲染模板
      ansible.builtin.template:
        src: app.yml.j2
        dest: /opt/app/conf.d/app.yml
        owner: "{{ owner }}"
        group: "{{ group }}"
        mode: "0640"
      when: not (path_facts['/opt/app/conf.d/app.yml'].exists | default(false))

下面是完整的可运行 playbook 骨架,把前面所有片段串起来,直接 ansible-playbook -i inventory site.yml --check 就能验证:

---
- name: 归并 stat 判空与 changed 抑制示例
  hosts: web
  gather_facts: false
  vars:
    conf_dir: /opt/app/conf.d
    owner: deploy
    group: deploy
    mode: "0755"

  tasks:
    - name: 统一探测关键路径
      ansible.builtin.stat:
        path: "{{ item }}"
        follow: false
      loop:
        - /opt/app
        - "{{ conf_dir }}"
        - "{{ conf_dir }}/app.yml"
      register: paths_stat

    - name: 整理为路径索引字典
      ansible.builtin.set_fact:
        path_facts: >-
          {{
            dict(
              paths_stat.results
              | map(attribute='item')
              | zip(paths_stat.results | map(attribute='stat') | list)
            )
          }}

    - name: 类型不对时立即失败
      ansible.builtin.fail:
        msg: >-
          路径 {{ item }} 存在但不是目录
          (exists={{ path_facts[item].exists | default(false) }},
          isdir={{ path_facts[item].isdir | default(false) }})
      loop:
        - /opt/app
        - "{{ conf_dir }}"
      when:
        - path_facts[item].exists | default(false)
        - not (path_facts[item].isdir | default(false))

    - name: 创建缺失目录
      ansible.builtin.file:
        path: "{{ item }}"
        state: directory
        owner: "{{ owner }}"
        group: "{{ group }}"
        mode: "{{ mode }}"
      loop:
        - /opt/app
        - "{{ conf_dir }}"
      when: not (path_facts[item].exists | default(false))

    - name: 只读探测残留锁文件
      ansible.builtin.command:
        cmd: "find {{ conf_dir }} -maxdepth 1 -name '*.lock' -print"
      register: lock_files
      changed_when: false
      failed_when: lock_files.rc not in [0, 1]

    - name: 清理残留锁文件
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop: "{{ lock_files.stdout_lines | default([]) }}"
      when: lock_files.stdout_lines | default([]) | length > 0

六、调试与验证:怎么确认抑制没把真正的变更吞掉

写完之后一定要做两轮验证,否则抑制写错了自己都不知道。

  1. 首次运行:ansible-playbook -i inventory site.yml,预期 changed 数量等于实际新建/修正的资源数。
  2. 二次运行:立刻再跑一遍,预期 changed=0(幂等验证)。如果第二次还有 changed,说明某个 task 的判定逻辑有问题,或者 file 里的 owner / mode 总在变。
  3. check 模式:ansible-playbook -i inventory site.yml --check --diff,check 模式下 stat 会成功执行(只读),file 会返回”将要变更”的预测,此时 changed_when 里的 mkdir_result.changed 是预测值,属于正常现象。
  4. verbose 排查:-vvv 可以看到每个条件的求值过程,failed_when 和 changed_when 最终算出的布尔值都会打印出来,比如 evaluated conditional: False、changed: [web01]。

一个高频坑:给 set_fact 任务加 changed_when: false 是多余的,因为 set_fact 默认 changed: false。反过来,给 debug 加 changed_when: false 也没意义,debug 本来就不报 changed。把抑制用在 command/shell/raw 上才是对症。

小结

把这套写法的要点压缩成一份清单:

  • stat 对不存在的路径不报错,返回 {exists: false},这是它适合做探测的根本原因。
  • 访问 stat 结果里的任何键之前,先 | default(false) 兜底,避免 'dict object' has no attribute。
  • when 在任务前求值,不能引用同任务 register 的变量;failed_when / changed_when 在任务后求值,可以引用。
  • 判空只负责”逻辑正确性”,失败信息用显式的 fail 任务补,比依赖 failed_when 的自动文案好排查。
  • changed_when: false 只给只读的 command/shell 用;幂等的 file、copy、template 不要抑制。
  • 多次探测同一批路径时,用 loop + register + zip + dict 归一成 path_facts,全 playbook 复用。

FAQ

Q1:为什么我写了 failed_when: st.stat.exists 反而报 'st' is undefined?

因为你把这个 failed_when 写在了 register: st 的那个 stat 任务之前的另一个任务上,或者写在了同任务但 Ansible 版本较老(2.x 早期)的情况下。确认顺序:先有 stat 任务并 register,下一个任务才能引用 st。同一个任务内,when 不能用,failed_when 可以(2.8+ 行为)。

Q2:stat.stat.exists is defined 和 stat.stat.exists | default(false) 有什么区别?

is defined 只判断”变量是否定义”,用于 when 里当布尔用;default(false) 是在变量未定义时给它一个默认值,适合直接参与布尔运算。前者更啰嗦但语义清晰,后者更紧凑。当 stat.stat 整个未定义时,两种写法都能兜住;但当 stat.stat 存在、只缺 exists 键时,default 在部分 Jinja2 版本上不一定兜底,建议对多层访问逐层 default。

Q3:follow: true 和 follow: false 该怎么选?

如果你的目标是”确认真实文件/目录存在”而不在乎中间有没有软链,用 follow: true(默认值就是 true)。如果你要识别”这个路径本身是个链接”并做特殊处理(比如不接受软链目录),用 follow: false,此时 islnk 才有意义。注意链接目标不存在时,follow: true 下 exists 为 false,容易误判为”路径不存在”。

Q4:changed_when: false 会不会让 CI 的幂等检查失效?

会,但只影响被抑制的那个任务。如果你的 CI 逻辑是”任何 changed 都视为变更、走审批”,那么在只读探测上加抑制恰恰是必要的,否则每次 CI 都被误触发。真正需要担心的是把写操作标成 unchanged,那会让审批流和信息流一起失真。

Q5:loop 里每个 item 的 register 结果怎么访问?

用了 loop 的 register 变量是 {changed, msg, results: [...]} 结构,每个元素的 item 字段是循环变量,stat 字段是该次的结果。所以不能写 paths_stat.stat,要写 paths_stat.results[i].stat,或者像我上面那样用 map(attribute='stat') 批量取出来。

Q6:dict(...) 和 zip(...) 是哪个版本引入的?

这两个是 Jinja2 内置过滤器,但 Ansible 在 2.10 之前对某些过滤器有 override。zip 在 Jinja2 2.7+ 可用,dict 在 Jinja2 2.7+ 可用。Ansible 2.11 起随附的 Jinja2 版本完全满足。如果你的控制节点很老,可以用 combine 配合 loop 逐个构建,或者改用 items2dict 的等价写法。