健康端点是一份会驱动后续动作的契约。处理器能够返回 200,并不证明实例可以完成有价值的业务请求。写代码之前应先明确观察者要据此做什么:等待初始化、接入新流量,还是重启一个无法自行恢复的进程。问题不同,需要的证据就不同,不能把它们压成一个名字叫“健康”的布尔值。
区分进程恢复和流量准入
存活检查应该对应重启能够改善的本地故障,例如无法继续工作的死锁。共享数据库不可用,通常不能靠把所有调用方重启一遍来修复。就绪检查回答这个实例现在是否适合接收它负责的流量,只纳入这条业务路径必需的依赖;可选的推荐功能失败,不应自动把仍能结算的实例整个摘出流量池。
启动检查保护耗时初始化不被过早判定为故障。在 Kubernetes 中配置 startup probe 后,存活和就绪探针要等启动探针成功才运行。就绪失败影响流量准入;启动或存活探针连续失败达到配置容忍度后,才可能结合重启策略触发重启。单独收到一份 503,并不代表重启、摘流或恢复已经在控制面完成。
状态契约必须以真实消费者为准。Kubernetes 的 HTTP 探针把大于等于 200、小于 400 的响应视作成功,所以误接入登录跳转的探针可能显示绿色。下面的三个 GET 健康路由刻意只返回 200 或 503,不靠正文里的“正常”字样决定健康,也不允许通过自动跟随重定向掩盖原始结果。
给探针引入的工作设预算
选择便宜、只读而且确实代表关键依赖的操作。取得连接或者完成一次小请求,只能证明那条具体路径,不代表所有业务事务都成功。内部期限应短于外层调用者的超时,并把取消信号传到下游;响应体和计时器都要释放。外层提前返回错误、内部请求却继续排队和执行,并不算真正限制了探针成本。
总负载还取决于实例数量、独立观察者数量和轮询频率。不要每次探测都展开整张依赖图,也不要让健康检查抢光业务恢复所需的连接。如果项目已有后台检查器,可以复用其结果,但必须定义结果年龄和失败含义,不能把很久以前的绿色无限期当成当前事实。中间缓存同样不能把旧成功响应发给负载均衡,让已经失败的实例继续被接入。
坏掉的样子
永远返回成功的端点,会在数据库断连、工作池耗尽或初始化尚未完成时继续放行流量;相反,把共享依赖故障挂到存活检查上,会让整批实例进入无效重启循环。探针自己也可能消耗稀缺连接,或者因容忍阈值过低,在短暂暂停时同时摘掉过多容量。应当用实际失败场景证明状态含义,而不是仅仅证明路由可以访问。
测试至少覆盖未启动、依赖明确失败、依赖不响应、主动排空以及恢复。本设计中的存活路由不能被共享依赖拖着失败。排空首先禁止新流量准入,之后才能等已有请求结束;但一个健康端点本身不会等待负载均衡传播,也不会替应用完成连接排空。公开响应保持简短,不返回内部依赖地址、凭据或堆栈,详细原因放在受控的运维观测中。
快速验证
保存下面程序为 health_probe.go,运行 go run health_probe.go。两个回环服务分别模拟应用及其唯一必需依赖,依赖返回 204 才算通过。应用通过一百毫秒的子 context 限制下游请求,外层测试客户端允许两秒;启动与排空由内存标志表示,不会改动真实服务、负载均衡或集群配置。
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var mode, dependencyCalls atomic.Int64
var started, draining atomic.Bool
canceled := make(chan struct{}, 1)
dependency := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dependencyCalls.Add(1)
switch mode.Load() {
case 1:
w.WriteHeader(503)
case 2:
<-r.Context().Done()
canceled <- struct{}{}
default:
w.WriteHeader(204)
}
}))
defer dependency.Close()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
w.WriteHeader(405)
return
}
ready := false
switch r.URL.Path {
case "/livez":
ready = true // No shared-dependency restart policy.
case "/startupz":
ready = started.Load()
case "/readyz":
if started.Load() && !draining.Load() {
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
request, err := http.NewRequestWithContext(ctx, "GET", dependency.URL, nil)
must(err)
response, err := client.Do(request)
if err == nil {
ready = response.StatusCode == 204
must(response.Body.Close())
}
}
default:
w.WriteHeader(404)
return
}
if !ready {
w.WriteHeader(503)
return
}
w.WriteHeader(200)
}))
defer server.Close()
probe := func(method, route string, status int, calls int64) {
before := dependencyCalls.Load()
request, err := http.NewRequest(method, server.URL+route, nil)
must(err)
response, err := client.Do(request)
must(err)
_, readError := io.Copy(io.Discard, response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1" && response.StatusCode == status, "wrong probe status")
check(response.Header.Get("Cache-Control") == "no-store", "probe response must not be cached")
check(dependencyCalls.Load() == before+calls, "unexpected dependency work")
if status == 405 {
check(response.Header.Get("Allow") == "GET", "method contract missing")
}
fmt.Printf("%s: %d, dependency calls=%d\n", route, status, dependencyCalls.Load())
}
probe("GET", "/livez", 200, 0)
probe("GET", "/startupz", 503, 0)
probe("GET", "/readyz", 503, 0)
started.Store(true)
probe("GET", "/startupz", 200, 0)
probe("GET", "/readyz", 200, 1)
mode.Store(1)
probe("GET", "/livez", 200, 0)
probe("GET", "/readyz", 503, 1)
mode.Store(2)
probe("GET", "/readyz", 503, 1)
select {
case <-canceled:
fmt.Println("deadline canceled the stalled dependency request")
case <-time.After(time.Second):
panic("dependency did not observe cancellation")
}
mode.Store(0)
probe("GET", "/readyz", 200, 1)
draining.Store(true)
probe("GET", "/readyz", 503, 0)
probe("GET", "/livez", 200, 0)
draining.Store(false)
probe("GET", "/readyz", 200, 1)
probe("POST", "/readyz", 405, 0)
probe("GET", "/unknown", 404, 0)
check(dependencyCalls.Load() == 5, "only five readiness checks should query the dependency")
fmt.Println("PASS: fourteen probes; live, starting, unready, stalled, draining, and recovered")
fmt.Println(runtime.Version())
}
十四次探测只产生五次依赖请求。启动前,startup 和 readiness 返回 503,liveness 仍为 200;初始化完成后 readiness 变为成功。依赖明确返回 503 时,只影响就绪结果,不影响存活。依赖停止响应时,子期限使就绪检查返回 503,依赖服务确实观察到请求取消;接下来一次健康请求成功,证明失败之后仍能恢复,而不是残留一个占着连接的检查。
排空标志开启时,就绪返回 503,而且根本不访问依赖,存活仍保持成功。清除实验中的标志后就绪恢复。POST 被独立判为 405,未知路由返回 404,这些结果不会被混入依赖状态。断言同时核对响应码、调用计数和取消事件,避免只有文字输出却没有验证条件的实验。
以上结果已在 macOS、Go 1.27.0 下运行通过,但未验证 Kubernetes 的重启阈值、EndpointSlice 传播、真实连接池耗尽或生产负载。这个实验中的 livez 只证明 HTTP 处理器能响应,并不能检测另一个业务工作池是否死锁;真实应用仍需提供符合自身不可恢复故障的存活条件。最终还要让已配置的观察者执行同样场景,才能确认它真的采取了预期动作。
延伸阅读
来源
- Kubernetes:存活、就绪和启动探针,探针动作、启动门控和 HTTP 成功状态,已核验 2026-09-09。
- Go context,期限传播和取消清理,已核验 2026-09-09。
- Go net/http,请求上下文、响应生命周期和跳转策略,已核验 2026-09-09。