KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 探针三件套:三个判定,三种后果 — keel 龙骨

这一章回答:为什么"健康"在 K8s 里被拆成三个字段?它们各自判错会发生什么?

这一章回答:为什么"健康"在 K8s 里被拆成三个字段?它们各自判错会发生什么?

这是本课最容易"知道名词但用错"的一章。三种探针的名字都很直白(startup / readiness / liveness),但它们的判定后果完全不同——一个只影响流量,一个会重启容器,一个会临时禁用另一个。混用它们的后果不是"不够优雅",而是雪崩。

现场

一次发布之后,运维同事发现新版本的 Pod 一直在重启,但同一个镜像在 staging 环境跑得好好的。日志里每次都是同一段:

listening on 3000
(十几秒后)
容器被重启

进程明明起来了,也打印了监听日志,为什么还被反复杀?

kubectl describe pod 的 Events 段给出了答案:

Warning  Unhealthy  ...  Liveness probe failed: Get "http://10.1.4.7:3000/healthz": dial tcp ...: connect: connection refused
Normal   Killing    ...  Container api failed liveness probe, will be restarted

探针在进程还没开始监听端口时就开始探测了。而这个服务需要先从数据库加载一批配置、建好连接池,前后约 25 秒——在此期间它完全正常,只是还没准备好。

于是形成了一个死循环:

启动 → 25 秒内不监听 → liveness 判失败 3 次 → 重启 → 再启动 → 再被杀……

这个循环永远不会自己走出,因为每次重启都从头开始,重新加载那 25 秒。

一、三种探针的判定后果

先给结论表,然后逐条解释它为什么是这样设计的:

探针 它在回答的问题 失败一次 连续失败到阈值 会不会重启容器
startupProbe 启动完了吗? 继续等 重启容器 会
readinessProbe 现在能服务吗? 从流量入口移除 保持在移除状态 不会
livenessProbe 它还活着吗? 继续观察 重启容器 会

这张表里最重要的是最后一列。 它划出了两组:

这个拆分回答了一个真实问题:"进程活着"和"能提供服务"是两件不同的事。

一个正在做长时间初始化的服务、一个下游数据库断连的服务、一个达到并发上限正在排队的服务——它们的进程都活着(杀掉重来只会更糟),但都不该接新流量。用一个布尔值表达这两件事,必然要在两种错误里选一个:

拆成两个字段,是为了让这两种错误都可以被避免。

二、startupProbe 的特殊规则:它在跑的时候,liveness 是关的

这是三件套里最容易被忽略、也最直接解决现场问题的一条规则:

startupProbe 未通过期间,livenessProbe 与 readinessProbe 都不会执行。

设计意图很明确:在启动完成之前,"活着"和"能服务"这两个判断都是没有意义的。进程本来就不该在被问"你死了吗"时给出答案。

所以现场那个 bug 的标准修法只有一步:加一个 startupProbe。启动期内 liveness 不生效,等 startupProbe 成功之后 liveness 才接管——此时进程已经在监听了,探针自然通过。

给慢启动服务配上:

          startupProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 2
            failureThreshold: 30

启动容忍预算是 periodSeconds × failureThreshold = 2 × 30 = 60 秒(本机从构建产物中读出的实测值)。含义是:允许这个容器最多花 60 秒完成启动。

换一个写法说明这个预算怎么用:如果你的服务冷启动 p99 是 25 秒,那么预算要大于 25 秒并留出余量(本课示例给了 60 秒,余量一倍多)。余量不是浪费——它买的是"在机器变慢、镜像层没缓存、依赖响应变慢时不会误杀"。反过来,如果预算给到 600 秒,那"启动真的挂住了"这种情况要 10 分钟才被发现。

(这与《Docker 应用课》第 03 章的 HEALTHCHECK --start-period 是同一件事的两种表达:都要为启动阶段单独开一个不计入失败统计的窗口。区别只是 Docker 里它写在 Dockerfile 上,K8s 里它写在清单上——而清单更好改。)

三、判定后果的完整分支

把三种探针的判定与后果画在一起。注意 readinessProbe 失败时容器不会被重启这条分支——它是现场那类故障与"雪崩"之间的分界线:

