健康检查端点是运维决策的一项输入,不是证明服务一切正常的通用证书。一个只返回 200 的小端点并非天然没有价值;如果消费者只需要确认这条处理路径能够回答,它就能提供有限但明确的证据。问题出在把“这个处理器回答了”扩大解释为“每一类用户请求都一定成功”。

写清问题,也写清消费者

存活检查关注应用是否进入了适合通过重启恢复的状态;就绪检查关注当前实例是否应该接收其契约覆盖的流量。诊断面板可以汇总更广泛的问题,但显示一项异常,不等于应该授权调度器立即重启实例。这些信号可以相关,却不应该默认触发同一种动作。

消费者决定信号是否真正生效。一个从不访问该端点的路由器,不会因为它开始返回 503 就自动摘除实例;就绪变更也不会撤销已经到达的请求。应写明谁来轮询、什么结果算失败、哪些流量受影响,以及判断传播需要多久,避免只有端点而没有行动链路。

必需依赖不等于所有依赖

严格必需的依赖可以合理地影响就绪:如果缺少必需输入,实例确实可能只能返回错误。但是,一份允许省略附加说明的报表,不应仅因可选注释服务暂时失效就完全退出服务。判断依据应是产品仍然支持什么结果,而不是依赖清单上列了多少组件。

同一个共享依赖故障,可能让全部副本同时不就绪。摘除所有副本不会修复这个依赖,还可能一并停止本来仍可工作的其他路由。把每个后端检查串到同一个流量闸口之前,应确认服务边界和降级响应契约,而不是认为“检查越深就一定越安全”。

远程依赖故障通常不应直接进入存活判断,除非有证据表明重启本进程能修复当前状态。数据库不可用时连续重启所有应用实例,往往只是增加初始化开销,并不能让数据库恢复。存活失败对应的恢复措施,也需要作为检查设计的一部分接受验证。

信号既要廉价,也要有时效

频繁执行的存活与就绪检查,不应反复运行昂贵的业务工作。反复生成报表、扫描数据表或占用紧张的连接,可能把检查对象进一步压垮,也会让探针自身制造的负载混入观察结果。检查耗时和并发开销应有边界,不能无限借用正常请求的资源。

端点可以暴露最近观察到的依赖状态,而不必每轮都同步访问所有后端,但这会改变契约:观察结果能保存多久、过期后如何判断,都必须明确。永久缓存的绿色标记可能掩盖故障,过于激进的过期策略又可能错误地移除健康容量,二者都需要现实证据来校准。

响应应小而明确,不向外暴露凭据、内部拓扑或完整异常栈。Cache-Control: no-store 告诉遵守 HTTP 缓存规则的中间层不要保存响应,但它既不是认证机制,也不能证明端点内部使用的健康观察足够新。响应缓存时效与观测数据时效是两个不同的问题。

坏掉的样子

独立的轻量 HTTP 处理器可能继续回答,而业务工作线程早已死锁;反过来,过深的存活检查也可能把一次外部波动变成重启风暴。无论路径叫 health、live 还是 ready,名字和绿色状态都不能证明所测条件与预期恢复动作真正对应。

路径写错、认证重定向、中间层缓存和探针网络不可达,都可能产生误导结果。因此,应测试消费者实际访问的路径,而不只是在进程内部调用一次函数。过紧的超时和失败阈值,也会让只是暂时繁忙的健康实例反复被摘除,进一步增加剩余实例压力。

Google SRE 区分内部观测与面向用户行为的黑盒检查。可以合理组合两者:从外部请求一份报表,能够发现内部就绪标记遗漏的用户路径故障,但这个外部失败仍不应不经判断就变成重启命令。发现症状、判断原因和决定动作,需要保持清楚的联系。

快速验证

保存为 health_check.py,用 Python 3.10 以上版本执行 python3 health_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。真实 HTTP 服务绑定系统分配的回环端口;依赖只是注入的状态标记,并非真实数据库。实验明确约定:报表可以在缺少可选注释时继续返回基础内容。

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Lock, Thread


state = {"initialized": False, "required_ok": True, "optional_ok": True,
         "draining": False, "reports": 0}
lock = Lock()


def change(**values):
    with lock:
        state.update(values)


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def do_GET(self):
        with lock:
            ready = state["initialized"] and state["required_ok"] and not state["draining"]
            if self.path == "/livez":
                status, body = 200, "live"  # Only proves this HTTP handler can answer.
            elif self.path == "/readyz":
                status, body = (200, "ready") if ready else (503, "not-ready")
            elif self.path == "/report":
                if ready:
                    state["reports"] += 1
                    status = 200
                    body = "report+annotation" if state["optional_ok"] else "report"
                else:
                    status, body = 503, "not-ready"
            else:
                status, body = 404, "not-found"
        payload = body.encode("ascii")
        self.send_response(status)
        self.send_header("Content-Type", "text/plain; charset=utf-8")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)


server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
server.daemon_threads = False
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
thread.start()


def get(route):
    connection = HTTPConnection("127.0.0.1", server.server_port, timeout=2)
    try:
        connection.request("GET", route)
        response = connection.getresponse()
        body = response.read().decode("ascii")
        assert response.getheader("Cache-Control") == "no-store"
        return response.status, body
    finally:
        connection.close()


try:
    assert get("/livez") == (200, "live")
    assert get("/readyz") == (503, "not-ready")
    assert get("/report") == (503, "not-ready")
    change(initialized=True)
    assert get("/readyz") == (200, "ready")
    assert get("/report") == (200, "report+annotation")
    print("warm-up: live 200, ready 503; initialized: ready 200 and a report is served")

    change(optional_ok=False)
    assert get("/readyz") == (200, "ready")
    assert get("/report") == (200, "report")
    change(required_ok=False)
    assert get("/livez") == (200, "live")
    assert get("/readyz") == (503, "not-ready")
    assert get("/report") == (503, "not-ready")
    print("optional annotation loss keeps readiness; required input loss fails readiness, not liveness")

    for _ in range(20):
        assert get("/livez")[0] == 200
        assert get("/readyz")[0] == 503
    with lock:
        assert state["reports"] == 2  # Probes did not execute the business report.
    change(required_ok=True)
    assert get("/readyz")[0] == 200
    change(draining=True)
    assert get("/readyz")[0] == 503 and get("/report")[0] == 503
    assert get("/livez")[0] == 200 and get("/unknown")[0] == 404
    print("40 probes add no reports; recovery restores readiness, and drain removes it without declaring death")
finally:
    server.shutdown()  # serve_forever runs in the other thread.
    thread.join(timeout=2)
    server.server_close()
    assert not thread.is_alive()

预热期间,存活返回 200,就绪和报表返回 503;初始化完成后,报表可以正常返回。丢失可选注释只会返回契约允许的基础报表,不影响就绪;丢失必需输入则让就绪失败,但存活仍然成功。断言检查的是这些不同问题对应的实际 HTTP 响应。

之后连续四十次探测,不会增加已经生成的报表数量。恢复必需输入会恢复就绪,进入排空状态则撤下就绪并拒绝报表,而存活处理器仍能回答。每个响应都带有 no-store,访问未定义的路径得到 404,避免把任何可达路径都当成健康成功。

这个实验验证的是 HTTP 响应与已声明的判断政策,不是真实依赖检测、观察结果时效、业务死锁、路由传播或生产资源隔离。Python 的 http.server 只用于本机测试,不是建议直接上线的健康服务;真实部署仍应依据实际框架和消费者契约验收。

延伸阅读

来源