Go 微服务优雅上下线:PreStop Hook 和 Readiness Probe 的协作

📅 2026/7/9 3:34:48 👁️ 阅读次数 📝 编程学习
Go 微服务优雅上下线:PreStop Hook 和 Readiness Probe 的协作

Go 微服务优雅上下线:PreStop Hook 和 Readiness Probe 的协作

一、每次发布都有人投诉"刷着刷着就报错了"

微服务滚动更新时的常见事故:
用户正在浏览商品列表,页面报 502。
刷新后正常了。
运维查看日志,发现是 Pod 被杀死时还有连接在处理。

另一个常见事故:新 Pod 已经就绪,开始接收流量。
但内部的数据库连接池还没初始化完毕。
前几秒的请求全部超时。
这就是没有区分"进程启动了"和"服务就绪了"。

Kubernetes 提供了一套优雅上下线机制。
但默认配置下,它做得并不好。
terminationGracePeriodSeconds 默认 30 秒。
但 PreStop Hook 默认是空的。
Readiness Probe 默认也没有。

二、Pod 生命周期的关键阶段

Kubernetes 的 Pod 生命周期有两个关键过渡点。
就绪阶段:容器启动了,但还没准备好服务。
终止阶段:容器收到 SIGTERM,需要优雅退出。

sequenceDiagram participant K as K8s API participant P as Pod participant S as Service/Endpoint participant L as LoadBalancer Note over K,L: ---- 启动阶段 ---- K->>P: 创建 Pod P->>P: Init Container 执行 P->>P: 主容器启动 Note over P: Readiness Probe 不断检测 P-->>K: Readiness 通过 K->>S: 加入 Endpoints S->>L: 开始分发流量 Note over K,L: ---- 终止阶段 ---- K->>P: 发送 SIGTERM P->>P: 🔑 执行 PreStop Hook P->>S: 从 Endpoints 摘除 Note over P: 等待已有请求处理完 P->>P: 关闭数据库连接 P->>K: 进程退出 K->>S: 清理 Pod 记录

关键时序:PreStop Hook 执行期间,Pod 仍在 Endpoints 上。
所以 PreStop Hook 的首要任务是:通知负载均衡别再发新请求。

三、Go 服务的优雅上下线配置

Kubernetes 配置(Deployment)

apiVersion: apps/v1 kind: Deployment metadata: name: order-service spec: replicas: 3 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: terminationGracePeriodSeconds: 45 # 留足时间优雅退出 containers: - name: app image: order-service:v1.2.0 ports: - containerPort: 8080 # 就绪探针:服务准备好后才接收流量 readinessProbe: httpGet: path: /healthz/ready port: 8080 initialDelaySeconds: 5 # 启动后 5 秒开始探测 periodSeconds: 3 # 每 3 秒探测一次 failureThreshold: 3 # 连续失败 3 次标记未就绪 # 存活探针:检测进程是否健康 livenessProbe: httpGet: path: /healthz/live port: 8080 initialDelaySeconds: 15 periodSeconds: 10 failureThreshold: 3 # 优雅停机前置操作 lifecycle: preStop: exec: command: - /bin/sh - -c - | # 1. 通知应用开始优雅关闭 curl -X POST http://localhost:8080/admin/shutdown # 2. 等待流量摘除(sleep 5-10 秒) sleep 10

Go 服务端实现

package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "sync" "sync/atomic" "syscall" "time" ) type Server struct { httpServer *http.Server ready atomic.Bool healthy atomic.Bool shutdowning atomic.Bool activeConns int64 wg sync.WaitGroup } func NewServer(addr string) *Server { s := &Server{} s.healthy.Store(true) // 进程启动即健康 mux := http.NewServeMux() // 就绪检查 mux.HandleFunc("/healthz/ready", func(w http.ResponseWriter, r *http.Request) { if s.ready.Load() && !s.shutdowning.Load() { w.WriteHeader(http.StatusOK) w.Write([]byte("ready")) return } w.WriteHeader(http.StatusServiceUnavailable) w.Write([]byte("not ready")) }) // 存活检查 mux.HandleFunc("/healthz/live", func(w http.ResponseWriter, r *http.Request) { if s.healthy.Load() { w.WriteHeader(http.StatusOK) w.Write([]byte("alive")) return } w.WriteHeader(http.StatusServiceUnavailable) }) // 管理接口:触发优雅关闭 mux.HandleFunc("/admin/shutdown", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } s.shutdowning.Store(true) w.Write([]byte("shutting down")) log.Println("收到关闭信号,标记为未就绪") }) // 业务接口 mux.HandleFunc("/api/order", func(w http.ResponseWriter, r *http.Request) { if s.shutdowning.Load() { w.WriteHeader(http.StatusServiceUnavailable) return } // 追踪活跃连接数 atomic.AddInt64(&s.activeConns, 1) defer atomic.AddInt64(&s.activeConns, -1) // 模拟业务处理 time.Sleep(100 * time.Millisecond) w.Write([]byte(`{"status":"ok"}`)) }) s.httpServer = &http.Server{ Addr: addr, Handler: mux, } return s } // Start 启动服务,返回就绪通知 Channel func (s *Server) Start() <-chan struct{} { readyCh := make(chan struct{}) go func() { // 模拟初始化:连接数据库、加载配置等 log.Println("初始化中...") time.Sleep(3 * time.Second) // 初始化完毕,标记就绪 s.ready.Store(true) close(readyCh) log.Println("服务就绪,开始接收流量") if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("服务启动失败: %v", err) } }() return readyCh } // GracefulShutdown 监听系统信号,优雅关闭 func (s *Server) GracefulShutdown(shutdownTimeout time.Duration) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) sig := <-sigCh log.Printf("收到信号: %v,开始优雅关闭", sig) // 1. 标记未就绪,拒绝新请求 s.shutdowning.Store(true) s.ready.Store(false) // 2. 等待活跃连接处理完(最多等 shutdownTimeout) waitStart := time.Now() for atomic.LoadInt64(&s.activeConns) > 0 { if time.Since(waitStart) > shutdownTimeout-5*time.Second { log.Println("等待超时,强制关闭") break } time.Sleep(500 * time.Millisecond) } // 3. 关闭 HTTP 服务 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.httpServer.Shutdown(ctx); err != nil { log.Printf("HTTP 关闭异常: %v", err) } log.Println("优雅关闭完成") } func main() { s := NewServer(":8080") readyCh := s.Start() // 等待就绪 <-readyCh // 阻塞直到收到终止信号 s.GracefulShutdown(30 * time.Second) }

四、常见陷阱与避坑指南

PreStop Hook 的 sleep 时间需要实测。
sleep 太短,负载均衡还没摘除流量。
sleep 太长,Pod 终止时间超过 terminationGracePeriodSeconds。
一般设为 5-10 秒,取决于服务网格刷新频率。

Readiness Probe 的 initialDelaySeconds 不可省略。
如果不设延迟,Pod 一启动就开始探测。
而数据库连接池可能还没初始化完成。
这会导致 Pod 在启动-未就绪之间反复横跳。

数据库连接池的关闭时机很关键。
必须在所有活跃请求处理完之后再关闭。
否则正在处理的事务会因连接断开而失败。

五、总结

优雅上下线依赖三个机制协同:PreStop Hook、Readiness Probe、Graceful Shutdown。
PreStop 通知应用停止接收流量并休眠等摘除。
就绪探针确保初始化完成前不接收请求。
Go 服务通过 atomic.Bool 标记状态,精确控制流量。
核心原则:先拒绝新请求,再等待旧请求完成,最后清理资源。