flowchart TD
  A["容器启动"] --> S{"startupProbe 通过?"}
  S -->|"未通过,还没到 failureThreshold"| SW["① 继续等<br/>此期间 liveness 与 readiness 都不生效"]
  SW --> S
  S -->|"超过 failureThreshold"| RS["重启容器"]
  RS --> A
  S -->|"通过(或未配置 startupProbe)"| L["② startupProbe 退出舞台<br/>liveness 与 readiness 生效"]
  L --> R{"readinessProbe 通过?"}
  R -->|"未通过"| OUT["③ 从 Service 的 endpoints 摘除<br/>不接新流量,容器继续跑"]
  OUT --> R
  R -->|"通过"| IN["④ 加入 endpoints,开始接流量"]
  IN --> LV{"livenessProbe 通过?"}
  LV -->|"未通过,还没到 failureThreshold"| LVW["⑤ 继续观察,容器仍在服务"]
  LVW --> LV
  LV -->|"超过 failureThreshold"| R2["⑥ 重启容器<br/>即使它此刻正在正常服务"]
  R2 --> A

图上有三条通向"重启"的路径(第 ① 跳的 RS、第 ⑥ 跳的 R2,以及未配 startupProbe 时 liveness 直接判死的分支),只有一条通向"摘流量不重启"(第 ③ 跳)。这个不对称是要点:想让服务"暂时不接流量但不要被重启",唯一的表达方式是 readinessProbe。

四、liveness 该检查什么:一条最容易踩反的设计原则

先看 livenessProbe 会导致重启这件事的严重性:它会让一次局部故障变成全局故障。

设想 livenessProbe 检查的是"数据库能不能连上"——这在直觉上很合理:"连不上数据库我就没法工作了,那我算活着吗?"

但把这个配置放到 20 个副本上,再加一次数据库主从切换:

数据库切换 → 20 个副本的 liveness 同时失败 3 次 → 20 个容器同时被重启
          → 启动过程中全部不接流量(如果还有 startupProbe 就是几十秒)
          → 请求全部失败

数据库只是抖了一下,服务却整体下线了几十秒。 更糟的是重启会让连接池重建、冷缓存,恢复期可能比数据库本身的抖动长得多。

由此得到一条可执行的原则:

livenessProbe 只检查这个进程自身是否已经不可恢复地卡住,不要去检查它的依赖。 依赖不可用时,正确的表达是"不能服务"(readinessProbe),不是"该重来了"(livenessProbe)。

readinessProbe 里检查依赖是对的——因为摘流量的代价是"这个副本暂时不接客",而流量会落到其他还好的副本上,甚至落到同一个副本的稍后某个时刻。代价的对称性决定了两个探针该检查什么。

一个实用的分类:

检查内容 放在 readiness 放在 liveness
进程的 HTTP 端口能否响应 ✅ ✅(这是"卡死"的判据)
数据库/缓存能否连接 ✅ ❌ 绝对不要
依赖的下游服务能否调用 ✅ ❌
内部线程池是否耗尽 ✅ ❌(耗尽可以恢复,重启代价更大)
进程进入了不可恢复的死锁 —— ✅(这才是 liveness 的正经用途)
磁盘写不进去了 ✅ ⚠️ 看情况(写不进去可能重启也修不好)

五、参数怎么定:从可观测量倒推,不抄阈值

具体参数不该照抄任何示例,而应该从可观测的分布倒推。三个量各有自己的依据:

参数 依据什么定 定错的典型症状
startupProbe.periodSeconds × failureThreshold 启动耗时的 p99(冷启动,不是热启动)+ 余量 太小:慢启动实例被反复杀;太大:真挂住了很久才发现
readinessProbe.periodSeconds × failureThreshold 你能接受的摘流量延迟 小:抖动即摘,流量在副本间来回甩;大:故障实例还在接流量
livenessProbe.periodSeconds × failureThreshold 你能接受的卡死检测延迟,且要大于 GC/长任务的最坏耗时 太小:正常的长 GC 被当成卡死,容器被白重启

第三行是最容易出错的:一次长 GC、一次大批量任务、一次磁盘 IO 阻塞,都可能让进程几秒钟不响应 HTTP。如果 liveness 的容忍窗口比这些正常事件的耗时要短,你就会得到一个"随机重启"的服务——而它每次都发生在负载高的时候,看起来像"负载高就会重启"。

要强调的是:这三个数字都不是"行业通用阈值"。它们取决于你的服务、你的依赖、你的机器。上表给的是倒推的路径,不是数值。

六、误判澄清

