本节解决 TaskHub 发布的最后一道关:滚动更新时,正在处理的请求不能被拦腰截断,新副本就绪前不能切走流量。
前两节把 TaskHub 部署起来了。但部署只是开始,每次发布都要经历一次滚动更新。如果这个过程没设计好,用户会在每次发布时看到一批 502——而这些错误恰恰发生在你最不希望出问题的时刻。这一节把「优雅停机」和「滚动更新」讲透,并用本机 docker stop 的真实数据证明它确实有效。
本机无 Kubernetes 集群,清单未 apply 验证。 但优雅停机的行为(收到 SIGTERM 后在途请求是否跑完、readiness 何时翻转)我完整地用
docker stop复现了,输出是真实的。
15.3.1 滚动更新的默认行为会丢请求
Kubernetes 滚动更新的朴素理解是「起一个新 Pod,删一个旧 Pod」。但删旧 Pod 时,实际发生的是两个异步事件:
- 控制平面开始从 Service 的 Endpoints 里摘除这个 Pod;
- 同时给容器进程发 SIGTERM。
这两件事不是同时完成的。 摘除 Endpoints 要经过 kube-proxy 更新 iptables/IPVS 规则,在集群规模大时可能耗时几秒。而进程收到 SIGTERM 会立刻开始退出——如果它不等待,那么在这几秒的窗口里:
- Endpoints 还没更新完,新请求仍在往这个 Pod 上打;
- 但进程已经开始关闭,或者已经退出了;
- 结果就是
connection refused或 502。
这就是「滚动更新丢请求」的根因:流量还在来,进程已经走了。
15.3.2 一条正确的时间线
要让更新不丢请求,需要把这条时间线排对:
t0 控制平面决定删 Pod
t0 从 Endpoints 摘除(异步,需时间传播)
t0 发送 SIGTERM 给容器
t0 preStop 钩子开始执行 ← 关键:这里「拖延」一段时间
t0+Δ preStop 完成,才真正发 SIGTERM 给主进程
t0+Δ 主进程开始排空:停止接收新连接,等在途请求完成
t0+Δ+ε 进程退出
t0+Δ+ε 若超过 terminationGracePeriodSeconds,SIGKILL 强杀
关键在两个地方:
preStop钩子制造一个「缓冲期」,让 Endpoints 的摘除有时间传播到所有节点。这段时间里进程还活着、还能正常响应——即使有漏网请求打进来也不会失败。- 主进程在排空期结束前不能被 SIGKILL,所以
terminationGracePeriodSeconds必须大于preStop 时长 + 排空时长。
15.3.3 preStop 钩子:用 sleep 填补摘除延迟
preStop 最经典的用法就是睡一小会儿:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
看起来「什么都不做只睡觉」很傻,但它解决的问题非常实在:给 Endpoints 摘除争取 5 秒传播时间。这 5 秒里,Pod 仍然健康、仍然在 Endpoints 里可能被命中、也仍然能正确响应。等 5 秒后主进程才开始关闭,此时绝大多数节点上的转发规则已经更新,新流量不会再打进来。
distroless 镜像里没有 /bin/sh(第 14 章),所以这个写法在 TaskHub 上跑不了。替代方案有两种:
# 方案一:用 HTTP 端点做 preStop(需要服务端配合实现 /shutdown 端点)
lifecycle:
preStop:
httpGet:
path: /shutdown
port: http
# 方案二:把「延迟」做进程序本身(TaskHub 的选择)
# 主进程收到 SIGTERM 后先 sleep 一小段,再开始 Shutdown
TaskHub 用的是方案二——程序里收到 SIGTERM 后先等 2 秒(给 Endpoints 传播),再调 srv.Shutdown()。这样就不依赖镜像里有 shell,与 distroless 天然兼容。前面 15.1 节实测的排空时间线,那个「2 秒窗口」就是这一步。
15.3.4 terminationGracePeriodSeconds 与排空预算
spec:
terminationGracePeriodSeconds: 30
这个字段的含义是:从发 SIGTERM 到 SIGKILL 之间,最多等多久。默认 30 秒。它必须覆盖整个关闭流程:
terminationGracePeriodSeconds > preStop 时长 + 最长在途请求耗时 + 余量
TaskHub 的实际配置:preStop(或程序内延迟)2 秒 + 最慢接口 5 秒 + 余量 = 设 30 秒,绰绰有余。
这个值设小了会怎样? 如果 terminationGracePeriodSeconds: 5,而 preStop 就睡了 5 秒,那么主进程还没来得及排空就被 SIGKILL——在途请求全被截断。这个错误非常隐蔽,因为日志里看到的是「Pod 正常终止」,只有监控上的 502 尖峰才会暴露。
Go 侧的对应配置是 srv.Shutdown(ctx) 的超时:
shutCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
logger.Error("shutdown", "err", err)
}
这个 15 秒必须小于 terminationGracePeriodSeconds 减去 preStop 时长。三者是一条链:K8s 给的总预算 → 程序排空预算 → 单个请求超时预算。任何一环超了,就会丢请求。
15.3.5 实测:在途请求跑完才退出
用一个 3 秒的慢接口验证。先发起请求,0.5 秒后发 SIGTERM(docker stop 发的就是 SIGTERM):
docker run -d --name th15 -p 28100:8080 --stop-timeout 30 taskhub15:1
curl "http://127.0.0.1:28100/slow?ms=3000" & # 后台发起慢请求
sleep 0.5
docker stop -t 30 th15 # t=0.5s 发 SIGTERM
客户端拿到的结果:
slow_request http=200 time=3.016064s
在 SIGTERM 到达后 2.5 秒,这个请求依然完整跑完并返回了 200,客户端耗时 3.016 秒——一分没少,没有被截断。
服务端日志把时间线记得清清楚楚:
{"time":"...12.303","level":"INFO","msg":"request start","path":"/slow","ms":3000}
{"time":"...12.871","level":"INFO","msg":"SIGTERM received, draining"}
{"time":"...15.304","level":"INFO","msg":"request done","path":"/slow"}
{"time":"...15.446","level":"INFO","msg":"shutdown complete","inflight":0}
读法:请求在 12.303 开始;12.871 收到 SIGTERM 开始排空;请求在 15.304 正常结束(没有被中断);15.446 排空完成、进程退出,inflight: 0 说明退出时没有任何在途请求残留。
配合 15.1 节测到的「排空期间 ready=503 live=200」,整条链路就闭合了:
t≈0.6s ready=503 live=200 ← Endpoints 摘除,新流量不再进来
t≈1.4s ready=503 live=200 ← 在途请求继续处理
t≈2.2s ready=000 live=000 ← 排空完成,进程退出
readiness 先翻、在途请求跑完、最后才退出——这正是「零丢请求滚动更新」需要的全部行为。
15.3.6 Deployment 的滚动策略
spec:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
两个参数决定更新期间的副本数:
| 参数 | 含义 | 影响 |
|---|---|---|
maxSurge | 最多超出期望副本数的 Pod 数 | 越大更新越快、越费资源 |
maxUnavailable | 最多可用的副本数下限(可少几个) | 设为 0 = 全程不减少可用副本 |
maxUnavailable: 0 是「零丢请求」的关键配置。 它保证更新过程中可用的 Pod 数永远不少于 replicas,配合 readiness 探针,新 Pod 没就绪就不会被计入可用数、也不会接收流量。
代价是更新会慢一些,而且需要足够的集群资源(因为要先起新 Pod 再删旧的,maxSurge 决定了能多起几个)。
一个常见的错误配置是 maxUnavailable: 1, maxSurge: 0——这是「先删后建」,更新期间可用副本数会短暂降到 replicas - 1。如果此时恰好有节点故障,可用副本可能不够,就会丢请求。除非资源极度紧张,否则推荐 maxSurge: 1, maxUnavailable: 0。
15.3.7 PodDisruptionBudget:主动驱逐也不能太少
滚动更新是「主动」的,但集群还有「被动」的 Pod 消失:节点维护、升级、缩容时,Kubernetes 会**驱逐(evict)**节点上的 Pod。PodDisruptionBudget(PDB) 限制这类驱逐,保证任何时候都有足够的副本在跑:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: taskhub-api
namespace: taskhub
spec:
minAvailable: 2
selector:
matchLabels:
app.kubernetes.io/name: taskhub-api
minAvailable: 2 表示「任何时刻至少 2 个 Pod 可用」。节点维护时,如果驱逐会导致可用数低于 2,驱逐会被阻塞(直到有新 Pod 起来)。没有 PDB,一次节点维护可能把 3 个副本里的 2 个同时驱逐,剩下 1 个扛全部流量——高峰期直接过载。
PDB 和 maxUnavailable: 0 是互补的:前者管「被动驱逐」,后者管「主动滚动」。
15.3.8 连接层的三个细节
时间线排对了,还有几个连接层面的细节会让「优雅」打折:
一、Shutdown 会拒绝新连接,但会等活跃连接。 srv.Shutdown(ctx) 的行为是:关闭监听套接字(不再接受新连接)、等待已建立且正在处理请求的连接结束。它不会强行关闭正在处理请求的连接——这正是我们要的。但注意它也不会等待空闲的 keep-alive 连接,这些连接会被直接关闭。
二、keep-alive 连接上的「最后一个请求」是漏网之鱼。 客户端复用一个已建立的 HTTP/1.1 长连接发请求时,可能在进程刚收到 SIGTERM 的瞬间发出。如果连接在 Shutdown 开始前就已建立,这个请求仍会被处理——好在 Shutdown 会等它跑完。真正的风险是「连接还在、但 Shutdown 已经开始」的竞态:Shutdown 关闭监听后,已有连接上若又来了新请求,Go 会处理它,但如果客户端反复复用连接发请求,这个窗口会一直存在。
三、排空期主动禁用 keep-alive。 让客户端尽快换到新副本:
ready.Store(false) // 先让 readiness 失败,摘掉流量
srv.SetKeepAlivesEnabled(false) // 关掉长连接复用,逼客户端重连到其他副本
顺序很重要:先翻 readiness,再禁 keep-alive,最后 Shutdown。如果顺序反了,客户端在连接被断开时会因为「以为连接还能用」而重试到同一个正在关闭的实例,反而增加失败率。
这些细节在单容器测试里看不出来(本机验证的是一个请求从开始到结束的完整过程),只有在真实的负载均衡 + 长连接客户端场景下才会暴露,属于「知道比不知道好」的储备知识。
15.3.9 滚动更新清单
| 关注点 | 配置 | 不做的后果 |
|---|---|---|
| 摘除延迟 | preStop sleep 或程序内延迟 | Endpoints 未更新就退出,502 |
| 排空预算 | terminationGracePeriodSeconds > preStop + 最慢请求 | 在途请求被 SIGKILL |
| 程序排空 | srv.Shutdown(ctx) 带超时 | 进程直接退出,连接被断 |
| 就绪信号 | readiness 探针 + 关闭时置 503 | 流量打到正在关闭的实例 |
| 更新策略 | maxSurge: 1, maxUnavailable: 0 | 更新期间可用副本减少 |
| 被动驱逐 | PodDisruptionBudget | 节点维护时副本骤减 |
| 探针参数 | failureThreshold × periodSeconds 合理 | 误杀或故障恢复慢 |
三条最实在的建议:
- 时间预算要从 K8s 一路算到单个请求。
terminationGracePeriodSeconds→preStop→srv.Shutdown超时 → 单请求超时,四层必须逐层递减。任何一层倒挂,就会丢请求。 maxUnavailable: 0是零丢请求的前提,但需要资源支撑。如果集群资源紧张,宁可让maxSurge小一点、更新慢一点,也别牺牲可用性。- 发布前用真实压测验证一遍。滚动更新期间打持续流量,观察 5xx 是否为零——这是唯一能证明「真的没丢请求」的方法。本机只能验证单请求场景,集群级别的滚动更新验证本机未实测。
小结
- 滚动更新丢请求的根因:Endpoints 摘除是异步的,而 SIGTERM 是即时的,两者有时间差。
preStop钩子制造缓冲期,让摘除有时间传播;distroless 无 shell,改用程序内延迟或 HTTP 钩子。terminationGracePeriodSeconds必须大于preStop 时长 + 最慢在途请求,否则 SIGKILL 会截断请求。- Go 侧
srv.Shutdown(ctx)的超时必须小于 K8s 给的排空预算,四层时间预算逐层递减。 - 实测:SIGTERM 到达后 2.5 秒,在途的 3 秒请求仍完整返回
http=200 time=3.016s,inflight: 0干净退出。 - 实测排空期间
ready=503 live=200,Endpoints 摘除与进程存活解耦。 maxSurge: 1, maxUnavailable: 0保证更新期间可用副本不减,是零丢请求的关键。- PodDisruptionBudget 限制节点维护时的被动驱逐,与滚动策略互补。
- 集群级滚动更新验证本机未实测(无集群),仅有单容器 SIGTERM 场景的真实数据。
至此,TaskHub 从一个能跑的 Go 服务,变成了一个能在 Kubernetes 上稳定运行、可自动伸缩、发布不丢请求的生产系统。但「部署上线」不是终点——下一章我们讲发布之后的运维:CI/CD 流水线、灰度与蓝绿发布、告警与 on-call 值班手册。
阅读导航:上一节:15.2 ConfigMap/Secret 与 HPA · 下一节:16.1 CI/CD 流水线 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。