Skip to main content

7. 编写 CRD

CRD 到底是什么​

上一章我们说 K8S 不认识 kind: MySQL。CRD 就是用来教它的。

CRD(CustomResourceDefinition) 是一份"词典条目"——你提交给 K8S,告诉它:"从现在起,kind: MySQL 是个合法资源,它的 spec 有这些字段,类型分别是什么。"

打个比方:K8S 是个翻译官,本来只会中文(内置资源)。你递一份"新词表"(CRD)给它,告诉它"MySQL 是名词,意思是关系型数据库"。之后你写 kind: MySQL,它就认了。

最小 CRD 长什么样​

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: mysqls.database.example.com
spec:
group: database.example.com # GVK 的 Group
names:
plural: mysqls # GVR 的 Resource
singular: mysql
kind: MySQL # GVK 的 Kind
shortNames: [mysql] # kubectl 别名
scope: Namespaced # 命名空间级还是集群级
versions:
- name: v1 # GVK 的 Version
served: true # 是否对外暴露
storage: true # 是否是存储版本
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
replicas:
type: integer
minimum: 1
maximum: 5
version:
type: string
enum: ["5.7", "8.0"]
status:
type: object
properties:
ready:
type: boolean
phase:
type: string
subresources:
status: {} # 启用 /status 子资源
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas

信息量有点大,拆开看。

metadata.name 的命名规则​

metadata.name: mysqls.database.example.com
│ │
│ └── group
└────────── plural

必须是 <plural>.<group> 格式,全局唯一。这是 CRD 自己的名字,跟它定义的资源名是两回事。

spec.group 和 spec.names​

这两个字段定义了 GVK 的 Group 和 GVR:

  • group: database.example.com → GVK 是 database.example.com/v1/MySQL
  • names.plural: mysqls → GVR 是 database.example.com/v1/mysqls
  • names.kind: MySQL → YAML 里的 kind: MySQL

shortNames 让你 kubectl get mysql 而不是 kubectl get mysqls.database.example.com。

spec.scope​

  • Namespaced:资源属于某个 namespace,跟 Pod 一样
  • Cluster:集群级,跟 Node 一样,不属于任何 namespace

MySQL 这种带业务属性的,放 Namespaced。节点、集群配置这种全局的,放 Cluster。

versions​

一个 CRD 可以有多个 version,但只有一个是 storage: true——存到 etcd 时用这个版本。其他 version 是对外暴露的"视图"。

versions:
- name: v1
served: true
storage: true # 这个版本存到 etcd
- name: v2alpha1
served: true
storage: false # 这个版本对外可见,但存储还是 v1

跟上一章说的"合同模板"一个道理——可以同时有正式版和草稿版,但归档只存正式版。

Schema:资源的说明书​

openAPIV3Schema 是 CRD 的灵魂——它定义资源长什么样。

schema:
openAPIV3Schema:
type: object
required: [spec] # spec 必填
properties:
spec:
type: object
required: [replicas] # replicas 必填
properties:
replicas:
type: integer
minimum: 1
maximum: 5
description: "MySQL 副本数,1-5 之间"
version:
type: string
enum: ["5.7", "8.0"]
description: "MySQL 版本"
storage:
type: string
pattern: "^[0-9]+Gi$"
description: "存储大小,如 10Gi"
config:
type: object
properties:
maxConnections:
type: integer
default: 100

Schema 的价值​

没有 Schema(K8S 1.15 之前 CRD 可选):你写 replicas: "三" 也能提交,Controller 拿到字符串"三"想转 int 直接崩。

有 Schema:API Server 在准入阶段就拦住——replicas 必须是整数,"三"直接报错,根本到不了 Controller。

Schema 像前台的门禁——没工牌的不让进,不用等到了工位才被赶出去。

常用约束​

约束作用例子
required必填字段required: [replicas]
minimum / maximum数值范围minimum: 1, maximum: 5
enum枚举值enum: ["5.7", "8.0"]
pattern正则匹配pattern: "^[0-9]+Gi$"
minItems / maxItems数组长度minItems: 1
x-kubernetes-list-type: map数组按 key 去重列表里的元素按 name 唯一

Spec 和 Status:期望 vs 现实​

CRD 的字段分两块:

apiVersion: database.example.com/v1
kind: MySQL
metadata:
name: my-db
spec: # 用户写的,期望状态
replicas: 3
version: "8.0"
storage: 50Gi
status: # Controller 写的,实际状态
ready: true
phase: Running
replicas: 3

spec 是用户的诉求,status 是 Controller 的汇报。

打个比方:spec 像你给装修公司的需求清单——"三室一厅,木地板"。status 像装修公司的进度汇报——"主卧完成,客厅施工中"。你改需求清单(spec),公司改进度(status),各管各的。

为什么 spec 和 status 要分开​

如果混在一起,Controller 写 status 时不小心把 spec 也改了——用户的期望就被篡改了。分开后,API Server 能做字段隔离:Controller 只能写 status,写 spec 会被拒。

subresources: status​

subresources:
status: {}

