探针不是”返回 200″那么简单
我见过不少团队遇到过这样一个故障模式:服务在 Kubernetes 里稳定运行了几个月,突然有一天数据库抖动了一下,紧接着所有 Pod 开始反复重启。看业务日志,服务本身没有 panic,但 liveness 探针连续失败,kubelet 认为容器已经不健康,于是杀掉重启。重启之后数据库还没恢复,探针继续失败,继续重启。直到数据库恢复,服务才重新稳定下来,但这个过程里,整个服务可能已经不可用了几分钟甚至更久。

问题出在哪里?出在探针设计上。很多人把健康检查理解成”加一个 /healthz 接口,返回 200 就万事大吉”,但 Kubernetes 里的健康检查是一套有明确语义的系统机制。你写什么端点、在端点里做什么检查、探针参数怎么配,直接决定了服务在异常时刻是被重启、被摘流量,还是被反复折腾。
这篇文章不打算从零讲一遍 Kubernetes 探针的基础概念,而是聚焦在 Go 服务的实际场景里:健康检查端点应该怎么写、liveness 和 readiness 应该怎么分工、探针参数怎么调、哪些地方最容易踩坑。
三种探针,各管一段生命周期
Kubernetes 提供三种探针,分别回答三个不同的问题。livenessProbe 回答”容器还活着吗”,失败之后 kubelet 会重启容器;readinessProbe 回答”容器能接收流量吗”,失败之后 kubelet 会把这个 Pod 从 Service 的 Endpoints 里摘掉;startupProbe 回答”容器启动完成了吗”,专门用来保护启动阶段的容器。
| 探针类型 | 回答的问题 | 失败动作 | 典型场景 |
|---|---|---|---|
| livenessProbe | 进程还活着吗 | 重启容器 | 死锁、goroutine 泄漏、OOM 后无法恢复 |
| readinessProbe | 能接收流量吗 | 从 Service 摘除 | 依赖未就绪、服务过载、正在优雅退出 |
| startupProbe | 启动完成了吗 | 重启容器 | 冷启动慢、加载模型或缓存、JIT 类应用 |
很多团队只配置了 livenessProbe,这是最常见的误区之一。liveness 失败意味着”这个 Pod 已经没救了,杀掉重启”,但有些情况服务本身并没有问题,只是依赖暂时不可用。数据库抖动、配置中心短暂超时、下游服务返回 5xx,这些都不是重启能解决的问题。如果把这些情况交给 liveness 处理,结果就是开头那个场景:Pod 反复重启,服务反而更不稳定。
readiness 探针的语义完全不同。它只决定流量要不要进来,而不是容器要不要存在。数据库没就绪,readiness 失败,Pod 继续运行,等数据库恢复,readiness 成功,流量自动恢复。整个过程不需要重启任何东西。
startupProbe 是 Kubernetes 1.16 之后引入的,解决的是启动阶段的问题。如果应用启动需要 30 秒,而 liveness 的 initialDelaySeconds 只设了 10 秒,liveness 在启动阶段就开始探测,失败一次就重启,形成一个永远启动不完的死循环。startupProbe 给启动阶段一个独立的宽限期,启动完成后自动让位给 liveness 和 readiness。
Go 健康检查端点的实现细节
Go 服务实现健康检查端点本身很简单,但越简单的东西越容易埋坑。先看一个最常见的实现:
func healthzHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}
func main() {
http.HandleFunc("/healthz", healthzHandler)
http.ListenAndServe(":8080", nil)
}
这个实现跑本地没问题,但放到生产环境至少有两个隐患。第一,它挂在 DefaultServeMux 上。服务代码一多,各种 handler、中间件都往 DefaultServeMux 上挂,健康检查请求很容易被业务中间件拦截。比如某个中间件里做了鉴权、限流或者链路追踪,探针请求带着 kubelet 的来源地址,可能就被挡在中间件外面,探针失败,Pod 被误杀。
第二,http.ListenAndServe 没有配置任何超时。Go 的 net/http 每个连接运行在独立的 goroutine 里,如果客户端建立了连接但不发送完整请求,或者响应写了一半不读完,goroutine 就会被一直占住。正常情况下这不是问题,但一旦流量异常,goroutine 数量暴涨,健康检查请求也会跟着变慢甚至超时。
一个更稳的做法,是把健康检查挂到独立的 mux 上,同时给 http.Server 配置完整的超时参数。readiness handler 的核心逻辑是:给整个检查流程设置一个 context 超时,依赖检查失败就返回 503,全部通过才返回 200。
func readinessHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := checkDatabase(ctx); err != nil {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthzHandler)
mux.HandleFunc("/readyz", readinessHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
这里有几个实践要点。不要让 liveness 去查数据库,因为数据库抖动会让 Pod 重启,而重启并不能恢复数据库。liveness 和 readiness 必须分开端点,liveness 只做最轻量的检查,readiness 才做依赖检查。每个依赖检查要单独设置超时,探针的 timeoutSeconds 是 HTTP 请求层面的超时,readiness handler 内部如果还要查数据库、查 Redis,需要给每个检查再加一层 context 超时,避免一个慢依赖拖垮整个探针。
另外,健康检查端点不要打业务日志。Kubernetes 默认每 10 秒探测一次,每个 Pod 每次成功都打一条日志的话,日志量会非常可观。只在失败时记录,或者用 metric 统计成功率。
还有一个容易被忽略的点:Go 的健康检查 handler 返回 200,并不能检测出死锁。net/http 的每个连接都是独立 goroutine,就算业务 goroutine 全部卡死,HTTP 请求依然能正常响应。所以 liveness 探针本质上只能检测进程级别的存活,检测不了业务逻辑的死锁。真要诊断死锁,得靠 pprof 的 goroutine 信息,而不是探针。
readiness 依赖检查:做多少,怎么做
readiness 探针做依赖检查是合理的,但依赖检查的深度和范围,需要想清楚。这里有一个很危险的场景:如果所有副本的 readiness 全部失败,Service 的 Endpoints 会被清空,流量打过来直接 502/503。更麻烦的是,如果服务本身有降级能力,比如缓存兜底、静态配置兜底,依赖挂了还能返回降级响应,但 readiness 一失败,降级响应也发不出来了。健康检查反而扩大了故障面。
一个经验法则:readiness 探针检查的是”服务有没有能力处理请求”,而不是”服务的所有依赖都健康”。
所以 readiness 依赖检查的设计,需要结合业务的实际降级能力。我建议用三个标准来判断一个依赖该不该进 readiness:
- 这个依赖挂了,服务还能不能提供有意义的响应?如果不能,加入检查;如果能降级,不要加入。
- 这个依赖的抖动频率有多高?如果它经常抖动,加入 readiness 会让服务频繁摘挂,对调用方反而更不友好。
- 摘掉流量之后,恢复的成本有多高?如果依赖恢复后服务还需要长时间预热,那就要谨慎,宁可保留部分流量也不全部摘除。
还有一点,readiness handler 里的依赖检查项不能太多。探针的 timeoutSeconds 是固定的,串行检查 5 个依赖,每个依赖只能分到几百毫秒。检查项太多,要么超时,要么探针周期被迫拉长。所以依赖检查要控制数量,优先挑最核心的 2 到 3 个,或者并行发起检查。
探针参数配置:默认值只是一个起点
探针参数是另一个容易被忽视的地方。很多人直接复制一份配置,initialDelaySeconds、periodSeconds、timeoutSeconds、failureThreshold、successThreshold 这些参数的含义和相互影响,没有仔细想过。
| 参数 | 默认值 | 作用 | 生产环境建议 |
|---|---|---|---|
| initialDelaySeconds | 0 | 容器启动后多久开始第一次探测 | 根据应用启动时间设置,一般 10 到 30 秒 |
| periodSeconds | 10 | 每隔多久探测一次 | 5 到 10 秒,不要低于 1 秒 |
| timeoutSeconds | 1 | 单次探测的超时时间 | liveness 保持 1 秒,readiness 建议 3 到 5 秒 |
| failureThreshold | 3 | 连续失败多少次才触发动作 | 3 到 5 次,避免网络抖动误杀 |
| successThreshold | 1 | 连续成功多少次才算恢复 | 一般保持 1 次 |
这几个参数之间有很强的关联。timeoutSeconds 决定单次探测能等多久,failureThreshold 决定连续失败多少次才触发动作,periodSeconds 决定多久探测一次。三个参数放在一起,意味着从第一次失败到真正触发重启,中间有一个延迟窗口。这个窗口要大于网络抖动的持续时间,否则一次短暂的网络抖动就会误杀 Pod。
还有一个常见误区:给 liveness 设置很长的 timeoutSeconds。liveness 的目的是快速判断进程是否存活,超时太长意味着进程已经卡死,但 kubelet 还在等响应,白白延迟了重启动作。liveness 的 timeoutSeconds 建议保持 1 秒,readiness 因为要做依赖检查,可以放宽到 3 到 5 秒。
启动阶段的保护是另一件容易被忽略的事。一个 Go 服务启动时要加载配置、初始化数据库连接池、预热缓存,冷启动可能需要 20 到 40 秒。如果 liveness 的 initialDelaySeconds 设成 10 秒,服务还没初始化完,liveness 就开始探测,一旦超时触发重启,重启后又要重新初始化,形成死循环。这时候应该用 startupProbe 给启动阶段一个独立的宽限期:
startupProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 30
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
startupProbe 的 failureThreshold 设成 30,periodSeconds 设成 5 秒,相当于允许最长 150 秒的启动时间。startupProbe 一旦成功,liveness 就开始接管,之后按正常的 liveness 规则来。
健康检查的演进路径
健康检查从简单到成熟,通常要经历几个阶段。第一阶段,只加一个 /healthz,返回 200。这个阶段适合本地开发和联调环境,上生产会很快遇到问题。第二阶段,区分 liveness 和 readiness,分别挂 /healthz 和 /readyz,readiness 检查核心依赖。这一步做好,绝大多数服务就能稳定运行了。第三阶段,引入 startupProbe 解决启动慢的问题,给 http.Server 配置完整的超时参数,readiness 依赖检查开始精细化。第四阶段,健康检查与优雅退出配合。
优雅退出是一个容易被忽略的联动场景。Pod 被删除时,kubelet 会发 SIGTERM,然后等待容器退出。如果 readiness 探针还在返回 200,Service 的 Endpoints 里依然有这个 Pod,流量还是会打进来。正确的做法是在收到 SIGTERM 时主动把 readiness 置为失败,让流量先摘干净,再开始优雅退出。或者用 preStop hook 加一段 sleep 时间,给 Endpoints 更新留出余量。
小结
健康检查的本质,是让 Kubernetes 知道你的服务在什么状态下可以被信任。liveness 回答”要不要重启”,readiness 回答”要不要接流量”,startupProbe 回答”启动期怎么保护”。这三个问题分开设计,比在同一个端点里做所有检查要清晰得多。
Go 服务的实现上,保持检查逻辑轻量、给每个依赖单独设置超时、不要把 liveness 和业务逻辑混在一起、不要用 DefaultServeMux 承载健康检查,这些都是从生产环境里反复踩坑总结出来的经验。
如果你的服务现在只有一个 /healthz,不妨先把它拆成 /healthz 和 /readyz,再根据服务的实际启动时间和依赖情况调整探针参数。这个改动不大,但能避免很多”服务明明活着,流量却进不来”的诡异问题。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/473/