一次发布把服务全杀了:K8s 健康检查与滚动更新的坑

一次寻常的版本上线,滚动更新刚启动,Pod 就一个接一个跳进 CrashLoopBackOff,可用副本像退潮一样往下掉。可 exec 进容器手动 curl 健康接口明明是 200——没有一行业务代码出错,凶手是一段从别处抄来、从没认真理解的健康检查 YAML。从这次"配置杀人"事故出发,这篇文章把 liveness/readiness/startup 三种探针的区别、探针阈值怎么算、resources 与 OOMKilled、滚动更新参数到优雅终止,一次讲透。

那次发布,我至今记得清清楚楚。一个再寻常不过的版本上线,新镜像推上去、滚动更新启动,我盯着 kubectl get pods 的输出,本以为几分钟就能看完一轮平滑替换。结果新起来的 Pod 一个接一个地从 Running 跳到 CrashLoopBackOff,可用副本数像退潮一样往下掉,没几分钟,整个服务的健康实例就跌到了告警线以下。

诡异的是,服务本身根本没坏。我火速 kubectl exec 进到一个还活着的容器里,手动 curl localhost:8080/healthz——干净利落的 200 OK。代码是好的,接口是通的,可 Kubernetes 却在锲而不舍地把我的 Pod 一个个杀掉。kubectl describe 翻到底部,那行字像是在嘲笑我:Liveness probe failed: HTTP probe failed with statuscode: 503

真相只用了几分钟就水落石出,但它给我的教训远比那几分钟沉重:这次事故里,没有一行业务代码出错,凶手是一段我从别处抄来、从没认真理解过的健康检查 YAML。是编排层的配置,亲手杀死了一个完全健康的服务。

这篇文章,就是我把那次"配置杀人"事故复盘到底之后,整理出的一份 Kubernetes 健康检查与滚动更新避坑指南。它不堆砌 API 字段,只讲那些真的会在生产里把你的服务推下悬崖的配置陷阱。

先纠正几个关于健康检查的常见误解

动手拆解之前,先把几个我曾经深信、后来被现实教做人的误解摆出来。如果你也这么以为过,这篇文章大概率能帮你避开同一个坑。

常见误解 真相
liveness 和 readiness 是一回事,配一个就行 两者目的完全不同:liveness 决定"要不要重启",readiness 决定"要不要给它导流量",混用会出大事
探针配得越严越好,能更快发现问题 liveness 配得过于激进,会把启动慢或偶尔抖动的健康服务反复误杀,制造 CrashLoopBackOff
启动慢就把 initialDelaySeconds 调大一点 这是治标。慢启动的正解是 startupProbe,它能把启动期和运行期的检查彻底解耦
不设 resources requests/limits 也能跑 不设 limit,容器可能吃光节点内存被 OOMKilled;不设 request,调度和 QoS 都会出问题
滚动更新是 K8s 的事,默认配置就够了 默认的 maxUnavailable/maxSurge 未必匹配你的容量,配不好发布期就会容量不足
健康检查接口里把所有依赖都 check 一遍最稳妥 恰恰相反:liveness 里查下游依赖,会因为依赖抖动而把自己重启,放大故障

第一件事:三种探针,管的根本不是同一件事

这次事故的全部根源,在于我把 Kubernetes 的三种探针当成了一种东西。它们名字都带 probe,但解决的问题截然不同,搞混任意两个都会埋雷:

  • livenessProbe(存活探针):回答"这个容器是不是已经死透了、需要重启?"——探测失败,kubelet 会杀掉并重启容器。它是给"进程假死/死锁、重启能救活"准备的最后手段。
  • readinessProbe(就绪探针):回答"这个容器现在能不能接流量?"——探测失败,只是把它从 Service 的 endpoints 里摘出去、不导流量,但绝不重启。它管的是"流量开关"。
  • startupProbe(启动探针):回答"这个容器启动完成了没?"——在它成功之前,liveness 和 readiness 都被挂起、不生效。它专治"启动慢"。

