2026 年 4 月某个周二中午,产品经理在群里发了一张监控截图,问了一个很有杀伤力的问题:"为什么我们网站监控里每隔几小时就有一根 30 秒的小毛刺?5xx 错误,几十个请求,没有客诉但很扎眼。"我看了一眼时间戳——12:14、14:32、16:08——和我们的 CI/CD 发布时间完全吻合。我们这个核心 API 服务每天发布 8 次,每次都给 SRE 看板捐 30 秒的红色,捐了 18 个月。所有人都默认"K8s 滚动更新嘛,正常",直到产品提出了这个问题。
接下来 3 天,我们带着平台组把 K8s Deployment 的滚动更新机制翻了个底朝天,把 5xx 毛刺压到了 0。根因不是单一配置错,而是一套"看起来都对但相互不兼容"的默认配置叠加:readinessProbe 的 initialDelaySeconds 设了 30 秒,导致新 Pod 已经能服务但还没被加进 Service;preStop hook 完全缺失,Pod 收到 SIGTERM 后立即关闭,而 kube-proxy 还在转发请求过来;minReadySeconds=0 让滚动跑得太快,新 Pod 还没真正稳定就被算"成功",老 Pod 已经被杀。这篇是完整复盘,涵盖 K8s Pod 生命周期、Service 流量切换的真实时序、零停机发布的 5 个必要条件、以及落地的《K8s 零停机发布清单》。
服务背景:这个发布频繁的 API 网关
| 维度 | 数值 |
|---|---|
| 业务 | 面向 C 端用户的 API 网关,接收 web/app 流量后转发到下游微服务 |
| 规模 | 日均请求 4500 万,QPS 1200,P99 60ms |
| 技术栈 | Go 1.22 + Gin,无状态,启动 ~ 3 秒 |
| 部署 | K8s,16 Pod,maxSurge=25%,maxUnavailable=0 |
| 发布频次 | 每天 5-10 次(业务团队 push 触发 CI/CD) |
| 事故现象 | 每次发布期间 5xx 错误约 20-40 个,持续 25-35 秒 |
| 持续时长 | 18 个月没人深究,直到产品提出 |
"每次发布 30 秒 5xx" 这种状况,如果当成"K8s 滚动更新的内在特性"接受,就会一直存在;一旦意识到"零停机发布是基础能力",就发现根本不该出现。这次产品的质疑是个很好的契机。
我们这套服务过去 18 个月的"发布抖动"被默认为"K8s 就这样",从来没人深究过。复盘的时候我特意翻了内部 wiki 和 oncall 记录,发现这个现象在 2024 年中就被记录过,标记"非紧急,持续观察",然后就一直被持续观察。这种"被持续观察"是技术债的一种典型形态——一旦贴上这个标签,问题就进入了"既不解决也不消失"的灰色地带。
事故复盘后,我们把团队的 oncall 处理规则改了一条:任何被标记"持续观察"的问题,30 天必须给出 (a) 已修复 (b) 已彻底确认无影响 (c) 升级到正式排期 三选一,不允许永久搁置。这条新规则在后续 6 个月里逼出了 17 个长期被忽略的小问题,其中 4 个被发现是潜在的中等风险事故源。
事故时间线:从产品质疑到根因落地的 3 天
| 时刻 | 事件 |
|---|---|
| 04-22 12:14 | 例行发布,5xx 毛刺出现,产品截图提问 |
| 04-22 下午 | 我手动触发一次发布,同步抓 kube-proxy 日志 + Pod 关闭日志 + 上游请求 access log |
| 04-22 18:00 | 初步发现:5xx 集中在"新 Pod 启动 0-30 秒"和"老 Pod 终止后 5 秒",两个时间窗都有问题 |
| 04-23 | 读 K8s 文档 + Endpoint Controller 源码,理解 Pod 状态变化和 Service 流量切换的时序差异 |
| 04-24 上午 | 定位四个核心问题:readinessProbe 配错、preStop 缺失、SIGTERM 处理粗暴、minReadySeconds=0 |
| 04-24 下午 | 修改 Deployment yaml + Go 代码加 graceful shutdown,预发跑 50 次发布,5xx 完全消失 |
| 04-25 | 生产上线,后续两周观察,18 个月的 30 秒 5xx 终于归零 |
第一反应:"K8s 滚动更新本来就有几秒抖动吧"
说实话,这是我和团队大多数人最初的想法。我们配的滚动更新策略是 maxSurge=25%, maxUnavailable=0——按理说不会出现"服务能力低于 100%"的状态。但实际看监控,每次发布都有几十个 5xx。一定有什么地方出了状况。
抓了一次发布的 access log,把所有 5xx 请求按时间排开:
# 错误时间分布(相对发布开始)
T+0s : 1 个 5xx (Connection refused)
T+2s : 5 个 5xx (Connection refused)
T+5s : 12 个 5xx (Connection refused + 502)
T+8s : 8 个 5xx
T+15s : 6 个 5xx
T+20s : 4 个 5xx
T+25s : 2 个 5xx
T+30s : 1 个 5xx
T+35s+ : 0
错误集中在前 30 秒,持续衰减。但有意思的是错误类型——既有 "Connection refused" 又有 502。Connection refused 是发到 Pod 但 Pod 没监听;502 是发到 Pod 但 Pod 提前关闭连接。两种错误同时出现,说明新 Pod 还没准备好接收请求和老 Pod 已经在关闭同时都在发生。
4 个原因叠加成稳定 30 秒毛刺
把 3 天的发现画成因果图,这次 5xx 毛刺是 4 个独立配置问题在同一个时间窗叠加的结果:
这张图最关键的发现是"新 Pod 太慢进流量"和"老 Pod 太快退流量"同时发生——两边都漏的话,系统在发布的 5-30 秒窗口里实际服务能力远低于配置的 100%,QPS 高的时候就会爆 5xx。任一边 fix 就能减一半,两边都 fix 才能归零。
真凶 1:readinessProbe initialDelaySeconds=30 的坑
翻 Deployment yaml,readinessProbe 配置长这样:
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30 # ❌ 这里, 等 30 秒才开始探测
periodSeconds: 5
successThreshold: 1
failureThreshold: 3
看起来合理——"给服务 30 秒启动时间,然后才检查"。但这个配置完全误解了 readinessProbe 的语义:
| 探针 | 含义 | 失败的后果 |
|---|---|---|
| livenessProbe | 判断 Pod 是否需要被重启 | 失败 → kubelet 重启 Pod |
| readinessProbe | 判断 Pod 是否能接收流量 | 失败 → Endpoint Controller 把 Pod IP 从 Service 移除 |
| startupProbe(K8s 1.20+) | 判断 Pod 是否启动成功(给 slow start 用) | 失败 → 阻塞 liveness/readiness 启动 |
关键点:readinessProbe 的 initialDelaySeconds 是"延迟开始探测",在这个延迟内 Pod 默认状态是"未就绪"。但是 K8s 的 Endpoint Controller 在 Pod 状态转为 Running 后,如果 readinessProbe 还没探测过,它把 Pod 视为 NotReady,流量不会转发——这倒是正确的。
那为什么还会有 Connection refused?因为我们的 Go 服务 3 秒就完成启动并开始 listen 8080,但 readinessProbe 要等 30 秒才探测、5 秒一次、成功 1 次后才算 Ready。也就是说从 T+3s 到 T+30s 这 27 秒里,Pod 已经能服务但 Service 不会把它加进来——这 27 秒是浪费的能力。
更要命的是,这一阶段如果有老 Pod 同时被终止(滚动更新批次推进),系统的总服务能力会临时下降。比如我们 16 个 Pod 滚动,maxSurge=25% 意味着可以多开 4 个新 Pod,如果这 4 个还没 Ready 老 4 个就被杀,实际能服务的 Pod 只有 12 个,QPS 高时直接打爆这 12 个。
正确的 readinessProbe 配置
readinessProbe:
httpGet:
path: /ready # 区分 /health(liveness) 和 /ready(readiness), 语义不同
port: 8080
initialDelaySeconds: 0 # 不延迟, 启动立刻开始探测
periodSeconds: 2 # 探测间隔短, 更快感知 ready
timeoutSeconds: 1
successThreshold: 1 # ready 一次就算
failureThreshold: 3
# 如果担心慢启动, 用 startupProbe
startupProbe:
httpGet:
path: /startup
port: 8080
failureThreshold: 30 # 最多等 30 * 2 = 60 秒
periodSeconds: 2
关键认知:readinessProbe 应该尽快开始探测,initialDelaySeconds 应该为 0 或非常小;如果服务慢启动,用 startupProbe 单独处理"启动期间"的探测,避免影响 readiness 的反应速度。/ready 端点的实现要做"真正的就绪检查"——比如能连数据库、能连下游服务、缓存预热完成,不仅是"进程在跑"。
真凶 2:preStop hook 缺失,SIGTERM 来了立刻就死
另一半错误(老 Pod 终止后的 502)源于 Pod 关闭流程。K8s 关闭一个 Pod 的标准流程:
- Pod 状态变为 Terminating
- Endpoint Controller异步把 Pod IP 从 Service 的 Endpoints 移除
- 同时 kubelet 给容器发 SIGTERM 信号
- 容器内进程收到 SIGTERM 后开始 graceful shutdown
- terminationGracePeriodSeconds(默认 30 秒)后,如果进程还没退出,发 SIGKILL
关键矛盾在第 2 步和第 3 步:"从 Service 移除 IP" 和 "SIGTERM 发送" 几乎同时发生,但 kube-proxy 同步 iptables 规则需要时间(通常 2-5 秒,大集群更长)。在这个同步窗口内,新请求仍然会被路由到正在关闭的 Pod,Pod 已经在 graceful shutdown,要么拒接、要么半挂起。
我们的 Go 服务收到 SIGTERM 后立即调用 server.Shutdown,几乎瞬间停止接收新连接——结果就是 kube-proxy 还在转发流量进来,Go 服务直接 Connection refused。
正确的关闭流程:preStop + graceful shutdown
解法是引入 preStop hook,在 SIGTERM 之前先"睡一会儿",让 kube-proxy 有时间同步 Endpoint 变化:
spec:
containers:
- name: api
lifecycle:
preStop:
exec:
command:
- /bin/sh
- -c
- "sleep 15" # 给 kube-proxy 15 秒同步 Endpoints
terminationGracePeriodSeconds: 60 # 默认 30, 我们改 60 给业务足够 graceful 时间
这个 sleep 15 看着土,但效果惊人。流程变成:
- Pod 状态 Terminating,Endpoint Controller 开始移除 IP
- kubelet 执行 preStop:sleep 15(15 秒内 kube-proxy 同步完成,新请求不再来)
- preStop 结束,发 SIGTERM
- Go 服务收到 SIGTERM,处理完手头的请求,清理资源,退出
"睡 15 秒"是经验值——大集群 kube-proxy 同步可能要 10 秒,15 秒是个安全 margin。某些用 Cilium / Calico eBPF 的集群同步更快,sleep 5 也够。
Go 服务端的 graceful shutdown
preStop 是必要条件,但应用层也要正确处理 SIGTERM:
func main() {
srv := &http.Server{Addr: ":8080", Handler: setupRouter()}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}()
// 捕获 SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
log.Println("received SIGTERM, starting graceful shutdown")
// 给 30 秒处理在途请求, 超过就强退
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
}
log.Println("shutdown complete")
}
关键:Shutdown 会停止 accept 新连接,但等所有现有连接处理完才返回。这是 Go HTTP server 的标准做法。其他语言/框架(Spring Boot 的 graceful shutdown、Express 的 close 事件)都要做类似处理。
真凶 3:minReadySeconds=0 让滚动跑得太快
K8s 滚动更新还有一个常被忽略的参数:minReadySeconds——新 Pod ready 后,要稳定多久才算"真的 ready",才可以推进下一批。默认 0 表示"ready 就立即推进"。
这个 0 的坑在于:有些服务 ready 后还要做一些"后启动"工作——比如建立到 Redis 的连接池(可能 ready 后 5 秒才建好)、JIT 编译预热、连接预热。如果 ready 后立刻被打爆流量,这些"后启动"工作会异常,导致前几秒的请求慢或失败。
我们改成:
spec:
minReadySeconds: 10 # ready 后稳定 10 秒才算"真 ready"
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0
10 秒让连接池预热、JIT 升温、可能的缓存填充都完成。新 Pod 在被打爆流量前已经"温热"。
真凶 4:Service 流量切换的真实时序
读了 K8s 源码后,我对 Service 切流的时序有了更清晰的认识。整个 rolling update 的"安全窗口"实际上由几个独立的异步过程组成:
| 步骤 | 组件 | 典型耗时 |
|---|---|---|
| Pod 状态变 Ready | kubelet 上报到 API server | ~ 100ms |
| Endpoint 列表更新 | Endpoint Controller (watch API server) | ~ 200-500ms |
| EndpointSlice 同步 | EndpointSlice Controller | ~ 200-500ms |
| kube-proxy iptables 规则同步 | kube-proxy (watch Endpoints) | 2-10 秒(大集群更长) |
| CoreDNS 缓存更新 | CoreDNS | 取决于 TTL |
| 客户端 DNS 缓存刷新 | 客户端应用 | 取决于客户端实现 |
这就是为什么"sleep 15 秒"是必要的——15 秒覆盖了 kube-proxy 同步的最坏情况,确保新请求在 SIGTERM 来之前已经不再被路由进来。
有些团队会用 service mesh(Istio/Linkerd)来"原生解决"这个问题——sidecar 直接监听 Endpoint 变化,反应时间在毫秒级。这是个真实的好处,但代价是引入 mesh 的运维复杂度。对中小集群,先用 preStop+sleep 这种简单方案足够。
顺便一提,这个 kube-proxy 同步时间长在大集群(> 500 节点)会变得更糟,因为每个 Node 上的 kube-proxy 都要 reconcile 全集群的 iptables 规则。我们测过一个 800 节点集群,kube-proxy 全量同步最长 18 秒。在这种规模下,preStop sleep 必须 20 秒以上才安全。如果你的集群规模在快速增长,建议早点切到 IPVS 或 Cilium eBPF,kube-proxy iptables 模式在 1000 节点以上基本不可用。我们去年下半年的集群迁移工作就把 kube-proxy 全部换成 Cilium,顺带把 preStop sleep 从 15 秒降到 5 秒,发布速度还快了 10 秒。
修法:Deployment 完整模板
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-gateway
spec:
replicas: 16
minReadySeconds: 10 # ✅ 新 Pod 稳定 10 秒
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 0 # ✅ 任何时候保持 100% 能力
template:
spec:
terminationGracePeriodSeconds: 60 # ✅ 给足关闭时间
containers:
- name: api
image: api-gateway:v2.18.4
ports:
- containerPort: 8080
lifecycle:
preStop: # ✅ 关键: 关闭前 sleep
exec:
command: ["/bin/sh", "-c", "sleep 15"]
readinessProbe: # ✅ 立刻开始探测
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 0
periodSeconds: 2
timeoutSeconds: 1
successThreshold: 1
failureThreshold: 3
livenessProbe: # ✅ 独立的 liveness, 间隔更长
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
startupProbe: # ✅ 慢启动用 startupProbe
httpGet:
path: /startup
port: 8080
failureThreshold: 30
periodSeconds: 2
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: 2000m, memory: 1Gi }
验证:50 次连续发布 0 个 5xx
修复后,我们让 CI/CD 在预发跑 50 次连续发布(每次 5 分钟间隔),压测客户端持续打 1000 QPS:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 每次发布期间 5xx 数量 | 20-40 | 0 |
| 每次发布期间 P99 漂移 | 60ms → 1.2s | 60ms → 80ms |
| 发布耗时 | 3 分钟 | 4 分钟(慢一点, 因为 preStop+minReadySeconds) |
| Pod 平均 ready 耗时 | 30 秒 | 4 秒 |
发布慢了 1 分钟,但对应的是从"每次 30 秒 5xx" 到 "零 5xx" 的质变。我们 4 月底 rollout 到生产,后续两周 SRE 看板上的"发布毛刺"完全消失,产品再没提过这事。
决策树:零停机发布方案怎么选
事后我们把可用方案画成决策树,给团队的 SRE 用作选型参考:
实际选型时还要考虑团队对 K8s 的熟练度。Service Mesh 看起来强大但运维复杂,小团队往往得不偿失;preStop + readiness 这套虽然"土"但稳定可靠,3 个月就能让全公司服务都升级到位。我们最后选的是后者。
横向对比:其他平台怎么处理 graceful rollout
| 平台 | 关闭通知机制 | 流量切换时机 | 需要应用配合 |
|---|---|---|---|
| K8s 默认 | SIGTERM(同步发) | 异步移除 Endpoint(~2-10 秒延迟) | 必须 preStop + graceful shutdown |
| AWS ECS | SIGTERM + stopTimeout | 先 deregister ALB target(同步等) | 只需 graceful shutdown |
| HashiCorp Nomad | SIGINT + kill_timeout | 先从 Consul 服务发现移除 | 需要 graceful shutdown |
| Heroku | SIGTERM + 30s 强杀 | 立即从 router 移除 | 需要 graceful shutdown,无需 preStop |
| 裸 Docker Swarm | SIGTERM + 10s | 异步, 时序类似 K8s | 需要 preStop + graceful shutdown |
| Knative Serving | queue-proxy 拦截 | queue-proxy 等待请求 drain | 不需要 preStop, queue-proxy 处理 |
结论:K8s 是少数将"通知关闭"和"流量切换"完全异步化的平台。AWS ECS / Heroku 把流量切换做成同步前置操作,所以应用层不用 preStop 也能零停机。K8s 选择了灵活性,代价就是开发者必须显式处理这个时序——这正是这次事故的根源。
3 天里被否决的"看似合理"方案
| 方案 | 否决原因 |
|---|---|
| 把 maxSurge 提到 100% | 资源成本翻倍, 且不解决"老 Pod 没等流量切走就死"的根本问题 |
| 客户端做大量 retry | 把责任推到调用方, 不是治本; 且 retry 会放大流量, 加剧故障 |
| 用 Blue/Green 全量切换 | 需要 2 倍资源, 且切换瞬间仍有连接断裂; 复杂度更高 |
| 禁用 readinessProbe 直接靠 livenessProbe | 错误理解 readiness, 这会让所有启动期的请求都失败 |
| 把 terminationGracePeriodSeconds 调到 5 秒 | 反向操作, 让 Pod 更快被杀, 5xx 会更多 |
| 升级到 Service Mesh 一步到位 | 引入大量复杂度, 3 天搞不定; 阶段目标应该是"先零停机, 再考虑 mesh" |
事故后 3 个月的长期收益
| 指标 | 事故前 | 修复后 3 个月 |
|---|---|---|
| 每次发布 5xx 数 | 20-40 | 0 |
| 发布期 P99 漂移 | 60ms → 1.2s | 60ms → 80ms |
| 每日发布次数 | 8 次(被毛刺劝退一些) | 14 次(团队敢于多发) |
| SRE 看板红色面积 | 每天 4 分钟红色 | ~ 0 |
| 客户投诉"系统偶发失败" | 每周 2-3 次 | 0 |
| 跟着升级的服务数 | 1 个 | 27 个核心服务全部 |
意外收获是团队对"发布"的态度变了。之前因为发布会有 5xx,团队倾向于"少发布、合并发布、晚上发布",每次发布都像个仪式。修复后发布完全无感,团队开始尝试更细粒度的 CI/CD(每个 commit 一发),代码迭代速度肉眼可见地变快。这种"基础能力解锁高级工作流"的收益,远超修复 5xx 本身。
这次复盘让我们更新的工程认知
(1) "默认配置 ≠ 推荐配置":K8s 的 Deployment 默认值是"能跑起来",不是"生产可用"。所有生产服务的 yaml 必须 review 每个字段,默认值都要质疑"在我的场景下是否合理"。我们的 helm chart 加了 lint 规则,任何缺失 preStop / minReadySeconds 的 Deployment 都不允许 helm install。
(2) "看起来无害的事故才最贵":这个 5xx 毛刺持续 18 个月,因为"没有客诉、没人重视"。事后算账:每次 20-40 个 5xx × 每日 8 次 × 18 个月 ≈ 200 万次 5xx。每个 5xx 至少损失 1 个用户操作意愿,长期下来用户体验和品牌的累计损耗远超想象。SRE 团队后来把"长期偶发性问题"列为 P3 但必须 30 天内解决,不再放任。
(3) "K8s 的复杂性主要在生命周期":K8s 调度、网络、存储这些"看起来复杂"的部分,实际上文档完善、社区资源多。真正难的是 Pod 生命周期 + 探针 + Endpoint 同步这种"看起来简单但默认值就是坑"的细节。我们把团队 K8s 培训重点改成生命周期 + 探针,而不是 CRD / Operator 这些花哨的东西。
(4) "应用层和平台层必须协同设计":零停机发布是个典型的"平台 + 应用"问题——平台提供 preStop 钩子,应用必须实现 graceful shutdown,两边任一缺失都不行。我们把这种边界问题列入"必须跨团队 review"的事项,后续 Service Mesh 升级、Sidecar 注入这些类似问题都受益。
顺手做的几件事
1. 把这个模板做成所有服务的基线
架构组把上面的 Deployment 模板做成 Helm chart,所有新服务必须基于这个 chart 部署。老服务排期 review,主要是把缺失的 preStop / minReadySeconds / 正确的 readinessProbe 补上。
2. CI 加发布质量检查
每次 CD 触发,自动跑一个简单脚本:
# 发布期间持续打 100 QPS, 持续 10 分钟
ab -t 600 -c 10 http://service/health > ab_output.log
# 解析 5xx 数量
errors=$(grep "5..\$" ab_output.log | wc -l)
if [ "$errors" -gt "0" ]; then
echo "FAIL: $errors 5xx errors during deployment"
# 通知 SRE
fi
这套机制至少能在 CD pipeline 里捕获"发布质量退化"——如果有人改了 Deployment 把 preStop 去掉,下次发布就会被这个脚本抓到。
3. /ready 端点的实现规范
所有服务的 /ready 必须实现"真实的就绪检查",不是简单 return 200:
// 错误的 readiness(只是说"我活着")
func readyHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}
// 正确的 readiness("我能服务流量")
func readyHandler(w http.ResponseWriter, r *http.Request) {
// 1. 数据库连接
if err := db.Ping(); err != nil {
http.Error(w, "db not ready", 503); return
}
// 2. 下游必要服务
if err := pingDownstream("user-service"); err != nil {
http.Error(w, "downstream not ready", 503); return
}
// 3. 关键资源加载(缓存预热、配置加载)
if !atomic.LoadInt32(&ready) == 1 {
http.Error(w, "warmup not complete", 503); return
}
w.WriteHeader(200)
}
// 在 SIGTERM 时把 ready 标记为 0, 让 K8s 提前感知
signal.Notify(quit, syscall.SIGTERM)
go func() {
<-quit
atomic.StoreInt32(&ready, 0) // 立即报告 not ready
// ... 继续 graceful shutdown
}()
这里有个非常重要的技巧:收到 SIGTERM 时主动把 readiness 设为 false。这样 K8s 探测到 not ready,会从 Endpoint 移除——这是"主动让流量走开",比单纯依赖 preStop sleep 更可靠。
立的《K8s 零停机发布清单》
- 所有 Deployment 必须有完整的 readinessProbe + livenessProbe + startupProbe,不允许只配 liveness。
- readinessProbe 的 initialDelaySeconds 必须为 0 或极小,慢启动用 startupProbe 处理。
- /ready 端点必须做真实就绪检查(连数据库、下游服务、关键资源就绪),不允许只是 return 200。
- 必须有 preStop hook:sleep 15(根据 kube-proxy 同步时间调整,最少 5 秒)。
- 必须有 graceful shutdown:捕获 SIGTERM,先把 readiness 设 false,再清理资源。
- terminationGracePeriodSeconds ≥ preStop 时间 + graceful shutdown 时间 + 安全 margin,推荐 60 秒。
- minReadySeconds ≥ 5(根据应用启动后的预热时间),不允许默认 0。
- maxUnavailable=0(任何时候保持 100% 能力);maxSurge 根据资源富余度调,推荐 25%。
- CI/CD 必须有"发布期 5xx 监控",任何 5xx 都报警,推动持续改进。
给读者的几条自查清单
- 下次发布时同步看 5xx 监控,有任何毛刺都说明发布流程有问题。
- 检查 Deployment yaml 的 readinessProbe.initialDelaySeconds,> 5 秒的基本都是反模式。
- 检查有没有 preStop hook。没有的服务,发布期 5xx 概率 80%。
- 测试一下 SIGTERM:本地 docker run 服务,kill -TERM <pid>,看进程是立即退出还是优雅退出。立即退出的服务上 K8s 必定有问题。
- 检查 /ready 端点的实现,只 return 200 的等同于没有 readiness 检查。
- 跑一次"破坏性测试":发布期间用 ab 或 k6 持续打 100 QPS,看错误数。这是验证"发布质量"的标准动作。
- 检查 kube-proxy 模式:iptables 在大集群慢,IPVS 或 Cilium eBPF 快。
这次事故是一个典型的"约定俗成的烂默认值"问题:K8s 的 Pod lifecycle 给了你足够的钩子,但默认配置对"零停机发布"完全不友好。K8s 的设计哲学是"提供原语,不做决定",这意味着你必须自己理解这些原语,自己组合出零停机发布。可惜大多数团队拿默认值上线,然后用"K8s 滚动嘛,正常"来合理化每次发布的 5xx——18 个月的 5xx,加起来其实是个挺大的数字。
另一个心得:"接受现状"是工程师最危险的态度。这次根本不是技术难度,是"愿不愿意花 3 天 fix 一个看起来'小'的问题"。这种小问题往往是技术品质的试金石——做了这个的团队,后面在故障防御、可观测性、流程严谨度上都会显著领先。所以下次再有人说"K8s 发布抖动正常",你可以微笑着甩这篇文章过去。
最后给所有还在为 K8s 发布抖动头疼的团队一个建议:这事修复成本非常低,3 天足够,且收益是永久的。把这个事项排进下个 sprint,优先级和功能开发同等——一个没有零停机发布能力的系统,后续所有 SLA 承诺、客户对账、合规审计都会被这个老大难拖后腿。早修早收益,晚修每次发布都在浪费团队和用户的时间。
事故复盘后我还做了一件事:把这篇分享给团队所有人后,鼓励大家"扫一眼自己负责的服务的 Deployment yaml,把缺失的 preStop / readiness / minReadySeconds 补上"。两周内 27 个核心服务全部完成补全,5xx 毛刺在全公司范围消失。这种"一次复盘带来全公司能力升级"的杠杆效应,是写这种长文最大的回报——希望读完这篇的你,也能在你的团队里复制同样的杠杆。如果你也修过类似的问题,欢迎在评论区留下你的踩坑经历和修法方案,大家一起把"零停机发布"这件事彻底搞定。
—— 别看了 · 2026