KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 异常状态分流与选型边界 — keel 龙骨
这一章回答:五种最常见的异常状态分别从哪个字段查起;以及"什么时候不该上 K8s"这个同样重要的问题。
这一章回答:五种最常见的异常状态分别从哪个字段查起;以及"什么时候不该上 K8s"这个同样重要的问题。
前面六章各讲了一个判断点:怎么收敛(01)、对象怎么分工(02)、怎么判定活着(03)、怎么判定装得下(04)、怎么安全换版本(05)、外部的东西怎么进去(06)。
这一章把它们的失败分支收口成一张分流表——因为现场不会告诉你"这是第 04 章的问题",它只会给你一个 Pending。
现场
凌晨两点,四个 Pod 状态各不相同:
$ kubectl get pods
NAME READY STATUS RESTARTS
orders-api-6d9f7c8b5d-2xk4n 1/1 Running 0
orders-api-6d9f7c8b5d-9m7qp 0/1 Running 0
orders-api-6d9f7c8b5d-t4wzc 0/1 CreateContainerConfigError 0
migrate-2vq8l 0/1 CrashLoopBackOff 7
(上面这段是构造的示例——本机没有集群,kubectl get pods 的真实输出没有实测。四种状态本身就够了,不需要真集群也能读懂分流逻辑。)
四个 Pod,四个不同的原因,而它们的修复动作互不通用:
- 一个是好的(
1/1 Running); - 一个是起来的但没被算作可用(
0/1 Running)——它没崩,只是没通过就绪判定; - 一个是容器根本没启动(
CreateContainerConfigError); - 一个是启动了又退出、反复重来(
CrashLoopBackOff,已经重启 7 次)。
把这四个混成一个"服务有问题"去查,是排障变慢的最主要原因。它们分属四章:0/1 Running 是 03 章的探针,CreateContainerConfigError 是 06 章的配置引用,CrashLoopBackOff 是应用自身,而那个看起来健康的 1/1 也可能所属的 Service 一个 endpoints 都没有(02 章的 selector 失配)。
一、五种状态的分流表
| 状态 | 第一个该看的字段 | 它回答什么 | 常见根因 | 对应章 |
|---|---|---|---|---|
Pending |
kubectl describe pod 的 Events 段(找 FailedScheduling) |
没调度上——Pod 还没被分配节点 | requests 超过任一节点可分配量(04 章);nodeSelector/亲和性无匹配节点;PVC 未绑定 |
04 |
ImagePullBackOff / ErrImagePull |
describe pod 的 Events(找 Failed to pull image) |
调度上了,但镜像没拿到 | 镜像名或 tag 拼错;私有仓库缺 imagePullSecrets;registry 网络不可达 |
— |
CreateContainerConfigError |
describe pod 的 Events |
镜像在,但配置引用解析不了 | 引用的 ConfigMap/Secret 不存在、或不在同一 namespace(06 章) |
06 |
CrashLoopBackOff |
kubectl logs <pod> --previous |
容器起来了,但进程自己退出了 | 应用启动即崩、连不上依赖、启动参数错——这是应用日志的问题,不是 K8s 的问题 | — |
Running 但接口 502 / 无流量 |
kubectl get endpoints <svc> |
Pod 在跑,但 Service 没把流量给它 | Service selector 匹配不到任何 Pod(02 章);Pod 未通过 readinessProbe(03 章) |
02 / 03 |
这张表里最重要的不是五行内容,而是它隐含的两个判别器。
二、两个判别器:describe 看在哪一步,logs 看进程怎么说
判别器一:容器启动过没有
flowchart TD
A["Pod 状态不是 Running 且 READY"] --> B{"describe 的 Events 里<br/>最后一跳是失败的吗?"}
B -->|"FailedScheduling"| C["还没调度:跟节点资源与选择器有关<br/>→ 查 requests / 亲和性 / PVC"]
B -->|"Failed to pull image"| D["调度好了但镜像没下来<br/>→ 查镜像名与拉取凭证"]
B -->|"configmap / secret not found"| E["镜像在但配置解析失败<br/>容器根本没被创建<br/>→ 查引用的对象是否存在"]
B -->|"Events 没有明显失败"| F{"容器启动过吗?<br/>(看 RESTARTS 与 --previous 日志)"}
F -->|"有重启、有上次日志"| G["CrashLoopBackOff:进程自己退出的<br/>→ 读应用日志,这是应用问题"]
F -->|"从没启动、无上次日志"| H["查容器命令 / 镜像 entrypoint 是否可执行"]
A --> I{"状态是 Running 呢?"}
I -->|"READY 0/1 且 RESTARTS 不涨"| J["readiness 未通过:不重启,只是摘流量<br/>→ 查 readinessProbe 与 /healthz"]
I -->|"READY 1/1 但 502"| K["get endpoints 为空<br/>→ 查 Service selector 与 Pod 标签"]
图里 F 这一跳是分流的关键:"容器起来了又退出"和"容器根本没有起来"是两类问题。
CrashLoopBackOff里的容器执行过,--previous能拿到它退出前的日志。这类问题去看应用日志——K8s 只是如实报告"它退了,我又拉起来,它又退了"。CreateContainerConfigError里的容器一次都没执行,所以logs会是空的。这时候去翻应用日志是白费力气,要看的是 Events 里那条"哪个对象没找到"。
一个实用判据:kubectl logs --previous 有内容 → 应用问题;为空 → 装配问题。
判别器二:重启计数在不在涨
三个信号要分开读:
| 信号 | 谁改的 | 意味着 |
|---|---|---|
STATUS |
kubelet 汇总 | 当前处于哪种状态 |
RESTARTS |
livenessProbe 失败或容器退出 |
容器被重启过 |
READY(0/1) |
readinessProbe 失败 |
容器没被重启,只是不进 Service 的 endpoints |
0/1 Running 且 RESTARTS 一直是 0 —— 这是 03 章最该记住的一条:readinessProbe 失败只摘流量,不重启。所以"Pod 是 Running 但服务不可用"不等于"Pod 坏了"。
反过来,RESTARTS 在涨说明 livenessProbe 在杀它,或者进程在自杀——这两者要去不同的地方查(探针参数 vs 应用日志)。
三、两套检查各自漏掉什么(本机实测)
01 章给出了三段验证链:kustomize(构建)→ kubeconform(单对象 schema)→ kubectl apply(集群)。这一节要回答一个更尖锐的问题:如果拿不到集群,前两段能替你挡住多少?
本课示例目录 k8slab/broken/ 里有五个故意写坏的清单。把它们分别喂给两套检查,结果如下(原始输出留档在 lab/evidence/kubernetes-engineering/05-schema-validation.txt 与 07-invariant-check-broken.txt)。
单对象 schema 的结果
broken/01-typo-field.yaml - invalid: additional properties 'livenessProb' not allowed
broken/02-missing-selector.yaml - invalid: at '/spec': missing property 'selector'
broken/03-rollout-zeros.yaml - valid
broken/04-service.yaml - valid
broken/05-deployment.yaml - valid
跨对象静态检查的结果
=== 检查 1 · Service selector ↔ Pod labels ===
✗ Service/selector-mismatch: selector={'app': 'orders-api'} → 0 个匹配
=== 检查 3 · 预算自洽性 ===
✓ typo-case: replicas=2 maxSurge=25% maxUnavailable=25%
✓ no-selector: replicas=2 maxSurge=25% maxUnavailable=25%
✗ rollout-zeros: maxSurge=0 + maxUnavailable=0
=== 汇总 ===
[严重] Service/selector-mismatch 的 selector 匹配不到任何 Pod
[严重] rollout-zeros: maxSurge=0 且 maxUnavailable=0 → 滚动更新会卡死
严重问题 2 个,警告 0 个,提示 7 条
合起来看
| 坏清单 | 单对象 schema | 跨对象静态检查 | 谁抓到的 |
|---|---|---|---|
01-typo-field(livenessProb 拼错) |
拦下 | 只给提示,没抓 | schema |
02-missing-selector(缺 spec.selector) |
拦下 | 只给提示,没抓 | schema |
03-rollout-zeros(maxSurge=0 + maxUnavailable=0) |
⚠️ 通过 | 抓出(死锁) | 静态检查 |
04/05(Service selector 选不到 Pod) |
⚠️ 通过(两个都 valid) | 抓出(0 匹配) | 静态检查 |
单独看任何一套,都会漏。 它们漏的方向正好相反:
- schema 只看单个对象。它知道
livenessProb不是合法字段、知道 Deployment 必须有selector,但它不知道rollout-zeros的maxSurge和maxUnavailable同时为 0 会让滚动永久卡死——那是一道跨字段的约束,而且官方 schema 没有编码它(实测:这个组合是 valid 的)。它更不知道 Service 的selector选不到任何 Pod——那需要跨两个对象比对。 - 静态检查只看关系,不看单个对象的字段合法性。
livenessProb这个拼写错误它根本没反应(因为拼错的键在它眼里只是一个它不关心的额外字段),missing-selector它也没抓到——它甚至照常给出了replicas=2 maxSurge=25%的"通过"结论。
这对你的流水线意味着什么
三种能力是递进但不覆盖的,把它们当同一件事是排障变慢的一个隐蔽原因:
| 能力 | 看什么 | 抓得到 | 抓不到 |
|---|---|---|---|
kubectl kustomize |
构建 | overlay 合成失败、patch 路径错、引用改写的实际结果 | 任何字段与关系错误 |
kubeconform(单对象 schema) |
字段名、类型、必填项 | 拼写错误、缺必填字段 | 跨字段约束、跨对象关系 |
| 跨对象不变量检查 | 对象之间的关系 | selector 失配、引用不可解析、预算倒挂 | 单对象的字段拼写、任何真实运行时行为 |
kubectl apply(集群) |
真实准入与运行 | 上面全部 + 不可变字段、配额、准入策略 | 运行起来之后才会发生的(调度、探针、OOM) |
而且它们全都替代不了集群。 03-rollout-zeros 之所以"只被静态检查抓到",还有一个更简单的原因:它的错法(maxSurge=0 + maxUnavailable=0)连 API 服务器也会拒——01 章引过那句"API 服务器会拒绝这个组合"。也就是说,一个真的连了集群的 --dry-run=server 也能抓。我们是因为没有集群,才需要自己补一道静态检查。
最后一条要写清楚:没有任何离线检查能证明"这份清单在集群里能跑起来"。 调度会不会成功、探针能不能通过、内存够不够、卷能不能挂上——这些只有跑起来才知道。静态检查的价值是把能在提交前算出来的错误挪到提交前,而不是把集群省掉。
(这两套检查各自也会误报:kubeconform 直接喂 patch 碎片会报 missing property 'selector'(02 章实测);静态检查会把"密钥由外部提供"报成"对象不存在"(06 章实测)。检查器报错时先判断是清单错了还是管道错了,这条判断本身就是能力。)
四、什么时候不该上 K8s
前面六章都在讲"怎么把 K8s 用对",但**"用不用"本身也是一个要给出的判断**。这一节是一个"不要用"的清单。
| 你的处境 | 更合适的方案 | 判据 |
|---|---|---|
| 一台机器、单体应用、流量不大 | Docker Compose / systemd | K8s 的核心收益是多节点上的声明式调度与自愈。单机时没有节点可调度,这份复杂度买不到东西 |
| 团队里没有人负责维护控制平面 | 用托管 K8s,或先不上 | 自建控制平面意味着 etcd 备份与恢复、证书轮换、版本升级窗口——这是一份长期职责,不是一次性安装 |
| 需要的只是"到点跑一次脚本" | cron / 平台定时器 | CronJob 能做,但为了几个定时脚本引入整个集群,代价与收益不成比例 |
| 极致冷启动、或需要内核级隔离 | 函数计算 / 虚拟机 | 容器共享宿主内核;Pod 启动还叠加调度与镜像拉取开销 |
| 只是把静态站点或简单页面放上线 | 对象存储 + CDN / 建站平台 | 见《站点上线与 SEO》专题——那里不需要任何编排 |
判据可以浓缩成一句:K8s 解决的是"很多台机器 + 很多个服务 + 需要人来声明期望状态"这个问题。缺少其中任一项,它就在替一个不存在的问题花复杂度。
反过来说,一旦同时具备这三项,再想用 Compose + 手写脚本去顶替,就要自己实现调度、健康检查、滚动更新、配置分发——那些正是本书前面六章逐条拆过的东西。
五、从 Compose 继承了什么、被拆分了什么
《Docker 应用课》07 章有一张 Compose → K8s 的概念映射表。这里把"被拆开"的那些点讲透——因为它们是换过来之后最容易被当成"多出来的负担"的部分。
| Compose 里的 | K8s 里的 | 变化 |
|---|---|---|
一个 service |
Deployment + Service |
一个概念拆成两个:工作负载(谁在跑)与网络入口(怎么被访问)。这正是 02 章那张"两种 selector"表的由来 |
replicas: 3 |
Deployment.spec.replicas |
从"启动时创建 3 个"变成"控制器持续维持 3 个"——少一个就补一个(01 章) |
healthcheck(一个) |
三个探针 | 一个判定拆成三个,且后果不同(重启 / 摘流量 / 延迟生效,03 章) |
depends_on |
没有直接对应 | K8s 不提供启动顺序。它用"等依赖可用"的方式解决(重试、initContainer 探活、迁移 Job),而不是"先起 A 再起 B" |
env_file / environment |
ConfigMap / Secret |
从文件或字面值变成对象——于是有了"引用的对象存在吗""更新会不会传播"这些新问题(06 章) |
volumes(本地路径) |
PersistentVolume / PersistentVolumeClaim |
从"挂一个路径"变成"声明式申请存储",多了一层绑定 |
networks |
Service + CNI |
从"默认共用一个网络"变成显式的 Service 抽象;地址不再固定,靠名字发现 |
deploy.resources |
requests + limits |
从一个字段拆成两个,且被两个不同的系统使用(调度器 / cgroup,04 章) |
stop_grace_period |
terminationGracePeriodSeconds |
语义一致,位置不同。这是少数几个"只是改名"的项 |
所以正确的说法不是"K8s 是更大号的 Compose",而是:K8s 把 Compose 里一个字段能表达的东西,拆成了对象之间的关系。 概念数量因此增加,但每个概念的判定后果变得更明确——healthcheck 判错了你不知道会发生什么,而 readinessProbe 判错了你知道是"摘流量、不重启"。
这也是为什么本课六章里有四章(03 04 05 06)都在讲"判定 → 后果":K8s 的学习量不在名词,在这条因果链。
六、误判澄清
| 读者常见的理解 | 核对后的事实 | 为什么会被误导 |
|---|---|---|
| "所有 Pod 异常都是同一类问题" | 五种状态的根因分属不同章:调度 / 镜像 / 配置 / 应用自身 / 流量 | kubectl get pods 只给一个状态字符串,看起来是同一列 |
"CrashLoopBackOff 说明 K8s 有问题" |
容器执行过并自己退出了,K8s 只是在如实重启它。要读应用日志 | 名字里有 BackOff,像是编排层的错 |
"CreateContainerConfigError 也可以去看日志" |
容器一次都没启动,logs 是空的。要看的是 Events |
两种状态都在"没起来"这一栏 |
"Pod 是 Running 就说明服务正常" |
0/1 Running 表示没通过就绪判定,Service 不会给它流量 |
Running 字面像"在正常工作" |
"0/1 说明 Pod 坏了,该重启" |
readinessProbe 失败不重启(RESTARTS 不涨)。要去查 /healthz 与探针参数 |
把"没就绪"当成"崩了" |
| "过了 schema 校验就说明清单是对的" | 实测:maxSurge=0 + maxUnavailable=0 与 selector 失配都能通过 schema |
以为 valid 等于"没毛病" |
| "有了静态检查就不用连集群了" | 静态检查只在提交前抓字段与关系;调度、探针、OOM、卷挂载都只有集群知道 | 把"能抓一部分"当成"能替代" |
| "K8s 是给所有生产系统的标配" | 单机、无人维护控制平面、只要定时任务这三类场景下它都是负收益 | 行业里"默认上 K8s"的氛围 |
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
| 构造的四种 Pod 状态 | kubectl describe / logs --previous / get events --watch 的真实输出 |
本机无集群,本章的排障命令是机制说明,未实测;真集群请以 describe 的 Events 为准 |
| 示例的五个坏清单 | 你仓库里真实的历史事故清单 | 把踩过的坑各留一份坏清单,做成一跑就红的回归用例——比记住状态名有用 |
| 手写的跨对象静态检查 | 准入策略(如 OPA / Kyverno)+ CI 里的清单校验步骤 | 准入策略是在集群侧拦,静态检查是在提交侧拦,两道不重复 |
| 本课的两段离线检查 | kubectl apply --dry-run=server + 准入控制 |
有集群时 server 端 dry-run 比静态检查强得多(它跑真实准入);没有集群才退而用静态 |
| "不要用 K8s"这份清单 | 团队的真实运维带宽与规模判断 | 判据是"多节点 + 多服务 + 需要声明式"三项同时成立,缺一项就要重新算账 |
动手
- 找出你手上集群(或清单)里所有非
Running的 Pod,逐个套第一章的分流表,写下每一个"第一个该看的字段"; - 把
k8slab/broken/的五个清单分别喂给kubeconform和静态检查,复现这张"谁抓到什么"的表; - 写一条你自己的检查规则:你团队最常犯的那个清单错误,能不能被静态检查抓出来?需要跨几个对象?
- 判断标准:能对一个异常状态说出"第一跳查什么、为什么不是别的",而不是"先重启试试";
- 完成标志:给出你们服务"要不要继续用 K8s"的一个有判据的结论——不是"大家都在用",而是三项条件各自是否成立。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
把 resources.requests 调到一个节点装不下的值 |
describe 的 Events 里 FailedScheduling 的原因 |
Pending 的根因在调度,不在应用 |
| 把镜像 tag 改成一个不存在的版本 | Pod 状态与 Events | ImagePullBackOff:调度成功、拉取失败,与调度无关 |
引用一个不存在的 Secret(06 章的坏例子) |
状态与 logs 输出 |
CreateContainerConfigError,且 logs 为空——容器没启动过 |
让容器命令直接 exit 1 |
RESTARTS 与 logs --previous |
CrashLoopBackOff,--previous 有输出——这才是应用问题 |
让 readinessProbe 指向一个不存在的路径 |
READY 与 RESTARTS |
0/1 Running 且 RESTARTS=0——只摘流量、不重启 |
Service selector 改成一个不存在的标签值 |
kubectl get endpoints 的输出 |
endpoints 为空 → 502;而两个对象各自都合法(02 章实测) |
自测
CrashLoopBackOff与CreateContainerConfigError的本质区别是什么?为什么一个要看--previous日志、另一个看了也没用?0/1 Running与0/1 CrashLoopBackOff分别由哪个探针(或哪个事件)造成?哪一个会重启容器?- 本章实测的两套检查里,
livenessProb拼错被谁抓到?maxSurge=0 + maxUnavailable=0被谁抓到?为什么对方抓不到? - 为什么说"过了 schema 校验"不等于"清单是对的"?举一个 schema 放过的真实例子。
- 给出三个"不该上 K8s"的具体处境,并说出共同判据。
- 从 Compose 迁到 K8s,哪几个概念是被拆开的?拆开之后多出来的判断点是什么?
- 你有没有可能既需要静态检查、又需要集群侧准入?它们各拦在哪一步?
现在能解释什么
到这里,整门课的判断可以收成三根柱子:
第一根:声明式 + 异步收敛。kubectl apply 成功不等于期望状态已达成。中间隔着一个持续运行的控制器,它可以卡住(Pending)、可以超时(探针)、可以永久无法达成(selector 失配)。所以"提交了什么"和"现在是什么"是两个必须分开读的东西——kubectl get 读的是后者。
第二根:判定与后果被拆开。requests 是给调度器的、limits 是给 cgroup 的;三个探针判定三种不同的东西、对应三种不同的后果;maxUnavailable 的承诺建立在 readinessProbe 之上。这门课的六章里,有四章的实质内容都是同一条因果链:用什么判定 → 判错了会发生什么。忘掉名词不会出事,忘掉这条链会。
第三根:能在提交前抓的,别留给集群;只有集群知道的,别假装静态能证明。
实测的这张"谁抓到什么"表说明:单对象 schema 与跨对象不变量互不覆盖,而两者都替代不了一次真实的 apply 与观察。这不是"工具不够好",而是三层问题本来就不同——把它们的边界搞清楚,比多装一个工具更值钱。
这门课到此结束。它没有教你写更复杂的 YAML,它想让你在看到一份清单时,脑中自动跑出那三个问题:它会在哪一步卡住、卡住时我第一个看哪个字段、以及这个判断是静态就能得出的,还是必须等集群说话。