Kubernetes 基础体系 · 第 76/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。

Kubernetes CRD:Schema、版本、Defaulting、Validation、Conversion 和存储

CustomResourceDefinition(CRD)是 Kubernetes 用来声明新资源类型的 API 扩展机制。它把一个资源类型接入 API Server,使该资源能够使用 Kubernetes API 的统一能力:

  • 通过 REST API 访问;
  • 使用 kubectl get/apply/patch/delete 操作;
  • 持久化到 etcd;
  • 通过 informer、cache、workqueue 被 Controller 监听;
  • 接受 schema 校验、默认值、Admission 和版本转换。

但 CRD 并不等于“为 YAML 注册一个新 Kind”。一个生产可用的 CRD 至少同时涉及六个问题:

  1. Schema:对象允许有哪些字段,它们的类型和结构是什么;
  2. Version:客户端可以访问哪些版本,哪个版本是存储版本;
  3. Defaulting:字段缺失时,API Server 是否补充默认值;
  4. Validation:哪些对象应被拒绝;
  5. Conversion:不同 API 版本之间如何保持语义一致;
  6. Storage:对象以什么版本、什么序列化形式写入 etcd,以及如何迁移已有数据。

这些机制存在严格的先后关系。以一个典型的写请求为例,逻辑上可以抽象为:

客户端请求某个 served 版本
        │
        ▼
API Server 解码对象
        │
        ▼
版本相关的默认值、字段处理和 Admission
        │
        ▼
必要时调用 Conversion Webhook
        │
        ▼
以 storage 版本校验并持久化
        │
        ▼
向客户端返回其请求版本的对象

具体调用顺序会受到 Admission、patch 类型和 Kubernetes 版本实现细节影响,因此不能把上图当作所有请求路径的逐字节时序。但有一条稳定原则:客户端版本、内部转换版本和存储版本是三个不同概念,不能混为一谈。


一、先建立 CRD 的对象模型

1.1 CRD、Custom Resource 和 Controller 的关系

CRD 是“资源类型定义”,例如:

kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  names:
    kind: Database
    plural: databases
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema: ...

安装 CRD 后,用户才能创建 Custom Resource(CR):

apiVersion: example.com/v1
kind: Database
metadata:
  name: prod-db
  namespace: default
spec:
  replicas: 3

这两个对象作用不同:

对象 作用
CRD 声明 API 资源类型
CR 该类型的一个实例
Controller 观察 CR,并将实际系统状态调整到期望状态

CRD 本身通常不实现业务逻辑。创建 Database 对象不会自动创建数据库,除非某个 Controller 监听 Database 并执行对应的 Reconcile。

因此,CRD 的 Schema、Defaulting 和 Validation 决定了“什么对象可以进入 API”,Controller 则决定“进入 API 的对象如何产生实际效果”。


1.2 Group、Version、Kind 和 Resource

CRD API 使用 Kubernetes 的 GVK/GVR 体系。

对于下面的对象:

apiVersion: example.com/v1
kind: Database

可以拆成:

  • Group:example.com
  • Version:v1
  • Kind:Database

CRD 中还需要声明资源名称:

names:
  plural: databases
  singular: database
  kind: Database
  shortNames:
    - db

于是资源通常通过以下路径访问:

/apis/example.com/v1/namespaces/default/databases

这里的 databases 是 Resource,也叫 plural;Database 是 Kind。两者不是同一个概念:

kubectl get databases
kubectl get database
kubectl get db

是否都可用,取决于 CRD 是否声明了 singularshortNames。Controller-runtime 或 client-go 中也需要使用正确的 GVR/GVK,否则可能出现“资源存在,但 informer 监听不到”的问题。

CRD 的 scope 只有两个选择:

scope: Namespaced

或:

scope: Cluster

Namespaced 资源的对象路径带 namespace;Cluster 资源没有 namespace。这个选择一旦确定,后续不能简单地当作字段修改,因为它改变了整个 API 路径和对象身份模型。


二、Schema:CRD 的结构契约

2.1 Schema 不只是文档

CRD 的 Schema 使用 OpenAPI v3 Schema 表达。它至少承担四类职责:

  1. 描述字段类型;
  2. 校验客户端提交的数据;
  3. 支持 Kubernetes API 的字段剪枝;
  4. 为 Defaulting、Server-Side Apply 和部分工具提供字段结构信息。

一个最小但可用的 apiextensions.k8s.io/v1 CRD 如下:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  scope: Namespaced
  names:
    plural: databases
    singular: database
    kind: Database
    shortNames:
      - db
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicas:
                  type: integer
                  format: int32
                  minimum: 1
                engine:
                  type: string
                  enum:
                    - postgres
                    - mysql
              required:
                - engine
            status:
              type: object
              properties:
                phase:
                  type: string
          required:
            - spec

对应的 CR:

apiVersion: example.com/v1
kind: Database
metadata:
  name: prod-db
  namespace: default
spec:
  engine: postgres
  replicas: 3

这里:

  • spec.replicas 必须是整数,且不小于 1;
  • spec.engine 只能取两个枚举值;
  • spec.engine 是必填字段;
  • spec 本身也是必填字段;
  • status 被描述,但没有被声明为必填。

type: object 很重要。一个字段如果实际是对象,却没有以结构化对象方式描述,会影响结构化 Schema 判定、剪枝、默认值和 CEL 验证。


2.2 Structural Schema

Kubernetes apiextensions.k8s.io/v1 要求 CRD 使用 Structural Schema。它不是“所有 OpenAPI Schema 都可以直接使用”,而是要求 Schema 的结构足够明确,便于 API Server 对字段进行统一处理。

一个常见的非结构化错误是:

properties:
  spec:
    properties:
      replicas:
        type: integer

