KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

01 · 声明式与调谐:`apply` 成功之后发生了什么 — keel 龙骨

这一章回答:kubectl apply 返回成功到底意味着什么?从"期望状态被写入"到"Pod 真的在跑",中间要经过哪几跳,每一跳会怎么失败?

这一章回答:kubectl apply 返回成功到底意味着什么?从"期望状态被写入"到"Pod 真的在跑",中间要经过哪几跳,每一跳会怎么失败?

如果这一章只能带走一句话,是这句:

kubectl apply 成功后,你只完成了"把期望状态说清楚"这一步。剩下的事由集群里的控制器异步完成,而它可能永远完成不了。

现场

一个发布流程:CI 构建镜像,然后 kubectl apply -f prod.yaml。日志里一切顺利:

$ kubectl apply -f prod.yaml
deployment.apps/orders-api configured
service/orders-api unchanged
configmap/orders-api-config configured

三行 configured / unchanged,退出码 0。CI 绿了。然后有人在群里说:线上还是旧版本。

排查的方向会在两个猜测之间摇摆:

要讲清这个区别,得先看两种工作方式的差别。

一、命令式与声明式:不是语法差别,是责任归属差别

同一个意图——"跑 4 个副本"——有两种表达方式:

命令式 声明式
典型表达 kubectl scale deploy/api --replicas=4 改清单里的 replicas: 4 再 apply
谁决定怎么做 你:你说要 4 个,工具照做一次 控制器:它持续比较实际与期望,自己决定增/减
中途被别的操作改过怎么办 不知道,下次你自己记得 会被纠正回去——这就是"期望状态"的意义
幂等性 看具体命令 天然幂等:同样的清单 apply 多少次结果一样
失败后 命令返回了就是结束了 控制器会持续重试,直到达成或你要它放弃

第二行和第三行是"声明式"的全部价值,也是它的全部代价:

二、期望状态与实际状态

K8s 里几乎所有对象都有这两层:

存在哪里 谁写 例子
期望状态(spec) etcd 你(通过 apply) 你要 4 个副本、用哪个镜像、给多少资源
实际状态(status) etcd 控制器 现在有 3 个副本可用了、上次发布在 30 秒前

"调谐(reconciliation)"就是控制器不停做的那件事:

读 spec(应该是什么样)
  → 读 status / 观察真实世界(现在是什么样)
  → 计算出差值
  → 采取行动缩小差值
  → 更新 status
  → 回到第一步,永远循环

这个循环是永远在跑的,不是"apply 时跑一次"。所以:

三、一次完整链路

从你敲下命令到"服务真的在跑",中间有八跳。第 ①–③ 跳是同步的,第 ④–⑧ 跳是异步的——这个分界线就是 apply 返回成功的位置:

flowchart TD
  A["① kubectl apply -f prod.yaml<br/>(可先经 kubectl kustomize 合成)"] --> C["② API Server<br/>鉴权 → 准入控制 → 字段校验"]
  C -.->|"拒绝:schema 报错 / 准入策略拦下"| R["apply 直接失败<br/>集群里什么都没发生"]
  C -->|"接受"| D["③ etcd<br/>写入了期望状态"]
  C -.->|"返回第一条成功<br/>止步于此,不代表后面任何一步成功"| A
  D --> K1["④ Deployment 控制器<br/>创建或更新 ReplicaSet"]
  K1 --> K2["⑤ ReplicaSet 控制器<br/>算出要增几个、减几个 Pod"]
  K2 --> K3["⑥ kube-scheduler<br/>按 requests 找装得下的节点"]
  K3 -.->|"没有任何节点装得下"| P["Pending<br/>无限期等待,不会自己好"]
  K3 --> K4["⑦ kubelet<br/>拉镜像 → 起容器 → 跑探针"]
  K4 -.->|"镜像拉不到"| I["ImagePullBackOff"]
  K4 -.->|"启动后立刻退出"| CR["CrashLoopBackOff"]
  K4 -.->|"readiness 不通过"| NR["Running 但 NotReady<br/>不接流量"]
  K4 --> OK["⑧ Running 且 Ready<br/>期望状态达成"]