这一行启用 /status 子资源。启用后:

  • 更新 status 要走 PUT /apis/.../mysqls/my-db/status 这个专门路径
  • status 跟 spec 的更新走不同请求,互不干扰
  • kubectl get mysql my-db -o jsonpath='{.status.phase}' 能精确拿 status

不启用的话,status 跟 spec 混在一个资源对象里更新,容易冲突。

subresources: scale​

subresources:
scale:
specReplicasPath: .spec.replicas
statusReplicasPath: .status.replicas

启用后,kubectl scale mysql my-db --replicas=5 能用——只改 spec.replicas,不用提交整个 YAML。

HPA(水平 Pod 自动扩缩容)也靠这个——它只认启用了 scale 子资源的资源。

字段设计的几条原则​

原则 1:spec 描述"要什么",不描述"怎么做"​

# 对:用户说我想要 3 副本
spec:
replicas: 3

# 错:用户不该关心用哪个 Pod 创建
spec:
podNames: [mysql-0, mysql-1, mysql-2]

用户说"我要 3 个",Controller 决定"叫什么名字、跑在哪台机器"。把实现细节从 spec 里赶出去。

原则 2:status 要反映真实状态​

# 对:Controller 汇报实际跑起来了几个
status:
replicas: 2
readyReplicas: 2
phase: Running

# 错:status 只有"已处理"这种没用的信息
status:
processed: true

status 的价值是让用户和监控系统能看到"现在到底什么情况"。phase、replicas、readyReplicas、conditions 这些字段是 K8S 生态的习惯写法。

原则 3:用 conditions 描述多维状态​

status:
conditions:
- type: Ready
status: "True"
lastTransitionTime: "2026-07-01T10:00:00Z"
reason: AllReplicasReady
message: "所有副本就绪"
- type: StorageReady
status: "False"
lastTransitionTime: "2026-07-01T10:05:00Z"
reason: PVCRunning
message: "PVC 绑定中"

phase 是单维度(一个字符串),conditions 是多维度(多个状态项)。复杂状态用 conditions——每个独立的子状态一条,监控系统能精确告警。

提交 CRD​

kubectl apply -f mysql-crd.yaml

提交后,K8S 的 CRD Controller(也在 controller-manager 里)会注册这个新资源类型。几秒钟后:

$ kubectl get crd mysqls.database.example.com
NAME CREATED AT
mysqls.database.example.com 2026-07-01T10:00:00Z

$ kubectl api-resources | grep mysql
mysqls database.example.com/v1 true MySQL

$ kubectl explain mysql.spec.replicas
KIND: MySQL
VERSION: database.example.com/v1

FIELD: replicas <integer>
Number of MySQL replicas.

kubectl explain 能用了,说明 schema 注册成功。之后你写 kind: MySQL 的 YAML,K8S 认了。

CRD 只是定义,逻辑还没写​

CRD 提交后,你写 kind: MySQL 的 YAML 能存进 etcd——但什么都不会发生。因为还没有 Controller 在调谐它。

你提交 MySQL CR → 存进 etcd → 然后……没了
(没 Controller 在看)

CRD 是菜单上加了一道菜,但还没厨师。下一章我们写最精简的 Controller——裸 client-go 实现 Informer + Workqueue + Reconcile,把厨师请来。

前置阅读​

  • 控制器模式与CRD — Starter 第 12 章,CRD 的概念入门
  • GVK与GVR — 本章前置,理解 GVK 才能看懂 CRD 的 group/version/kind 配置
  • 存储管理 — Starter 第 7 章,CRD 经常需要配 PVC 管理数据持久化

思考题​

问题 1:CRD 的 metadata.name 为什么必须是 <plural>.<group> 格式?

问题 2:spec 和 status 分开存,但同一个资源对象。Controller 更新 status 时,会不会把用户刚改的 spec 覆盖掉?

问题 3:为什么 subresources.scale 启用后 HPA 才能用?


解答​

问题 1:CRD 自己也是个 K8S 资源(kind: CustomResourceDefinition),资源名必须全局唯一。用 <plural>.<group> 格式保证不冲突——不同 group 下可以有同名的资源(比如 mysqls.a.com 和 mysqls.b.com),但 CRD 名字不一样。

问题 2:不会。启用 /status 子资源后,Controller 走 PUT /status 路径,API Server 只更新 status 字段,spec 保持不变。即使没启用 status 子资源,API Server 也有 resourceVersion 乐观锁——Controller 读到 v1,用户已经改成 v2,Controller 提交时版本号对不上,会被拒。Controller 重新读再写就行。

问题 3:HPA 的逻辑是"读当前副本数,算目标副本数,写回去"。它需要一个稳定的接口来读 replicas 和写 replicas。/scale 子资源就是这个接口——HPA 不用关心资源是 MySQL 还是 Redis 还是 Deployment,只要有 /scale 子资源,它就能 scale。这是 K8S 的接口抽象设计——把"扩缩容"这个能力标准化了。

下一章​

CRD 已经教 K8S 认识了新资源。下一章我们写最精简的 Controller——用裸 client-go 实现 Informer + Workqueue + Reconcile,让它真的动起来。