spec 没有声明 type: object。正确写法是:

properties:
  spec:
    type: object
    properties:
      replicas:
        type: integer

结构化 Schema 的核心直觉是:API Server 必须能在不执行用户代码的情况下判断每个字段的类型和位置。例如它需要知道:

spec.replicas 是整数
spec.template.metadata.labels 是 map[string]string
spec.backends 是一个数组

否则它无法可靠地完成默认值、剪枝、字段管理和验证。

安装 CRD 后可以查看 API Server 是否接受了 Schema:

kubectl apply -f database-crd.yaml
kubectl get crd databases.example.com -o yaml

如果 Schema 不满足要求,常见失败表现包括:

spec.versions[0].schema.openAPIV3Schema:
  Required value: schemas must be structural

或 CRD 状态中出现异常条件:

kubectl describe crd databases.example.com

应重点查看:

  • NamesAccepted
  • Established
  • NonStructuralSchema
  • Terminating

CRD 创建成功不等于 CRD 已经可以正常服务。API Server 还需要建立对应的 API 资源。


2.3 未知字段与剪枝

默认情况下,CRD 对象中 Schema 未声明的字段会被剪枝,也就是从对象中移除。

例如 Schema 只声明:

spec:
  type: object
  properties:
    replicas:
      type: integer

用户提交:

spec:
  replicas: 3
  debug: true

如果没有特殊配置,存储后的对象通常相当于:

spec:
  replicas: 3

debug 不会因为客户端 YAML 中写过就自动保留。

这会导致一个常见误解:用户以为 Controller 能看到所有原始字段,但 API Server 可能已经在持久化前删除了未声明字段。Controller 从 informer cache 读到的是 API Server 接受并存储后的对象,而不是用户最初提交的原始 YAML。

如果确实要保留任意未知字段,可以使用:

x-kubernetes-preserve-unknown-fields: true

例如:

properties:
  spec:
    type: object
    x-kubernetes-preserve-unknown-fields: true

这表示 spec 下未被 Schema 描述的字段不剪枝。但它不是“免费获得动态 JSON”的开关:

  • 失去未知字段的结构化校验;
  • 默认值无法作用于未知字段;
  • Server-Side Apply 无法像处理结构化字段那样精确管理字段所有权;
  • Controller 必须自行处理任意 JSON 结构。

如果只需要存放一段 JSON,通常可以把它明确设计为 RawExtension 或 JSON 对象;如果业务字段本身稳定,优先写出明确 Schema。


2.4 Kubernetes 特殊扩展字段

某些 Kubernetes 语义无法仅靠标准 OpenAPI 表达,因此 CRD Schema 提供了扩展字段。

常见例子包括:

x-kubernetes-preserve-unknown-fields: true

表示保留未知字段。

x-kubernetes-list-type: map
x-kubernetes-list-map-keys:
  - name

表示数组逻辑上是按 name 唯一标识的 map-like list,而不是普通按位置比较的数组。

例如:

ports:
  type: array
  x-kubernetes-list-type: map
  x-kubernetes-list-map-keys:
    - name
  items:
    type: object
    properties:
      name:
        type: string
      port:
        type: integer

这会影响 Server-Side Apply 的合并语义。下面两个列表在 map-like 语义下,name 相同的元素被认为是同一项:

- name: http
  port: 80
- name: metrics
  port: 9090

与普通数组不同,普通数组通常按索引或整体集合处理,合并冲突行为也不同。

其他常见扩展包括:

  • x-kubernetes-int-or-string
  • x-kubernetes-embedded-resource
  • x-kubernetes-map-type
  • x-kubernetes-validations

使用这些扩展时,必须同时考虑 kubectl、Server-Side Apply 和 Controller 使用的结构化客户端是否理解对应语义。它们不是普通 YAML 注释,而是 API 行为的一部分。


三、Defaulting:缺省值如何进入对象

3.1 Schema 默认值

CRD 可以在 Schema 中声明默认值:

properties:
  spec:
    type: object
    properties:
      replicas:
        type: integer
        format: int32
        minimum: 1
        default: 1

提交:

apiVersion: example.com/v1
kind: Database
metadata:
  name: test-db
spec:
  engine: postgres

API Server 处理后,客户端再次读取时通常可以看到:

spec:
  engine: postgres
  replicas: 1

关键点是:默认值不是 Controller 在内存中临时推导出来的,而是 API Server 对对象执行 defaulting 后写入对象模型,并参与后续处理。

但默认值有一个重要边界:默认值通常只在对象创建或更新等写入路径上应用,不会因为一次纯 GET 就改写旧对象。

因此,一个历史对象可能在 etcd 中没有 spec.replicas

spec:
  engine: postgres

即使后来 CRD Schema 为 replicas 增加了 default: 1,直接执行:

kubectl get database test-db -o yaml

也不应假设对象一定已经被回写为:

spec:
  replicas: 1

要让已有对象获得新默认值,需要触发合法的更新,或者由迁移程序批量读取并更新对象。不能把“更新 CRD Schema”误认为“自动重写所有旧 CR”。


3.2 嵌套对象的默认值陷阱

考虑下面的 Schema:

spec:
  type: object
  properties:
    backup:
      type: object
      properties:
        enabled:
          type: boolean
          default: false

用户提交:

spec: {}

很多人会预期 API Server 自动生成:

spec:
  backup:
    enabled: false

backup 本身不存在时,嵌套对象内部的默认值未必会被触发。更明确的写法是同时给父对象默认值:

spec:
  type: object
  properties:
    backup:
      type: object
      default: {}
      properties:
        enabled:
          type: boolean
          default: false

或者直接给完整对象默认值:

backup:
  type: object
  default:
    enabled: false
  properties:
    enabled:
      type: boolean
      default: false

这里的逻辑是:

  1. backup 缺失;
  2. 只有先把 backup 创建为 {},API Server 才有对象节点可以继续处理 backup.enabled
  3. 然后 enabled 的默认值才有机会应用。

实际行为还会受 Kubernetes 版本和 Schema 结构影响,因此嵌套默认值应通过目标集群的集成测试验证,而不是只依赖 YAML 直觉。


3.3 Defaulting 与 Controller 默认值

Controller 也可以在 Reconcile 中处理缺省值:

if obj.Spec.Replicas == nil {
    replicas := int32(1)
    obj.Spec.Replicas = &replicas
    // Update 或 Patch
}

但这与 Schema defaulting 的语义不同。

API Server 默认值:

  • 在对象进入存储前生效;
  • 对其他 Admission、Controller 和客户端可见;
  • 可以避免多个 Controller 各自实现不同默认值;
  • 依赖 CRD Schema 与 API Server 能力。

Controller 默认值:

  • 只在 Controller 观察到对象后生效;
  • 如果不写回 API Server,其他客户端看不到;
  • 可能造成第一次 Reconcile 需要额外一次更新;
  • Controller 停止时不会发生。

因此,静态、与资源类型定义直接相关的默认值优先放在 Schema 中;需要依赖外部状态、集群能力或业务计算的值不能简单写成 Schema default。

例如:

replicas: 1

适合 Schema 默认值;而:

storageClass: 当前集群中默认 StorageClass

通常不适合,因为它依赖外部资源,且默认结果可能随时间变化。


四、Validation:拒绝不合法对象

4.1 OpenAPI 类型校验

Schema 校验首先覆盖基本类型和范围:

replicas:
  type: integer
  format: int32
  minimum: 1
  maximum: 100

engine:
  type: string
  enum:
    - postgres
    - mysql

以下对象会被 API Server 拒绝:

spec:
  replicas: "3"

因为字符串不是整数。

spec:
  replicas: 0

因为小于 minimum: 1

spec:
  engine: oracle

因为不在 enum 中。

错误通常类似:

Database.example.com "test-db" is invalid:
spec.replicas: Invalid value: 0: must be greater than or equal to 1

验证是在对象进入持久化之前执行的,因此 Controller 通常不会收到一个已经违反 OpenAPI Schema 的对象。


4.2 必填字段与可选字段

下面两个 Schema 语义不同:

properties:
  replicas:
    type: integer

和:

required:
  - replicas
properties:
  replicas:
    type: integer

前者表示字段可以缺失;后者表示字段必须存在。

如果字段可选但有默认值:

replicas:
  type: integer
  default: 1

其语义通常是:

用户不提供 replicas → API Server 写入 1
用户提供 replicas: 3 → 保留 3
用户提供 replicas: 0 → 先默认无效,因为字段已提供,随后因 minimum 校验失败

默认值不是用来覆盖用户输入的修正器,而是缺省值。


4.3 CEL 验证规则

对于跨字段约束,OpenAPI 的 minimumenum 等单字段规则不够用。CRD 支持通过 x-kubernetes-validations 使用 CEL 表达式。

例如要求 maxReplicas 不小于 minReplicas

spec:
  type: object
  properties:
    minReplicas:
      type: integer
      format: int32
      minimum: 1
    maxReplicas:
      type: integer
      format: int32
      minimum: 1
  x-kubernetes-validations:
    - rule: "self.maxReplicas >= self.minReplicas"
      message: "maxReplicas must be greater than or equal to minReplicas"

这里 self 表示当前 Schema 作用范围内的对象,即整个 spec

也可以限制字段只能在创建时设置,或者更新时不能降低某个值:

x-kubernetes-validations:
  - rule: "self == oldSelf"
    message: "field is immutable"

oldSelf 表示更新前的字段值。使用这类规则前必须确认作用范围和 Kubernetes 版本支持情况,因为 CEL 的可用变量、宏和 Kubernetes 扩展会随着版本演进。

CEL 的验证逻辑可以形式化为:

accept(object) 当且仅当 rule(object, oldObject) = true

其中:

  • object 是待写入对象;
  • oldObject 是更新前对象,创建时通常不存在;
  • rule 是 CEL 表达式。

例如:

maxReplicas >= minReplicas

若提交:

minReplicas: 5
maxReplicas: 3

则:

3 >= 5 = false

API Server 拒绝请求。

CEL 适合表达资源内部的声明式约束,但不适合查询其他资源、访问网络或执行复杂业务逻辑。它不能替代 Controller 的业务校验,例如“外部数据库是否可连接”。


4.4 Validation 与业务校验的边界

可以把校验分成三层:

层次 示例 适合位置
语法/类型 replicas 必须是整数 OpenAPI Schema
对象内部约束 max >= min CEL
外部状态/业务可执行性 StorageClass 是否存在 Controller 或 Admission Webhook

例如:

storageClass: fast-ssd

Schema 可以验证它是字符串,但无法仅凭 Schema 判断 fast-ssd 是否在当前集群中存在。即使 Admission Webhook 能检查,也要考虑并发变化和 Webhook 故障;最终 Controller 仍然必须处理实际执行失败。


五、版本:访问版本与存储版本

5.1 一个 CRD 可以声明多个版本

多版本 CRD 的结构如下:

versions:
  - name: v1alpha1
    served: true
    storage: false
    schema:
      openAPIV3Schema: ...

  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema: ...

两个布尔字段含义不同:

  • served: true:API Server 接受客户端通过该版本访问;
  • storage: true:该版本用于持久化对象。

同一个 CRD 必须满足:

恰好一个版本 storage: true