用一句话区分:liveness 失败会"重启你",readiness 失败只会"不理你",startup 则是"等你启动好再说"。而我那段抄来的 YAML 犯的致命错误,就是把一个启动需要 40 秒预热的服务,直接交给了一个启动 25 秒就开始判死刑的 liveness 探针——没有 startupProbe 兜底,容器还在老老实实加载缓存,就被 kubelet 当成"死了"给杀了,重启后又是同样的剧本,于是陷入了 CrashLoopBackOff 的死循环。

下面这张图,把三种探针各自的判断和后果理清楚:

看懂这张图,就看懂了这次事故的全部:我缺了最左边那个 startupProbe 的判断节点,启动期的慢被直接喂给了运行期的 liveness,于是一条本该平滑的发布,变成了一台绞肉机。

第二件事:那段杀死服务的 YAML,到底错在哪

先看我当时抄来的那段"凶器"。乍一看人畜无害,问题全藏在数字里:

# 反例:对一个启动要 40 秒的服务,这套 liveness 是灾难
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 10   # 容器起来 10 秒后就开始探测
  periodSeconds: 5          # 每 5 秒探一次
  failureThreshold: 3       # 连续失败 3 次判定为"死"
  # 算一下:10 + 5*3 = 启动后第 25 秒,就会被杀掉重启

把这几个数字连起来算一笔账就触目惊心:容器启动后第 10 秒开始探测,每 5 秒一次,连续 3 次失败就重启——也就是说,启动后第 25 秒,只要服务还没就绪,kubelet 就会果断把它杀掉。可我的新版本要预热 40 秒的本地缓存才能对外服务,它在第 25 秒被杀时,正干得热火朝天。杀掉、重启、再预热、再到第 25 秒被杀……完美的死循环。

正确的修法,是引入 startupProbe 给慢启动兜底,让 liveness 只负责"运行期是否假死",两者各司其职:

# 正解:startupProbe 兜住启动期,liveness 只管运行期
startupProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 5
  failureThreshold: 30      # 5 * 30 = 最多容忍 150 秒启动,足够预热
  # startupProbe 成功前,下面两个探针都不生效

livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  periodSeconds: 10
  failureThreshold: 3       # 运行期连续失败才重启,不再误杀启动期

readinessProbe:
  httpGet:
    path: /ready           # 注意:就绪用单独的接口,语义不同
    port: 8080
  periodSeconds: 5
  failureThreshold: 2

关键变化是:startupProbe 给了最多 150 秒的启动宽限,在它成功之前,liveness 完全不生效——容器再也不会因为"启动慢"被误判成"死了"。等启动探针放行,liveness 才接管,而它只在运行期真正假死时才出手。把"启动慢"和"运行死"这两件本质不同的事,交给两个不同的探针,是这次事故留给我最值钱的一条经验。

第三件事:liveness 探针里,千万别去查下游依赖

事故复盘时,我还发现了一颗更隐蔽的雷,只是那天没引爆而已:我们的 /healthz 接口,为了"检查得彻底",在里面顺手 ping 了一下数据库和 Redis,任何一个不通就返回 503。这在 readiness 里也许还能接受,放在 liveness 里却是埋了一颗核弹。

// 反例:liveness 健康检查里查下游依赖 —— 危险
func healthz(w http.ResponseWriter, r *http.Request) {
    if err := db.Ping(); err != nil {       // 数据库抖一下...
        w.WriteHeader(503)                   // ...就报"我不健康"
        return
    }
    if err := redis.Ping(); err != nil {
        w.WriteHeader(503)
        return
    }
    w.WriteHeader(200)
}

想象一下:数据库出现了几秒钟的网络抖动。此时所有 Pod 的 liveness 探针同时失败,kubelet 于是把整个集群的 Pod 一起重启——可数据库抖动跟你的应用进程死没死,半毛钱关系都没有!你不但没解决问题,反而在下游最脆弱的时候,亲手把自己的整个服务也重启了一遍,把一次小抖动放大成了一场全面雪崩。

正确的原则是:liveness 探针只检查"我自己这个进程还活着、没死锁",做最轻量的自检即可,绝不碰任何外部依赖;依赖是否可用,顶多放到 readiness 里(让它在依赖不通时不接流量,但不重启)。一句话——liveness 要"自私",只关心自己的死活;把对外部世界的关心留给 readiness。

第四件事:不设 resources,迟早被 OOMKilled 偷袭

