Go 服务健康检查实战——liveness、readiness、startup 三种探针的实现
Go 服务健康检查实战——liveness、readiness、startup 三种探针的实现
适读人群:在 K8s 上部署 Go 服务的工程师 | 阅读时长:约14分钟 | 核心价值:真正搞清楚三种探针的区别,以及在 Go 中如何正确实现每一种
从一个奇怪的故障说起
大概9个月前,我们有一个 Go 服务在 K8s 上表现很奇怪:Pod 一直处于 Running 状态,但 API 全部返回 500。
K8s 显示 liveness 探针是通过的,所以 Pod 没有被重启。但实际上服务已经"死了"——连接数据库失败,所有需要数据库的接口全挂了。
问题在于:我只配了 liveness 探针,健康检查路径是 /health,里面只返回 200 OK,一点都不检查数据库连接。
这是一个教科书级别的反面案例:liveness 和 readiness 探针配置错误,导致故障无法自愈。
三种探针的本质区别
很多人搞混这三种探针,这里先说清楚:
Liveness(存活探针)
- 回答的问题:这个容器还活着吗?是否需要被重启?
- 探针失败时:K8s 会重启容器
- 检查什么:进程是否处于死锁、无限循环等不可恢复状态
- 不应该检查:外部依赖(数据库、Redis),因为外部依赖故障会导致容器被无限重启
Readiness(就绪探针)
- 回答的问题:这个容器准备好接收流量了吗?
- 探针失败时:K8s 将 Pod 从 Endpoints 中移除(不接收流量)
- 检查什么:服务是否真正可以处理请求,包括外部依赖是否就绪
- 这是对外暴露服务能力的核心判断
Startup(启动探针)
- 回答的问题:容器是否完成了启动?
- 探针失败时:K8s 等待,超时后重启容器
- 用途:慢启动的应用(如需要加载大量数据的服务),避免被 liveness 探针过早认为是死锁
踩坑实录
坑一:Liveness 检查了数据库连接,导致数据库故障时服务被无限重启
现象: 数据库维护期间,Pod 被 K8s 反复重启,重启日志把磁盘写满了,而且因为重启中会有瞬间流量损失,导致用户体验雪上加霜。
原因: liveness 检查了 db.Ping(),数据库维护时 Ping 失败,探针失败,Pod 被重启。但重启后数据库还是不可用,于是一直循环。
解法: liveness 只检查进程自身的存活性,不检查外部依赖。把数据库连通性移到 readiness 探针里。
坑二:Startup 探针超时设太短,应用刚启动就被认为失败
现象: 新版本部署后,Pod 一直处于 CrashLoopBackOff 状态,但手动运行镜像是正常的。
原因: 新版本加了一个"启动时加载所有配置到内存"的逻辑,要跑 31 秒。而 startup 探针的 failureThreshold * periodSeconds = 10 * 3 = 30 秒,在加载完成前就超时了。
解法: 根据实际启动时间调整 startup 探针的超时,或者把启动时的慢加载逻辑改为异步(先启动服务,再后台加载数据,期间 readiness 探针返回 not ready)。
坑三:健康检查接口本身成了性能瓶颈
现象: K8s 每 5 秒检查一次健康,3 个实例 × 3 种探针 × 每 5 秒 = 每秒 1.8 次请求打到健康检查接口,但我们的 readiness 探针每次都会查数据库,这额外增加了约 1.8 QPS 的数据库查询。
解法: 对健康检查做缓存,不要每次探针请求都真的做数据库 Ping:
// 带缓存的健康检查,每 5 秒实际检查一次,减少探针带来的数据库压力
type cachedHealthChecker struct {
checkFn func(ctx context.Context) error
lastResult error
lastCheckAt time.Time
cacheDur time.Duration
mu sync.RWMutex
}完整实现
package health
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"sync"
"sync/atomic"
"time"
)
// Status 服务健康状态
type Status struct {
Status string `json:"status"` // "ok" or "degraded"
Components map[string]ComponentStatus `json:"components,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
type ComponentStatus struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
// Checker 健康检查器
type Checker struct {
db *sql.DB
readyFlag atomic.Bool // 是否就绪(可以手动控制)
lastDBCheck struct {
mu sync.RWMutex
result error
at time.Time
}
dbCacheDur time.Duration
}
func NewChecker(db *sql.DB) *Checker {
c := &Checker{
db: db,
dbCacheDur: 5 * time.Second,
}
c.readyFlag.Store(true) // 默认就绪
return c
}
// SetReady 手动控制就绪状态(优雅停机时调用)
func (c *Checker) SetReady(ready bool) {
c.readyFlag.Store(ready)
}
// LivenessHandler liveness 探针:只检查进程是否活着
// 不检查任何外部依赖
func (c *Checker) LivenessHandler(w http.ResponseWriter, r *http.Request) {
// 只要进程能响应请求,就说明它活着
// 如果进程真的卡死了,这个接口会超时,K8s 会判定探针失败
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(Status{
Status: "ok",
Timestamp: time.Now(),
})
}
// ReadinessHandler readiness 探针:检查服务是否可以处理请求
// 包括检查外部依赖
func (c *Checker) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
// 检查手动设置的就绪标志(优雅停机时会设为 false)
if !c.readyFlag.Load() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(Status{
Status: "degraded",
Components: map[string]ComponentStatus{
"app": {Status: "not_ready", Message: "service is shutting down"},
},
Timestamp: time.Now(),
})
return
}
// 检查数据库连通性(带缓存,避免每次探针都查 DB)
components := make(map[string]ComponentStatus)
allOK := true
dbErr := c.checkDBCached(r.Context())
if dbErr != nil {
components["database"] = ComponentStatus{
Status: "unhealthy",
Message: dbErr.Error(),
}
allOK = false
} else {
components["database"] = ComponentStatus{Status: "healthy"}
}
status := Status{
Components: components,
Timestamp: time.Now(),
}
if allOK {
status.Status = "ok"
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
} else {
status.Status = "degraded"
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
}
json.NewEncoder(w).Encode(status)
}
// StartupHandler startup 探针:检查应用是否完成初始化
// 通常和 readiness 检查相同,或者检查更多初始化项
func (c *Checker) StartupHandler(w http.ResponseWriter, r *http.Request) {
// startup 探针和 readiness 探针类似
// 区别在于 K8s 配置层面的超时参数不同
c.ReadinessHandler(w, r)
}
// checkDBCached 带缓存的数据库检查
func (c *Checker) checkDBCached(ctx context.Context) error {
c.lastDBCheck.mu.RLock()
if time.Since(c.lastDBCheck.at) < c.dbCacheDur {
result := c.lastDBCheck.result
c.lastDBCheck.mu.RUnlock()
return result
}
c.lastDBCheck.mu.RUnlock()
// 缓存过期,重新检查
c.lastDBCheck.mu.Lock()
defer c.lastDBCheck.mu.Unlock()
// double-check(可能另一个 goroutine 刚刚更新了)
if time.Since(c.lastDBCheck.at) < c.dbCacheDur {
return c.lastDBCheck.result
}
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
c.lastDBCheck.result = c.db.PingContext(checkCtx)
c.lastDBCheck.at = time.Now()
return c.lastDBCheck.result
}
// RegisterHandlers 注册健康检查路由
func (c *Checker) RegisterHandlers(mux *http.ServeMux) {
mux.HandleFunc("/healthz/live", c.LivenessHandler)
mux.HandleFunc("/healthz/ready", c.ReadinessHandler)
mux.HandleFunc("/healthz/startup", c.StartupHandler)
}K8s 配置
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3 # 连续3次失败才重启
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
successThreshold: 1
timeoutSeconds: 5
startupProbe:
httpGet:
path: /healthz/startup
port: 8080
failureThreshold: 30 # 最多等 30 * 5 = 150 秒完成启动
periodSeconds: 5一个小细节:探针路径不要放在鉴权中间件后面
K8s 的探针请求没有 Authorization header,如果健康检查路径被鉴权中间件保护,探针会一直返回 401,导致 Pod 被反复重启或无法接收流量。
健康检查路径要注册在鉴权中间件之前,或者明确豁免这些路径。