通常也要求至少有一个版本 served: true,否则资源无法被正常访问。

客户端使用:

apiVersion: example.com/v1alpha1
kind: Database

API Server 接受请求后,不一定以 v1alpha1 写入 etcd;如果 v1 是 storage 版本,则对象会转换为 v1 后保存。


5.2 版本不是字符串别名

下面这些版本具有 Kubernetes API 约定的稳定性排序:

v1alpha1
v1beta1
v1

版本号还会影响 API Discovery 和客户端选择。v1 不只是 v1alpha1 的别名,它通常意味着更稳定的兼容承诺。

版本设计应区分:

  • API 版本:面向用户和客户端;
  • Go 类型版本:Controller 代码中的结构体包路径;
  • 存储版本:etcd 中对象采用的版本;
  • 内部版本:某些 Kubernetes 内部 API 使用的 hub 类型。

在 CRD Controller 中,api/v1api/v1alpha1 往往对应两个 Go package:

api/v1alpha1
api/v1

它们可以有不同字段,但不能只改包名而不定义转换语义。


5.3 版本 Schema 可以不同

例如旧版本使用:

spec:
  replicas: 3

新版本改为:

spec:
  size: 3

这不是简单的字段重命名,因为两个版本的语义映射是:

v1alpha1.spec.replicas ↔ v1.spec.size

如果仍然使用 conversion.strategy: None,API Server 不会知道 replicassize 是同一个业务字段。结果可能是:

  1. 客户端提交 v1alpha1.spec.replicas
  2. API Server 原样转换或不转换;
  3. v1.spec Schema 校验;
  4. replicas 因未在 v1 Schema 中声明而被剪枝;
  5. Controller 读取到的 size 为空。

因此,当不同版本的字段结构或语义不兼容时,必须使用 Conversion Webhook。


六、Conversion:不同版本之间保持同一语义

6.1 Conversion 的目标

Conversion 不是普通的数据格式转换,而是把一个版本的 API 表示转换成另一个版本的 API 表示,同时保持业务含义。

定义两个版本:

S = v1alpha1 表示
H = v1 表示

v1 是 Hub 版本,需要实现:

toHub:   S → H
fromHub: H → S

理想情况下,对可表示的对象满足:

fromHub(toHub(s)) ≈ s
toHub(fromHub(h)) ≈ h

这里的 不是字节级完全相等,而是语义等价。例如:

  • 字段顺序不同不影响语义;
  • API Server 增加默认值后,旧对象可能多出显式字段;
  • 旧版本无法表达新版本字段时,不能保证完全往返。

如果 v1alpha1 支持:

spec:
  replicas: 3

而 v1 支持:

spec:
  size: 3

则转换函数应明确实现:

v1alpha1.replicas = 3
        ↓
v1.size = 3

反向转换则为:

v1.size = 3
        ↓
v1alpha1.replicas = 3

6.2 Hub-and-Spoke 模式

在多版本 CRD 中,常见设计是选择一个 Hub 版本。其他版本叫 Spoke。

flowchart LR
    A[客户端 v1alpha1] -->|请求对象| APIServer[API Server]
    B[客户端 v1] -->|请求对象| APIServer
    APIServer -->|ConversionReview| Webhook[Conversion Webhook]
    Webhook -->|v1alpha1 -> Hub v1| H[Hub v1]
    Webhook -->|Hub v1 -> v1alpha1| A
    H --> Storage[etcd storage version]

例如:

v1alpha1 ──转换──> v1 Hub
v1beta1  ──转换──> v1 Hub
v1        ─────────> v1 Hub

这样可以避免为每一对版本实现独立转换。若有三个版本:

  • 直接两两转换需要 6 个方向;
  • 统一通过 Hub,只需每个版本实现到 Hub 和从 Hub 的转换。

Hub 必须能表达其他版本的重要语义。若 Hub 丢失旧版本信息,转换就不是可逆的。


6.3 不可逆转换与丢失字段

假设新版本支持:

spec:
  replicas: 3
  backup:
    enabled: true

而旧版本没有 backup 字段。转换:

v1 → v1alpha1

时无法表达 backup.enabled: true

有三种处理策略:

  1. 明确旧版本不支持该对象;
  2. 将信息编码到旧版本允许的扩展字段;
  3. 接受信息丢失,但记录清楚且禁止往返覆盖。

第三种风险很大。一个典型数据路径是:

1. 用户使用 v1 创建 backup.enabled=true
2. 对象存储为 v1
3. 用户用 v1alpha1 读取
4. 转换器无法表达 backup,返回旧版本对象
5. 用户修改 replicas 并提交 v1alpha1
6. 转换器再转回 v1
7. backup.enabled 被丢失

因此转换器必须区分:

读取旧版本展示时暂时隐藏字段

和:

将旧版本对象更新回存储版本

后者可能覆盖新字段。生产设计中通常需要:

  • 禁止旧版本修改无法表达的新字段;
  • 使用 annotation 保存无法表达的信息;
  • 或让旧版本只读;
  • 并通过测试覆盖“新版本创建 → 旧版本更新 → 新版本读取”的路径。

七、Conversion Webhook 的配置与请求

7.1 CRD 配置

一个使用 Webhook 转换的 CRD 片段如下:

spec:
  conversion:
    strategy: Webhook
    webhook:
      conversionReviewVersions:
        - v1
        - v1beta1
      clientConfig:
        service:
          namespace: operators
          name: database-conversion
          path: /convert
          port: 443
        caBundle: BASE64_ENCODED_CA

注意:

  • strategy 设置为 Webhook
  • conversionReviewVersions 表示 Webhook 支持的 ConversionReview API 版本;
  • clientConfig 指向一个 Kubernetes Service;
  • Webhook 必须使用 API Server 信任的 TLS 证书;
  • Service、Deployment、证书和 CRD 安装顺序必须协调。

