
CRD 增加一个新版本时，旧对象不会自动变成新结构。客户端按 `v1alpha1` 读取，控制器按 `v1beta1` 写入，中间需要 API Server 做版本转换；同时，schema 可能裁掉旧客户端不认识的字段。多版本迁移并不是修改 `versions` 数组就结束。

本文在 Kubernetes v1.35.1 上创建两版 `Widget` CRD，用一个小型 Conversion Webhook 把 `spec.label` 与 `spec.displayName` 互相转换，并观察未知字段裁剪、storage version 切换和停止服务旧版本后的读取行为。实验在 `zj` 的三节点 kind v0.33.0 集群 `article-next` 执行，客户端和服务端均为 v1.35.1（提交 `8fea90b45245ef5c8ba54e7ae044d3e777c22500`）。

<!--more-->

## served、storage 与客户端看到的版本

CRD 的每个 version 有两个独立属性：

- `served`：API Server 是否为该版本提供读取和写入入口。
- `storage`：对象最终以哪个版本写入 etcd。一个 CRD 必须有且只有一个 storage version。

客户端请求 `v1alpha1` 时，API Server 可能从以 `v1beta1` 存储的对象转换后返回。反之，写入 `v1alpha1` 后，API Server 需要先把对象转换成 storage version 再持久化。客户端读到的版本不能直接证明 etcd 中的字节就是该版本。

```mermaid
sequenceDiagram
    participant C as 客户端
    participant A as API Server
    participant W as Conversion Webhook
    participant E as etcd
    C->>A: GET v1alpha1 Widget
    A->>E: 读取 storage version 对象
    E-->>A: v1beta1 bytes
    alt 请求版本与 storage version 不同
        A->>W: ConversionReview，desiredAPIVersion=v1alpha1
        W-->>A: convertedObjects
    end
    A-->>C: v1alpha1 表示
    C->>A: 写入 v1alpha1
    alt 需要写入的版本不同
        A->>W: 转换为 storage version
        W-->>A: 转换后对象
    end
    A->>E: 保存 storage version 对象
```

Webhook 是 API 请求链的一部分。它不可用、证书错误或返回错误对象时，读取和写入都可能失败；这不同于控制器异步调谐失败。

## 两个版本先要有同一份结构语义

实验的 `v1alpha1` 定义 `spec.label`，`v1beta1` 定义 `spec.displayName`，两者都有 `spec.replicas`。两个版本都使用 structural schema，声明了允许的字段。对象写入时，API Server 会按目标版本的 schema 校验；没有被 schema 描述、又没有 `x-kubernetes-preserve-unknown-fields` 的字段会被裁剪。

实验输入包含一个未声明的 `spec.unknown`。因为使用了 `--validate=false`，客户端没有在本地拒绝它，最终 API Server 保存并返回的对象只有：

```json
{
  "spec": {
    "label": "alpha",
    "replicas": 3
  }
}
```

`unknown` 没有留下。这个结果来自 schema pruning，不是 Webhook 主动删除：请求以 `v1alpha1` 写入，schema 直接裁掉未声明字段，压根没有经过转换。把“Webhook 转换后的对象”和“schema 允许保留的字段”混在一起，会导致升级后对象莫名变空或写入失败时找错组件，例如把裁剪当成转换丢字段去查 Webhook。

