KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · 清单解剖:谁管什么,以及它们靠什么连起来 — keel 龙骨
这一章回答:Deployment、Service、ConfigMap、Job 各自的职责边界在哪?为什么"改一个标签"能让服务整体不可用?
这一章回答:Deployment、Service、ConfigMap、Job 各自的职责边界在哪?为什么"改一个标签"能让服务整体不可用?
上一章讲了"期望状态会被持续收敛"。但期望状态不是一个整体——它被拆进了多个对象,每个对象只负责一段。理解了分段,才能理解为什么某些改动是安全的、某些是灾难性的。
现场
一个服务在跑,功能正常。有人做了一次"命名规范化":把 Pod 标签从 app: orders-api 改成 app: orders-api-v1,想着"以后多版本好区分"。
他改了 Deployment 的 Pod 模板标签,也改了 Deployment 的 selector——因为 K8s 要求这两个必须一致,不改会报错。
然后:
$ kubectl apply -f orders-api.yaml
deployment.apps/orders-api configured
返回 configured,成功。但服务立刻 502。
kubectl get endpoints 显示这个 Service 下的地址一个都没有:
$ kubectl get endpoints orders-api
NAME ENDPOINTS AGE
orders-api <none> 43d
原因不是他改错了 Deployment——Deployment 完全正常,4 个 Pod 都 Running 且 Ready。问题在于 Service 的 selector 还是旧的 app: orders-api,而世界上已经没有带这个标签的 Pod 了。
这是一个"改了一处、另一处没跟着改"的问题,但在 K8s 里它有一个特别的性质:两份清单各自都合法,字段校验一个都不报错。 单个对象是对的,错的是它们之间的关系——而单对象校验器(第 01 章的②段)恰恰不看关系。
一、四类对象,四种职责
先把"谁管什么"划清楚。判据是:它管的是"要跑什么"、"怎么找到"、还是"给它什么输入"。
| 对象 | 管什么 | 不管什么 |
|---|---|---|
| Deployment | Pod 的副本数与版本:要几个、用哪个镜像、怎么换版本 | 不管流量怎么进来;不管端口对外暴露 |
| StatefulSet | 有身份的 Pod:稳定网络标识、有序启停、每副本独立存储 | 不管流量策略 |
| DaemonSet | 每个节点各跑一份(日志、监控 agent) | 不适用"副本数"这个概念 |
| Job / CronJob | 跑完就结束的任务:迁移、批处理 | 不管长驻服务(它结束是正常的) |
| Service | 稳定的虚拟地址 + 一组 Pod 的负载均衡入口 | 不管 Pod 怎么建;不管端口在节点上是否暴露 |
| ConfigMap / Secret | 给容器输入配置与凭据 | 不管怎么用(环境变量还是挂载,由容器决定) |
| Ingress | 从集群外按域名/路径路由到 Service | 不管 Service 内部怎么选 Pod |
三条最容易记错的边界:
- Service 不创建 Pod,只"选择" Pod。 它是订阅式的:声明"我要所有带这些标签的 Pod",然后由控制器持续维护地址列表。所以 Pod 的标签一变,Service 的成员就变了——哪怕 Pod 是同一个。
- Deployment 不管端口。 清单里的
containerPort是声明性信息,它不"开端口"(端口是进程监听决定的事)。真正影响流量的是 Service 的targetPort与 Pod 上实际监听的端口。这与 Docker 的EXPOSE是同一种性质的字段(见《Docker 应用课》第 03 章)。 - Job 结束是正常的。 把它当成"服务"去看状态会得到持续的错误结论——它的 Pod 变成
Completed是对的,不是崩溃。
二、标签是连接组织,而 selector 有两种完全不同的角色
K8s 里对象之间不用引用 ID 连接,而用标签连接。这带来一个后果:selector 这个字段在不同对象里,语义和可变性都不一样。
Deployment.spec.selector |
Service.spec.selector |
|
|---|---|---|
| 回答什么问题 | 哪些 Pod 归我管(我负责维持它们的副本数) | 哪些 Pod 收我的流量 |
| 匹配方式 | 必须与 Pod 模板标签一致,否则 API 拒绝 | 宽松匹配:匹配到几个算几个,0 个也合法 |
| 能不能改 | 不能(apps/v1 起,selector 不可变) |
能,随时改,立刻生效 |
| 改成无效值会怎样 | API 直接拒绝 apply | apply 成功,但服务没有 endpoints(现场那个 bug) |
| 排查时看什么 | kubectl describe deploy |
kubectl get endpoints <svc> |
倒数第二行是本章最重要的一行:Service.spec.selector 匹配不到任何 Pod 时,K8s 不会报错。因为"匹配到 0 个"在语义上是合法的——它可能只是暂时没有 Pod。于是失败从"提交时"被推到了"访问时",表现为连接被拒或 502,而不是 apply 报错。
**"Deployment 的 selector 不可变"**这一条也值得单独记住,因为它决定了修复方式:
改 Deployment 的 selector → apply 报错(字段不可变)
正确的修复 → 删掉 Deployment 再重建(会造成停机),
或者:保留旧 selector 不动,只改 Service 的 selector 去匹配新标签
第二条(只改 Service)往往才是对的——如果原本的目的只是"命名规范化",那么让 Service 去跟新标签匹配就完成了目标,而 Pod 不需要重建。这个判断的代价差别很大,而它取决于你是否清楚两种 selector 的可变性不同。
三、overlay 在改什么、不改什么
清单不可能只写一份。多环境(staging / prod)需要同一套基础结构配不同的参数,所以有了 overlay 机制。本课用 kustomize(已内置在 kubectl 里)。
一个最小结构:
k8slab/
├── base/ ← 基础:所有环境共有的结构与默认值
│ ├── kustomization.yaml
│ ├── deployment-api.yaml
│ ├── service-api.yaml
│ ├── deployment-worker.yaml
│ ├── configmap.yaml
│ └── job-migrate.yaml
└── overlays/
├── staging/ ← 只写"与 base 不同的部分"
└── prod/
prod 的 overlay 声明四件事:换命名空间、加名字前缀、改副本数与资源、换镜像 tag。构建结果(本机实测,kubectl kustomize overlays/prod):
kind=ConfigMap name=prod-orders-api-config ns=orders-prod
kind=Service name=prod-orders-api ns=orders-prod
kind=Deployment name=prod-orders-api ns=orders-prod
kind=Deployment name=prod-orders-worker ns=orders-prod
kind=Job name=prod-orders-migrate ns=orders-prod
Deployment orders-api 的 replicas: 4
镜像 tag: registry.example.com/orders-api:1.4.2
是否含 env: prod 标签: true
这一步有几处"它替你改了,但你要知道它改了"的地方:
3.1 namePrefix 改了名字,也连带改了引用
overlay 里写了 namePrefix: prod-。于是 orders-api-config 变成 prod-orders-api-config——同时,容器里引用它的地方也自动被改写了:
envFrom:
- configMapRef:
name: prod-orders-api-config # ← 被自动改写
这是 kustomize 的 nameReference 转换器在做的事:它知道"ConfigMap 的 metadata.name"和"configMapRef.name"指的是同一个名字,所以一起改。
但它不认识的引用它不会改。 例如一个写在 ConfigMap 内容里的、指向另一个 Service 的主机名,或者写在应用配置文件里的服务地址——那些是字符串,kustomize 无从判断。这解释了一类常见故障:加了前缀之后,资源名和引用都对,但应用里硬编码的服务地址失效了。
3.2 namePrefix 没有改 selector
同一个构建产物里,Service 的 selector 原样保留:
selector:
app: orders-api # ← 没被加前缀
Deployment 的 matchLabels 也一样。原因是它们是标签值,不是对象名。kustomize 严谨地区分了这两件事:前缀加在"名字"上,名字的引用跟着改;标签是另一套命名空间,不碰。
这个区分很有价值,因为它同时解释了两件事:
- 为什么加前缀之后服务还能正常工作(selector 和标签都没动,匹配关系不变);
- 以及为什么不能靠加前缀来隔离两套部署——两套不同前缀的清单如果落在同一个命名空间里,它们选的 Pod 标签是一样的,Service 会同时把两套的 Pod 都当成自己的成员。
3.3 labels 默认不进 selector,而 commonLabels 会
kustomize 里给所有资源打标签有两个写法,差别是决定性的:
# ✅ 现代写法:includeSelectors 默认 false,标签只加到 metadata
labels:
- includeSelectors: false
pairs:
env: prod
# ❌ 旧写法(已弃用):会把标签同时写进 selector
commonLabels:
env: prod
实测确认了前者的效果:构建产物里 env: prod 出现在 metadata.labels 里,而 Service 的 selector 与 Deployment 的 matchLabels 都只有 app: orders-api。
为什么 commonLabels 危险?因为它会去改 Deployment.spec.selector——而这个字段不可变。于是:
给一个已存在的 Deployment 加上 commonLabels
→ 构建产物里 selector 多了一个标签
→ apply 报错:field is immutable
→ 想让它生效,必须删掉 Deployment 重建(停产)
"给所有资源统一打一个标签"看起来是纯增益的操作,在 K8s 里却可能是不可逆的。这是 includeSelectors: false 成为默认值的原因,也是本节最该记住的一条。
3.4 patch 是"部分对象",不是"完整对象"
overlay 里的 patch 文件只写要改的字段:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 4
它不是一个完整的 Deployment。这带来一个副作用:把 patch 文件直接喂给 schema 校验器会误报。
本机实测(kubeconform -strict overlays/prod):
overlays\prod\replicas.yaml - Deployment orders-api is invalid: ...
at '/spec': missing properties 'selector', 'template'
overlays\prod\resources.yaml - Deployment orders-api is invalid: ...
at '/spec': missing property 'selector'
这些"错误"全是假的——patch 本来就不需要写 selector(kustomize 会把它合并进 base 的对象里)。正确的管道是先构建再校验:
$ kubectl kustomize overlays/prod | kubeconform -strict -summary -ignore-missing-schemas
Summary: 5 resources found parsing stdin - Valid: 5, Invalid: 0, Errors: 0, Skipped: 0
-ignore-missing-schemas 也是必需的:kustomization.yaml 这个 kind 没有对应的 K8s JSON Schema,不加这个参数它会算进 Errors。
这条判断的适用范围比 K8s 更广:校验的对象必须是"实际要部署的东西",不是仓库里的碎片文件。 任何"模板 + 数据 = 产物"的系统里都适用。
四、误判澄清
| 读者常见的理解 | 核对后的事实 | 为什么会被误导 |
|---|---|---|
| "改标签只是改名,不影响功能" | 标签是连接组织。Pod 标签一变,所有按标签选它的 Service 立刻失去成员 | 名字看起来是给人看的,实际是给机器用来连接的 |
| "Service 的 selector 写错了,apply 会报错" | 不会。匹配 0 个是合法状态,只会表现为没有 endpoints | 单对象校验器不看关系,而且"暂时没有 Pod"确实是合法中间态 |
| "Deployment 的 selector 也能改" | apps/v1 起不可变,改了 apply 直接拒绝 |
清单里的其它字段大多可改 |
"commonLabels 和 labels 是一回事,旧写法也能用" |
commonLabels 会改 selector → 撞上不可变字段 → 必须重建 Deployment |
名字更像"给所有东西打个标签"这个朴素意图 |
| "patch 文件也能单独过校验" | patch 是部分对象,直接校验必误报;必须校验构建产物 | 文件后缀一样、长得一样 |
"加了 namePrefix 就能隔离两套环境" |
前缀只改名字不改 selector;同命名空间下两套会互相选中对方的 Pod | 名字变了,看起来"隔开了",但匹配关系没变 |
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
本地 kustomize overlay |
流水线里按环境构建产物;或 GitOps 控制器直接构建 | 产物必须落地成 artifact 以便审计;否则"当时部署的是什么"只能从集群反查 |
| base + 两份 overlay | 每环境一份 overlay,共享 base 合同 | overlay 里写的每一处差异都是一份"这个环境特殊在哪"的文档,散着写就会失控 |
| 手写 patch 文件 | 结构化的参数化(Helm values / Jsonnet) | 参数化越强,产物越难预测;kustomize 的取舍是"产物可读" |
| 静态检查引用与 selector | 策略引擎做准入校验(如 Kyverno 强制 readinessProbe 存在) |
准入是最后一道闸:它能拦住将来的错误,但不能修复已经在跑的 |
| 单命名空间 | 按环境/团队划分命名空间 + RBAC | Service 的 selector 是命名空间内匹配的,所以"隔命名空间"才是真正有效的隔离手段 |
动手
- 用
kubectl kustomize构建你的清单,把产物逐对象读一遍,圈出所有selector字段; - 对每个
selector,找出它要匹配的那些 Pod 的标签,逐个核对键值(这一条不做,第 07 章那类问题就找不出来); - 检查 overlay 里有没有用
commonLabels;有的话确认这些 Deployment 是否已存在(已存在就改不动); - 判断标准:能说出"我改 Pod 标签时,还有哪些对象需要跟着改",并给出一个不需要重建 Pod 的修复路径;
- 完成标志:故意把 Service 的 selector 改错一个值,用
kubectl get endpoints确认它变成<none>——并且确认 apply 是成功的。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
| 把 Service 的 selector 改成一个不存在的标签值 | kubectl get endpoints 的输出 |
<none>,而 apply 返回成功——失败从提交时被推到访问时 |
把 Deployment 的 spec.selector 改一个标签 |
apply 是否成功 |
报 field is immutable;要生效只能删重建 |
改用 commonLabels 加标签后 apply 一个已存在的 Deployment |
报错内容 | 它会改 selector → 撞不可变字段 |
| 把 overlay 里的 patch 文件直接喂给 schema 校验器 | 报错内容 | 全是假错(missing property 'selector');必须校验构建产物 |
加 namePrefix 后检查 Service selector |
selector 是否被加前缀 | 不会(标签值不是对象名);两套前缀的清单在同命名空间会互相选中 |
| 在 ConfigMap 内容里写一个服务地址,然后给资源加前缀 | 引用是否被自动改写 | 不会——kustomize 只改它认识的引用结构,不解析字符串内容 |
自测
Deployment.spec.selector与Service.spec.selector在"匹配 0 个"时的行为差别是什么?为什么这个差别会造成"apply 成功但服务不可用"?- 为什么 Deployment 的 selector 被设计成不可变?这给"重构标签"带来了什么约束,正确的做法是什么?
namePrefix会改写哪一类引用、不改写哪一类?各举一例,并说明后者为什么是常见故障来源。labels与commonLabels的差别是什么?为什么后者可能要求你删掉 Deployment 重建?- 为什么把 overlay 里的 patch 文件直接喂给 schema 校验器会误报?正确的校验管道是什么?
- 有人想用"不同前缀"来在同一命名空间里跑两套环境。为什么这行不通?正确的隔离手段是什么?
现在能解释什么
- 四类对象各有明确职责:Deployment 管副本与版本、Service 管发现与入口、ConfigMap/Secret 管输入、Job 管一次性任务。
- 对象之间靠标签连接,所以标签不是"名字",是"连接组织";改标签是改拓扑,不是改注释。
- 两种 selector 的可变性完全不同:Deployment 的不可变(撞上就只能重建),Service 的可变(改错不报错、只是没 endpoints)。
- overlay 会替你改名字与它认识的引用,但不碰标签值、不解析字符串内容——这两处正是"看起来都对却不通"的来源。
- 校验对象必须是构建产物而不是仓库碎片;
-ignore-missing-schemas处理没有 schema 的 kind。
下一章进入第一个具体判断点:怎么告诉 K8s"这个容器活着"以及"可以给它流量了"——三种探针,三种判错后果。