在生产环境中运行 Kubernetes 集群时,很多开发者都遇到过这样的问题:滚动更新时出现短暂的 502 错误、数据库连接没有正确关闭导致连接池耗尽、批处理任务在节点驱逐时被意外中断。这些问题的根源往往指向同一个被忽视的细节——容器的优雅终止(Graceful Termination)机制没有正确配置。
本文将从 Kubernetes Pod 终止的底层流程出发,深入剖析 preStop 钩子、terminationGracePeriodSeconds、readinessProbe 三者如何协作实现零停机部署,并给出生产环境中的完整配置方案和常见排错路径。

一、Pod 终止的完整生命周期:比你想象的更复杂
当你在 Kubernetes 中执行
1 | kubectl delete pod |
或触发滚动更新时,集群并不会立刻杀死容器。它遵循一套精心设计的终止流程,理解这套流程是配置零停机部署的基础。
1.1 终止流程的七个阶段
一个 Pod 从收到删除请求到完全消失,会经历以下步骤:
- 客户端(kubectl 或控制器)向 API Server 发送 DELETE 请求
- API Server 将 Pod 的
1metadata.deletionTimestamp
设为当前时间,Pod 进入 Terminating 状态
- kubelet 在节点上检测到 Pod 进入 Terminating,开始执行本地终止流程
- 端点控制器从所有 Service 的 Endpoints 中移除该 Pod 的 IP
- kubelet 并行执行所有容器的
1preStop
钩子(如果配置了)
- preStop 钩子执行完毕后(或超时后),kubelet 向容器发送 SIGTERM 信号
- 等待
1terminationGracePeriodSeconds
(默认 30 秒),如果容器还没退出就发 SIGKILL 强制杀死
这里的关键在于:步骤 4(从 Endpoints 移除)和步骤 5/6(preStop + SIGTERM)是并行执行的,而非串行。这是一个极其重要却常被误解的细节。
1.2 并行执行带来的竞态条件
正因为 Endpoints 移除和容器收到 SIGTERM 是并行的,所以存在一个时间窗口:Pod 已经收到 SIGTERM 开始关闭了,但一些客户端的连接可能还在通过旧的 Endpoints 信息发往这个 Pod。这就是滚动更新时出现 502 错误的根本原因。
解决这个竞态条件的标准方案就是使用
1 | preStop |
钩子引入一个延迟,让 Pod 在收到 SIGTERM 后先 sleep 一小段时间,等 Endpoints 更新完全传播到所有客户端后再真正开始关闭应用。我们稍后会给出完整配置。
二、preStop 钩子:优雅终止的第一道防线
1 | preStop |
是 Kubernetes 提供的生命周期钩子(Lifecycle Hook)之一,在容器被终止之前同步调用。它的执行时间包含在
1 | terminationGracePeriodSeconds |
的宽限期内,因此 preStop 执行时间加上应用自身优雅关闭的时间不能超过这个宽限期,否则容器会被强制杀死。
2.1 preStop 的两种执行模式
preStop 钩子支持两种类型的处理程序:
- exec:在容器的命名空间内执行一条命令,退出码 0 表示成功
- httpGet:向容器内指定的端口和路径发起 HTTP GET 请求,2xx/3xx 状态码视为成功
最经典的用法是用 exec 执行一段 sleep,为 Endpoints 更新争取时间:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34 apiVersion: apps/v1
kind: Deployment
metadata:
name: web-server
spec:
replicas: 3
selector:
matchLabels:
app: web-server
template:
metadata:
labels:
app: web-server
spec:
terminationGracePeriodSeconds: 60
containers:
- name: nginx
image: nginx:1.25
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- "sleep 15 && nginx -s quit"
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
ports:
- containerPort: 8080
这个配置的工作流程是:Pod 被删除时,kubelet 先执行
1 | sleep 15 |
,15 秒后执行
1 | nginx -s quit |
让 Nginx 优雅关闭(等待已接受的连接处理完毕)。与此同时,Endpoints 控制器已经将这个 Pod 从 Service 的端点列表中移除,15 秒的延迟足以让所有客户端的 kube-proxy/iptables 规则更新完毕。
2.2 用 httpGet 钩子触发应用内部清理
对于有内置 shutdown 端点的应用(如 Spring Boot Actuator),可以用 httpGet 钩子触发应用内部的优雅关闭逻辑:
1
2
3
4
5
6 lifecycle:
preStop:
httpGet:
path: /actuator/shutdown
port: 8080
scheme: HTTP
Spring Boot 收到这个请求后会开始关闭 ApplicationContext、释放数据库连接、完成进行中的请求。这种方式比 exec 更适合 Java 应用,因为 JVM 的关闭钩子可以充分利用 Spring 的生命周期管理。
2.3 preStop 钩子执行的可靠性保证
Kubernetes 对 preStop 钩子的执行有严格保证:
| 特性 | 说明 |
|---|---|
| 同步阻塞 | preStop 执行完成前,kubelet 不会发送 SIGTERM |
| 超时保护 | 总执行时间不超过 terminationGracePeriodSeconds |
| 失败不重试 | preStop 失败不会阻止终止流程,kubelet 仍会继续 |
| 独立于就绪探针 | preStop 期间 Pod 已经不在 Endpoints 中,就绪探针不再有意义 |
需要特别注意的是:preStop 钩子失败不会阻止容器终止。如果钩子命令返回非零退出码,kubelet 会记录一个事件但仍然继续发送 SIGTERM。因此在 preStop 中执行的命令应该尽量简单可靠。
三、terminationGracePeriodSeconds:宽限期的正确设置
1 | terminationGracePeriodSeconds |
控制 kubelet 等待容器优雅退出的最长时间,默认 30 秒。这个值需要同时满足两个约束:足够长让应用完成清理,又不能太长影响滚动更新的速度。
3.1 宽限期计算的数学模型
假设你的应用需要满足以下条件才能安全关闭:
- 最长活跃请求处理时间:
1T_request
(例如 10 秒)
- 数据库连接池清理时间:
1T_db
(例如 3 秒)
- 消息队列 ack 时间:
1T_mq
(例如 2 秒)
- Endpoints 传播延迟:
1T_ep
(通常 5-10 秒)
那么宽限期至少应该设置为:
1 terminationGracePeriodSeconds = T_ep + max(T_request, T_db + T_mq) + buffer
代入示例值:宽限期 = 10 + max(10, 5) + 5 = 25 秒,可以设为 30 秒(默认值刚好够用)。但如果你的应用有长轮询连接或大文件上传,T_request 可能长达 60 秒以上,这时就需要调大宽限期。
3.2 不同场景下的推荐配置
| 应用类型 | preStop | 宽限期 | 说明 |
|---|---|---|---|
| 无状态 Web 服务 | sleep 5-15s | 30s | 等 Endpoints 更新即可 |
| 长连接应用(WebSocket/gRPC) | 发送关闭通知 + sleep | 60-120s | 需要等客户端主动断开 |
| 消息消费者 | 停止消费 + 等当前消息处理完 | 60s | 确保消息不丢失 |
| 数据库代理/中间件 | 拒绝新连接 + drain | 30-60s | 等连接池自然耗尽 |
| 批处理 Job | 通常不配 preStop | 设为 Job 的 timeout | Job 有自己的超时机制 |
四、零停机部署的完整配置方案
将 preStop 钩子、terminationGracePeriodSeconds 和 readinessProbe 三者配合使用,才能实现真正的零停机滚动更新。下面是一个生产级别的完整配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83 apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
namespace: production
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # 滚动更新时最多多出 1 个 Pod
maxUnavailable: 0 # 滚动更新时允许的不可用 Pod 数为 0
selector:
matchLabels:
app: api-gateway
template:
metadata:
labels:
app: api-gateway
spec:
terminationGracePeriodSeconds: 60
containers:
- name: gateway
image: registry.example.com/api-gateway:v2.4.1
ports:
- containerPort: 8080
name: http
# 就绪探针:决定 Pod 是否接收流量
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
successThreshold: 1
# 存活探针:决定 Pod 是否需要重启
livenessProbe:
httpGet:
path: /health/live
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# 生命周期钩子:优雅终止的核心
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- |
# 1. 标记自身为不健康,让 readinessProbe 快速失败
touch /tmp/shutdown.flag
# 2. 等待 Endpoints 更新传播
sleep 10
# 3. 通知应用开始优雅关闭
curl -X POST http://localhost:8080/internal/shutdown
# 4. 等待应用完成关闭
sleep 5
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
# 优雅终止的环境变量
env:
- name: SHUTDOWN_GRACE_PERIOD
value: "45"
- name: MAX_REQUEST_DURATION
value: "30"
4.1 maxSurge 和 maxUnavailable 的配合
零停机部署的关键配置在于
1 | maxUnavailable: 0 |
,这保证滚动更新期间始终有足够的 Pod 处于 Ready 状态来处理流量。配合
1 | maxSurge: 1 |
,集群会先创建一个新 Pod,等它 Ready 后再删除一个旧 Pod,如此循环。
但要注意:如果
1 | maxUnavailable: 0 |
且新 Pod 一直无法 Ready(比如镜像拉取失败或健康检查不过),滚动更新会卡住。这时需要配合 PodDisruptionBudget 和合理的超时设置来避免死锁。
4.2 应用侧需要配合的代码
preStop 钩子只是外部的编排层面控制,应用代码本身也需要正确处理 SIGTERM 信号。以下是一个 Go 语言的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68 package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type GracefulServer struct {
server *http.Server
wg sync.WaitGroup
}
func (gs *GracefulServer) Shutdown(ctx context.Context) error {
// 1. 停止接受新连接
if err := gs.server.Shutdown(ctx); err != nil {
return err
}
// 2. 等待所有进行中的请求完成
gs.wg.Wait()
// 3. 关闭数据库连接、刷新缓存等
cleanupResources()
return nil
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health/ready", readyHandler)
mux.HandleFunc("/health/live", liveHandler)
mux.HandleFunc("/api/", apiHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
gs := &GracefulServer{server: srv}
// 启动信号监听
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
log.Printf("Received signal: %v, shutting down...", sig)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
if err := gs.Shutdown(ctx); err != nil {
log.Printf("Shutdown error: %v", err)
os.Exit(1)
}
log.Println("Shutdown complete")
os.Exit(0)
}()
log.Println("Server starting on :8080")
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}
五、常见问题与排错指南
5.1 滚动更新时出现 502/连接拒绝
根因:Pod 收到 SIGTERM 后立即关闭了监听端口,但 kube-proxy 的 iptables 规则还没更新,仍有流量发往已关闭的 Pod。
解决方案:添加 preStop sleep 延迟。延迟时间取决于集群规模——集群越大,iptables 规则传播越慢。通常 5-15 秒足够,大型集群(500+ 节点)可能需要 20 秒以上。
5.2 preStop 钩子执行超时被杀
根因:preStop 执行时间 + 应用关闭时间超过了
1 | terminationGracePeriodSeconds |
。
排查方法:
1
2
3
4
5 # 查看 Pod 终止事件
kubectl get events --field-selector involvedObject.name=<pod-name> --sort-by='.lastTimestamp'
# 关注这类事件信息
# "Container preStop hook failed: context deadline exceeded"
解决方案:调大 terminationGracePeriodSeconds,或优化 preStop 脚本使其更快完成。注意 preStop 中的 sleep 时间要和宽限期留出足够余量给应用自身的关闭流程。
5.3 Java 应用 SIGTERM 后没有优雅关闭
根因:JVM 对 SIGTERM 的默认行为是执行 shutdown hook 后退出,但如果应用注册了
1 | sun.misc.Signal |
自定义处理器,或者使用了
1 | -XX:+UseContainerSupport |
但容器内 PID 1 不是 JVM(比如用了 shell 启动脚本),SIGTERM 可能无法正确传递。
解决方案:
- 确保容器入口直接启动 JVM,而不是通过 shell 脚本包装
- 如果必须用 shell 脚本,用
1exec java ...
让 JVM 成为 PID 1
- 设置
1-XX:MaxRampUpPeriod=0
加快预热,减少关闭时活跃请求
- 使用 Spring Boot 的
1server.shutdown=graceful
配置
1
2
3
4
5
6 # application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
5.4 节点驱逐时 Pod 被强制杀死
当节点维护(
1 | kubectl drain |
)触发 Pod 驱逐时,如果 Pod 没有配置 PodDisruptionBudget,驱逐请求可能不尊重 terminationGracePeriodSeconds。实际上 drain 操作确实会尊重宽限期,但如果使用了
1 | --force --delete-emptydir-data |
参数,Pod 会被立即删除。
最佳实践是为关键服务配置 PDB:
1
2
3
4
5
6
7
8
9 apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: api-gateway-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: api-gateway
这样在节点维护时,调度器会保证至少 2 个 Pod 始终可用,驱逐操作会等待 Pod 优雅终止后再继续。
六、生产环境检查清单
最后,给出一套生产环境部署前的完整检查清单,确保你的应用能够优雅终止:
| 检查项 | 验证方法 | 通过标准 | ||
|---|---|---|---|---|
| 应用处理 SIGTERM | 本地
测试 |
应用在宽限期内优雅退出,日志显示关闭完成 | ||
| preStop 钩子配置 | 查看 Deployment YAML | 有 sleep 或 drain 逻辑 | ||
| 宽限期合理 | 计算公式验证 | 大于 preStop + 应用关闭时间之和 | ||
| readinessProbe 配置 | 查看 Deployment YAML | 有 /health/ready 端点,failureThreshold 合理 | ||
| 滚动更新策略 | 查看 Deployment YAML | maxUnavailable: 0 | ||
| PDB 配置 | kubectl get pdb | 关键服务有 PDB,minAvailable 合理 | ||
| 容器 PID 1 正确 | docker inspect / kubectl exec | PID 1 是应用进程而非 shell | ||
| 连接池 drain | 应用日志 | 关闭时日志显示连接池已清空 |
总结
Kubernetes 的容器优雅终止机制看似简单,实际涉及 API Server、kubelet、端点控制器、kube-proxy 多个组件的协作。要实现真正的零停机部署,需要从三个层面同时着手:
- 编排层:配置合理的 preStop 钩子引入延迟,让 Endpoints 更新先于应用关闭传播完毕
- 应用层:正确处理 SIGTERM 信号,实现优雅关闭逻辑,确保 PID 1 是应用进程本身
- 策略层:设置 maxUnavailable: 0 和 PodDisruptionBudget,保证滚动更新和节点维护期间的可用性
三者缺一不可。只配 preStop 而应用不处理 SIGTERM,容器收到信号后直接退出,sleep 的延迟就被浪费了;应用处理了 SIGTERM 但没有 preStop,Endpoints 还没更新完流量就涌入了正在关闭的 Pod。只有三者协同,才能构建出真正具备生产可用性的零停机部署体系。
建议在 CI/CD 流水线中加入终止行为的自动化测试——在预发环境模拟滚动更新,持续向服务发送请求并监控错误率,确保优雅终止配置在每次代码变更后仍然有效。
汤不热吧