☰
Ansible循环实战:with_items与loop的取舍及避坑指南
2026/11/3 16:21:04 网站建设 项目流程

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_itemsloop
引入时间早期版本即有较新版本引入
底层机制走 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 里,往往能砍掉一半以上的循环任务,执行时间和日志可读性都跟着改善。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询