TT Lab
开始
学习 学习路径 课程

Ansible 基础

出错的那一刻,什么停下,什么继续跑

在 TT Lab 中继续学习

一句话总结

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 并对数字求和的工具,把六次运行整理成一份报告。

参考文档