滚动发布或批量重启之后,很多团队会用一个 sleep 任务加一次 curl 探测来「确认服务起来了」。这种写法在测试环境勉强能用,到了生产环境就会暴露三个问题:探测太早,服务还在初始化,任务直接失败;探测太晚,流水线白白空转几十秒;服务确实挂了却没有明确的重试策略,失败点分散在多个 task 里,很难定位到底是谁把服务拖垮的。Ansible 的 uri 模块配合 until 与 failed_when,可以把「轮询—等待—判定—报告」压缩到一个结构清晰的 task 里,并且区分「还没就绪」和「确定故障」两类结果。
本文给出可直接落地的写法:用 uri 请求健康检查端点,用 until 控制重试节奏,用 failed_when 把 HTTP 状态码与响应体一起纳入判定,再通过 register 输出的变量做分级报告。所有示例都基于 Ansible 2.14 以上版本实测可运行,涉及模块参数均以官方文档为准。
先把探测语义定清楚:什么算就绪,什么算故障
健康检查端点通常分两类语义:liveness(存活)与 readiness(就绪)。liveness 只回答「进程还活着吗」,readiness 回答「我现在能接流量吗」。发布后轮询的对象应当是 readiness,因为它会在依赖(数据库连接池、下游 RPC、配置热加载)尚未完成时返回非 200。
常见的端点约定如下表所示。注意 Spring Boot Actuator 在整体状态为 DOWN 时默认返回 503,而不是 200,这一点决定了重试逻辑必须能区分「503 但还在启动」与「503 且已彻底失败」。
| 端点 | 正常返回 | 未就绪典型返回 | 说明 |
|---|---|---|---|
/actuator/health |
200 + {"status":"UP"} |
503 + {"status":"OUT_OF_SERVICE"} |
Spring Boot 默认聚合,包含 db/redis 等组件 |
/actuator/health/liveness |
200 | 503 | 需开启 management.endpoint.health.probes.enabled=true |
/actuator/health/readiness |
200 | 503 | 同上,适合发布后轮询 |
/healthz |
200 + ok |
404 / 502 / 连接拒绝 | 自研服务的常见简化实现 |
/nginx_status |
200 + 文本 | 403 | Nginx stub_status,需 allow 来源白名单 |
由此得出判定规则:连接层失败(DNS 解析失败、连接拒绝、超时)与 5xx 属于「可能还在启动」,可以重试;401/403 属于配置错误,重试一万次也不会成功,应立即失败;200 但响应体里 status 不是 UP,同样视为未就绪。这套规则后面的 failed_when 会逐条映射成表达式。
最小可用骨架:uri + until + failed_when 三件套
先看核心 task。它以 3 秒间隔最多轮询 20 次(约 60 秒窗口),只要返回码是 200 且响应体含 UP 就提前退出。
- name: 轮询服务就绪状态
ansible.builtin.uri:
url: "http://127.0.0.1:8080/actuator/health/readiness"
method: GET
status_code: [200, 503]
timeout: 3
return_content: true
register: hc
until: hc.status == 200 and (hc.content | from_json).status | default('') == 'UP'
retries: 20
delay: 3
changed_when: false
failed_when: >
hc.status in [401, 403, 404]
or (hc.status == 200 and ((hc.content | from_json).status | default('')) == 'DOWN')
几个参数必须解释清楚,否则很容易踩坑:
status_code决定「哪些状态码不触发模块失败」。默认值是[200],如果不把 503 加进去,第一次探测就会因为Status code was 503 and not [200]: HTTP Error 503: Service Unavailable直接判定任务失败,until根本没机会生效。这是最常见的误用。timeout是单次请求的 socket 超时,单位秒,默认 30。轮询场景应设小(2~5 秒),否则一次卡住的连接就会吃掉大半个重试窗口。return_content: true才会把响应体放进hc.content,默认只返回status、headers等元数据。retries+delay的总时长是retries × delay,并且retries是总尝试次数而非「额外重试次数」。上例最多请求 20 次,不是 21 次。changed_when: false让这个 task 永远是ok,避免污染幂等性报告;健康检查是只读操作,本就不该算变更。
注意 until 和 failed_when 的职责边界:until 决定「要不要再试一次」,failed_when 决定「这次结果是不是失败」。当一个结果既满足 until 的继续条件、又满足 failed_when 的失败条件时,任务会立即失败并且不再重试。这个优先级正是分级判定的实现基础,后面的章节会专门展开。
uri 常用参数与返回值速查
轮询健康检查时真正会用到的参数不多,但每一个都影响判定结果。下面这张表按使用频率排序,参数名与默认值均对应 ansible.builtin.uri。
| 参数 | 默认值 | 轮询场景建议 | 作用 |
|---|---|---|---|
url |
无(必填) | 用具体 IP 或 ansible_host |
请求地址,需带 scheme |
method |
GET |
保持 GET | HTTP 方法 |
status_code |
[200] |
[200, 503] |
可接受状态码列表,不在列表内则模块失败 |
timeout |
30 |
2~5 |
连接与读取超时(秒) |
return_content |
false |
true |
是否把响应体写入 content |
validate_certs |
true |
HTTPS 自签时设 false |
是否校验 TLS 证书 |
headers |
{} |
内网探针可加鉴权头 | 请求头字典 |
follow_redirects |
safe |
跨域重定向时设 all |
重定向策略,none/safe/all |
body_format |
raw |
POST 探活时设 json |
请求体序列化方式 |
返回值里可用的字段:status(int 状态码)、content(字符串响应体)、json(当响应 Content-Type 为 JSON 时自动解析,Ansible 2.9+)、headers(字典)、elapsed(请求耗时,秒,浮点)、redirected(bool)、msg(失败时的错误信息)。
优先用
hc.json.status而不是(hc.content | from_json).status。前者在响应头不是application/json时可能为None,后者在响应体为空时会抛Expecting value: line 1 column 1 (char 0)。两者都需要| default('')兜底。
failed_when 分级判定:把「未就绪」与「确定性失败」分开
单条 failed_when 表达所有规则会迅速变得不可读。更工程化的做法是:先在一次探测中收集足够信息,再用一个独立 task 做分级判定,输出 ok / pending / failed 三种结论,最后按结论路由。
第一步:探测 task 只负责取数
- name: 探测 readiness 端点(只取数,不做判定)
ansible.builtin.uri:
url: "{{ health_url }}"
method: GET
status_code: [200, 401, 403, 404, 500, 502, 503, 504]
timeout: 3
return_content: true
validate_certs: false
register: probe
until: probe.status == 200
retries: 12
delay: 5
changed_when: false
failed_when: false
ignore_unreachable: false
这里 failed_when: false 是关键:让 uri 模块即使拿到 500 或 401 也不判失败,把结果原样带回。这样一来 probe 一定带有 status,后续判定可以完全基于数据而不是基于异常。若主机网络层不可达(unreachable),uri 模块会抛 Connection failure: timed out 并进入 probe.msg,此时 probe.status 不存在,判定表达式要用 probe.status | default(0)。
第二步:分级判定
- name: 分级判定健康检查结果
ansible.builtin.set_fact:
health_verdict: >-
{{
'ok' if (probe.status | default(0)) == 200
and ((probe.json | default({})).status | default('UP')) == 'UP'
else 'fatal' if (probe.status | default(0)) in [401, 403, 404]
else 'fatal' if (probe.status | default(0)) == 200
else 'pending'
}}
- name: 确定性失败立即终止并给出可读原因
ansible.builtin.fail:
msg: >-
健康检查判定为 fatal:status={{ probe.status | default('N/A') }},
url={{ health_url }},msg={{ probe.msg | default('no message') }}。
401/403 请检查鉴权头与 allow 白名单;404 请确认端点路径与 context-path;
200 但 status 非 UP 说明依赖组件(db/redis/mq)异常,需查看应用日志。
when: health_verdict == 'fatal'
- name: 超时仍未就绪时给出诊断信息
ansible.builtin.fail:
msg: >-
健康检查在 {{ 12 * 5 }} 秒内未就绪:最后一次 status={{ probe.status | default('N/A') }},
elapsed={{ probe.elapsed | default('N/A') }}s,msg={{ probe.msg | default('no message') }}。
请确认端口监听(ss -lntp | grep 8080)、启动日志与应用依赖。
when: health_verdict == 'pending'
这套写法的收益是可观测性:失败信息里带上了状态码、耗时、URL 和后续排查命令,值班同学不用再翻 Ansible 原始输出。同时 401/403/404 这类「重试没有意义」的错误会在第一轮就终止,不会白等 60 秒。
分级判定与 until 的交互顺序
如果坚持把判定塞回同一个 task,务必记住执行顺序:模块执行 → failed_when 求值 → until 求值 → 若 until 为假则按 delay 等待后重试。也就是说 failed_when 为真会直接终止,不会进入重试。利用这一点可以写出「重试 5xx,但遇到 404 立刻死」的单 task 版本:
- name: 单 task 版分级轮询
ansible.builtin.uri:
url: "{{ health_url }}"
status_code: [200, 401, 403, 404, 500, 502, 503, 504]
timeout: 3
return_content: true
register: hc
until: hc.status == 200
retries: 15
delay: 4
changed_when: false
failed_when: hc.status in [401, 403, 404]
当端点返回 404 时,任务立即失败,错误信息为 fatal: [web01]: FAILED! => {"changed": false, "failed_when_result": true, "status": 404, ...};返回 503 时则会安静地重试,直到 15 次耗尽后报 Retries exceeded。缺点是无法自定义 msg,因此生产环境仍推荐两段式。
完整 Playbook:发布后轮询并输出结构化报告
下面是一个可以直接 ansible-playbook 运行的完整示例,包含并发控制、TLS 场景、超时兜底与最终汇总。建议保存为 healthcheck.yml。
---
- name: 发布后健康检查轮询
hosts: app_servers
gather_facts: false
serial: "25%"
max_fail_percentage: 0
vars:
health_scheme: http
health_host: "{{ ansible_host | default(inventory_hostname) }}"
health_port: 8080
health_path: /actuator/health/readiness
health_url: "{{ health_scheme }}://{{ health_host }}:{{ health_port }}{{ health_path }}"
probe_retries: 18
probe_delay: 5
probe_timeout: 3
tasks:
- name: 探测 readiness 端点
ansible.builtin.uri:
url: "{{ health_url }}"
method: GET
status_code: [200, 401, 403, 404, 429, 500, 502, 503, 504]
timeout: "{{ probe_timeout }}"
return_content: true
validate_certs: false
headers:
X-Probe-By: ansible
register: probe
until: probe.status == 200 and ((probe.json | default({})).status | default('UP')) == 'UP'
retries: "{{ probe_retries }}"
delay: "{{ probe_delay }}"
changed_when: false
failed_when: false
- name: 计算判定结果
ansible.builtin.set_fact:
health_status: "{{ probe.status | default(0) }}"
health_verdict: >-
{{
'ok' if (probe.status | default(0)) == 200
and ((probe.json | default({})).status | default('UP')) == 'UP'
else 'fatal' if (probe.status | default(0)) in [401, 403, 404]
else 'fatal' if (probe.status | default(0)) == 200
else 'pending'
}}
- name: 输出单机结果
ansible.builtin.debug:
msg: >-
host={{ inventory_hostname }} verdict={{ health_verdict }}
status={{ health_status }} elapsed={{ probe.elapsed | default('N/A') }}s
url={{ health_url }}
- name: 判定失败时终止
ansible.builtin.fail:
msg: >-
[{{ health_verdict }}] {{ inventory_hostname }} 健康检查未通过:
status={{ health_status }},elapsed={{ probe.elapsed | default('N/A') }}s,
msg={{ probe.msg | default('no message') }}。
排查建议:ss -lntp | grep {{ health_port }};
journalctl -u app -n 200 --no-pager;curl -sv {{ health_url }}
when: health_verdict != 'ok'
- name: 汇总报告(仅本地执行一次)
hosts: localhost
gather_facts: false
tasks:
- name: 生成 CSV 报告
ansible.builtin.copy:
dest: "./health-report-{{ lookup('pipe', 'date +%Y%m%d%H%M%S') }}.csv"
mode: "0644"
content: |
host,verdict,status,elapsed,url
{% for h in groups['app_servers'] %}
{% set p = hostvars[h].probe | default({}) %}
{{ h }},{{ hostvars[h].health_verdict | default('unknown') }},{{ p.status | default('') }},{{ p.elapsed | default('') }},{{ hostvars[h].health_url | default('') }}
{% endfor %}
delegate_to: localhost
run_once: true
几处值得注意的细节:serial: "25%" 让轮询批次与滚动发布节奏一致,避免一次性把连接打满;max_fail_percentage: 0 保证任意一台失败就中止整批;health_host 优先取 ansible_host,这样在跳板机场景下走的是真实管理 IP 而不是 inventory 别名。
容易忽略的是 429 Too Many Requests。如果服务前端有 WAF 或限流网关,5 秒一次的探测可能触发限流,表现为前几次 200、之后全部 429。此时应调大 probe_delay,或在 headers 里带上白名单标识,而不是简单地提高 retries。
真实报错与排查对照
下面这些报错都是在实际项目中反复出现的,按出现频率排列。每一条都给出了根因与修法。
| 报错信息(节选) | 根因 | 修法 |
|---|---|---|
Status code was 503 and not [200]: HTTP Error 503: Service Unavailable |
status_code 未包含 503 |
status_code: [200, 503] |
Retries exceeded |
until 条件始终为假,次数耗尽 |
调大 retries/delay,或检查判定表达式是否写错字段 |
Expecting value: line 1 column 1 (char 0) |
对空响应体做 from_json |
改用 probe.json | default({}) 并加 default('') 兜底 |
Connection failure: timed out |
端口未监听或安全组拦截 | 先 wait_for 等端口,或检查防火墙 |
Request failed: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED]> |
自签证书 | validate_certs: false,或把 CA 装到目标机信任库 |
HTTP Error 403: Forbidden |
Nginx allow/deny 未放行探测来源 |
在 location 中 allow 管理网段 |
unsupported parameter for module: retries |
把 retries 写进了 uri: 参数块 |
retries/delay/until 是 task 级关键字,必须与 uri: 同级 |
Invalid data passed to 'loop' |
在循环里同时用了 until 且变量互相引用 |
拆成独立 task,或改用 include_tasks |
其中 unsupported parameter for module: retries 最容易发生在从别的模块抄配置时——retries、delay、until、register、changed_when、failed_when 全都是 task 关键字,缩进必须与模块名对齐,放进模块参数里就会被当成非法参数。同理,until 里能引用的只有 register 出来的变量和事实变量,不能直接写 Jinja 表达式引用 task 名。
关于 wait_for 与 uri 的选择
如果只是等 TCP 端口起来,ansible.builtin.wait_for 更轻量:wait_for: host=127.0.0.1 port=8080 state=started timeout=60 delay=2。但它只能判断端口,无法判断应用是否真的能处理请求。实践中推荐的顺序是:先用 wait_for 等端口(快、开销小),再用 uri 轮询 readiness(准、能读到业务状态)。两段式比直接把 uri retries 拉到 60 次更快给出结果,因为连接拒绝的重试间隔往往远大于端口就绪所需时间。
- name: 等待端口监听
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
state: started
delay: 2
timeout: 60
changed_when: false
- name: 再轮询应用就绪
ansible.builtin.uri:
url: "http://127.0.0.1:8080/actuator/health/readiness"
status_code: [200, 503]
timeout: 3
return_content: true
register: hc
until: hc.status == 200
retries: 20
delay: 3
changed_when: false
调参基准与生产建议
参数不是拍脑袋定的,应当由两个数字推导:应用 P99 冷启动时间和可接受的最大阻塞时长。设冷启动 P99 为 T,则 retries × delay 至少取 1.5 × T;设流水线允许阻塞上限为 L,则 retries × (delay + timeout) ≤ L。举例:Spring Boot 应用冷启动 P99 约 40 秒,流水线允许阻塞 120 秒,取 delay: 4、timeout: 3、retries: 18,总窗口 72 秒(放大约 1.8 倍),最坏阻塞 126 秒,略超上限时可降到 retries: 16。
- 不要用固定 sleep。
pause: seconds=30在服务只花 8 秒就绪时白等 22 秒,在花了 35 秒时又直接失败。 - 单次 timeout 要小于 delay,否则请求本身就吃掉了等待窗口,实际重试次数会少于设定值。
- 批量场景下用
serial,否则 200 台机器同时每 5 秒探测一次,探测流量本身就会打垮刚起来的服务。 - 把探测结果写进报告。即使任务成功,也建议
debug或写 CSV,发布复盘时能看出「这次比上次慢了 15 秒就绪」这类趋势。 - 失败信息必须自解释。用
fail的msg把状态码、URL、耗时、排查命令一次性带出,比让值班同学去看until的原始输出高效得多。 - 区分环境超时。测试环境可以把
retries降到 3,避免 CI 在明显坏掉的服务上浪费一分钟;生产环境则按上面公式放大。
一个反直觉的细节:
until为真时任务返回ok,但它依然会在第一次就满足条件时不产生任何重试;而retries设为 1 等价于「不重试」,只剩一次探测。很多「轮询没生效」的排查,最后都落在retries: 1或忘了写register上。
小结
把健康检查写对,核心是把三件事分开:uri 负责取数(用 status_code 收编 5xx,用 timeout 控制单次开销),until 负责节奏(retries × delay 由冷启动 P99 推导),failed_when 负责结论(401/403/404 立即失败,5xx 继续等待)。生产环境推荐两段式:先 wait_for 等端口,再 uri 轮询 readiness,最后用独立 task 做 ok / pending / fatal 分级判定并输出自解释的失败信息。这样流水线既不会过早误判,也不会在确定性错误上空转。
FAQ
Q1:until 和 retries 写在 uri: 下面为什么报错?
因为它们是 task 关键字而不是模块参数。uri: 内部只接受模块文档列出的参数,其余一律报 unsupported parameter for module。把 register、until、retries、delay、changed_when、failed_when 的缩进全部提到与 ansible.builtin.uri: 同级即可。
Q2:为什么明明还在启动,任务却直接失败了?
九成是 status_code 没包含 503(或 502)。uri 默认只接受 200,其余状态码会让模块本身失败,此时 until 还没来得及求值。加上 status_code: [200, 503] 后重试才会生效。另一个可能是 failed_when 误写了 hc.status != 200,那等价于禁用重试。
Q3:连接被拒绝时 hc.status 是 0 吗?
不是「0」,而是该字段不存在,任务会以 msg 形式返回 Connection failure: ...。因此判定表达式里要用 hc.status | default(0),否则会报 'dict object' has no attribute 'status'。若希望连接失败也走重试而不是立即红色,可以设 failed_when: false 并在分级阶段把默认值映射为 pending。
Q4:hc.json 和 hc.content | from_json 该用哪个?
响应头是 application/json 时 hc.json 已经解析好,直接可用;响应头不规范(比如 text/plain)时 hc.json 为 None,需要 hc.content | from_json。但后者在响应体为空时会抛 JSON 解析错误,所以两种写法都要配 | default({}) 与 | default('') 兜底。
Q5:如何让轮询支持 HTTPS 自签证书的内网服务?
用 health_scheme: https 拼接 URL,并设置 validate_certs: false。如果安全要求必须校验,把自签 CA 通过 copy 下发到目标机并设置 validate_certs: true,同时在任务里设 environment: {REQUESTS_CA_BUNDLE: /etc/pki/ca-trust/source/anchors/internal-ca.crt},uri 模块底层的 requests 才会使用该 CA 包。
Q6:可以并行探测多台机器然后统一汇总吗?
可以,但要区分两个层次。forks(默认 5,可在 ansible.cfg 调大)控制主机级并发;serial 控制批次。若想「全部探完再统一报错」,给失败 task 加 ignore_errors: true 并在末尾用 run_once 的汇总 task 统计 hostvars 里的 health_verdict;若想「错一台就停」,保持默认行为并设 max_fail_percentage: 0。注意 ignore_errors 对 unreachable 无效,需要额外加 ignore_unreachable: true。
Q7:轮询期间可以加入退避(backoff)吗?
until 的 delay 是固定间隔,原生不支持指数退避。需要退避时改成手写循环:include_tasks 一个探测文件,在文件里用 set_fact 递增 attempt 并把 delay 设为 powers_of_2 计算值,配合 when: attempt < max_attempts 控制退出。多数健康检查场景固定 3~5 秒间隔已足够,退避主要用于探测外部第三方依赖。