健康检查端点是运维决策的一项输入,不是证明服务一切正常的通用证书。一个只返回 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 只用于本机测试,不是建议直接上线的健康服务;真实部署仍应依据实际框架和消费者契约验收。
延伸阅读
来源
- Kubernetes:存活、就绪与启动探针,不同决策、严格依赖与重启风险,已核验 2026-09-09。
- Google SRE:监控分布式系统,内部观测与用户可见的黑盒检查,已核验 2026-09-09。
- RFC 9111,第 5.2.2.5 节的 no-store 响应指令及其限制,已核验 2026-09-09。
- Python HTTP 服务,线程化本地实验与不建议生产使用的警告,已核验 2026-09-09。
- Python socketserver,服务关闭和请求线程生命周期,已核验 2026-09-09。