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 绿了。然后有人在群里说:线上还是旧版本。
排查的方向会在两个猜测之间摇摆:
- "是不是镜像没推上去?" —— 不是,镜像在,
docker pull能拉。 - "是不是 apply 没生效?" —— 这条更接近,但表述是错的。apply 确实生效了,只是它生效的是"写入期望状态"这件事。
要讲清这个区别,得先看两种工作方式的差别。
一、命令式与声明式:不是语法差别,是责任归属差别
同一个意图——"跑 4 个副本"——有两种表达方式:
| 命令式 | 声明式 | |
|---|---|---|
| 典型表达 | kubectl scale deploy/api --replicas=4 |
改清单里的 replicas: 4 再 apply |
| 谁决定怎么做 | 你:你说要 4 个,工具照做一次 | 控制器:它持续比较实际与期望,自己决定增/减 |
| 中途被别的操作改过怎么办 | 不知道,下次你自己记得 | 会被纠正回去——这就是"期望状态"的意义 |
| 幂等性 | 看具体命令 | 天然幂等:同样的清单 apply 多少次结果一样 |
| 失败后 | 命令返回了就是结束了 | 控制器会持续重试,直到达成或你要它放弃 |
第二行和第三行是"声明式"的全部价值,也是它的全部代价:
- 价值:你不需要写"先检查有没有、再决定创建还是更新"这种逻辑——控制器替你做了收敛。手工
kubectl edit改一个生产字段,会被下一个人 apply 时覆盖回清单里的值(前提是清单用kubectl apply而不是replace)。这种"漂移会自己消失"的性质,是 GitOps 能成立的基础。 - 代价:你交出了"什么时候完成"的控制权。命令式命令的返回值是"做完了",声明式提交的返回值只是"记下了"。这是一个语义上的降级,而多数人第一次用时并不会注意到。
二、期望状态与实际状态
K8s 里几乎所有对象都有这两层:
| 存在哪里 | 谁写 | 例子 | |
|---|---|---|---|
| 期望状态(spec) | etcd | 你(通过 apply) | 你要 4 个副本、用哪个镜像、给多少资源 |
| 实际状态(status) | etcd | 控制器 | 现在有 3 个副本可用了、上次发布在 30 秒前 |
"调谐(reconciliation)"就是控制器不停做的那件事:
读 spec(应该是什么样)
→ 读 status / 观察真实世界(现在是什么样)
→ 计算出差值
→ 采取行动缩小差值
→ 更新 status
→ 回到第一步,永远循环
这个循环是永远在跑的,不是"apply 时跑一次"。所以:
- 你删掉一个由 Deployment 管理的 Pod,它会自己长回来(因为期望是 4 个,现在只有 3 个);
- 你手工把副本数改成 2,下一次控制器调谐时会可能被改回清单里的值(取决于谁在写 spec);
- "什么都不做"也是一种状态:控制器发现没有差值时就什么都不做——它不像人那样"闲下来就找事"。
三、一次完整链路
从你敲下命令到"服务真的在跑",中间有八跳。第 ①–③ 跳是同步的,第 ④–⑧ 跳是异步的——这个分界线就是 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 章) |
动手
- 用
kubectl kustomize离线构建你手上的清单目录,把产物存下来读一遍——你会看到很多自己没写过但确实生效的字段; - 把构建产物喂给一个 schema 校验器,记下它报了哪些;
- 断网或断开集群,试一次
kubectl apply --dry-run=client,亲眼确认它是否可用; - 判断标准:能画出你项目从"提交清单"到"Pod 就绪"的完整链路,并指出哪几跳是同步的、哪几跳是异步的;
- 完成标志:能回答"如果 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),不必等到集群 |
自测
- 用一句话说清
kubectl apply返回成功时,确定已经发生了什么、确定还没发生什么。 - "调谐循环"为什么是永远在跑的?请用它解释"手工删掉一个 Pod 后它自己长回来"。
- 为什么"没集群就用
--dry-run=client"这条建议不成立?它到底卡在哪一步? - 三段验证(构建 / 单对象校验 / 部署)各自的能力边界是什么?举一个"能通过前两段、只在第三段暴露"的问题。
Pending与CrashLoopBackOff在"会不会自己好"这一点上有什么本质区别?这个区别如何影响你的处置优先级?
现在能解释什么
- 声明式与命令式的真正差别在谁负责收敛与何时算完成;
apply成功只覆盖到"期望状态被写入"。 - 调谐循环持续运行,所以"漂移"会被纠正,也所以"没完成"会一直悬着。
- 从
apply到 Pod 就绪之间有八跳,第 ④–⑧ 跳全部在成功返回之后,且各自有独立的失败形态。 --dry-run=client需要集群(实测),真正离线可用的是kubectl kustomize。- 三段验证能力递进但互不覆盖;"单对象合法"与"跨对象自洽"是两件事,后者是本课后面几章要补的层。
下一章把清单拆开:Deployment、Service、ConfigMap、Job 各自负责哪一段,以及它们靠什么连起来。