conversionReviewVersions 是 ConversionReview 的版本,不是自定义资源本身的 v1v1alpha1。这两个“版本”经常被混淆。


7.2 ConversionReview 数据流

API Server 调用 Webhook 时,会发送类似结构:

{
  "apiVersion": "apiextensions.k8s.io/v1",
  "kind": "ConversionReview",
  "request": {
    "uid": "request-id",
    "desiredAPIVersion": "example.com/v1",
    "objects": [
      {
        "apiVersion": "example.com/v1alpha1",
        "kind": "Database",
        "metadata": {
          "name": "prod-db"
        },
        "spec": {
          "replicas": 3
        }
      }
    ]
  }
}

Webhook 必须:

  1. 读取 request.uid
  2. desiredAPIVersion 转换每个对象;
  3. 保留对象的 metadata、resourceVersion、UID 等必要信息;
  4. 返回相同 UID;
  5. 返回与目标版本匹配的对象列表。

返回结构类似:

{
  "apiVersion": "apiextensions.k8s.io/v1",
  "kind": "ConversionReview",
  "response": {
    "uid": "request-id",
    "convertedObjects": [
      {
        "apiVersion": "example.com/v1",
        "kind": "Database",
        "metadata": {
          "name": "prod-db"
        },
        "spec": {
          "size": 3
        }
      }
    ]
  }
}

uid 不匹配时,API Server 应将响应视为错误。Webhook 不应生成新的对象身份,也不应在转换阶段执行创建 Deployment、修改外部数据库等副作用操作。转换请求可能因 GET、LIST、WATCH、写入或存储迁移被调用,必须设计为无副作用且可重复。


7.3 转换函数的 Go 结构

replicassize 为例,类型可以设计为:

// api/v1alpha1/types.go
type DatabaseSpec struct {
	Replicas int32 `json:"replicas,omitempty"`
	Engine   string `json:"engine"`
}

type Database struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec              DatabaseSpec `json:"spec"`
}

// api/v1/types.go
type DatabaseSpec struct {
	Size   int32  `json:"size,omitempty"`
	Engine string `json:"engine"`
}

转换逻辑的核心应是纯函数:

func alphaToV1(in *v1alpha1.Database) *v1.Database {
	out := &v1.Database{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "example.com/v1",
			Kind:       "Database",
		},
		ObjectMeta: *in.ObjectMeta.DeepCopy(),
		Spec: v1.DatabaseSpec{
			Size:   in.Spec.Replicas,
			Engine: in.Spec.Engine,
		},
	}
	return out
}

func v1ToAlpha(in *v1.Database) *v1alpha1.Database {
	out := &v1alpha1.Database{
		TypeMeta: metav1.TypeMeta{
			APIVersion: "example.com/v1alpha1",
			Kind:       "Database",
		},
		ObjectMeta: *in.ObjectMeta.DeepCopy(),
		Spec: v1alpha1.DatabaseSpec{
			Replicas: in.Spec.Size,
			Engine:   in.Spec.Engine,
		},
	}
	return out
}

生产代码还必须处理:

  • 指针字段与缺失字段;
  • metav1.ObjectMeta 深拷贝;
  • status
  • spec 中嵌套列表和 map;
  • enum、单位和时间格式;
  • 旧版本无法表达的新字段;
  • nil 与零值的区别。

例如:

Replicas int32 `json:"replicas,omitempty"`

中,整数 0 可能因 omitempty 不被序列化;如果 0 是非法值,应由 Schema 明确拒绝,不能依赖 JSON 是否输出来表达业务含义。对于“缺失”和“显式为零”必须区分的字段,应使用指针:

Replicas *int32 `json:"replicas,omitempty"`

7.4 Webhook 故障路径

Conversion Webhook 是 API Server 请求链上的依赖。以下故障都可能使资源不可用:

  • Service 不存在;
  • DNS 解析失败;
  • TLS 证书不受信任;
  • Pod 无健康实例;
  • Webhook 超时;
  • 返回非法 ConversionReview;
  • 返回对象版本错误;
  • 转换逻辑 panic 或处理大批量对象过慢。

可能的表现包括:

conversion webhook for example.com/v1alpha1 failed

或:

context deadline exceeded

重要的是,Webhook 故障不只影响“创建旧版本对象”。API Server 在读取、列出、观察或存储转换时也可能需要转换。因此下列操作都可能受影响:

kubectl get databases
kubectl get databases -o yaml
kubectl apply -f old-version.yaml
kubectl delete database prod-db

如果 Conversion Webhook 已经不可用,不应贸然删除 CRD。删除 CRD 会删除该资源类型及其所有 Custom Resource 数据,这是高风险破坏性操作。恢复顺序通常是:

  1. 恢复 Webhook Deployment;
  2. 恢复 Service 和 TLS;
  3. 检查 API Server 到 Service 的网络;
  4. 查看 CRD 条件和 API Server 日志;
  5. 重新执行只读查询;
  6. 再执行写入或迁移。

八、Defaulting、Validation 和 Conversion 的组合关系

多版本 CRD 中,每个版本可以有自己的 Schema:

versions:
  - name: v1alpha1
    served: true
    storage: false
    schema:
      openAPIV3Schema: ...
  - name: v1
    served: true
    storage: true
    schema:
      openAPIV3Schema: ...

这产生几个必须测试的路径:

v1alpha1 创建 → v1 读取
v1 创建 → v1alpha1 读取
v1alpha1 更新 → v1 读取
v1 更新 → v1alpha1 更新 → v1 读取

假设:

v1alpha1:
  replicas: 可缺失,默认 1