裁剪和转换的先后顺序可以从代码里定下来。v1.35.1 把转换器包了一层 `schemaCoercingConverter`：`Convert` 先调用真正的 delegate（也就是 Webhook 转换器或 nop 转换器），成功之后才对输出对象跑 `validator.apply` 做结构校验和裁剪（[`customresource_handler.go:1357-1370`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go#L1357-L1370)）。顺序是“先转换、再按目标版本 schema 裁剪”，所以即使 Webhook 多返回了目标版本没声明的字段，也会在写回之前被丢掉。这一条是源码阅读结论，本次实验没有构造“Webhook 故意返回多余字段”的用例。

对于必须保留任意字段的对象，schema 可以使用 `x-kubernetes-preserve-unknown-fields`；这会放宽裁剪行为，也意味着 API Server 不再能对那部分字段提供结构化校验。选择前要明确兼容性和安全边界，不能为了迁移方便就全局放开。字段裁剪规则见官方文档 [Versions in CustomResourceDefinitions](https://v1-35.docs.kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/)。

## Conversion Webhook 负责版本之间的字段映射

实验的 Webhook 监听一个 Service，返回 `ConversionReview` 响应。请求里包含 `desiredAPIVersion` 和待转换对象，响应必须返回同一 UID 的成功结果和 `convertedObjects`。v1.35.1 的 [`webhook_converter.go`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion/webhook_converter.go) 先比对响应 UID（`getConvertedObjectsFromResponse`），再检查返回对象的 groupVersion 和 kind，最后由 `validateConvertedObject` 要求名称、namespace 和 UID 与请求一致。返回值里只有 `spec`、`status` 这类字段会被采用，`metadata` 内容由 `restoreObjectMeta` 从原对象恢复。

实验读取同一个对象，得到两种表示：

```json
{
  "v1alpha1": {"spec": {"label": "alpha", "replicas": 3}},
  "v1beta1": {"spec": {"displayName": "alpha", "replicas": 3}}
}
```

两种表示指向同一个对象（`converted-read.json` 里两侧 `resourceVersion` 都是 2754），只是 API 版本不同。转换函数必须保持 UID、名称、namespace 和资源语义一致；字段重命名、默认值和删除字段都要有明确规则。

这次转换在 API 请求链里同步完成，控制器没有参与。v1.35.1 的 `webhookClientConfigForCRD` 用 CRD 中的 `service.namespace`、`name`、`port`、`path` 和 `caBundle` 构造调用客户端，`NewWebhookConverter` 在 CRD 生效时就建好它。因此证书 SAN、Service 名、端口和 CA bundle 任一处与 CRD 声明不一致，请求会在 TLS 建链阶段失败，根本走不到字段转换。本次实验证书按 `widget-webhook.article-crd-conversion.svc` 生成，只验证了证书正确时 ConversionReview 返回 200，没有构造 SAN 不匹配的场景；这类失败发生在连接阶段而非转换逻辑，排查时不要把 TLS 错误当成字段映射错误。

因为是同步调用，转换延迟直接算在请求上。代码里留了一条性能基准：单个对象的转换 SLO 约 4ms，加上 API Server 侧序列化约 4ms，所以超过 `50 + 8×对象数` 毫秒的调用会被单独记为慢调用（[`webhook_converter.go:274-279`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion/webhook_converter.go#L274-L279)）。这段注释还留了一个 `TODO`，讨论要不要给调用加一秒超时——说明截至这个版本，转换调用没有独立于 HTTP 客户端的自定义超时。批量转换时对象数量会放大这个开销，Webhook 里做重活会直接拖慢 API 请求。

## 切换 storage version 之后对象还在旧版本

实验先让 `v1alpha1` 同时 served 和 storage，然后修改 CRD：

- `v1alpha1` 仍 served，但不再 storage。
- `v1beta1` served 且 storage。

切换后，CRD 的 `status.storedVersions` 记录为：

```json
["v1alpha1", "v1beta1"]
```

这表示 etcd 中可能仍存在旧版本写入的对象。storage version 的改变只决定后续写入和默认存储格式，不会在同一个操作里遍历所有对象并重写。即使 `spec.versions` 里 `v1alpha1.storage` 已经是 false，也不能假定旧版本对象已经全部迁移。`storedVersions` 的字段注释写明了两条约束：它列出所有曾经持久化过的版本，且只要某个版本还留在这个列表里，就不能把它从 `spec.versions` 删除（[types.go](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1/types.go#L374-L382)）。

之后用 `v1beta1` 更新同一个对象，这次写入的版本就是 storage version，不需要转换，`webhook-log.json` 里也没有对应的 POST。这次更新把 etcd 中的对象重写成了 `v1beta1`。再停止服务 `v1alpha1` 后，旧的版本入口返回 NotFound，但通过 `v1beta1` 仍能读取对象：

```text
v1alpha1: the server could not find the requested resource
v1beta1:  {"spec": {"displayName": "alpha", "replicas": 3}}
```

这个结果只证明在实验范围内，对象可以继续通过新版本访问。它不证明所有历史对象都已经按新版本重写，也不证明可以直接删除旧版本定义。

`old-version-unserved.json` 里还有一个容易被忽略的细节：停掉 `v1alpha1` 的 served 之后，`status.storedVersions` 仍然是 `["v1alpha1", "v1beta1"]`。删掉 served 不会自动从 `storedVersions` 里去掉旧版本，这个列表只由迁移动作更新。所以此时的 CRD 处于一个看上去矛盾、实际合法的状态：旧版本不再对外服务，但仍被记录为“曾经落盘过”。

下一步想从 `spec.versions` 里删掉 `v1alpha1` 会被拒绝。v1.35.1 的 [`validation.go:260-285`](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go#L260-L285) 逐项比对 `storedVersions` 与 `spec.versions`：`storedVersions` 里有、`spec.versions` 里没有的版本会报错，错误信息直接要求“先用存储迁移确认没有数据残留在该版本，再从 `status.storedVersions` 移除”。这一段是源码阅读结论，本次实验没有真的提交这次删除。生产迁移要结合 `storedVersions`、对象数量、Webhook 可用性和回滚窗口决定何时完成重写。

```mermaid
flowchart TB
    A[旧版本 served + storage] --> B[新增新版本]
    B --> C[新版本 served，旧版本仍 storage]
    C --> D[校验旧新版本转换]
    D --> E[切换 storage version]
    E --> F[storedVersions 可能同时包含新旧版本]
    F --> G[读取和更新旧对象]
    G --> H{所有对象已重写?}
    H -->|未完成| I[继续服务旧版本并重试]
    H -->|完成| J[停止服务旧版本]
    J --> K[验证 storedVersions 和 API 入口]
```

图中的“所有对象已重写”需要单独的迁移和验证过程，不是修改 CRD 字段自动完成。

## 迁移时逐项验证

1. 用两个版本分别读取同一对象，核对字段映射和默认值。
2. 验证 schema 裁剪和必须保留的字段，避免把 pruning 当成转换错误。
3. 检查 Webhook 证书 SAN、Service、CA bundle 和超时设置。
4. 切换 storage version 后查看 `status.storedVersions`，不要只看 `served`。
5. 停止旧版本前，确认旧对象已重写且客户端不再依赖旧 endpoint。

字段归属和冲突合并与 CRD 多版本是两层问题：前者决定同一字段由谁管理，见 [Server-Side Apply 文章](/posts/kubernetes-server-side-apply/)；后者决定对象以哪个版本存储。准入阶段对 CRD 请求的拦截见 [准入控制文章](/posts/kubernetes-admission-security/)。

实验目录和 TLS 生成步骤见 [k8sdev/article-crd-conversion](https://github.com/jimyag/k8sdev/tree/917db9bc548a27980afe404592b862cafea22db4/article-crd-conversion)。本次验证了字段裁剪、两版本转换、storage version 切换和停止服务旧版本；没有验证大规模对象重写、Webhook 高可用、失败回滚、证书错误场景或客户端跨版本并发写入。

