首页 / 资讯中心 / 文章详情

Ansible循环实战:with_items与loop的取舍及避坑指南

Ansible循环实战:with_items与loop的取舍及避坑指南 ★ FEATURED ARTICLE
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 语法糖你才会去留意数据是怎么被喂进来的。对比一下looploop是后来引入的、更纯粹的循环关键字它接收一个列表直接迭代不做 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(attributeitem) | 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 里往往能砍掉一半以上的循环任务执行时间和日志可读性都跟着改善。
阅读完成 · 觉得有帮助?
咨询建站