v1:
  size: 必填,默认 1

客户端通过 v1alpha1 创建且未填写 replicas 时,期望语义是:

请求 v1alpha1
→ v1alpha1 defaulting 得到 replicas=1
→ 转换为 v1.size=1
→ 使用 v1 Schema 验证
→ 以 v1 存储

但不能仅凭这个例子推断所有版本的 defaulting 都自动互相等价。版本之间的默认值必须满足语义一致性:

default_v1(toHub(x)) ≈ toHub(default_v1alpha1(x))

如果两边不相等,就会出现版本依赖的结果。例如:

v1alpha1 默认 replicas=1
v1 默认 size=3

那么同一个“未指定副本数”的用户意图,在不同请求版本下会得到不同结果。除非这是明确设计,否则属于 API 不一致。

工程上通常应遵循:

  • 默认值属于版本 API 合约;
  • 转换器必须保持最终业务语义;
  • 新版本新增字段时,旧版本转换必须定义缺省行为;
  • 不要让 Conversion Webhook 依赖“某个版本一定先默认过”这一未验证假设;
  • 对每个请求版本和对象状态建立集成测试。

九、存储:对象最终如何进入 etcd

9.1 storage 版本

CRD 的 storage: true 指定对象持久化使用的 API 版本。

例如:

versions:
  - name: v1alpha1
    served: true
    storage: false
  - name: v1
    served: true
    storage: true

客户端即使请求:

example.com/v1alpha1

API Server 也可以执行:

v1alpha1 → v1 → 序列化 → etcd

读取时则可能执行:

etcd 中的 v1 对象
        ↓
转换为客户端请求的 v1alpha1
        ↓
返回 HTTP 响应

因此,kubectl get -o yaml 显示的 apiVersion 不一定等于 etcd 内部实际保存的版本。kubectl 显示的是客户端请求或协商得到的 API 表示。


9.2 Schema 不会自动迁移历史对象

把 CRD 的 storage 版本从 v1alpha1 改成 v1,不等于 etcd 中所有旧对象已经立即改写成 v1

历史对象可能仍以旧存储版本存在。API Server 需要能够读取旧版本,并在必要时转换。要真正完成存储迁移,通常需要显式触发对象重写,例如:

kubectl get databases --all-namespaces -o yaml

只是读取,不保证回写存储版本。

可以使用 Kubernetes 提供的存储版本迁移工具或控制器执行迁移。迁移的本质是:

读取旧存储对象
→ 转换到目标 storage 版本
→ 对同一对象执行更新
→ etcd 写入新表示

迁移前必须确认:

  • 所有版本的 Conversion Webhook 都可用;
  • 转换是可逆或丢失字段已被明确处理;
  • Controller 能处理迁移产生的 Update 事件;
  • ResourceVersion 和并发更新冲突可重试;
  • 迁移规模不会导致 API Server、Webhook 或 etcd 压力失控;
  • 有备份和恢复方案。

9.3 如何观察存储版本

可以查看 API Server 暴露的 API Discovery 信息:

kubectl get --raw /apis/example.com/v1 | jq .

还可以检查 CRD 状态:

kubectl get crd databases.example.com -o yaml

部分 Kubernetes 版本会在 CRD 状态中提供存储版本相关信息,例如 storedVersions。它表示 API Server 观察到的对象存储版本集合,而不是简单的“当前 CRD 配置”。

示例:

status:
  storedVersions:
    - v1alpha1
    - v1

如果已经把 CRD 配置为只使用 v1,但 storedVersions 仍包含 v1alpha1,通常说明仍有历史对象没有完成迁移,或者迁移状态尚未收敛。

不能通过手工编辑 CRD 的 status 就声称数据已经迁移。状态字段是观察结果,真实迁移必须通过对象重写完成。


9.4 etcd 中存储的不是“原始 YAML”

CR 在 API Server 中经过以下处理后才进入存储:

YAML
→ JSON 解码
→ Schema/defaulting/Admission/Conversion 等处理
→ API 对象序列化
→ etcd 持久化

因此:

  • YAML 中的注释不会被保留;
  • 字段顺序不是可靠语义;
  • 未知字段可能已被剪枝;
  • 默认值可能已经变成显式字段;
  • resourceVersionuidmanagedFields 等由 API Server 管理;
  • etcd 使用的具体内部 key 和序列化细节不应被业务代码依赖。

如果集群启用了静态数据加密,CR 的机密性还取决于 API Server 的 EncryptionConfiguration。CRD 不会自动把其中的字段当作 Secret 加密;“自定义资源中包含密码”与“密码被安全存储”是两个不同问题。


十、从旧版本迁移到新版本的完整例子

假设初始 API 为:

apiVersion: example.com/v1alpha1
kind: Database
spec:
  replicas: 3
  engine: postgres

新 API 改为:

apiVersion: example.com/v1
kind: Database
spec:
  size: 3
  engine: postgres

迁移涉及四个独立动作。

第一步:增加新版本

versions:
  - name: v1alpha1
    served: true
    storage: true
  - name: v1
    served: true
    storage: false

此时旧版本仍是 storage,避免在转换器尚未稳定时立即改变持久化格式。

第二步:部署并验证 Conversion Webhook

先验证 Webhook:

kubectl -n operators get deploy,svc,pods
kubectl describe crd databases.example.com

然后用两个版本读取同一对象:

kubectl get database prod-db -o yaml
kubectl get database prod-db -o yaml --api-version=example.com/v1alpha1

两个输出的字段名称可以不同,但业务语义必须一致:

v1.spec.size == v1alpha1.spec.replicas == 3

第三步:切换 storage

versions:
  - name: v1alpha1
    served: true
    storage: false
  - name: v1
    served: true
    storage: true