读懂这张图的关键是那条从第 ② 跳折回第 ① 跳的虚线:它是 apply 返回成功的实际位置。图上有四条失败分支(第 ⑥、⑦ 跳上的虚线),它们全部发生在成功返回之后——这正是"CI 绿了但线上没变"的完整解释。

第 ⑥ 跳那条分支尤其值得单独说:Pending 不会自己好。 调度失败的原因是"没有任何节点满足这个 Pod 的 requests",而节点容量不会因为等待而变大,所以它会一直等下去,直到你改小 requests、加节点,或者腾出资源。这是一个"永远重试但永远失败"的状态——和 CrashLoopBackOff 不同,后者至少有"重试可能成功"的机会。

四、离线能验证什么:三段验证,能力各不相同

既然第 ④–⑧ 跳要等集群,那么提交之前你能验证到哪一步?这个问题的答案比想象中反直觉。

反直觉的实测:--dry-run=client 也需要集群

流传很广的一条建议是"没集群就用 kubectl apply --dry-run=client 做离线自查"。本机实测(kubectl v1.34.1,无集群):

$ kubectl apply --dry-run=client --validate=false -f base/deployment-api.yaml
E1006 ... memcache.go:265] "Unhandled Error" err="couldn't get current server API group list:
  Get \"http://localhost:8080/api?timeout=32s\": dial tcp [::1]:8080: connectex: ...
error: unable to recognize "base/deployment-api.yaml": Get "http://localhost:8080/api?timeout=32s":
  dial tcp [::1]:8080: connectex: No connection could be made because the target machine actively refused it.

即使显式关掉字段校验(--validate=false),它仍然失败。 原因不难理解:--dry-run=client 只表示"不把结果写服务器",但它仍需要向 API 服务器询问"这个集群认识哪些 API 组和资源类型"(API discovery)——这一步走不通,它连"这份 YAML 是哪种对象"都判断不了。

结论很硬:--dry-run=client 不是离线工具。 判断一个工具能不能离线用,唯一的办法是拔掉集群跑一次。

真正离线可用的是 kubectl kustomize

本机实测(同一台机器、同样没有集群):

$ kubectl kustomize base
apiVersion: v1
data:
  FEATURE_NEW_PRICING: "false"
  LOG_LEVEL: info
  PORT: "3000"
kind: ConfigMap
metadata:
  labels:
    app.kubernetes.io/part-of: orders
  name: orders-api-config
---
apiVersion: v1
kind: Service
...

它输出的是合并后的完整清单,可以直接喂给任何静态校验器。这给出一条三段验证链:

阶段 用什么 能发现什么 不能发现什么
① 构建 kubectl kustomize <dir> overlay 合成错、路径错、patch 目标找不到、资源 id 冲突 语义是否正确
② 校验 kubeconform -strict(官方 JSON Schema) 字段名拼错、必填字段缺失、类型不对 跨对象的关系(selector 选不到、引用不存在)、语义约束(如滚动参数组合)
③ 部署 kubectl apply + 看 rollout 一切真实问题 ——(但它已经到集群了)

这三段的能力是严格递进但互不覆盖的:①只保证"合成成功",②只保证"单个对象长得对",③才是真的。把②当成③用,是 K8s 里最常见的一种过度自信。

一个真实例子,后面几章会逐个展开:

坏清单 ①构建 ②schema ③集群
字段名拼成 livenessProb 通过 拦下(additional properties 'livenessProb' not allowed) 会被拒
Deployment 缺 spec.selector 通过 拦下(missing property 'selector') 会被拒
Service selector 与 Pod 标签不一致 通过 通过(两份清单各自合法) 跑起来才发现没 endpoints
maxSurge=0 且 maxUnavailable=0 通过 通过(是合法整数) 被拒(语义约束)

最后两行是本章的重点:它们能通过你能在本地跑的一切检查,只在集群里才暴露。这正是为什么本课在第 03–07 章要逐个补上"跨对象不变量"这一层检查——它补上了第②段的盲区,但也仍然替代不了第③段。

