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 |
它还活着吗? | 继续观察 | 重启容器 | 会 |
这张表里最重要的是最后一列。 它划出了两组:
readinessProbe失败只影响流量。容器继续跑,继续占资源,继续写日志。它像一个"我不接客"的牌子,而不是"我下班了"。startupProbe与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" | 准入能保证将来不出错,但改不了已经在跑的 |
| 单副本验证 | 多副本下的"下游抖动 → 全部重启"放大效应 | 单副本永远测不出这个放大,它是副本数带来的问题 |
动手
- 用
kubectl kustomize构建你的清单,读出每个容器的三种探针配置; - 算出三个预算:启动容忍(startup)、摘流量延迟(readiness)、卡死检测延迟(liveness);
- 对照你服务的真实可观测量:冷启动 p99 是多少?最长一次 GC / 长任务耗时是多少?这两个数字决定后两个预算够不够;
- 判断标准:能回答"如果我的下游数据库抖 10 秒,我的服务会发生什么"——答案必须包含"会不会重启",而不只是"会不会报错";
- 完成标志:写出你的
/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 里 |
自测
- 三种探针的失败后果分别是什么?为什么"会不会重启容器"是区分它们的核心?
startupProbe在跑的时候,另外两个探针处于什么状态?这条规则解决了哪一类问题?- 为什么
livenessProbe不应该检查下游依赖?请描述它把一个局部故障放大成全局故障的完整链条。 - 有人在
livenessProbe里检查"数据库能连",理由是"连不上我就不能工作"。请指出他混淆的两个概念,并给出正确的表达方式。 initialDelaySeconds与startupProbe都能"给启动留时间",为什么后者更稳?- 探针失败了,为什么容器日志里可能什么都没有?这时候该看哪里?
现在能解释什么
- "健康"被拆成三个判断,因为**"进程活着"与"能服务"是两件事**;拆开是为了让两类错误都能被避免。
startupProbe运行期间另外两个探针不生效——这是慢启动服务不被反复误杀的标准解法。livenessProbe只该检查"进程自身是否不可恢复地卡住",不要检查依赖,否则一次下游抖动会变成所有副本同时重启。- 探针参数应从启动耗时与响应延迟分布倒推,而不是抄阈值;三个预算各有各的依据。
- 探针失败的证据只在 Events 里,不在容器日志里。
下一章进入第二个判断点:requests 与 limits 为什么必须分开,以及超卖意味着什么。