必须确保恰好一个版本为 storage: true

第四步:迁移历史对象

使用合适的迁移工具或批量更新程序重写对象。迁移程序不能只做:

kubectl get ...

而必须产生写入,例如使用经过审慎设计的更新操作。更新时要处理:

409 Conflict

因为 Controller 或用户可能同时修改对象。迁移程序应重新读取最新对象,再基于最新 resourceVersion 重试,而不是盲目覆盖。

迁移完成后,检查:

kubectl get crd databases.example.com -o jsonpath='{.status.storedVersions}'

确认旧版本不再被使用后,才考虑停止旧版本服务:

- name: v1alpha1
  served: false
  storage: false

停止 served 前,必须确认:

  • 所有客户端已切换;
  • Controller 不再请求旧 GVK;
  • 旧版本对象已全部迁移;
  • Webhook 不再需要处理旧版本,或仍能处理历史访问;
  • 回滚方案已验证。

十一、Schema 演进的兼容性推导

可以把一个版本的可表达对象集合记为:

L(v)

其中 L(v) 表示版本 v 的 Schema 允许的所有对象。

如果新版本只增加一个可选字段:

L(old) ⊆ L(new)

通常更容易兼容,因为旧对象仍然能被新版本表达。

但如果新版本把字段改成必填:

old: field 可缺失
new: field 必须存在

则可能存在:

x ∈ L(old),但 x ∉ L(new)

例如旧对象:

spec:
  replicas: 3

新版本要求:

spec:
  replicas: 3
  engine: postgres

旧对象转换到新版本时缺少 engine,转换器必须选择:

  • 提供明确默认值;
  • 拒绝转换;
  • 或改变 Schema,让字段保持可选。

同样,缩小枚举集合也是不兼容变更:

旧版本 enum = {postgres, mysql, sqlite}
新版本 enum = {postgres, mysql}

若历史对象使用 sqlite,新 Schema 无法接受它。

把字段类型从整数改成字符串也不是安全的增量变更:

int32 → string

即使字符串看起来可以写成 "3",这也改变了客户端、CEL、排序和转换语义。

因此,常见兼容性判断如下:

变更 通常风险
增加可选字段 较低,但要考虑剪枝和 SSA
增加默认值 会改变写入后的对象,需检查 Controller 行为
增加必填字段 高,旧对象可能无法转换
缩小 enum 高,历史对象可能非法
字段重命名 需要明确 Conversion
改变字段类型 高,通常视为不兼容
改变 list 的 map/set 语义 会影响 Apply 和合并行为
改变 scope 基本属于新资源设计,而非普通版本演进

十二、与 Controller、Cache 和幂等的关系

CRD API 定义稳定后,Controller 通常通过 client-go 或 controller-runtime 访问它。无论使用哪种框架,都要注意版本和存储机制带来的事件行为。

Controller 的典型路径是:

flowchart TD
    APIServer[API Server] --> Watch[Watch CR]
    Watch --> Cache[Informer Cache]
    Cache --> Queue[Work Queue]
    Queue --> Reconcile[Reconcile]
    Reconcile --> Read[读取期望状态]
    Reconcile --> Act[创建或更新下游资源]
    Act --> APIServer

如果 CRD 默认值在对象创建时被写入,Controller 可能收到:

Add:原始对象
Update:加入默认值后的对象

具体事件数量取决于请求和控制器实现,Controller 不应依赖“只会收到一次事件”。

如果迁移工具批量更新对象,Controller 也会收到 Update 事件。Reconcile 必须幂等:

相同的 spec + 相同的外部实际状态
→ 重复执行不会产生额外错误或无界副作用

例如,不要在每次 Reconcile 时无条件生成新的随机密码、无条件创建同名资源,或把每次观察到的字段差异都写回 CR。否则 Defaulting、Conversion、存储迁移产生的更新会放大成控制器风暴。

版本方面,Controller 应明确使用哪个版本:

var databaseGVK = schema.GroupVersionKind{
    Group:   "example.com",
    Version: "v1",
    Kind:    "Database",
}

如果 Controller 监听 v1,而用户通过 v1alpha1 修改对象,API Server 会先转换后产生可观察对象;但 Controller 代码仍应只依赖它声明监听的类型,不应假设 cache 中保留客户端原始版本。


十三、常见失败表现与诊断路径

13.1 CRD 创建成功,但 CR 被拒绝

先检查 CRD 状态:

kubectl get crd databases.example.com -o yaml
kubectl describe crd databases.example.com

再确认 API Discovery:

kubectl api-resources | grep -i database
kubectl api-versions | grep example.com

如果资源未建立,可能是:

  • CRD Schema 非结构化;
  • plural 与 CRD 名称不匹配;
  • 版本配置非法;
  • API Server 尚未完成 CRD 建立;
  • CRD 正处于删除流程。

13.2 字段提交后消失

诊断命令:

kubectl apply -f database.yaml
kubectl get database prod-db -o yaml

如果原始文件中存在字段,但读取结果没有,重点检查:

  1. Schema 是否声明该字段;
  2. 父级对象是否为 type: object
  3. 是否在某个版本 Schema 中声明、但另一个版本没有声明;
  4. 是否经过 Conversion 后字段丢失;
  5. 是否配置了 x-kubernetes-preserve-unknown-fields
  6. 是否使用了错误的 apiVersion

多版本场景尤其要分别执行:

kubectl get database prod-db \
  -o yaml \
  --api-version=example.com/v1

kubectl get database prod-db \
  -o yaml \
  --api-version=example.com/v1alpha1

如果一个版本能看到字段,另一个看不到,问题通常在 Schema 或 Conversion,而不是 etcd 随机丢数据。


13.3 默认值没有出现在已有对象中

