Go 服务健康检查与探针设计:Kubernetes 下 Liveness、Readiness 与 Startup 实践

本文深入讲解 Kubernetes 环境下 Go 服务的健康检查与探针设计,包括 Liveness、Readiness、Startup 三种探针的区别与配置方法,Go 健康检查端点的实现细节、常见配置误区,以及适合生产环境的探针参数建议与演进路径。

探针不是”返回 200″那么简单

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

Go 服务健康检查与探针设计:Kubernetes 下 Liveness、Readiness 与 Startup 实践

问题出在哪里?出在探针设计上。很多人把健康检查理解成”加一个 /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/

(0)
上一篇 1天前
下一篇 24分钟前

相关推荐