1. 为什么一个循环能让人卡住半天
with_items这个写法在 Ansible 的老文档里出现频率极高,几乎每个刚接触 Playbook 的人都会在头几天撞上它。表面上看它就是个"循环",写起来也简单,但真正上手之后你会发现,围绕它的困惑往往不在语法本身,而在于几个更隐蔽的地方:它和loop到底什么关系、什么时候该用它、传进去的列表元素被 Ansible 做了哪些"看不见的处理"、以及为什么有时候明明写对了却报出一堆看不懂的警告。
我见过不少场景,一个批量创建用户、批量装包、批量建目录的 Playbook,逻辑本身没问题,但with_items那一行反复改来改去,最后靠试错才跑通。这类问题的根源通常不是"不会写循环",而是没搞清楚with_items在 Ansible 的循环体系里处于什么位置,以及它对输入数据做了哪些隐式约定。
这篇内容面向的是已经能写基本 Playbook、但在循环这块总感觉"差一口气"的读者。我会把with_items的定位、它和loop的取舍、列表元素的扁平化行为、嵌套结构的处理、以及实际项目里最容易踩的几个坑,一条条拆开讲清楚。读完之后你应该能做到:看到一个需要循环的任务,立刻判断该用哪种写法,并且知道为什么这么选。
需要先说明一点:with_items属于 Ansible 早期就存在的with_*系列循环关键字,官方在后来的版本里主推loop,但with_items至今仍然可用,大量存量 Playbook 也在用它。所以这不是一个"过时就可以不管"的知识点,恰恰相反,维护老项目时你必须能读懂它、改对它。
2. with_items 在 Ansible 循环体系里的真实位置
2.1 它本质上是一个"查找插件"的语法糖
很多人以为with_items是 Ansible 内置的循环语法,这个理解只对了一半。准确地说,with_<lookup>是一类写法,with_items对应的是items这个 lookup 插件。也就是说,你写的with_items: [...]在解析阶段会被转换成一次 lookup 调用,把结果交给任务去逐项执行。
理解这一点很关键,因为它解释了几个现象:为什么with_items后面可以接列表、也可以接字符串;为什么它和with_dict、with_fileglob这些写法长得一模一样;为什么某些 lookup 特有的行为会"渗透"到你的循环里。把它当成"循环语法"会让你只关注迭代,而把它当成"lookup 语法糖"你才会去留意数据是怎么被喂进来的。
对比一下loop:loop是后来引入的、更"纯粹"的循环关键字,它接收一个列表,直接迭代,不做 lookup 那一层转换。这也是官方推荐新写作用loop的核心原因——少一层抽象,行为更可预测。
2.2 with_items 与 loop 的取舍表
实际项目里到底用哪个,我整理了一张对照表,按维度拆开看会更清楚:
| 维度 | with_items | loop |
|---|---|---|
| 引入时间 | 早期版本即有 | 较新版本引入 |
| 底层机制 | 走 items lookup 插件 | 原生迭代,无 lookup 层 |
| 单层列表 | 支持 | 支持 |
| 嵌套列表自动扁平化 | 会,且是隐式的 | 不会,需显式处理 |
| 与 with_* 系列风格统一 | 是 | 否 |
| 官方新写法推荐 | 否 | 是 |
| 存量项目出现频率 | 很高 | 视项目新旧而定 |
这张表里最容易被忽略的是"嵌套列表自动扁平化"那一行,它是with_items最有个性、也最容易让人困惑的行为,后面会单独用一节展开。
2.3 什么时候我仍然会用 with_items
虽然新代码我基本都写loop,但有几种情况我会刻意保留with_items。第一种是维护一个已经大量使用with_*系列的老 Playbook,为了风格统一,混着写反而增加阅读成本。第二种是需要"顺手"利用它的扁平化特性时——比如我手上有一个列表,里面混着单个值和子列表,我希望它们被拉平成一个序列去迭代,这时候with_items的隐式行为正好省事。
但这里要提醒一句:依赖隐式行为是有代价的。如果这个 Playbook 未来要交给别人维护,或者要迁移到更严格的写法,扁平化就可能变成一个"惊喜"。我的做法是,只要用了扁平化,就在任务上方加一行注释说明"此处依赖 with_items 的列表拉平行为",把隐式变显式,减少后人踩坑的概率。
3. 列表元素被"悄悄处理"的那些细节
3.1 单层列表:最常规也最容易想当然的用法
最基础的形态长这样:
- name: 批量安装基础软件包 ansible.builtin.package: name: "{{ item }}" state: present with_items: - git - curl - vim这段没什么好说的,item依次取到三个包名。但我想强调的是变量命名这件事:item是默认的循环变量名,你当然可以用loop_control配合loop_var改掉它,不过在with_items场景下改名的收益不大,除非你遇到了嵌套循环变量冲突——那种情况在with_items里其实不常见,因为它的扁平化特性让嵌套循环的写法本身就受限。
真正容易想当然的是数据类型。with_items里的元素不一定是字符串,它可以是字典、可以是数字、可以是布尔值。当你写name: "{{ item }}"时,如果item是个字典,Jinja2 会把它渲染成字典的字符串表示,结果往往不是你想要的。所以只要元素不是纯字符串,就要显式取字段,比如{{ item.name }}。
3.2 嵌套列表的隐式扁平化:省事也是坑
这是with_items最有辨识度的行为。看这个例子:
- name: 演示扁平化 ansible.builtin.debug: msg: "{{ item }}" with_items: - [a, b] - [c, d] - e如果你以为它会迭代三次、每次拿到一个子列表,那就错了。with_items会把子列表拉平,实际迭代五次,依次拿到a、b、c、d、e。这个行为在早期被当成便利特性,很多人用它来"合并多个列表再统一迭代"。
但坑也在这里。假设你的数据结构本来就有层级含义,比如"每个环境对应一组主机",你写成嵌套列表,期望外层循环按环境走,结果被拉平后层级信息全丢了。我踩过一次类似的坑:一个批量配置的任务,输入是按机房分组的 IP 列表,用with_items之后所有 IP 被摊平,任务确实都执行了,但日志里完全看不出哪个 IP 属于哪个机房,排查问题时非常痛苦。
提示:如果你的数据结构有层级语义,不要指望
with_items帮你保留它。要么改用loop配合subelements,要么在数据准备阶段就把结构拍平成"带标签的扁平列表",让每个元素自带归属信息。
3.3 字符串与列表的边界
with_items后面接一个字符串会怎样?比如with_items: "git"。这种情况下它不会把字符串拆成字符迭代,而是当成单元素处理,迭代一次拿到git。这个行为相对安全,但我不建议依赖它——写列表就老老实实写列表,语义清晰比省两个字符重要得多。
另一个边界是变量。当你写with_items: "{{ my_list }}"时,如果my_list本身是个列表,行为符合预期;如果my_list是个字符串(比如从外部传入的逗号分隔串),它会被当成单个元素。这时候需要先用split处理:
- name: 处理逗号分隔的输入 ansible.builtin.debug: msg: "{{ item }}" with_items: "{{ my_packages.split(',') }}"这种"外部输入类型不确定"的场景在自动化脚本里很常见,尤其是变量来自命令行-e或者外部配置文件时。我的习惯是在使用前加一个断言或者类型转换,别让类型问题拖到任务执行时才暴露。
4. 从单层到结构化:with_items 的实战写法
4.1 元素是字典时的标准姿势
批量创建用户是with_items最经典的用例之一,因为用户信息天然是结构化的:
- name: 批量创建运维账号 ansible.builtin.user: name: "{{ item.name }}" groups: "{{ item.groups }}" shell: "{{ item.shell | default('/bin/bash') }}" state: present with_items: - { name: deploy, groups: wheel } - { name: monitor, groups: wheel, shell: /sbin/nologin } - { name: backup, groups: wheel, shell: /sbin/nologin }这里有几个实操细节值得说。第一,default过滤器的用法:不是每个用户都需要自定义 shell,用default给个兜底值,列表里就可以省略这个字段,数据更干净。第二,字典用行内{}写法还是展开写,取决于字段多少——字段少用行内更紧凑,字段多(比如五六个以上)展开写可读性更好。第三,groups这种字段如果某个用户不需要附加组,记得在数据里显式给空值或者用default(omit),否则模板渲染可能出问题。
default(omit)这个技巧值得单独提一句。它的作用是"当变量未定义时,整个参数从模块调用里省略掉",而不是传一个空值。对于user模块的groups参数,传空字符串和省略参数的行为是不一样的,前者可能触发意外。所以更稳妥的写法是groups: "{{ item.groups | default(omit) }}"。
4.2 用 with_items 驱动文件与目录操作
批量建目录、批量拷贝文件也是高频场景。这里的关键是路径拼接和权限控制:
- name: 批量创建应用目录 ansible.builtin.file: path: "{{ base_dir }}/{{ item.path }}" state: directory owner: "{{ item.owner }}" group: "{{ item.group }}" mode: "{{ item.mode }}" with_items: - { path: logs, owner: app, group: app, mode: "0755" } - { path: data, owner: app, group: app, mode: "0750" } - { path: tmp, owner: app, group: app, mode: "1777" }注意mode一定要加引号写成字符串。YAML 里0755如果不加引号,某些解析路径下会被当成八进制数字处理,虽然 Ansible 对这种情况有一定兼容,但显式加引号是零成本的保险。我见过因为mode类型问题导致权限设错的案例,排查时盯着 Playbook 看了半天才发现是引号的事。
另外base_dir这种公共前缀抽成变量,比在每个path里重复写全路径要好维护得多。列表里只放相对路径,语义也更清楚——"这些是应用目录下的子目录"。
4.3 循环里做条件判断的正确位置
一个常见误区是把when写在循环内部期望"跳过某些元素"。实际上when是任务级的,它对整个任务生效,在with_items场景下会对每个item求值一次——也就是说when里可以引用item,从而实现"按元素过滤"的效果:
- name: 只对生产环境的主机装监控代理 ansible.builtin.package: name: monitoring-agent state: present when: item.env == "prod" with_items: - { env: dev, host: host-a } - { env: prod, host: host-b } - { env: prod, host: host-c }这段能跑,但要注意它的语义:任务会对三个元素各求值一次when,只有env为prod的两次真正执行模块。这跟"先过滤列表再循环"结果一样,但执行路径不同。如果列表很大,先过滤再循环效率更高,也更清晰:
- name: 先过滤再循环 ansible.builtin.package: name: monitoring-agent state: present with_items: "{{ hosts | selectattr('env', 'equalto', 'prod') | list }}"selectattr配合equalto是 Jinja2 里做列表过滤的标准手法,值得记住。它把"过滤"和"执行"两件事分开,逻辑更干净,日志里也不会出现一堆"skipped"。
5. 那些年 with_items 给我挖的坑
5.1 变量作用域与 item 被覆盖
item是循环的默认变量,它在任务作用域内有效。问题出在嵌套或者 include 场景:如果你在一个with_items任务里 include 了另一个也用了with_items的任务文件,内层的item会覆盖外层,导致外层后续引用item时拿到的是内层的值。这种 bug 极其隐蔽,因为 Playbook 不报错,只是结果不对。
解决办法有两个。一是用loop_var给内层循环换个变量名:
- name: 外层循环 ansible.builtin.include_tasks: inner.yml with_items: "{{ outer_list }}" loop_control: loop_var: outer_item然后在inner.yml里正常用item,两者互不干扰。二是干脆避免嵌套 include 循环,把逻辑拍平。我倾向于第一种,因为loop_var是显式声明,读代码的人一眼能看出这里做了隔离。
5.2 大列表下的性能与输出噪音
with_items迭代几百上千个元素时,两个问题会浮现。第一是执行时间,每个元素都是一次独立的任务执行,模块调用有固定开销,元素多了累积起来很可观。第二是输出,每个元素都会打印一条任务结果,屏幕刷得飞快,真正有用的信息被淹没。
对于性能,能合并的操作尽量合并——比如装包,与其循环装十个包,不如把包名列表直接传给package模块的name参数,它本身就支持列表,一次调用搞定。对于输出噪音,可以用no_log或者调整回调插件,但更根本的是反思:这个循环是不是本来就不该存在。
注意:
package、user、file这些模块的很多参数本身就接受列表,能用模块原生批量能力就别用循环。循环是"逐项执行",原生批量是"一次执行多项",后者通常更快也更省日志。
5.3 与 register 配合时的结果结构
循环任务加register之后,结果是一个results列表,每个元素对应一次迭代。很多人第一次拿到这个结构会懵,因为它的形状和普通任务的结果不一样:
- name: 检查多个服务状态 ansible.builtin.command: systemctl is-active {{ item }} register: svc_status with_items: - nginx - redis changed_when: false failed_when: false - name: 汇总失败的服务 ansible.builtin.debug: msg: "{{ svc_status.results | selectattr('stdout', 'ne', 'active') | map(attribute='item') | list }}"这里svc_status.results是列表,每个元素有item、stdout、rc等字段。用selectattr加map可以优雅地提取出"状态不是 active 的服务名"。这套组合拳在批量巡检类任务里非常实用,比写一堆when判断清爽得多。
要提醒的是failed_when: false和changed_when: false的配合。command模块默认认为命令执行就算 changed,而systemctl is-active只是查询,不该标记变更,所以关掉 changed。同时服务没启动时命令返回非零,如果不关掉 failed,任务会直接失败中断,拿不到完整结果。这两个开关在"批量查询"场景里几乎是标配。
5.4 空列表与未定义变量的边界
with_items接一个空列表时,任务会被跳过,这是合理行为。但如果接的是未定义变量,就会直接报错。所以当列表来源不确定时,加个default([])是稳妥做法:
- name: 可选的可选包安装 ansible.builtin.package: name: "{{ item }}" state: present with_items: "{{ optional_packages | default([]) }}"这样即使optional_packages没定义,任务也只是跳过而不是失败。这个模式在写可复用的 role 时特别有用——调用方不传这个变量,role 也能正常跑。
6. 把 with_items 用对,比用不用更重要
绕了一圈,回到最实际的问题:今天写 Playbook,到底该怎么对待with_items。我的判断标准很朴素——新写的、结构清晰的循环,用loop;维护存量、需要和with_*风格统一、或者确实想利用扁平化特性的,用with_items。两者不是对立关系,而是同一套循环能力的不同入口。
真正决定代码质量的,从来不是选了哪个关键字,而是你有没有想清楚三件事:数据结构是什么形状、循环变量在作用域里会不会被覆盖、以及这个循环是不是本来就能被模块的原生批量能力替代。这三件事想明白了,with_items也好loop也罢,都不会成为障碍。
我在实际维护中养成了一个习惯:凡是用了with_items的地方,如果依赖了它的隐式行为(扁平化、字符串单元素化),一定在任务上方留一行注释。这不是形式主义,而是因为隐式行为在代码评审和交接时最容易被忽略,一行注释能省下后来人半小时的困惑。另外,批量操作优先查模块文档看参数是否支持列表,能不用循环就不用——这条经验在我优化过的几个 Playbook 里,往往能砍掉一半以上的循环任务,执行时间和日志可读性都跟着改善。