如果你维护过 Kubernetes 集群,大概率经历过一个这样的半夜:业务进程没挂、日志干干净净、监控曲线也不突兀,但 Pod 却在反复重启,告警群里哗啦啦刷屏。我负责的模拟项目 X 就真实发生过一次。当时我盯着kubectl get pod的输出看了半天,满脑子问号:容器明明活着,K8s 为什么要动它?直到我把事件翻出来,才发现真正操刀的是 k8s 探针——livenessProbe、readinessProbe、startupProbe这三个配置项。
这篇文章想写给所有正在被探针问题折磨的人:刚接触 K8s 的开发者、排查容器反复重启的 SRE、希望发布过程更丝滑的运维。我会从一次事故复盘讲起,把三种探针的分工、参数时间线、三种探测方式的选型逻辑、高频踩坑链路和发布场景配合完整过一遍,最后聊聊我调参时坚持的原则。
1. 半夜两点,容器被反复重启但业务进程还活着
1.1 一个“看起来健康”的容器为何被判了死刑
那次事故的具体表现是这样的:某订单服务发完版本后,部分实例在 10 分钟内被重启了 6 次,但节点 CPU、内存曲线都平稳得不像话,容器内的主进程也一直存活。更诡异的是,业务侧的报警很轻微,只是偶发超时,重启完成后立刻恢复,看上去就像“间歇性抽风”。
我先把 Pod 的 YAML 和事件看了一遍。kubectl describe pod里躺着一行关键信息:
Warning Unhealthy Liveness probe failed: HTTP probe failed with status code: 500注意,不是 OOMKilled,不是容器退出,而是 liveness 探针判定失败。也就是说,是节点上的 kubelet 主动杀掉了容器并重新拉起。
顺着这条线翻应用日志,才发现健康检查接口偶尔会返回 500,而业务接口一切正常。追进代码后发现,这个/healthz接口内部会同步查询一个外部缓存组件,缓存节点当时正在做滚动调整,某些请求直接超时。探针一连失败几次,kubelet 就按restartPolicy把容器给重启了。
这个场景非常典型:你以为探针监督的是“业务健康”,实际上它执行的是“kubelet 视角下的判活逻辑”。只要探针接口本身写得有问题,哪怕业务好端端地跑着,K8s 也会把你珍爱的进程当尸体处理。
1.2 三个探针,三个完全不同的问题
那次事故逼我把探针的定位重新理了一遍。K8s 提供三种探针,很多人一开始会把它们混为一谈,但它们的职责根本不重叠:
livenessProbe:回答“进程还活着吗”。失败后 kubelet 会杀死容器并决定是否重启,用来兜底死循环、死锁、内存泄漏这类“进程在但服务已经废了”的问题。readinessProbe:回答“可以接流量了吗”。失败后容器不会被杀,而是被从 Service 的 Endpoints 中摘除,流量暂时不进来。startupProbe:回答“启动流程走完了吗”。在它成功之前,liveness 和 readiness 都不会启动,专门给慢启动应用一个安全窗口。
我后来总结了一个类比:liveness 是急救电话,readiness 是门店门口挂着的“暂停营业”牌子,startup 则是开业准备期的保安,负责拦住所有人直到店里真正收拾好。三者没有一个能互相替代。
1.3 事故的真正根因:把“探活”和“业务健康”混在了一起
那件事的根因不是探针太敏感,而是我把健康检查接口设计得太“重”了。一个 liveness 探针接口,内部却去查外部缓存,等于让每个实例都成了故障放大器。
想象一下:外部缓存抖动一次,所有实例的探针几乎同时失败,kubelet 就会把所有容器轮流重启一遍。新 Pod 启动、拉镜像、注册、接流量,整个集群的流量在这一段时间里到处乱撞。原来的一个小故障,被“探针 + 自愈”这套机制放大成了一次集群级抖动。
所以从那个晚上之后,我给自己立了一条规矩:liveness 探针接口必须做到“纯本地、无外部依赖”,能查内存状态就查内存状态,不要碰任何外部系统。至于那些真正需要检查的依赖,放进 readiness 里,并且拉长探测周期,让它们只负责“决定是否接流量”,不负责“决定是否杀人”。
2. 三种探针的分工:急救、营业牌子和开业等待
2.1 livenessProbe:只该管“僵尸进程”
livenessProbe最适合的场景,是那种进程还活着、但已经无法正常工作的“僵尸状态”。比如服务里出现死循环,所有工作线程全部卡死;比如 goroutine 泄漏,内存被一点点吃光后接口响应变成超时;再比如某些原生程序长期运行后文件描述符耗尽,再也无法 accept 新连接。
这些问题的共同点是:外部很难通过“端口能不能连通”来判断,但进程本身确实已经形同废物。此时重启是成本最低的修复手段。
判断一条 liveness 是否合格,我一般会问自己一个问题:这个检查失败时,我是否真的希望立刻杀掉容器重启?如果答案犹豫,那这个检查就不该放在 liveness。
特别要注意的是,liveness 失败后的行为还受restartPolicy控制。默认Always会重启;如果设为Never,即使 liveness 检测失败,容器也不会被重新拉起,只会被标记为异常。很多人改了restartPolicy却对结果一脸困惑,就是因为没把这一层想清楚。
2.2 readinessProbe:流量入口真正的闸门
readiness 是我最建议多花心思的一个探针。它决定了 Service 是否把流量转发给这个 Pod。
机制上,kubelet 定期执行 readiness 探针。失败后,Pod 的 IP 会从所有匹配标签的 Service Endpoints 中移除;成功后再加回来。整个过程容器不会重启,只是进入“不接客”的状态。
这意味着我们可以放心地在 readiness 里检查相对“重”的东西:依赖的数据库是否连通、配置中心是否拉取完毕、本地缓存是否预热完成。即使检查失败,也只是暂时摘流量,不产生杀容器这种剧烈动作。
我在发布场景里尤其依赖 readiness:新版本 Pod 还没就绪,旧 Pod 就不会被替换,流量也不会提前打入“还在热身”的新实例。可以说,readiness 是发布流程里最重要的一道闸门,比什么蓝绿切换的设置都实在。
2.3 startupProbe:给慢启动应用一条生路
Java 服务、里面加载大量规则或模型的应用,启动很可能会超过 60 秒。如果 liveness 的initialDelaySeconds按启动时间设置,那一旦某次重启变慢就会再次被杀,陷入“越重启越起不来”的死循环。
startupProbe就是为这类应用设计的。它的逻辑很简单:在探针成功之前,liveness 和 readiness 都先不工作;只有 startup 成功之后,其他两种探针才开始接管。
所以当一个应用是典型的“启动慢但一旦起来就很稳”时,我会把启动窗口完全交给startupProbe,给它一个很大的failureThreshold,比如 60 甚至 120 次,periodSeconds设置成 2~3 秒,给足整个启动时间。启动成功后,再让 liveness 按照正常节奏工作。注意,在 startup 成功之前,readiness 也不会启动,所以这个 Pod 是不会提前接入流量的。
3. 探针参数时间线:从 Pod 启动到判定死亡的完整推演
3.1 六个参数,每个管住哪一段
探针配置里常被提到的参数有六个,很多人只知道字面意思,但组合在一起就能画出完整的时间线:
| 参数 | 作用 | 典型取值 |
|---|---|---|
initialDelaySeconds | 容器启动后多久开始第一次探测 | 按启动时间留缓冲 |
periodSeconds | 每次探测的时间间隔 | 10~15 秒 |
timeoutSeconds | 单次探测等待响应的上限 | 2~5 秒 |
successThreshold | 连续成功多少次才判定健康 | 默认 1,readiness 可设 1~2 |
failureThreshold | 连续失败多少次才判定不健康 | liveness 3~5,readiness 2~3 |
terminationGracePeriodSeconds | 判定失败后给 Pod 的优雅退出宽限 | 30 秒起 |
这里有个容易踩的细节:successThreshold和failureThreshold都是“连续”计数。也就是说,失败两次后中间成功一次,计数会重置回零。理解这一点,你就知道为什么一个抖动型的接口最容易导致偶发重启——它总是差一次到失败线,又总是被一次成功救回来,但最终某个长时抖动突破阈值后,容器还是会被杀。
3.2 一个具体的时间线推演:为什么探针“反应慢”
很多人觉得探针“太灵敏”,但按默认配置算出来,探针实际并不快。我们用一个常见配置做推演:
livenessProbe: httpGet: path: /healthz/live port: 9090 initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3容器启动后的时间轴大致是:
- t=10s:第一次探测发出。
- 最糟糕情况下 t=15s 才因超时判定为一次失败。
- 之后每隔 10s 探测一次,每次最坏消耗 5s。
- 第三次失败判定发生在大约 t=35s。
- 这时 kubelet 开始终止容器,还要再算上
terminationGracePeriodSeconds(默认 30s)。
也就是说,一个彻底无响应的容器,从“业务肉眼可见地挂了”到“真正被重启”,中间可能拖到一分钟以上。所以感觉探针“反应慢”不是错觉,而是timeout和周期共同作用的结果。
我早期踩过一个坑:为了追求“快速发现故障”,把periodSeconds设成 1 秒、timeoutSeconds设成 1 秒。结果接口偶发慢一点就被判定失败,若干个实例的探针请求像轰炸一样打在应用上,反而把服务拖得更慢。探针不是越快越好,它需要和应用的响应时间分布匹配。
3.3 参数设计的三条建议
基于上面的时间线,我一般这样分配参数,可以当做一个起点:
livenessProbe:initialDelaySeconds给足启动缓冲,periodSeconds=10~15,timeoutSeconds=3,failureThreshold=3以上。因为杀容器代价大,所以参数要保守。readinessProbe:failureThreshold可以设成 2~3,让它比 liveness 更早摘流量。摘流量代价小,宁可误摘也不能让流量打到不健康的 Pod。startupProbe:periodSeconds可以短,通常是 1~3 秒,failureThreshold按“需要等待的总时长 / periodSeconds”来算。比如想给 2 分钟启动时间,period 为 2s,就设 60 次。
4. 探针实现选型:httpGet、tcpSocket、exec 背后的逻辑
4.1 三种方式的判活逻辑
K8s 支持三种探测方式,它们的判活逻辑完全不同:
httpGet:kubelet 向指定路径发起 HTTP GET 请求,返回码在 200~399 之间视为成功。最贴近用户请求路径,是 HTTP 服务的首选。tcpSocket:kubelet 尝试向容器的某个端口建立 TCP 连接,能连上就算成功。只看“端口有没有被监听”,不关心应用层是否真正可用。exec:kubelet 在容器内执行一条命令,退出码为 0 就算成功。可以写任意脚本逻辑,但对容器镜像有要求,必须有可执行的 shell 或二进制。
三个 YAML 片段分别长这样:
livenessProbe: httpGet: path: /healthz/live port: 9090 httpHeaders: - name: X-Health-Check value: "1"readinessProbe: tcpSocket: port: 8080startupProbe: exec: command: - /bin/sh - -c - "test -f /tmp/app_started"4.2 选型标准:探针行为必须贴近真实请求路径
很多人用tcpSocket图省事,我吃过亏之后非常不建议把tcpSocket用在关键服务上。它只能证明某个端口上有进程在听,但进程可能已经死锁、线程池已经打满、缓存已经失效。一个能建立 TCP 连接但不返回业务数据的应用,对用户来说和挂了没有区别。
所以我的选型逻辑很简单:
- HTTP 服务优先用
httpGet,并且探测的路径最好是真实业务链路的前置路径,而不是一个空返回的/ping。 - 非 HTTP 协议的服务(比如某些内部 TCP 中间件)起步可以用
tcpSocket,但团队最好尽快补一个支持自定义命令的exec探针做深度检查。 exec适合组合判断,比如检查本地状态文件是否生成、执行特定命令验证内部组件是否就绪。
还有一个容易忽略的点:httpGet默认请求的Host头是 Pod IP。如果应用在网关层对 Host 做了域名校验,探针请求可能被判为异常流量。此时可以像上面那个 YAML 里一样,通过httpHeaders显式注入 Host。
另外,探针由节点上的 kubelet 发起,不是从集群某个中心组件发起的。如果容器内部的监听地址绑定的是127.0.0.1而不是0.0.0.0,探针从外部访问就会失败,但你在容器内curl localhost一切正常。这类问题只看应用日志基本发现不了,必须结合探针失败的 events 和容器网络命名空间一起排查。
4.3 独立健康端口:把健康检查和业务请求拆开
我在项目里通常会让应用同时监听两个端口:业务端口(比如 8080)和健康检查专用端口(比如 9090)。/healthz/live只返回进程和本地状态,/healthz/ready负责检查依赖是否就绪。
这样做有几个好处:
- 健康检查产生的访问日志不会混入业务日志,日志存储压力小很多。
- 业务中间件上那些耗时过滤器、权限校验、链路追踪逻辑不会影响探针响应。
- 两个端口的超时行为可以独立控制,不至于因为业务高峰处理不过来,导致探针跟着超时。
这个设计看起来简单,但在排障时帮我挡掉了至少三类“探针把业务拖死”的问题。
5. 探针踩坑五连:从 Describe 到根因的排查链路
5.1 坑一:liveness 被外部依赖绑架,集群变成故障放大器
- 现象:Pod 反复重启,但容器日志没有明显错误,业务也时好时坏。
- 排查链路:先
kubectl get pod看到 CrashLoopBackOff,再kubectl describe pod找 events,看到Liveness probe failed,最后翻应用日志发现探针接口在查外部缓存。 - 根因:liveness 不适合包含外部依赖检查。
- 修复方案:把探针接口拆开,
/healthz/live只读本地状态,/healthz/ready才检查外部依赖,并把 readiness 的周期拉长到 30 秒以上。
5.2 坑二:readiness 没配,发布时流量一波全打到新 Pod
- 现象:新版本刚上线,接口瞬间出现大量 5xx 或超时,但旧版本一切正常。
- 排查链路:查看滚动更新过程,发现新 Pod 状态是 Running 后立刻被加入 Endpoints,应用还没来得及完成初始化。
- 根因:没配置 readiness,K8s 只能看到“进程活着”,看不到“服务就绪”。
- 修复方案:加上
readinessProbe,让新 Pod 在初始化完成、具备接流量能力后才被放行。
5.3 坑三:exec 探针里的命令根本不存在
- 现象:容器一直重启,describe 里显示
exec error或者Error: Internal。 - 排查链路:使用
kubectl exec进入容器手动执行同一条命令,发现提示sh: xxx: not found。 - 根因:使用
scratch或distroless镜像的应用,容器里根本没装 shell;或者命令写的是相对路径,找不到二进制。 - 修复方案:尽量用绝对路径;如果依赖 shell,写成
command: ["/bin/sh", "-c", "check.sh"],并确保镜像包含这个二进制。
5.4 坑四:GC 停顿撞上探针超时,JVM 应用反复被杀
- 现象:Java 服务每隔一段事件就重启一次,时间点恰好和 Full GC 吻合;重启前监控能看到 GC 暂停达到几秒。
- 排查链路:先排除 OOMKilled,再看探针超时,然后对比 GC 日志和探针失败时间戳。
- 根因:
timeoutSeconds设置过短,Full GC 期间整个进程暂停响应,探针等不到响应就判定失败。 - 修复方案:把 liveness 的
timeoutSeconds调大到能覆盖 GC 暂停时长;同时让探针接口本身保持轻量,不要把耗时操作放在里面。
5.5 坑五:探针把日志和存储写爆了
- 现象:业务没出问题,但日志存储突然涨了好几倍,告警先从存储系统发出来。
- 排查链路:查看日志来源,发现大量
/healthz的访问日志;如果每个副本periodSeconds=1、集群有几十个副本,每秒几十条日志很轻松。 - 根因:健康检查被打到了业务日志链路里。
- 修复方案:使用独立健康检查端口,或者在日志框架里单独过滤
/healthz路径,让探针产生的请求进入独立 access log,甚至直接丢弃,避免冲垮日志系统。
6. 发布场景里的探针组合拳:readiness、preStop 与优雅排空
6.1 RollingUpdate 的等待逻辑:探针是发布保护器
很多团队第一次遇到滚动更新一直“hang 住”都会紧张。其实这往往是探针在保护你:readinessProbe一直不通过,Deployment 就不会继续替换旧 Pod,新 Pod 也不会被接入流量。
这时候正确的处理不是强行删掉新 Pod,而是先看新 Pod 是否真的准备好了。如果应用启动慢,给足startupProbe时间;如果应用本身有问题,那发布 hang 住反而是好事,避免你把故障直接带到生产环境。
6.2 preStop + readiness 摘流量:优雅下线的黄金组合
滚动更新最怕“旧 Pod 还在接流量就被 SIGTERM 杀掉”。K8s 删除 Pod 的流程里,Pod 进入 Terminating 的同时会从 Endpoints 摘除,但摘除动作在全集群的传播需要时间。这个窗口里,负载均衡仍然可能把新请求转发到 Terminating Pod。
业界最常见的解法,就是用preStop钩子做一个缓冲:
lifecycle: preStop: exec: command: - /bin/sh - -c - "sleep 15"这段 sleep 不是为了拖延删除,而是给 Endpoints 摘除和kube-proxy规则刷新留出时间。配合 readiness 在收到删除信号前先返回失败,几乎可以做到“先断流量,再停进程”。
更讲究的做法是在应用层做“主动下线”:应用收到 SIGTERM 前先把 readiness 接口切到失败状态,然后 sleep 几秒,最后再退出。这个方案对长连接服务的帮助非常明显,能有效避免大量连接被半途掐断。
6.3 探针和资源限额的联动:先别急着调探针参数
容器反复重启时,我要求团队先看终止原因,而不是一股脑调探针:
OOMKilled:这是内存 limit 不够,探针是无辜的。先调内存配置或排查内存泄漏。Error:可能是探针失败,也可能是容器本身异常退出。
CPU limit 设置太紧同样会坑探针。CPU 被限流后,健康检查接口的响应时间会变长,轻量的探针请求也会超时。所以如果你发现“探针偶发失败、失败规律跟业务高峰高度相关”,先检查 CPU throttling 再动探针参数。
7. 给探针调参时我死守的几条铁律
7.1 默认值永远不是银弹
K8s 里探针不写参数,就完全没有默认探测行为。很多人以为不配探针系统会有什么默认健康检查,其实不会。更常见的误区是把initialDelaySeconds随便写个 10 秒,结果应用启动要 40 秒,liveness 早早杀掉了它。每个服务的启动时长、响应延迟分布都不一样,探针参数必须按实际压测数据来定。
7.2 liveness 要保守,readiness 要敏感
这是探针设计里最重要的一句话。杀容器是代价很大的操作,所以 liveness 的阈值要拉宽,尽量只对“彻底没救”的情况触发;而摘流量是低成本操作,readiness 可以更灵敏,宁可多摘几次也别把流量送给不健康的 Pod。
7.3 探针不是监控系统,别拿它当业务健康上报
探针的唯一使命是给 kubelet 的调度决策提供依据。业务黄金指标应该走专门的 metrics 体系,比如每个接口的成功率、延迟分位数、队列积压量。把这些重逻辑塞进探针接口,最后收获的只会是一堆误杀和难排查的偶发重启。
7.4 改探针配置本身也是高风险变更
我见过团队为了修一个偶发重启,直接把failureThreshold改成 100,结果服务真正死锁之后十几分钟都没人发现。探针参数的改动,本质上是在调整“K8s 对故障的反应速度”,属于高风险变更。每次改动我都要先在测试环境模拟极端延迟,再到金丝雀集群观察两轮发布,最后才全量更新。
最后再分享一个小习惯:每次调整探针,我会先去压测环境观察服务的真实响应延迟分布,用 P99 和 P99.9 的数值来反推timeoutSeconds和failureThreshold,而不是靠感觉拍脑袋。探针不是三个配置字段那么简单,它直接决定 K8s 什么时候“动手”。调教好了,K8s 才真正从调度器变成可靠的自愈系统;调教不好,它就是你深夜告警群里的主角。