这是预期行为的可能性很高。新增 Schema default 后,先创建一个新对象:

kubectl apply -f new-database.yaml
kubectl get database new-db -o yaml

再对旧对象执行一次经过 API Server 的更新,并重新读取。不要通过 GET 判断默认值是否会自动回填历史对象。

还要检查嵌套对象是否缺少父级默认值:

backup:
  type: object
  default: {}

13.4 Conversion Webhook 超时

建议按以下顺序检查:

kubectl get svc -n operators database-conversion
kubectl get endpointslice -n operators
kubectl get pods -n operators
kubectl logs -n operators deploy/database-conversion
kubectl describe crd databases.example.com

如果 Service 存在但没有 Endpoint,Webhook 没有可用 Pod。若 Pod 正常,还要检查:

  • Service 端口与 targetPort;
  • Webhook TLS 证书的 DNS 名称;
  • caBundle 是否正确;
  • API Server 到目标网络是否可达;
  • /convert 路径是否实现;
  • Webhook 是否处理了批量 objects
  • 响应中的 UID 和目标版本是否正确。

Conversion Webhook 不应依赖它自身管理的 CRD 才能启动,否则可能形成启动死锁:

Webhook 启动需要读取 CR
→ 读取 CR 需要 Conversion
→ Conversion 需要 Webhook 已启动

Webhook 的配置、证书和运行所需资源应尽量独立。


十四、生产设计中的关键取舍

14.1 是否一开始就设计多版本

如果资源尚处于快速变化阶段,过早发布多个可写版本会增加:

  • Schema 维护成本;
  • Conversion 测试成本;
  • Controller 版本兼容成本;
  • 存储迁移和回滚复杂度;
  • Webhook 可用性依赖。

但已经公开使用的 v1alpha1 也不能随意修改字段含义。是否增加 v1beta1v1,应基于 API 合约已经稳定,而不是仅因为代码包准备好了。


14.2 Schema 严格还是动态

严格 Schema 的优势:

  • 错误更早暴露;
  • Unknown field 不会静默进入 Controller;
  • kubectl 和 Apply 能理解字段;
  • 便于版本转换;
  • 便于文档和客户端生成。

动态字段的优势:

  • 扩展灵活;
  • 适合代理第三方 JSON;
  • 不需要频繁修改 CRD。

代价是动态字段的校验、合并、默认值和兼容性都转移给 Controller。对于核心业务字段,通常应使用严格 Schema;对于真正需要扩展的局部 payload,再有边界地使用保留未知字段。


14.3 是否把状态放在 status

建议将用户意图放在 spec,Controller 观察到的结果放在 status

spec:
  replicas: 3

status:
  readyReplicas: 2
  phase: Reconciling
  conditions:
    - type: Ready
      status: "False"

Schema 可以对两者分别描述:

properties:
  spec:
    type: object
    properties:
      replicas:
        type: integer
  status:
    type: object
    properties:
      readyReplicas:
        type: integer

Controller 更新状态时应使用 status 子资源:

subresources:
  status: {}

这样用户对 spec 的更新和 Controller 对 status 的更新可以分离,减少互相覆盖。注意:启用 status 子资源不会自动让 Controller 正确更新状态,代码仍需使用对应的 Status Update 或 Patch API。


十五、验证一个 CRD 设计是否真正可用

一个只验证“CRD 能安装”的测试远远不够。至少应覆盖以下对象生命周期:

1. 安装 CRD
2. 创建最小合法 CR
3. 创建非法类型、非法范围和非法枚举的 CR
4. 检查默认值
5. 提交未知字段,检查剪枝结果
6. 更新不可变字段,检查拒绝结果
7. 通过每个 served 版本读取同一个对象
8. 通过每个 served 版本更新同一个对象
9. 模拟 Conversion Webhook 不可用
10. 执行存储版本迁移
11. 检查 Controller 是否重复处理并保持幂等
12. 删除和恢复测试资源

一个最小命令验证流程可以是:

kubectl apply -f database-crd.yaml

kubectl wait \
  --for=condition=Established \
  crd/databases.example.com \
  --timeout=60s

kubectl apply -f database-valid.yaml

kubectl get database prod-db -o yaml

kubectl api-resources | grep databases
kubectl explain database.spec

kubectl explain 能否显示字段,取决于 CRD Schema 是否被 API Server 正确发布。若 kubectl explain database.spec.replicas 无法工作,先检查 CRD Schema 和 Discovery,而不是直接修改 Controller。


结语

CRD 的真正 API 合约不是一段 kind: CustomResourceDefinition YAML,而是以下内容的组合:

资源身份
+ Schema
+ 默认值
+ 校验规则
+ 版本集合
+ 版本转换
+ 存储版本
+ 状态子资源
+ Controller 的幂等行为

Schema 决定对象能否被结构化理解;Defaulting 决定缺省输入如何变成显式对象;Validation 决定哪些状态在 API 层面不可接受;Conversion 决定多版本表示是否保持同一业务含义;storage 决定对象最终采用哪种 API 表示持久化到 etcd。

最危险的误解有三个:

  1. served 就是存储版本:实际上 served 只表示客户端可访问;
  2. 修改 CRD Schema 会自动迁移旧对象:实际上历史对象通常需要显式重写;
  3. Conversion 只是改 apiVersion 字符串:实际上字段重命名、默认值、枚举和不可表达字段都需要明确的语义设计。

当 CRD 只有一个稳定版本时,使用严格 Structural Schema、清晰的默认值和 CEL 校验即可建立可靠基础。当 API 开始演进时,则必须把版本转换、存储迁移、Webhook 高可用和 Controller 幂等性当作同一个系统问题来设计。


系列导航与关联阅读

官方资料

本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。