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,四个不同的原因,而它们的修复动作互不通用:

把这四个混成一个"服务有问题"去查,是排障变慢的最主要原因。它们分属四章: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 这一跳是分流的关键:"容器起来了又退出"和"容器根本没有起来"是两类问题。

一个实用判据: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 匹配) 静态检查

单独看任何一套,都会漏。 它们漏的方向正好相反:

这对你的流水线意味着什么

三种能力是递进但不覆盖的,把它们当同一件事是排障变慢的一个隐蔽原因:

能力 看什么 抓得到 抓不到
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"这份清单 团队的真实运维带宽与规模判断 判据是"多节点 + 多服务 + 需要声明式"三项同时成立,缺一项就要重新算账

动手

  1. 找出你手上集群(或清单)里所有非 Running 的 Pod,逐个套第一章的分流表,写下每一个"第一个该看的字段";
  2. 把 k8slab/broken/ 的五个清单分别喂给 kubeconform 和静态检查,复现这张"谁抓到什么"的表;
  3. 写一条你自己的检查规则:你团队最常犯的那个清单错误,能不能被静态检查抓出来?需要跨几个对象?
  4. 判断标准:能对一个异常状态说出"第一跳查什么、为什么不是别的",而不是"先重启试试";
  5. 完成标志:给出你们服务"要不要继续用 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 章实测)

自测

  1. CrashLoopBackOff 与 CreateContainerConfigError 的本质区别是什么?为什么一个要看 --previous 日志、另一个看了也没用?
  2. 0/1 Running 与 0/1 CrashLoopBackOff 分别由哪个探针(或哪个事件)造成?哪一个会重启容器?
  3. 本章实测的两套检查里,livenessProb 拼错被谁抓到?maxSurge=0 + maxUnavailable=0 被谁抓到?为什么对方抓不到?
  4. 为什么说"过了 schema 校验"不等于"清单是对的"?举一个 schema 放过的真实例子。
  5. 给出三个"不该上 K8s"的具体处境,并说出共同判据。
  6. 从 Compose 迁到 K8s,哪几个概念是被拆开的?拆开之后多出来的判断点是什么?
  7. 你有没有可能既需要静态检查、又需要集群侧准入?它们各拦在哪一步?

现在能解释什么

到这里,整门课的判断可以收成三根柱子:

第一根:声明式 + 异步收敛。
kubectl apply 成功不等于期望状态已达成。中间隔着一个持续运行的控制器,它可以卡住(Pending)、可以超时(探针)、可以永久无法达成(selector 失配)。所以"提交了什么"和"现在是什么"是两个必须分开读的东西——kubectl get 读的是后者。

第二根:判定与后果被拆开。
requests 是给调度器的、limits 是给 cgroup 的;三个探针判定三种不同的东西、对应三种不同的后果;maxUnavailable 的承诺建立在 readinessProbe 之上。这门课的六章里,有四章的实质内容都是同一条因果链:用什么判定 → 判错了会发生什么。忘掉名词不会出事,忘掉这条链会。

第三根:能在提交前抓的,别留给集群;只有集群知道的,别假装静态能证明。
实测的这张"谁抓到什么"表说明:单对象 schema 与跨对象不变量互不覆盖,而两者都替代不了一次真实的 apply 与观察。这不是"工具不够好",而是三层问题本来就不同——把它们的边界搞清楚,比多装一个工具更值钱。


这门课到此结束。它没有教你写更复杂的 YAML,它想让你在看到一份清单时,脑中自动跑出那三个问题:它会在哪一步卡住、卡住时我第一个看哪个字段、以及这个判断是静态就能得出的,还是必须等集群说话。

进入 keel 阅读