用 Ansible uri 模块轮询健康检查端点:until 重试与 failed_when 分级判定

滚动发布或批量重启之后,很多团队会用一个 sleep 任务加一次 curl 探测来「确认服务起来了」。这种写法在测试环境勉强能用,到了生产环境就会暴露三个问题:探测太早,服务还在初始化,任务直接失败;探测太晚,流水线白白空转几十秒;服务确实挂了却没有明确的重试策略,失败点分散在多个 task 里,很难定位到底是谁把服务拖垮的。Ansible 的 uri 模块

滚动发布或批量重启之后,很多团队会用一个 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 秒间隔已足够,退避主要用于探测外部第三方依赖。