Go 健康检查与优雅关闭——云原生标准接入 Liveness/Readiness 探针
Go 健康检查与优雅关闭——云原生标准接入 Liveness/Readiness 探针
适读人群:Go 服务准备上 K8s 的工程师 | 阅读时长:约14分钟 | 核心价值:正确实现探针接口和优雅关闭,避免服务发版时出现请求失败
发版时偶发 502 的噩梦
我在公司主导了第一次 Go 服务上 K8s 的迁移,以为把 Docker 镜像推上去、配个 Deployment 就搞定了。
结果第一次发版就出事了——业务同事反映,发版期间有零星的 502 错误,虽然持续时间只有几秒,但刚好在业务高峰期,被抓住了。
我去查日志,发现是这样的场景:K8s 把新 Pod 拉起来,旧 Pod 被 terminate,但流量切换和 Pod 生命周期没对齐好——新 Pod 还没完全初始化完(数据库连接池还没建立、配置文件还没加载),K8s 就把流量打过来了;旧 Pod 收到 SIGTERM 信号后立刻退出,但还有几个正在处理的请求没有等完。
这两个问题,前者靠 Readiness 探针解决,后者靠优雅关闭解决。搞清楚这两件事之后,我们的发版再也没有出现过业务可感知的中断。
K8s 探针的三种类型
在实现之前,先把概念弄清楚。K8s 支持三种探针:
| 探针 | 作用 | 失败后果 |
|---|---|---|
| Liveness | 服务是否存活 | 重启容器 |
| Readiness | 服务是否能接收流量 | 从 Service Endpoint 摘除 |
| Startup | 服务是否完成初始化(防止 Liveness 误杀慢启动服务) | 在通过前屏蔽 Liveness 检查 |
我见过最多的错误是:把 Liveness 和 Readiness 配成了同一个检查逻辑。这会导致一个很诡异的现象——服务因为某个下游依赖(比如 Redis)不可用,Liveness 失败,K8s 就去重启容器,重启后 Redis 还是不可用,进入无限重启循环(CrashLoopBackOff)。
正确的做法:
- Liveness:只检查自身是否活着(能处理请求、没有死锁),不依赖外部服务
- Readiness:检查是否能正常服务(依赖的外部服务是否可用)
完整实现代码
package health
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync/atomic"
"time"
)
// HealthStatus 表示组件健康状态
type HealthStatus struct {
Status string `json:"status"` // "ok" | "degraded" | "unhealthy"
Timestamp time.Time `json:"timestamp"`
Checks map[string]Check `json:"checks,omitempty"`
}
type Check struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Latency string `json:"latency,omitempty"`
}
// Checker 是一个健康检查项
type Checker func(ctx context.Context) error
// HealthHandler 管理所有健康检查
type HealthHandler struct {
// ready 表示服务是否已完成初始化,可以接收流量
// 使用 atomic 避免并发读写问题
ready int32
// livenessCheckers:只检查自身(不含外部依赖)
livenessCheckers map[string]Checker
// readinessCheckers:包含外部依赖检查
readinessCheckers map[string]Checker
}
func NewHealthHandler() *HealthHandler {
return &HealthHandler{
livenessCheckers: make(map[string]Checker),
readinessCheckers: make(map[string]Checker),
}
}
// AddLivenessChecker 添加存活检查项(不含外部依赖)
func (h *HealthHandler) AddLivenessChecker(name string, checker Checker) {
h.livenessCheckers[name] = checker
}
// AddReadinessChecker 添加就绪检查项(含外部依赖)
func (h *HealthHandler) AddReadinessChecker(name string, checker Checker) {
h.readinessCheckers[name] = checker
}
// SetReady 标记服务已就绪
func (h *HealthHandler) SetReady(ready bool) {
if ready {
atomic.StoreInt32(&h.ready, 1)
} else {
atomic.StoreInt32(&h.ready, 0)
}
}
// LivenessHandler 处理 /healthz 请求
func (h *HealthHandler) LivenessHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
status := &HealthStatus{
Timestamp: time.Now(),
Checks: make(map[string]Check),
}
allOK := true
for name, checker := range h.livenessCheckers {
start := time.Now()
err := checker(ctx)
latency := time.Since(start)
if err != nil {
allOK = false
status.Checks[name] = Check{
Status: "unhealthy",
Message: err.Error(),
Latency: latency.String(),
}
} else {
status.Checks[name] = Check{
Status: "ok",
Latency: latency.String(),
}
}
}
if allOK {
status.Status = "ok"
w.WriteHeader(http.StatusOK)
} else {
status.Status = "unhealthy"
w.WriteHeader(http.StatusServiceUnavailable)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// ReadinessHandler 处理 /readyz 请求
func (h *HealthHandler) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
// 先检查是否已经 SetReady
if atomic.LoadInt32(&h.ready) == 0 {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{
"status": "not_ready",
"message": "service is initializing",
})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
status := &HealthStatus{
Timestamp: time.Now(),
Checks: make(map[string]Check),
}
allOK := true
for name, checker := range h.readinessCheckers {
start := time.Now()
err := checker(ctx)
latency := time.Since(start)
if err != nil {
allOK = false
status.Checks[name] = Check{
Status: "unhealthy",
Message: err.Error(),
Latency: latency.String(),
}
} else {
status.Checks[name] = Check{
Status: "ok",
Latency: latency.String(),
}
}
}
if allOK {
status.Status = "ok"
w.WriteHeader(http.StatusOK)
} else {
status.Status = "degraded"
w.WriteHeader(http.StatusServiceUnavailable)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}优雅关闭实现
package main
import (
"context"
"database/sql"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"your-project/health"
)
func main() {
// 初始化健康检查
healthHandler := health.NewHealthHandler()
// 初始化数据库连接
db, err := sql.Open("mysql", "root:password@tcp(localhost:3306)/mydb")
if err != nil {
panic(err)
}
defer db.Close()
// 注册 Liveness 检查:只检查进程自身状态,不含外部依赖
healthHandler.AddLivenessChecker("goroutine_check", func(ctx context.Context) error {
// 可以在这里检查 goroutine 泄漏、死锁等自身问题
// 简化示例:永远返回 ok(实际可加 goroutine 数量阈值检查)
return nil
})
// 注册 Readiness 检查:含外部依赖
healthHandler.AddReadinessChecker("database", func(ctx context.Context) error {
return db.PingContext(ctx)
})
// 注册 Readiness 检查:Redis(示例)
healthHandler.AddReadinessChecker("redis", func(ctx context.Context) error {
// redisClient.Ping(ctx).Err()
return nil // 示例用
})
// 业务 HTTP Server
businessMux := http.NewServeMux()
businessMux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond) // 模拟业务处理
fmt.Fprintln(w, "Hello, World!")
})
businessServer := &http.Server{
Addr: ":8080",
Handler: businessMux,
}
// 健康检查 HTTP Server(单独端口,不对外暴露)
healthMux := http.NewServeMux()
healthMux.HandleFunc("/healthz", healthHandler.LivenessHandler)
healthMux.HandleFunc("/readyz", healthHandler.ReadinessHandler)
healthServer := &http.Server{
Addr: ":8081",
Handler: healthMux,
}
// 启动服务
go func() {
if err := healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("健康检查服务启动失败: %v\n", err)
os.Exit(1)
}
}()
go func() {
if err := businessServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("业务服务启动失败: %v\n", err)
os.Exit(1)
}
}()
// 模拟初始化过程(数据库连接池预热、配置加载等)
fmt.Println("正在初始化...")
if err := db.PingContext(context.Background()); err != nil {
fmt.Printf("数据库连接失败: %v\n", err)
os.Exit(1)
}
// 初始化完成,标记就绪
// 在这一刻之前,Readiness 探针返回 503,K8s 不会把流量打过来
healthHandler.SetReady(true)
fmt.Println("服务就绪,开始接收流量")
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("收到退出信号,开始优雅关闭...")
// 步骤一:先标记不就绪,让 K8s 把流量从这个 Pod 摘除
// K8s 感知到 Readiness 失败需要一点时间,这里等几秒确保流量已切走
healthHandler.SetReady(false)
fmt.Println("已标记 Not Ready,等待流量摘除...")
time.Sleep(5 * time.Second) // 根据 K8s 探针检查间隔调整
// 步骤二:关闭业务服务,等待正在处理的请求完成
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
fmt.Println("关闭业务服务...")
if err := businessServer.Shutdown(ctx); err != nil {
fmt.Printf("业务服务关闭超时: %v\n", err)
}
// 步骤三:关闭健康检查服务
if err := healthServer.Shutdown(context.Background()); err != nil {
fmt.Printf("健康检查服务关闭超时: %v\n", err)
}
fmt.Println("服务已安全退出")
}K8s Deployment 配置示例
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-go-service
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: my-go-service:v1.0
ports:
- containerPort: 8080 # 业务端口
- containerPort: 8081 # 健康检查端口
# Startup 探针:给慢启动服务时间,防止被 Liveness 误杀
startupProbe:
httpGet:
path: /healthz
port: 8081
failureThreshold: 30 # 最多等 30 * 2 = 60 秒
periodSeconds: 2
# Liveness 探针:只检查服务是否活着
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 0 # Startup 通过后才生效
periodSeconds: 10
failureThreshold: 3 # 连续 3 次失败才重启
# Readiness 探针:检查是否能接收流量
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 2 # 连续 2 次失败就摘除流量
# 优雅终止时间:必须大于代码里的 Shutdown 超时
terminationGracePeriodSeconds: 60三个踩坑实录
坑一:Liveness 检查外部依赖,导致 CrashLoopBackOff
现象:Redis 集群做维护,短暂不可用,结果所有 Go 服务的 Pod 都进入 CrashLoopBackOff,业务完全中断。
原因:Liveness 探针里加了 Redis Ping 检查,Redis 不可用导致 Liveness 失败,K8s 重启 Pod,重启后 Redis 还不可用,无限重启。
解法:Liveness 只检查自身存活,Redis/MySQL 这类外部依赖放到 Readiness 里。Readiness 失败只是暂时把流量摘掉,不会重启 Pod。
坑二:没等流量摘除就关闭服务,请求失败
现象:发版时有少量请求返回 502/503。
原因:收到 SIGTERM 后直接调 Shutdown,但此时 K8s 还没来得及把这个 Pod 从 Service Endpoint 里摘除,新请求还在打进来。
解法:先 SetReady(false),睡几秒等 K8s 感知到 Readiness 失败并摘除流量,然后再 Shutdown。这个等待时间要大于探针的 periodSeconds。
坑三:terminationGracePeriodSeconds 太短
现象:有长耗时请求(比如大文件导出,要处理 20 秒)在发版时被强制中断。
原因:terminationGracePeriodSeconds 默认是 30 秒,K8s 等 30 秒后如果进程还没退出,直接 SIGKILL 强杀。加上流量摘除等待时间,实际留给业务的时间更少。
解法:根据业务最长请求时间设置 terminationGracePeriodSeconds,同时在代码里给 Shutdown 设合理的超时时间,超时后主动拒绝新连接但等老请求处理完。
Java 对比
Spring Boot 里做优雅关闭要配:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s然后还需要在 K8s 里用 preStop hook 补一个 sleep 来等流量摘除,因为 Spring Boot 没有内置的"标记不就绪"步骤。
Go 里我们自己控制整个生命周期,代码更冗长,但每一步都清清楚楚,不需要猜框架底层在做什么。对于线上问题排查,这种透明度非常值钱。
小结
实现探针和优雅关闭的关键点:
- Liveness 不要检查外部依赖:否则下游故障会触发你的服务无限重启
- 先摘流量再关服务:SetReady(false) + sleep,然后再 Shutdown
- terminationGracePeriodSeconds 要够大:要大于 sleep 时间 + Shutdown 超时
- 健康检查用独立端口:不要和业务端口混在一起
