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

三条最容易记错的边界:

  1. Service 不创建 Pod,只"选择" Pod。 它是订阅式的:声明"我要所有带这些标签的 Pod",然后由控制器持续维护地址列表。所以 Pod 的标签一变,Service 的成员就变了——哪怕 Pod 是同一个。
  2. Deployment 不管端口。 清单里的 containerPort 是声明性信息,它不"开端口"(端口是进程监听决定的事)。真正影响流量的是 Service 的 targetPort 与 Pod 上实际监听的端口。这与 Docker 的 EXPOSE 是同一种性质的字段(见《Docker 应用课》第 03 章)。
  3. 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 严谨地区分了这两件事:前缀加在"名字"上,名字的引用跟着改;标签是另一套命名空间,不碰。

这个区分很有价值,因为它同时解释了两件事:

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 是命名空间内匹配的,所以"隔命名空间"才是真正有效的隔离手段

动手

  1. 用 kubectl kustomize 构建你的清单,把产物逐对象读一遍,圈出所有 selector 字段;
  2. 对每个 selector,找出它要匹配的那些 Pod 的标签,逐个核对键值(这一条不做,第 07 章那类问题就找不出来);
  3. 检查 overlay 里有没有用 commonLabels;有的话确认这些 Deployment 是否已存在(已存在就改不动);
  4. 判断标准:能说出"我改 Pod 标签时,还有哪些对象需要跟着改",并给出一个不需要重建 Pod 的修复路径;
  5. 完成标志:故意把 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 只改它认识的引用结构,不解析字符串内容

自测

  1. Deployment.spec.selector 与 Service.spec.selector 在"匹配 0 个"时的行为差别是什么?为什么这个差别会造成"apply 成功但服务不可用"?
  2. 为什么 Deployment 的 selector 被设计成不可变?这给"重构标签"带来了什么约束,正确的做法是什么?
  3. namePrefix 会改写哪一类引用、不改写哪一类?各举一例,并说明后者为什么是常见故障来源。
  4. labels 与 commonLabels 的差别是什么?为什么后者可能要求你删掉 Deployment 重建?
  5. 为什么把 overlay 里的 patch 文件直接喂给 schema 校验器会误报?正确的校验管道是什么?
  6. 有人想用"不同前缀"来在同一命名空间里跑两套环境。为什么这行不通?正确的隔离手段是什么?

现在能解释什么

下一章进入第一个具体判断点:怎么告诉 K8s"这个容器活着"以及"可以给它流量了"——三种探针,三种判错后果。

进入 keel 阅读