1. 先想清楚:day5 这天我们到底要在 Playbook 里解决什么
如果你是从 day1 一路追过来的,前几天的内容基本都在讲"怎么把一件事描述清楚"——hosts 怎么写、vars 怎么定义、template 怎么渲染。但到了 day5 任务控制,问题就变了:我们要管的是"什么时候执行、执行几遍、失败了怎么办、怎么只跑其中一部分"。
我自己带过不少新人,发现一个规律:很多人 Playbook 能写、能跑,但一遇到"这个任务只在某些机器上执行""失败之后要自动回滚""几十个服务器要逐个操作并控制节奏"这类需求,就开始在 Playbook 里堆 shell 脚本,把 Ansible 写成了远程 SSH 执行器。这是最典型的弯路。任务控制的几个核心关键字——when、loop、handler、block/rescue/always、tags——才是让 Playbook 从"能跑"变成"好用"的分水岭。
这篇文章就按我实际项目中最高频的使用场景来拆,每个知识点我都会结合踩过的坑讲,不会只给你语法。适合刚学完 Playbook 基础、想让自动化脚本具备真实业务逻辑的运维和开发同学。
2. 条件执行:when不是简单的 if,关键在判断源
2.1 基础判断与 fact 的合理利用
when是任务控制里最基础也最常用的关键字。最简单的写法是:
- name: 安装 Nginx apt: name: nginx state: present when: ansible_os_family == "Debian"这个语法本身不复杂,但很多人没意识到when里能用的信息源有多少。最常见的是两种:一是gather_facts收集到的系统信息,二是用register注册的变量,三是你自己定义的变量。
fact 这块一定不要只停在ansible_os_family。我遇到过一个真实场景:公司内部同时存在 CentOS 7 和 Rocky Linux 9 两套环境,同一个 Playbook 要在两边跑。CentOS 7 的 systemd 服务单元写法和 Rocky 9 有细微差异,如果只判断ansible_os_family == "RedHat",两边都满足,模板文件还是可能出问题。正确做法是叠加版本判断:
- name: 配置旧版 systemd 参数 template: src: unit-old.j2 dest: /etc/systemd/system/app.service when: - ansible_os_family == "RedHat" - ansible_distribution_major_version == "7" - name: 配置新版 systemd 参数 template: src: unit-new.j2 dest: /etc/systemd/system/app.service when: - ansible_os_family == "RedHat" - ansible_distribution_major_version == "9"注意这里when后面跟的是一个 YAML 列表,列表内每一项用"与"的关系叠加,比写一整行带and的长表达式可读性好太多。项目维护到后面,别人接手一看就懂。
2.2 register 变量与 when 的组合判断
第二类高频场景是"前一个任务的执行结果决定下一个任务要不要跑"。这里最典型的坑就是:很多人不先看注册变量的真实结构,凭感觉写判断条件。
- name: 检查配置文件是否存在 stat: path: /etc/app/config.yml register: cfg_stat - name: 如果不存在则生成默认配置 template: src: config.yml.j2 dest: /etc/app/config.yml when: not cfg_stat.stat.exists这里的注册变量cfg_stat是一个 dict,真实路径是cfg_stat.stat.exists。我见过不少人直接写when: not cfg_stat.exists,跑完才发现变量取值不对。我的习惯是:注册变量之后先加一个 debug 任务把结构打出来看一遍,再写条件。调试代码的时候省这一个动作,后面排查问题能省半小时。
还有一个容易翻车的点是未定义变量。某台机器如果没有执行到定义某个变量的任务,后面引用它的when直接报undefined variable。保险做法是用default过滤器兜底:
when: env_name | default("dev") == "prod"但我得提醒一句:default能兜底,但也容易掩盖"变量根本没定义"这个事实。正式项目的做法是在 play 级别声明默认值,或者提前用set_fact初始化,别全指望default。
3. 循环执行:loop的进阶用法比你想的更实用
3.1 遍历列表和字典的正确姿势
批量创建用户、批量开放端口、批量部署多个服务配置,这些场景都离不开循环。Ansible 2.5 之后官方推荐用loop替代with_items,写法上更统一,排查逻辑也更顺。
最基础的是遍历列表:
- name: 批量创建运维账号 user: name: "{{ item }}" state: present loop: - ops_zhang - ops_li - ops_wang但实际生产里更常用的是遍历字典列表——每个元素带多个参数:
- name: 批量开启防火墙端口 firewalld: port: "{{ item.port }}/tcp" zone: "{{ item.zone }}" permanent: true state: enabled immediate: true loop: - { port: 8080, zone: public } - { port: 8443, zone: internal } - { port: 9090, zone: internal }这种写法最大的价值是可维护性。端口和 zone 的对应关系一目了然,新增一条规则直接往列表里加一行,不需要动任务本身。我在项目里凡是遇到"一组参数要批量应用"的场景,一律用字典列表,从不用字符串拼接再 split 的做法——后者写起来爽,排错的时候你能疯掉。
3.2 loop_control:控制循环变量、节奏与输出
loop_control是很多人忽略但极其救命的功能。先说循环变量冲突。嵌套循环时,内外层都用默认的item,内层会把外层覆盖掉,这是新手最容易遇到的神秘问题之一。解决办法是给外层循环指定独立变量名:
- name: 多环境多服务部署 debug: msg: "环境 {{ env.name }} 下的服务 {{ svc.name }}" loop: "{{ env_list }}" loop_control: loop_var: env再看index_var。假设你有两个等长的列表,一个存用户名,一个存初始密码字符串,想按索引对应起来:
- name: 批量设置用户密码 user: name: "{{ item }}" password: "{{ init_pwds[idx] | password_hash('sha512') }}" loop: - zhangsan - lisi - wangwu loop_control: index_var: idx这里有个前提:两个列表长度必须一致,否则越界报错。我通常会在 task 前加一个assert校验长度,明确失败原因:
- name: 校验列表长度一致 assert: that: - user_list | length == init_pwds | length fail_msg: "用户列表和密码列表长度不一致,请检查变量定义"loop_control还有两个我每天都会用的参数。一个是pause,控制每轮循环的间隔秒数。批量调用外部构建接口时,如果一次性打过去,接口侧限流直接把我们拒了。加上pause: 2之后稳定多了。另一个是label,当循环项是很大的 dict 时,默认输出会把整个 dict 打出来刷屏,用label: "{{ item.name }}"收敛成一行可读的任务名。
3.3 循环的失败语义要提前想明白
循环中某一个元素执行失败,默认行为是整个 task 报错,后续元素不再处理。这不一定是你想要的。比如批量创建账号时,第 5 个账号重名失败了,你希望第 6 个继续。这时可以考虑配合ignore_errors或者用rescue单独处理。
但我的建议是:别轻易给整个循环挂ignore_errors。更好的做法是明确你想保留哪些元素的结果,比如用failed_when只对特定的失败状态感兴趣。循环的失败处理,最好结合下一节要说的block/rescue来做精细控制。
4. 失败处理机制:把 Playbook 的容错能力建起来
4.1 block / rescue / always:try-catch 的 Ansible 版
如果说when和loop是控制"正常流程",那block/rescue/always就是控制"异常流程"。这套机制从 Ansible 2.1 就有了,思路跟编程语言里的 try-catch-finally 几乎一样。
我印象最深的一次事故:某次发布流程里,拉代码成功、构建成功,但启动服务的任务失败了,导致机器上残留了构建产物和半启动状态,后面所有健康检查任务连环报错。当时我还没有用 block/rescue 的习惯,是手动 SSH 上去清理的。后来我把发布流程改成了这样:
- name: 部署并启动应用 block: - name: 拉取最新发布代码 git: repo: "{{ repo_url }}" dest: /data/app version: "{{ release_tag }}" - name: 构建项目 command: make build - name: 重启服务 systemd: name: app state: restarted rescue: - name: 回滚到上一个稳定版本 command: make rollback - name: 记录发布失败日志 shell: echo "deploy failed at {{ ansible_date_time.iso8601 }}" >> /var/log/deploy.log always: - name: 清理构建临时文件 file: path: /tmp/app-build-* state: absent逻辑很清楚:block里的任务任何一个失败,后续的 block 内任务不再执行,直接进rescue;always无论成功失败都会执行,适合做临时文件清理这类收尾工作。
这里必须补一个实战教训:rescue里的任务一旦自身失败,整个 Play 依然会判失败。所以回滚操作要写得保守、简单、可靠,比如直接cp备份文件恢复,而不是在失败状态下再跑一遍完整构建。失败状态下跑重流程,等于赌它这次会成功,赌输的概率不小。
4.2 failed_when:用业务结果而不是退出码判定失败
很多命令的退出码为 0 不代表成功。比如一些自定义脚本,业务逻辑失败也返回 0;反过来有的命令返回非 0,但你并不想让它判失败。failed_when就是用来精确表达"什么才是失败"的。
我常用的是配合uri模块做健康检查:
- name: 健康检查接口 uri: url: "http://127.0.0.1:8080/health" status_code: 200 register: health_check failed_when: - health_check.status != 200 - '"healthy" not in health_check.content'两个条件,任何一个成立就判失败。这样比只看 HTTP 状态码或者只看内容都可靠。真实环境里有些服务健康检查返回 200 但 JSON 里status: "degraded",只有把两层条件都控制住,才算是真正健康。
4.3 retries / until:对临时故障多一次宽容
云环境和容器环境下,SSH 连接抖动、服务启动但端口还没监听,是常态。retries和until组合专门解决这类问题:
- name: 等待应用端口就绪 wait_for: port: 8080 host: 127.0.0.1 timeout: 5 register: port_result retries: 12 delay: 5 until: port_result.is_successful is defined and port_result.is_successfulwait_for本身有超时,加上retries/delay等于双重保险。注意until里引用的变量必须来自register,否则 Ansible 会直接报错。判断条件里加上is defined能避免变量未定义导致的二次报错,这个细节我在生产上吃过亏。
5. Handler:理解触发式任务的执行时机才不会被它坑到
5.1 notify 到 handler:基础配合与执行顺序
Handler 是任务控制里门槛不高但坑特别多的机制。核心思想是延迟执行:某个任务改了配置,但不立刻重启服务,而是等整个 Play 的任务都跑完,再统一触发重启。这样避免中途重启影响后续任务。
基础用法:
- name: 更新应用配置 template: src: app.yml.j2 dest: /etc/app/app.yml notify: restart app handlers: - name: restart app systemd: name: app state: restarted三个容易踩的坑,按我遇到的频率排序:
第一,notify通知的是 handler 的name,不是模块名、不是别的字段。拼写不一致,handler 就悄无声息不执行——注意是"不执行"而不是报错,排查起来极度隐蔽。我排查这种问题的方式是执行时加-v,看输出里 handler 那一节有没有任务被 skipped。
第二,同一个 handler 在一个 Play 内只会被触发一次。即使 3 个任务都 notify 了它,最终也只执行一次。大多数场景这是合理的,但如果你真的希望每次 notify 都执行,就得用下面说的listen配合不同的通知名来绕过,或者明确接受"只执行一次"的语义。
第三,handler 默认在 Play 结束前统一执行,而不是收到 notify 立即执行。如果你的任务 B 依赖"配置已经被新值加载后"的状态,但 handler 还没跑,任务 B 就会用旧配置状态,结果诡异得让人头大。
5.2 用 flush_handlers 控制执行节奏
meta: flush_handlers可以在 Play 中途强制把所有已触发的 handler 先执行掉:
- name: 更新 Nginx 配置 template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: reload nginx - name: 立即执行已触发的 handler meta: flush_handlers - name: 用新配置做配置检测 command: nginx -t这个例子很典型:nginx -t必须在新配置生效后才验证,不走flush_handlers就会拿旧配置做检测,结果自然不准。在发布流程、配置变更流程里,flush_handlers是你的好朋友。
5.3 listen:一个通知触发多个 handler
当一个配置变更需要连带重启多个服务时,listen比每个任务写多个notify干净得多:
handlers: - name: restart app systemd: name: app state: restarted listen: "app config changed" - name: restart sidecar systemd: name: sidecar state: restarted listen: "app config changed"任务只要写notify: "app config changed",两个 handler 都会被触发。在"配置变化要联动重启应用和它的伴随服务"的场景下,这个设计非常实用,而且后续新增一个关联服务,只需要在 handlers 里加条目,不用回头改所有任务。
6. tags:让一个 Playbook 支持局部定向执行
6.1 tags 基础:从全量执行到精准执行
Playbook 变大之后,全量执行越来越奢侈。一个完整的发布流程往往包括备份、拉代码、构建、迁移数据库、重启服务、健康检查,你只想跑"重启服务"这一步时,总不能把前面全都设为 skip。tags就是为这个场景设计的。
- name: 备份数据库 shell: mysqldump -u root app_db > /backup/app_db.sql tags: - backup - name: 构建前端资源 command: make build tags: - build - name: 重启应用服务 systemd: name: app state: restarted tags: - restart执行时定向筛选:
ansible-playbook deploy.yml --tags restart排除某些步骤:
ansible-playbook deploy.yml --skip-tags backup我的经验是:不是每个任务都需要 tags,但耗时任务、破坏性任务必须有。比如全量备份可能跑 20 分钟,清缓存、删表这类高危操作更是必须能单独控制。让团队其他人接手时能从 tags 快速识别哪些任务能跳过、哪些是危险动作,这种隐性的规范性价值比省几次执行时间更重要。
6.2 always 特殊 tag 与筛选叠加的坑
always是内置特殊 tag,含义是"无论筛选什么标签,只要执行了 Playbook,这些任务都会跑"。适合放那些前置条件类任务:
- name: 创建应用日志目录 file: path: /var/log/app state: directory tags: - always再给一个必须留心的坑:--tags对 play 级别和 task 级别的过滤逻辑不完全一样。如果你给整个 play 打了 tag,再配合--skip-tags使用,有时候会出现"整个 play 直接没跑"的诡异现象。我建议 tags 尽量打在 task 或 block 级别,play 级别的 tags 只在你对过滤逻辑非常确定时再用,否则排查起来很绕。
7. 高频问题与排查技巧实录
7.1 任务控制相关的报错速查表
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
循环里item值不对 | 嵌套循环变量名冲突 | 外层循环用loop_control.loop_var改变量名 |
| 任务 notify 了但 handler 没跑 | notify 名称拼错与 handler name 不一致 | 执行时加-v看 handler 段输出确认是否被调用 |
when报 undefined variable | 变量在某台机器上未定义 | 用default过滤器或提前set_fact初始化 |
| handler 执行顺序不符合预期 | 默认 Play 结束才统一执行 | 在需要的位置插入meta: flush_handlers |
ignore_errors: true后变量仍报错 | 任务失败导致 register 变量没有成功赋值 | 改用failed_when精确控制失败条件 |
--tags筛选后跑多了任务 | always标签叠加效应 | 检查是否把 always 打在了不合适的任务上 |
| 循环中一项失败全部中断 | 循环默认失败即停 | 评估后用 block/rescue 包裹或调整失败处理策略 |
7.2 两个真正高效的排查手法
第一个是加-vvv看全量输出。很多人一遇到问题就只盯着最后的报错信息,但 Ansible 的报错上下文信息非常丰富,-vvv模式下能看到实际执行的命令、远端返回的 JSON、模板渲染后的具体内容。尤其是排查模板或条件判断问题,不看到渲染后的真实字符串,基本是在瞎猜。
第二个是--check预演加--diff对比。写复杂的when和loop逻辑时,先--check --diff跑一遍,输出会明确告诉你每个任务在假跑模式下"会执行、会跳过、会报错",以及文件将被改动的具体内容。它不能保证实际结果完全一致,但对验证控制流的正确性非常有价值。我在给任务控制逻辑做重构时,几乎必开这两个参数。
8. 最后分享一个我自己的练习方式
如果按 day1 到 day5 的顺序走下来,你会发现任务控制是 Playbook 从"能跑"到"好用"的关键一跃。前面你只是把操作描述清楚,现在你能控制它什么时候跑、跑几遍、失败后怎么处理、只跑哪一部分——这些才是运维自动化的逻辑骨架。
我当初练这一块的方法很简单:找一个自己维护的真实服务,把它的发布流程写成一个完整 Playbook,强制要求包含多环境条件判断、依赖服务的 handler、失败自动回滚三个要素。写完跑通之后,再把之前的旧 Playbook 翻出来看,你会明显感觉到哪里写得糙。这个对比练习比看十篇文章都有用。
还有一个小技巧:在关键任务后面多加几个debug打印变量值,确认条件判断的输入没问题。调试 Playbook 最大的成本不是写代码,而是猜"这个变量此刻到底是什么值",把这个环节主动显性化,你踩坑的频率会直线下降。