读者常见的理解 核对后的事实 为什么会被误导
"探针就是健康检查,三种都一样" 三者判定后果完全不同:一个延迟生效、一个摘流量、一个重启 名字都像"检查是否健康"
"readinessProbe 失败也会重启容器" 不会,只摘流量。这是"暂时不接客"与"该重来"的分界 默认"检查失败就有后果",而后果有两类
"livenessProbe 检查数据库能连是负责任的" 会把下游抖动放大成全部副本同时重启 直觉上"依赖不可用我就不健康"
"探针周期越短,故障发现越快,所以越短越好" 会误杀正常的长 GC / 长任务,也会在滚动更新时拖慢节奏 只看"发现速度",没算"误判成本"
"没有 startupProbe 也能靠调大 initialDelaySeconds 解决" 能缓解但更脆:initialDelaySeconds 是固定等待,不随实际启动时间变化,慢了照样被杀 两者都是"给启动留时间",但一个是固定延迟、一个是可配置的宽限窗口
"探针失败的消息不重要,看容器日志就行" 探针失败只出现在 Events 里,容器日志里什么都没有(因为进程不知道有人在探它) 习惯了从应用日志找问题

最后一行值得单独强调:现场那个 bug 的容器日志里只有一行正常的 listening on 3000——什么都没有错的痕迹。因为探针的探测与判定完全发生在容器外面,进程既不知道、也不会记录。"日志里没有异常"与"没有问题"在 K8s 里是两件事,Events 与日志是两个独立的信息源(第 07 章会展开)。

生产边界

教学替身 真实替换点 要注意什么
清单里手写探针参数 从指标倒推(启动耗时分布、响应延迟分布)+ 变更时复算 参数是需要随服务演进而复核的,不是一次性配置
单个 /healthz 端点 分开的 /healthz(进程存活)与 /readyz(依赖就绪) 两个端点服务两个探针,是最省事也最不容易错的实现方式
只用 httpGet 探针 HTTP / TCP / exec 三种形式按场景选 exec 探针会在容器内起进程,开销高于 HTTP;distroless 镜像没有 shell,exec 探针写不了
静态检查探针参数是否自相矛盾 准入策略强制"所有 Deployment 必须有 readinessProbe" 准入能保证将来不出错,但改不了已经在跑的
单副本验证 多副本下的"下游抖动 → 全部重启"放大效应 单副本永远测不出这个放大,它是副本数带来的问题

动手

  1. 用 kubectl kustomize 构建你的清单,读出每个容器的三种探针配置;
  2. 算出三个预算:启动容忍(startup)、摘流量延迟(readiness)、卡死检测延迟(liveness);
  3. 对照你服务的真实可观测量:冷启动 p99 是多少?最长一次 GC / 长任务耗时是多少?这两个数字决定后两个预算够不够;
  4. 判断标准:能回答"如果我的下游数据库抖 10 秒,我的服务会发生什么"——答案必须包含"会不会重启",而不只是"会不会报错";
  5. 完成标志:写出你的 /healthz 与 /readyz 各检查什么,并解释为什么某个检查没有放进 liveness。

故障注入

注入方式 观察什么 说明的现象
给一个启动需要 25 秒的服务配激进的 livenessProbe(3 秒一次、容错 3 次)而不配 startupProbe Pod 的重启次数与 Events 死循环:每次启动都被杀,永远不会就绪
加上 startupProbe(预算 60 秒) 同样的 Pod 是否稳定 启动期内 liveness 不生效,服务正常起来
把依赖检查(数据库连通性)写进 livenessProbe,然后停掉数据库 10 秒 所有副本的重启次数 局部故障被放大成全部副本重启
把同一个检查改成只放在 readinessProbe,再停掉数据库 10 秒 副本的重启次数与 endpoints 只摘流量、不重启;流量落到还好的副本
让 readinessProbe 周期与容错都极小(如 1 秒 × 1 次) endpoints 的变动频率 抖动即摘,流量在副本间反复甩动
只翻容器日志,不看 Events 与 describe 能否找到探针失败的证据 找不到——探针失败只在 Events 里

自测

  1. 三种探针的失败后果分别是什么?为什么"会不会重启容器"是区分它们的核心?
  2. startupProbe 在跑的时候,另外两个探针处于什么状态?这条规则解决了哪一类问题?
  3. 为什么 livenessProbe 不应该检查下游依赖?请描述它把一个局部故障放大成全局故障的完整链条。
  4. 有人在 livenessProbe 里检查"数据库能连",理由是"连不上我就不能工作"。请指出他混淆的两个概念,并给出正确的表达方式。
  5. initialDelaySeconds 与 startupProbe 都能"给启动留时间",为什么后者更稳?
  6. 探针失败了,为什么容器日志里可能什么都没有?这时候该看哪里?

现在能解释什么

下一章进入第二个判断点:requests 与 limits 为什么必须分开,以及超卖意味着什么。

进入 keel 阅读