K8s 滚动更新每次发布 30 秒 5xx 毛刺持续 18 个月的 3 天复盘:readinessProbe + preStop + minReadySeconds 四因素叠加 + 零停机发布完整模板

每次 K8s Deployment 滚动发布都给监控看板捐 30 秒 5xx 红色,持续 18 个月没人当回事。3 天复盘揪出 readinessProbe initialDelaySeconds 30 秒、preStop 缺失、minReadySeconds=0、SIGTERM 直接断流四因素叠加根因,5 种修法 + K8s/ECS/Nomad/Heroku/Swarm/Knative 横向对比 + 决策树 + 8 条零停机发布工程纪律,5xx 毛刺压到 0。

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 的标准流程:

  1. Pod 状态变为 Terminating
  2. Endpoint Controller异步把 Pod IP 从 Service 的 Endpoints 移除
  3. 同时 kubelet 给容器发 SIGTERM 信号
  4. 容器内进程收到 SIGTERM 后开始 graceful shutdown
  5. 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 看着土,但效果惊人。流程变成:

  1. Pod 状态 Terminating,Endpoint Controller 开始移除 IP
  2. kubelet 执行 preStop:sleep 15(15 秒内 kube-proxy 同步完成,新请求不再来)
  3. preStop 结束,发 SIGTERM
  4. 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 都报警,推动持续改进。

给读者的几条自查清单

  1. 下次发布时同步看 5xx 监控,有任何毛刺都说明发布流程有问题。
  2. 检查 Deployment yaml 的 readinessProbe.initialDelaySeconds,> 5 秒的基本都是反模式。
  3. 检查有没有 preStop hook。没有的服务,发布期 5xx 概率 80%。
  4. 测试一下 SIGTERM:本地 docker run 服务,kill -TERM <pid>,看进程是立即退出还是优雅退出。立即退出的服务上 K8s 必定有问题。
  5. 检查 /ready 端点的实现,只 return 200 的等同于没有 readiness 检查。
  6. 跑一次"破坏性测试":发布期间用 ab 或 k6 持续打 100 QPS,看错误数。这是验证"发布质量"的标准动作。
  7. 检查 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

K8s 集群偶发 5 秒延迟的 3 天复盘:ndots:5 + glibc 双栈 + conntrack race 五因素叠加 + NodeLocal DNSCache 落地

2026-5-26 11:57:48

技术教程

LLM 客服流式输出被 Nginx + Cloudflare + uvicorn + HTTP/2 四层代理悄悄变成批量的 3 天复盘:SSE 链路全栈优化 + 零缓冲发布模板

2026-5-26 12:07:26

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索