五、误判澄清

读者常见的理解 核对后的事实 为什么会被误导
"apply 成功 = 部署完成" apply 成功 = 期望状态被 API Server 接受并写入 etcd。Pod 还没被创建 命令式工具养成的习惯:返回值就是"做完的确认"
"没集群的话 --dry-run=client 可以离线验清单" 不行,它需要 API discovery(本机实测失败) 名字叫 client,听起来是纯本地行为
"Pending 的 Pod 等一会儿就好了" 调度失败不会自己好:节点容量不会因为等待而变大 和 CrashLoopBackOff 混淆了,后者至少会重试
"YAML 能 apply 就说明写得对" 单对象合法 ≠ 跨对象自洽 ≠ 语义被接受 只做过第②段检查
"声明式和命令式只是写法不同" 差别在谁负责收敛、以及什么时候算完成 两种写法都能达成同一结果,所以短期看不出区别

生产边界

教学替身 真实替换点 要注意什么
本地 kubectl kustomize 流水线里的构建步骤,产物就是待部署的清单 产物要能被人读到(存成 artifact),否则排查时只能从集群反查
kubeconform 单对象校验 流水线闸门之一 它需要 schema 来源;离线环境要预置 schema 或用镜像缓存
静态不变量脚本 自己维护的检查,或引入策略引擎(OPA/Kyverno)做准入 自制脚本会误报(本课第 07 章会看到实例),要有白名单机制
kubectl apply + rollout status GitOps 控制器(持续对齐) 换成 GitOps 后,"什么时候完成"由控制器而非 CI 决定
单集群 多集群 / 多环境 期望状态从"一份"变成"每环境一份",overlay 的组织方式变成关键问题(第 02 章)

动手

  1. 用 kubectl kustomize 离线构建你手上的清单目录,把产物存下来读一遍——你会看到很多自己没写过但确实生效的字段;
  2. 把构建产物喂给一个 schema 校验器,记下它报了哪些;
  3. 断网或断开集群,试一次 kubectl apply --dry-run=client,亲眼确认它是否可用;
  4. 判断标准:能画出你项目从"提交清单"到"Pod 就绪"的完整链路,并指出哪几跳是同步的、哪几跳是异步的;
  5. 完成标志:能回答"如果 CI 绿了但线上没变,我的排查顺序是什么"——答案必须从第 ④ 跳开始,而不是从"镜像是不是没推"开始。

故障注入

注入方式 观察什么 说明的现象
在没有集群时跑 kubectl apply --dry-run=client 是否成功 失败:它需要 API discovery(本机实测)
给 Deployment 的 Pod 模板加一个装不下的 requests Pod 状态 Pending,且不会自己好(第 ⑥ 跳失败分支)
手工删掉一个由 Deployment 管理的 Pod 副本数是否恢复 会恢复——控制器持续调谐,这就是"期望状态"的含义
把 apply 换成在集群里手工 kubectl edit 改一个字段 下一次 apply 之后的值 被清单覆盖回去(漂移会被纠正,前提是用 apply 语义)
在清单里把字段名拼错(如 livenessProb) 校验与 apply 的表现 第②段拦下(additional properties ... not allowed),不必等到集群

自测

  1. 用一句话说清 kubectl apply 返回成功时,确定已经发生了什么、确定还没发生什么。
  2. "调谐循环"为什么是永远在跑的?请用它解释"手工删掉一个 Pod 后它自己长回来"。
  3. 为什么"没集群就用 --dry-run=client"这条建议不成立?它到底卡在哪一步?
  4. 三段验证(构建 / 单对象校验 / 部署)各自的能力边界是什么?举一个"能通过前两段、只在第三段暴露"的问题。
  5. Pending 与 CrashLoopBackOff 在"会不会自己好"这一点上有什么本质区别?这个区别如何影响你的处置优先级?

现在能解释什么

下一章把清单拆开:Deployment、Service、ConfigMap、Job 各自负责哪一段,以及它们靠什么连起来。

进入 keel 阅读