Ansible 默认的执行模型是「遇错即停」:playbook 里任何一个 task 在目标机上返回非零退出码,host 就会被标记为 failed,后续所有任务直接跳过。这对一次性脚本够用,对滚动部署、数据库迁移、服务重启这类「失败后需要原地收拾残局」的场景就非常别扭——你真正想要的是「这一步可能失败,失败了先回滚/降级/重试,收拾干净再决定整体成败」。block/rescue/always 提供的就是这套异常语义,而 failed_when 决定了「失败」的边界到底由谁说了算。
这篇文章把两者组合起来,拆成一个可以直接跑的部署角色:编排层用 block/rescue 做阶段化事务控制,任务层用 failed_when 精确判定失败阈值,再配合真实会遇到的报错(UNREACHABLE、Failed to lock apt、Health check returned 503)说明怎么定位和修。文中的 playbook 都是完整可运行的片段,改掉 inventory 就能复现。
一、先搞清 block/rescue 的异常语义,别把它当 try/catch 用
很多同学把 block 类比成 try、rescue 类比成 catch,然后写出「捕获一切异常」的结构,结果就是失败被静默吞掉,CI 显示绿色,线上服务已经挂了。Ansible 的规则比这更细,落地前必须记住下面几条:
- rescue 只处理 block 内部 task 的失败。block 外面同一 play 里的 task 失败,不会被任何 rescue 接住。
- rescue 里任何一个 task 再失败,整个 block 视为失败,异常继续向上冒泡,host 标记 failed。所以 rescue 里的清理动作要用
ignore_errors: true或failed_when: false兜住。 - always 总会执行,无论 block 成功、失败还是被 rescue 处理过。适合放「必须收尾」的动作:释放锁、删除临时文件、恢复 upstream 权重。
- rescue 成功执行后,这个 host 不会被标记 failed,playbook 继续往下走。这既是特性也是坑:如果你想「回滚后仍然让流水线红掉」,必须在 rescue 末尾用
fail模块显式抛出。 - 连接失败(UNREACHABLE)默认不进 rescue。SSH 超时、主机不可达属于 “unreachable” 而非 “failed”,要接管它得用
ignore_unreachable: true(Ansible 2.7+)配合meta: clear_host_errors,或者在任务粒度上处理。 - include_tasks / import_tasks 放进 block 时,被包含文件里 task 的失败会冒泡到外层 block;但用
include_tasks的rescue不能反过来包裹调用方。层级不要绕太深。
一个最小可验证的例子,用来确认你本机 Ansible 的行为和上文一致:
- name: 验证 block/rescue/always 的执行顺序
hosts: localhost
gather_facts: false
tasks:
- block:
- name: 故意失败的任务
ansible.builtin.command: /bin/false
changed_when: false
rescue:
- name: 进入 rescue
ansible.builtin.debug:
msg: "rescue 捕获到了失败"
- name: rescue 内再失败(观察是否导致 host failed)
ansible.builtin.command: /bin/false
failed_when: false
always:
- name: 收尾总会执行
ansible.builtin.debug:
msg: "always 执行"
- name: block 之后的任务仍会执行
ansible.builtin.debug:
msg: "play 继续"
输出里会看到 rescue 捕获到了失败 → always 执行 → play 继续,最后 PLAY RECAP 是 failed=0。把 rescue 里那条 failed_when: false 删掉,RECAP 立刻变成 failed=1。这个 30 秒实验比读十页文档有效。
二、failed_when 决定「什么算失败」,把它和 block 拆成两层
failed_when 接收一个 Jinja2 表达式,表达式为真即判定失败;它和 changed_when 一样,是在任务返回之后对 result 对象的再加工。result 里常用的字段有 rc、stdout、stderr、stdout_lines、failed、changed。关键点:failed_when 会覆盖模块自身的失败判定,而不是叠加。也就是说,模块返回 rc=1,你写 failed_when: false,它就真的不算失败;反过来,模块返回 rc=0,你写 failed_when: result.rc != 0 or 'ERROR' in result.stderr,它也能变失败。
实际部署中最常需要重定义失败边界的场景有三类:
| 场景 | 默认行为的问题 | failed_when 写法 |
|---|---|---|
| 命令行工具返回非零但属于「预期内」 | 如 grep 无匹配返回 1、systemctl is-active 停服务时返回 3 |
failed_when: result.rc not in [0, 1] |
| 需要按输出内容而非退出码判定 | 健康检查脚本 rc=0 但 body 是 {"status":"degraded"} |
failed_when: "'"status":"ok"' not in result.stdout" |
| 幂等探测,失败是正常分支 | 查询是否存在某文件/某用户,不存在返回非零 | failed_when: false + register 后判断 |
写 failed_when 时有两个高频坑。第一,引用未注册的变量会导致整个 task 报 AnsibleUndefinedVariable,这是 playbook 语法层面的失败,不会进 rescue(严格说它在任务求值阶段就炸了,错误信息形如 The conditional check 'result.rc != 0' failed. The error was: error while evaluating conditional ... 'result' is undefined)。第二,stdout 里带换行和引号,直接字符串比较很容易假阴性,建议用 stdout_lines + select('search', ...) 或者正则。
- name: 收紧失败边界:只有 rc 不在白名单或输出含 FATAL 才算失败
ansible.builtin.shell: |
set -o pipefail
/usr/local/bin/appctl status --json
args:
executable: /bin/bash
register: appctl_result
changed_when: false
failed_when: >-
appctl_result.rc not in [0, 1, 3]
or 'FATAL' in appctl_result.stderr
or (appctl_result.rc == 0 and '"healthy":true' not in appctl_result.stdout)
注意 shell 模块必须加 set -o pipefail(或 args.executable: /bin/bash 后显式开启),否则管道里最后一个命令成功就会掩盖前面命令的失败,rc 永远是 0,failed_when 再精确也没用。这是线上排查「为什么明明失败了 rc 还是 0」的第一嫌疑。
三、把部署拆成阶段化事务:block 编排 + rescue 回滚
设计原则是一个 block 只包裹一个可回滚的阶段,不要把「拉代码 + 装依赖 + 改配置 + 重启 + 健康检查」塞进同一个 block,否则 rescue 里没法判断该回滚到哪一步。下面这个角色把一次滚动发布拆成三块:构建产物落地、服务切换、健康验证。每块的 rescue 只负责撤销本块造成的副作用。
- name: 可恢复的滚动部署
hosts: app_servers
become: true
serial: "25%"
max_fail_percentage: 0
vars:
release_root: /opt/myapp/releases
release_id: "{{ ansible_date_time.epoch }}"
current_link: /opt/myapp/current
health_url: "http://127.0.0.1:8080/actuator/health"
health_retries: 12
health_delay: 5
tasks:
- name: 阶段一 准备发布目录并同步产物
block:
- name: 创建 release 目录
ansible.builtin.file:
path: "{{ release_root }}/{{ release_id }}"
state: directory
owner: deploy
group: deploy
mode: "0755"
- name: 同步制品到目标机
ansible.builtin.synchronize:
src: "dist/"
dest: "{{ release_root }}/{{ release_id }}/"
delete: true
rsync_opts:
- "--chown=deploy:deploy"
- "--checksum"
register: sync_result
retries: 3
delay: 5
until: sync_result is succeeded
rescue:
- name: 同步失败时清理半成品目录
ansible.builtin.file:
path: "{{ release_root }}/{{ release_id }}"
state: absent
failed_when: false
- name: 阶段一失败,终止本机
ansible.builtin.fail:
msg: "制品同步失败(已重试 3 次),release {{ release_id }} 未落地"
always:
- name: 记录阶段一时间
ansible.builtin.set_fact:
phase1_done: "{{ ansible_date_time.iso8601 }}"
- name: 阶段二 切换软链接并重启服务
block:
- name: 记录旧版本(用于回滚)
ansible.builtin.command: readlink -f {{ current_link }}
register: prev_release
changed_when: false
failed_when: false
- name: 原子切换 current 软链接
ansible.builtin.file:
src: "{{ release_root }}/{{ release_id }}"
dest: "{{ current_link }}"
state: link
force: true
follow: false
- name: 重启应用
ansible.builtin.systemd:
name: myapp
state: restarted
daemon_reload: true
register: restart_result
failed_when: >-
restart_result.failed
or ('Failed to restart' in (restart_result.msg | default('')))
rescue:
- name: 指向旧版本
ansible.builtin.file:
src: "{{ prev_release.stdout }}"
dest: "{{ current_link }}"
state: link
force: true
follow: false
when: prev_release.stdout | length > 0
- name: 回滚后重启旧版本
ansible.builtin.systemd:
name: myapp
state: restarted
failed_when: false
- name: 阶段二失败,终止本机
ansible.builtin.fail:
msg: "服务切换失败,已回滚到 {{ prev_release.stdout }}"
- name: 阶段三 健康检查
block:
- name: 轮询健康接口直到 UP
ansible.builtin.uri:
url: "{{ health_url }}"
method: GET
status_code: 200
return_content: true
register: health
until: >-
health.status == 200
and (health.json.status | default('')) == 'UP'
retries: "{{ health_retries }}"
delay: "{{ health_delay }}"
failed_when: >-
health.status != 200
or (health.json.status | default('DOWN')) != 'UP'
rescue:
- name: 抓取失败时刻的日志片段
ansible.builtin.shell: |
journalctl -u myapp --since "-3 min" --no-pager | tail -n 80
register: tail_log
changed_when: false
failed_when: false
- name: 打印日志辅助定位
ansible.builtin.debug:
msg: "{{ tail_log.stdout_lines }}"
- name: 健康检查失败,显式抛错
ansible.builtin.fail:
msg: "健康检查未通过,已保留现场,请查看上方日志"
always:
- name: 清理超过 5 个的历史 release
ansible.builtin.shell: |
set -euo pipefail
ls -1dt {{ release_root }}/*/ | tail -n +6 | xargs -r rm -rf
changed_when: false
failed_when: false
几个刻意的设计点值得单独说明:
- rescue 末尾一定有
fail。Ansible 在 rescue 成功后不会把 host 标记为 failed,如果你希望这个 host 从serial批次里退出、阻止后续机器继续升级(通常是想要的),就必须显式 fail。反之,若你的策略是「坏一台不影响整体、继续发」,那就不写 fail,只做回滚。 serial: "25%"+max_fail_percentage: 0组合起来才是金丝雀:第一批 25% 有一台失败,整个 play 立即中止,剩余 75% 不会动。只写 serial 不写 max_fail_percentage,默认容忍度是 0%,其实效果一样,但显式写出来可读性更好,团队协作时意图明确。- 软链接切换用
file模块而不是ln -sfn。file state=link force=true follow=false是幂等的,重复执行不产生 changed 抖动之外的副作用;而shell: ln -sfn每次都会 changed,并且follow默认行为在某些 Ansible 版本里会把软链接解析成目录,导致「切到了旧目录里的子目录」这种诡异问题。 until和failed_when可以同时用。until控制重试,failed_when控制最终判定。上例里重试 12 次 × 5 秒 = 60 秒窗口,这是 Spring Boot 应用冷启动的常见区间;如果你的应用加载缓存要 3 分钟,把health_retries提到 40。
四、真实报错与排查对照表
下面这些错误信息都是部署里高频出现的原文,按「报错 → 根因 → 处理」给对照,方便直接搜索定位。
| 报错原文(节选) | 根因 | 处理 |
|---|---|---|
fatal: [web01]: UNREACHABLE! => {"msg": "Failed to connect to the host via ssh: ssh: connect to host 10.0.1.21 port 22: Connection timed out"} |
连接层失败,默认不进 rescue | 加 ignore_unreachable: true 到具体 task,或在 rescue 后加 meta: clear_host_errors;同时把 ssh 超时调到 ansible_ssh_timeout=15 |
fatal: [db01]: FAILED! => {"changed": false, "msg": "Failed to lock apt for exclusive operation: Failed to lock directory /var/lib/apt/lists/ for installing new packages"} |
unattended-upgrades 或另一个 apt 进程占锁 | 在 block 前加 wait_for 等锁文件释放,或设 apt: 的 lock_timeout: 120(Ansible 2.12+ 支持 lock_timeout 参数) |
fatal: [web01]: FAILED! => {"msg": "Health check returned 503 within 60s"}(自定义脚本输出) |
应用起来了但依赖(DB/Redis)未就绪 | 把健康检查拆成 liveness(进程在)和 readiness(依赖通),rescue 里只回滚 readiness 失败,liveness 失败直接 fail 不回滚 |
fatal: [web01]: FAILED! => {"msg": "The conditional check 'health.status == 200' failed. The error was: error while evaluating conditional (health.status == 200): 'dict object' has no attribute 'status'"} |
uri 模块在连接被拒时没有 status 字段,只有 msg |
条件里统一走 health.status | default(0),或先判 health is failed |
ERROR! 'block' is not a valid attribute for a Task |
把 block 写在了 tasks 里的某个 task 内部,或写在 handler 的错误层级 |
block 只能作为 tasks 列表的顶层项,不能嵌套在 with_items 里;循环包裹用 include_tasks + 内层 block |
fatal: [web01]: FAILED! => {"changed": false, "msg": "Could not find the requested service myapp: host"} |
单元文件未安装或 daemon 未 reload | 重启任务加 daemon_reload: true;rescue 里回滚重启同样要带 |
一个容易被忽略的细节:uri 模块遇到 4xx/5xx 时,如果没有设置 status_code 允许该状态,任务会直接失败并把错误塞进 msg,此时 register 到的变量里没有 json 字段。上面的 playbook 写了 status_code: 200,等于告诉模块「只接受 200」,非 200 照样失败——这正是我们要的语义,但要配合 default 过滤器防止条件表达式二次炸掉。如果想把 5xx 的响应体也拿到手做判断,要显式写 status_code: [200, 503],然后用 failed_when 自己判定。
五、把模式固化成角色,并做可测的降级分支
playbook 里堆 block 会越来越难维护,实际项目应该把「一个阶段一个 task 文件」拆开,用 include_tasks 在 block 里引用。这样每个阶段的 rescue 只和它自己的文件放在一起,代码审查时能一眼看完「出事了干什么」。
- name: roles/rolling_deploy/tasks/main.yml
block:
- name: 执行阶段一
ansible.builtin.include_tasks: phase1_artifact.yml
- name: 执行阶段二
ansible.builtin.include_tasks: phase2_switch.yml
- name: 执行阶段三
ansible.builtin.include_tasks: phase3_health.yml
rescue:
- name: 统一回滚入口(阶段脚本内部已各自撤销副作用)
ansible.builtin.include_tasks: rollback.yml
always:
- name: 释放部署锁
ansible.builtin.file:
path: /var/lock/rolling_deploy.lock
state: absent
failed_when: false
降级分支的写法是另一个重点:并不是所有失败都值得回滚。配置中心暂时连不上、非关键指标上报失败,更合理的策略是「记录并继续」。这类场景不要放进 rescue,而是直接在该 task 上用 failed_when 把它从失败降级为 ok,再用 register 的结果决定后续是否需要告警。
- name: 上报部署事件到监控(失败不影响发布)
ansible.builtin.uri:
url: "https://monitor.internal/api/events"
method: POST
body_format: json
body:
app: myapp
release: "{{ release_id }}"
host: "{{ inventory_hostname }}"
timeout: 5
register: report
failed_when: false # 不让上报失败中断部署
changed_when: false
- name: 上报失败时只打警告
ansible.builtin.debug:
msg: "监控上报失败:{{ report.msg | default('unknown') }},发布继续"
when: report is failed or report.status | default(0) not in [200, 201, 202]
测试这块,用 ansible-playbook --check --diff 只能验证语法和幂等性,验证不了 rescue 路径。想真正跑通失败分支,用 --limit 挑一台测试机,然后人为制造故障:把 health_url 改成不存在的端口,或者临时 systemctl stop myapp 后再执行 playbook。观察三件事——rescue 有没有进、软链接有没有回到旧版本、PLAY RECAP 的 failed 数是不是 1。这三样对上了,回滚链路才算可信。
还要注意 serial 与 rescue 的交互:rescue 成功且你没有 fail 的话,该 host 会继续参与后续任务和后续 batch 的并发计算。若你的告警依赖 RECAP 的 failed 数,务必在 rescue 末尾抛错;若依赖自定义指标,则在 rescue 里 set_stats 打点更准确:
- name: 回滚计数(供 CI 读取)
ansible.builtin.set_stats:
data:
rollback_count: 1
per_host: true
failed_when: false
小结
block/rescue/always 解决的是「失败之后干什么」的控制流问题,failed_when 解决的是「什么才算失败」的判定问题。两者分工清晰:先想清楚每个阶段的失败阈值(退出码白名单、输出关键字、HTTP 状态),用 failed_when 把它固定下来;再按可回滚边界切分 block,让每个 rescue 只撤销自己能撤销的副作用,末尾用 fail 决定这台机器还要不要留在批次里。最后用 serial + max_fail_percentage 把爆炸半径限制在第一批,日志留在 always 里,回滚路径用人为故障实测过一遍,这套模板才敢接到生产流水线上。
FAQ
1. rescue 执行成功了,为什么 PLAY RECAP 还是 failed=0,流水线却显示部署成功?
这是 Ansible 的既定设计:rescue 正常结束即视为异常已被处理。想要流水线感知失败,在 rescue 最后一个 task 用 ansible.builtin.fail 显式抛出,或把关键信息 set_stats 出去由 CI 判断。前者更直接。
2. block 里用 loop 循环,其中一项失败,rescue 会接住吗?
会,但粒度是整个 task。循环中任何一项失败,Ansible 默认立即中断该 task 并进入 rescue,已成功的项保持生效,不会自动回退。需要「部分成功也要保留」时,改用 failed_when: false 收集结果,再在 rescue 里按 item 判断该撤销哪些。注意 Ansible 2.16 起 loop 支持 rescue 级别的细粒度控制仍没有,还是全有全无。
3. SSH 连不上(UNREACHABLE)为什么不进 rescue?
因为 rescue 处理的是 task 执行失败,而不可达在建立连接阶段就终止了,属于 host-level 错误。解决办法是对该 task 设 ignore_unreachable: true,或者用 block 前先跑一个 wait_for_connection 做前置探测。注意 ignore_unreachable 加在 block 上不生效,要加在 task 上。
4. failed_when 里想引用 register 变量但报 undefined,怎么办?
确认变量名拼写,确认 register 写在同一个 task 上且位置正确(task 级别,不是 block 级别)。另一个常见原因是任务因 check_mode 被跳过,或者前面的 when 为假,此时 register 变量都没有被赋值。用 result.rc | default(1) 之类的默认值兜底最稳。
5. 回滚时 readlink -f 返回的不是旧的 release 目录,为什么?
readlink -f 会解析到最终真实路径,如果 current 本来就是软链接,行为正确;但如果某次部署误用了 ln -sfn 或者带 follow: true,current 可能被替换成真实目录,此时 readlink -f 返回自身路径,回滚就会「指向自己」。修复方法是在切换任务里固定 follow: false,并在回滚前加断言:assert: that: prev_release.stdout != current_link。
6. until 重试和 block/rescue 该选哪个?
两者解决不同问题。until 用于「同一操作可能瞬时失败、重试能成功」——如健康检查、服务注册、DNS 生效;block/rescue 用于「重试也没用,必须撤销副作用」——如产物损坏、端口被占、迁移脚本中断。实践中经常叠加:给任务加 retries/until 提升瞬时成功率,外层放 block/rescue 兜住最终失败。