探针的坑填完,紧挨着的另一个高频翻车点是资源配置。很多人(包括当时的我们)图省事,Deployment 里压根不写 resources,觉得"能跑就行"。直到某天 kubectl describe pod 里冒出 Last State: Terminated, Reason: OOMKilled——容器因为吃内存太多,被内核连招呼都不打地干掉了。

# 给容器明确声明它要多少、最多用多少
resources:
  requests:        # 调度依据:节点至少要能腾出这么多,才把 Pod 排进来
    cpu: "250m"
    memory: "256Mi"
  limits:          # 硬上限:超过 memory limit 会被 OOMKilled;超过 cpu limit 会被限流
    cpu: "500m"
    memory: "512Mi"

这里 requestslimits 是两个完全不同的概念,务必分清:requests 是"调度承诺"——调度器据此决定把 Pod 放到哪个节点,保证节点有这么多资源留给它;limits 是"使用红线"——内存一旦越线立即 OOMKilled,CPU 越线则被限流(throttle,只是变慢不会被杀)。两者还共同决定了 Pod 的 QoS 等级,直接影响节点资源紧张时谁先被驱逐:

QoS 等级 条件 节点缺资源时
Guaranteed 每个容器 requests == limits(都设了) 最后才被驱逐,最稳
Burstable 设了 requests,但 limits 更大或缺失 居中
BestEffort requests/limits 都没设 第一个被驱逐,最危险

所以"不写 resources"不只是少了个配置,而是把 Pod 直接打成了最低优先级的 BestEffort——节点一紧张,它就是第一个被踢下车的。给每个容器认真估一份 requests/limits,既是对调度器负责,也是给自己的服务在资源争抢中买一份保险。对 JVM、Node 这类有自己堆内存概念的运行时还要额外注意:容器的 memory limit 要和运行时的堆上限对齐,否则运行时以为自己能用整台机器的内存,撑过 limit 一样被 OOMKilled。

第五件事:滚动更新参数,决定发布期会不会"容量塌方"

回到那次发布本身。即便探针都配对了,滚动更新的两个参数没配好,发布期照样可能容量不足。它们是 maxUnavailablemaxSurge:

spec:
  replicas: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1   # 更新过程中,最多允许几个副本不可用(可写百分比)
      maxSurge: 2         # 更新过程中,最多允许超出期望数几个新副本

这两个参数共同决定了滚动的"节奏"和"安全垫":maxUnavailable 控制最坏情况下你的可用容量能跌多少——设成 0,就是"必须先拉起新的、就绪了,才允许下掉老的",容量全程不打折,代价是发布稍慢、且需要 maxSurge 留出富余资源;maxSurge 控制临时能多起多少新副本,值越大滚得越快,但发布瞬间占用的资源也越多。对在线服务,我的经验是maxUnavailable: 0 + maxSurge: 1~2:宁可慢一点,也保证发布全程容量不掉线。配合前面配对的 readiness 探针(新副本真正就绪才接流量、才下老副本),才能实现真正意义上的"无损发布"。

把这次事故的排查与配置思路收个尾,下面这张决策树,是我把它沉淀成的"Pod 起不来/被杀速查表":

几条可以直接抄走的铁律

  1. 三种探针分清职责:liveness 管"重启",readiness 管"导不导流量",startup 管"启动宽限"。别用一个顶三个。
  2. 启动慢必上 startupProbe,而不是把 liveness 的 initialDelaySeconds 越调越大。
  3. liveness 只做轻量自检,绝不查下游依赖。否则依赖一抖,你会把整个集群一起重启。
  4. 每个容器都写 requestslimits,别让服务沦为最先被驱逐的 BestEffort。
  5. 容器 memory limit 要和运行时堆上限对齐(JVM/Node 尤其),否则照样 OOMKilled。
  6. 在线服务发布用 maxUnavailable: 0 + 适度 maxSurge,配对 readiness,做到容量不掉线。
  7. 抄来的 YAML 一定要把每个数字算一遍。探针阈值连乘起来,就是你的服务被判死刑的倒计时。

顺带说说:探针的三种探测方式,别用错

除了"三种探针",每种探针还能选三种探测方式,选错了同样会误判。它们各有适用场景:

# 方式一:httpGet —— 最常用,适合有 HTTP 健康接口的 Web 服务
livenessProbe:
  httpGet: { path: /healthz, port: 8080 }

# 方式二:tcpSocket —— 只看端口能不能连上,适合非 HTTP 的服务(如数据库、消息队列)
livenessProbe:
  tcpSocket: { port: 5432 }

# 方式三:exec —— 在容器里跑命令,退出码 0 即健康。最灵活但开销最大
livenessProbe:
  exec:
    command: ["sh", "-c", "pgrep myapp || exit 1"]

这里有个容易踩的坑:tcpSocket 只能证明"端口开着",却证明不了"服务真的能处理请求"——一个卡死的进程,端口可能照样监听着。所以对 HTTP 服务,优先用 httpGet 探一个真正会触达业务逻辑的轻量接口,而不是图省事用 tcpSocket 探个端口了事。而 exec 方式每次探测都要在容器里 fork 一个进程,频率高、容器多时开销不容小觑,非必要不用。探测方式的选择,本质是在"探得准"和"探得轻"之间找平衡。

那些年我对 K8s 健康检查的几个误解

误解一:"探针配得越严越好,能更快发现问题。"——这正是我那次事故的根源。探针不是越敏感越好:liveness 太激进会把启动慢、偶尔抖动的健康服务反复误杀,反而制造了本不存在的故障。健康检查的目标是"准确",不是"灵敏";宁可对真死亡的判定稍微迟钝一点,也不要把活着的服务误判成死的。

误解二:"健康接口里把数据库、缓存、下游全 check 一遍最稳妥。"——大错特错,至少在 liveness 里是灾难。把外部依赖塞进存活检查,等于把别人的故障变成自己重启的理由,会在依赖抖动时引发集群级联重启。自己的死活自己判,别人的死活别越俎代庖。

误解三:"YAML 能跑起来就说明配对了。"——能 apply、Pod 能 Running,完全不代表配置是对的。我那段致命的 liveness 在低流量、启动快的测试环境里跑得好好的,偏偏在生产的真实启动耗时下原形毕露。配置的正确性,不能用"测试环境没出事"来证明;探针的每个阈值,都要对着你服务真实的启动时间和响应特征去算。

一个延伸:Pod 下线时也要"体面",别在发布期甩出一堆 503

把视角从"Pod 怎么起来"转到"Pod 怎么下线",还藏着一个发布期常见的毛刺来源。很多人以为滚动更新时老 Pod 是"先摘流量、再被杀"的,实际上 Kubernetes 删除 Pod 时,"从 Service endpoints 摘除"和"向容器发 SIGTERM"这两件事几乎是同时发生的。这就有了一个危险的时间窗:Pod 已经开始关闭,可 kube-proxy 的转发规则还没更新完,流量仍在往这个正在死去的 Pod 上打——客户端于是收获一片 503 或连接重置。

解法是用 preStop 钩子,在收到终止信号后先睡一小会儿,给集群留出更新转发规则的时间,再让进程真正退出:

lifecycle:
  preStop:
    exec:
      # 先等几秒,让 endpoints 摘除、流量停止打过来,再开始关闭
      command: ["sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 30   # 给 preStop + 优雅退出留足总时长

这几秒的 sleep 看起来很蠢,却恰恰填上了那个时间窗:等流量真正停了,进程再退出,客户端就不会撞上"半死不活"的 Pod。配合应用内部"收到 SIGTERM 后先停止接收新请求、把在途请求处理完"的优雅退出逻辑,一次发布才能做到真正的启动无误杀、运行不雪崩、下线无毛刺一个负责任的服务,既要稳稳地起来,也要体面地退场——健康检查管前者,优雅终止管后者,两头都不能漏。

实战:CrashLoopBackOff 当场怎么查

光懂原理不够,事故现场你得有一套能立刻敲出来的命令。当一个 Pod 陷入 CrashLoopBackOff,我的排查顺序是固定的几步:

# 1) 先看 Events 和上次终止原因(OOMKilled?Liveness failed?退出码几?)
kubectl describe pod  | tail -30

# 2) 看"上一次"挂掉的容器日志 —— 注意是 --previous,当前容器可能还没起来就没日志
kubectl logs  --previous

# 3) 容器起得来但被探针误杀时,直接 exec 进去手动验证健康接口
kubectl exec -it  -- curl -i localhost:8080/healthz

# 4) 实时观察重启次数和状态变化,确认修复是否生效
kubectl get pod  -w

这套命令里,最容易被忽略却最关键的是第二步的 --previous:CrashLoopBackOff 的容器往往刚启动就挂、又被重启,你直接 kubectl logs 看到的是新一轮还没崩的容器,根本看不到崩溃现场;加上 --previous,才能调出上一个已经死掉的容器留下的最后日志——真正的死因通常就写在那里。记住这个顺序:先看 Events 定性(是被杀还是自己崩),再看 previous 日志定因,最后 exec 进去验证假设。有了它,面对一屏红色的 CrashLoopBackOff,你就不会再手足无措。

写在最后

那次事故最终的修复,改动其实小得可怜:补上一个 startupProbe,把 liveness 里查数据库的逻辑挪进 readiness,再给 Deployment 配齐 resources 和 maxUnavailable: 0。改完重新发布,Pod 平滑地一个个替换上去,可用副本数曲线稳如平地——那种眼睁睁看着实例数往下掉、自己却不知道为什么的窒息感,再也没有重演。

但这件事给我的冲击,远超过那几行 YAML。在这之前,我一直觉得"运维"和"写代码"是两回事:代码是我的本职,编排配置不过是上线前抄一抄、填一填的体力活。那次事故狠狠扇醒了我——在云原生时代,那段 YAML 和你的业务代码一样,是会真真切切决定服务生死的"代码",而且它出手时往往更狠、更隐蔽。一个 failureThreshold 的数字,杀伤力不亚于一个没关的连接、一条全表扫描的 SQL。

所以,别再把从别处复制来的 Deployment 当成黑盒了。下次粘贴一段探针配置时,请停下来花一分钟,把那几个 initialDelaySecondsperiodSecondsfailureThreshold 连乘一遍,问问自己:按这套数字,我的服务在启动到第几秒会被判死刑?这个时间,够它真正准备好吗?这一分钟的心算,可能就帮你拦下了一次本会让整个服务雪崩的发布。基础设施即代码,那它就该被当成代码一样认真对待——读懂每一行,算清每一个数,而不是闭着眼睛 apply。

如果让我把这篇文章浓缩成一个今天就能做的动作,那就是:打开你手上任意一个跑在 Kubernetes 上的服务的 Deployment,只看三件事——有没有 startupProbe 兜住启动期、liveness 里有没有偷偷查下游依赖、有没有写全 resources 的 requests 和 limits。这三点里只要缺了一项,它就是一颗潜伏的雷,平时安安静静,只等下一次"启动变慢"或"依赖抖动"把它引爆。趁现在没出事,花十分钟把它们补齐,远比某个深夜被 CrashLoopBackOff 的告警惊醒要划算得多。

说到底,编排配置这门功课,会 apply 只是入门,能读懂每个字段背后"它在替你做什么决定、什么时候会出手杀掉你的服务",才算真正上道。把 YAML 当代码读、当代码审、当代码测——这是云原生时代每个工程师都绕不开的基本功。

那次发布之后,我给团队立了一条不成文的规矩:任何改动了探针、resources 或滚动更新策略的 PR,描述里都必须写清楚"按这套参数,服务从启动到被 liveness 接管要经过多少秒,这个时长是怎么估出来的"。一开始大家觉得啰嗦,可正是这条规矩,后来又拦下了好几次"阈值拍脑袋填、上线即雪崩"的隐患。配置的可靠,从来不靠运气,而靠每一个数字都有人认真算过、有人能解释清楚。

毕竟在云原生这套体系里,真正难的从来不是把容器跑起来,而是让它在启动、运行、发布、下线的每一个环节都行为可预期。会部署只是起点,懂得让编排层在任何状态下都站在你这边、而不是反过来误伤你的服务,才算真正摸到了 Kubernetes 这门手艺的门道。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

三万个 CLOSE_WAIT 压垮服务:看懂 TCP 连接状态机

2026-5-29 22:18:59

技术教程

RAG 上线即翻车:从 demo 惊艳到胡说八道的检索调优

2026-5-29 22:30:41

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