出错的那一刻,什么停下,什么继续跑
一句话总结
Ansible 的默认行为是“失败的主机悄悄退出,其余的继续”,而失败设计,就是去问这个默认行为是否适合我们的情况,只在不适合的地方用旋钮改变它。
为什么需要它
有一个向 40 台服务器部署的 playbook。有一天,有三台因为磁盘满了,写入配置文件失败。部署打印了几行红字后继续运行,最后一行显示 37 台成功。流水线以失败告终,但没有人知道哪台处于什么状态。那三台上,新代码在读取旧配置,并且就在这种状态下,照样挂在负载均衡器上。
这里错的不是“失败了”。错的是没有人事先规定失败时会发生什么。Ansible 只是选了一个默认值,这个默认值是“一台主机的失败不会妨碍其他主机的工作”。对于逐台独立修理服务器的工作,这是好的默认值,但如果 40 台共同构成一个服务,这就是坏的默认值。
所以失败设计不是背诵异常处理的语法,而是为三个问题定好答案。这个失败是真正的失败吗?失败的那台主机怎么处理?其他主机怎么处理?
工作原理
默认行为——只有失败的主机退出
如果 task 在某台主机上失败,Ansible 会把这台主机从本次 play 的对象中排除。剩下的 task 根本不会在那台主机上执行。其他主机不受任何影响,一直运行到最后。整个 playbook 的退出码不是 0,但那是运行结束之后的事了。
这一行为的结果是会留下部分应用的状态。在第 5 个 task 失败的主机,停在第 1 到第 4 个已应用的状态。如果是幂等的 playbook,修复原因后重新运行就行,但像“只改了配置却没能重启”这样中间状态危险的情况,这个默认值就不合适。
这个失败是真正的失败吗——failed_when 与 ignore_errors
command 和 shell 会把退出码不为 0 视为失败。但有很多命令,退出码不为 0 才是正常的。grep 找不到时返回 1。diff 有差异时返回 1。systemctl is-active 在未运行时返回 3。这些值不是错误,而是答案。
- name: 설정에 그 키가 있는지 센다
ansible.builtin.command: grep -c max_conn /etc/app/app.conf
register: keycount
changed_when: false
failed_when: keycount.rc > 1
failed_when 会为每个 task 重新定义“把什么视为失败”。grep 找不到时返回 1,文件不存在或参数错误时返回 2 以上,所以上面的条件意味着“找不到是答案,只有真正的错误才算失败”。同时加上 changed_when: false,是为了避免查询 task 每次都被报告为 changed。
ignore_errors: true 则是完全不同的东西。它不改变判定,只是忽略结果。task 仍然被记录为失败,PLAY RECAP 的 ignored 列会增加,但主机不会退出,而是继续下一个 task。
| task 的判定 | 下一个 task | RECAP | |
|---|---|---|---|
| 默认 | 失败 | 该主机退出 | failed |
failed_when |
按我所定的 | 不是失败则继续 | 视条件而定 |
ignore_errors |
仍是失败 | 继续 | ignored |
混淆这两者,会发生悄无声息的事故。ignore_errors 只用于“这确实是失败,但现在先放过”的时候。如果根本不是失败,就应该用 failed_when 校正标准。习惯性地加 ignore_errors 的 playbook 只会吞掉失败,让只应用了一半的服务器挂着绿灯留下来。而且 ignore_errors 对连接不上的主机不起作用——那不是 task 的失败,而是连接的失败,所以另有 ignore_unreachable。
失败的那台主机怎么处理——block、rescue、always
block 把多个 task 捆成一组。给这一组加上 rescue 和 always,就成了与其他语言的 try、except、finally 相同的形态。
- name: 새 판으로 바꿔 보기
block:
- name: 새 설정 배치
ansible.builtin.copy: {dest: /etc/app/app.conf, src: new.conf}
- name: 헬스 체크
ansible.builtin.command: /usr/local/bin/healthcheck
rescue:
- name: 옛 설정으로 되돌리기
ansible.builtin.copy: {dest: /etc/app/app.conf, src: old.conf, remote_src: true}
always:
- name: 무슨 일이 있었든 기록을 남긴다
ansible.builtin.lineinfile: {path: /var/log/deploy.log, line: "attempt finished"}
有几条规则。block 内出现失败时,会在那个位置中断 block,转到 rescue。失败之后的 block task 不会执行。如果 rescue 一直成功到最后,这台主机会被视为没有失败,继续运行 play。PLAY RECAP 中显示的不是 failed,而是 rescued。always 无论成功、失败还是被 rescue,始终运行。
在 rescue 内,可以通过 ansible_failed_task 和 ansible_failed_result 查看是什么以及为什么失败。用于记录日志或发送通知。而且如果在 rescue 内再次失败,那就是真正的失败了——把结构层层叠加,通常是设计有问题的信号。
要注意一点。如果 rescue 成功,流水线就会是绿灯。也就是说,回滚发生过这件事不会留在退出码里。所以在 rescue 内,必须放一个留下“发生了回滚”这一证据的 task。无论是文件、通知还是指标,都必须是人之后可以统计的形式。
其他主机怎么处理——any_errors_fatal 与 max_fail_percentage
any_errors_fatal: true 会在哪怕只有一台主机失败时,就在那个位置停止整个 play。没有失败的主机也不会执行剩下的 task。用于像集群搭建这样“要么全部成功,要么全都不成”的事,或者像部署前的预检这样只要有一台条件不符就不能开始的地方。
max_fail_percentage 是介于两者之间的值。失败主机的比例超过这个值时,其余的也会停止。
| 3 台主机中 | max_fail_percentage 50 |
结果 |
|---|---|---|
| 1 台失败 | 33% 不超过 50 | 其余 2 台继续 |
| 2 台失败 | 66% 超过 50 | play 在那个位置停止 |
重要的是,这个比例是按批次判定的。在用 serial 分批的滚动部署中,每一批结束时都会计算,所以成为“一批中有一半以上失败,就不碰其余批次”的安全装置。max_fail_percentage: 0 的含义几乎与 any_errors_fatal: true 相同,因为只要比例超过 0 就会停止。
连接不上的主机——unreachable 不是 failed
当 SSH 本身不通时,统计为 unreachable 而不是 failed。因为不是 task 失败,而是连开始都没能开始。这个区分在 PLAY RECAP 中体现为列不同,而且 ignore_errors 无法忽略它。如果把 ignore_unreachable: true 加到那个 task 上,主机就不会退出,而是转到下一个 task。
在实际工作中,这个区分之所以重要,是因为原因完全不同。failed 通常是我们的代码或目标状态的问题,而 unreachable 通常是网络、电源、inventory 笔误的问题。看部署报告时,如果把这两个数字加在一起,就无从知道那天该修什么了。
在开始之前拦截——assert
成本最低的失败,是在改变任何东西之前发生的失败。assert 接收一个条件列表,只要有一个为假,就让 task 失败。
- name: 배포 전제 확인
ansible.builtin.assert:
that:
- deploy_env in ["dev", "stage", "prod"]
- app_version is match("^[0-9]+\.[0-9]+\.[0-9]+$")
fail_msg: "배포 전제가 어긋났습니다 (env={{ deploy_env }}, version={{ app_version }})"
success_msg: "전제 조건 통과"
之所以建议一定要写 fail_msg,是有原因的。默认消息只有 Assertion failed 一行,所以在有多个条件时,无法知道是哪一个为假。如果把当前值直接写进消息,一行日志就能解决。并且,如果把这个 task 与 any_errors_fatal 一起放在 play 的最前面,只要有一台的前提不符,就会在不碰任何东西的情况下停止。fail 模块是与 when 一起使用的姊妹模块,想亲自写条件时使用。
PLAY RECAP 的读法
web1 : ok=6 changed=2 unreachable=0 failed=0 skipped=1 rescued=0 ignored=0
web2 : ok=3 changed=1 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
db1 : ok=5 changed=2 unreachable=0 failed=0 skipped=0 rescued=1 ignored=0
ghost: ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=1
从这四行中要读出的是这些。web2 大约在第 4 个 task 失败,之后什么都没做。db1 失败了,但 rescue 把它救了回来,一直运行到了最后——虽然是绿灯,但回滚发生过。ghost 的 SSH 不通,而且被写成忽略这一点。ignored 不为 0 的那一行,始终值得看一眼。因为决定要忽略的东西,是否仍然可以忽略,会随时间而改变。
在现场相遇的样子
案例 1——ignore_errors 不断累积的 playbook。某个 role 里有十一处 ignore_errors: true。每一处都有理由——“这个软件包可能已经装好了”“这个服务在有的环境中不存在”。可是其间混进了一处。那是部署证书的 task。证书签发失败,部署也是绿灯,四个月后证书过期时才暴露出,那台服务器已经用旧证书运行了两个月。此后确立了一条规则——ignore_errors 必须写注释说明为什么可以忽略。写着写着就会发现,大部分其实都应该改成 failed_when 或 when。
案例 2——把预检放在后面的那天。部署 playbook 把配置全部推送之后,在最后检查版本格式。因为一个笔误,版本成了 1.2.3-rc 的那天,是在 40 台的配置全部上线之后才停下来的。回退花了两个小时。现在 assert 组与 any_errors_fatal 一起放在 play 的最前面。如果出现同样的笔误,会在 3 秒内,在不碰任何东西的情况下停止。
案例 3——回滚是绿灯的流水线。加上 block/rescue 自动回滚之后,部署失败数变成了 0。这被解读为好迹象,但实际上 rescue 每周运行好几次,而没有人去统计它。在 rescue 内放入留下“回滚发生”的 task,并把这个数字加入每周报告的第二天,一个小时内就发现,只有特定镜像 tag 的健康检查会失败。
下一项实验要做什么
在有三台主机的 inventory 中,让其中只有一台失败,用标记文件确认默认行为是否真的让其余两台继续。然后分别测量,加上 ignore_errors 会有什么不同,用 failed_when 校正标准会有什么不同。用 block/rescue/always 搭建回滚结构,看到 rescued 列增加,再用 assert 在前面拦截前提。然后用 any_errors_fatal 和 max_fail_percentage 以两种方式设置“其他主机怎么处理”,确认结果如何分歧,再加入一台连接不上的主机,看 unreachable 与 failed 有何不同。最后编写读取 PLAY RECAP 并对数字求和的工具,把六次运行整理成一份报告。