CRD 多版本迁移:Conversion Webhook、字段裁剪与存储版本
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)。
served、storage 与客户端看到的版本
CRD 的每个 version 有两个独立属性:
served:API Server 是否为该版本提供读取和写入入口。storage:对象最终以哪个版本写入 etcd。一个 CRD 必须有且只有一个 storage version。
客户端请求 v1alpha1 时,API Server 可能从以 v1beta1 存储的对象转换后返回。反之,写入 v1alpha1 后,API Server 需要先把对象转换成 storage version 再持久化。客户端读到的版本不能直接证明 etcd 中的字节就是该版本。
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 保存并返回的对象只有:
| |
unknown 没有留下。这个结果来自 schema pruning,不是 Webhook 主动删除:请求以 v1alpha1 写入,schema 直接裁掉未声明字段,压根没有经过转换。把“Webhook 转换后的对象”和“schema 允许保留的字段”混在一起,会导致升级后对象莫名变空或写入失败时找错组件,例如把裁剪当成转换丢字段去查 Webhook。
裁剪和转换的先后顺序可以从代码里定下来。v1.35.1 把转换器包了一层 schemaCoercingConverter:Convert 先调用真正的 delegate(也就是 Webhook 转换器或 nop 转换器),成功之后才对输出对象跑 validator.apply 做结构校验和裁剪(customresource_handler.go:1357-1370)。顺序是“先转换、再按目标版本 schema 裁剪”,所以即使 Webhook 多返回了目标版本没声明的字段,也会在写回之前被丢掉。这一条是源码阅读结论,本次实验没有构造“Webhook 故意返回多余字段”的用例。
对于必须保留任意字段的对象,schema 可以使用 x-kubernetes-preserve-unknown-fields;这会放宽裁剪行为,也意味着 API Server 不再能对那部分字段提供结构化校验。选择前要明确兼容性和安全边界,不能为了迁移方便就全局放开。字段裁剪规则见官方文档 Versions in CustomResourceDefinitions。
Conversion Webhook 负责版本之间的字段映射
实验的 Webhook 监听一个 Service,返回 ConversionReview 响应。请求里包含 desiredAPIVersion 和待转换对象,响应必须返回同一 UID 的成功结果和 convertedObjects。v1.35.1 的 webhook_converter.go 先比对响应 UID(getConvertedObjectsFromResponse),再检查返回对象的 groupVersion 和 kind,最后由 validateConvertedObject 要求名称、namespace 和 UID 与请求一致。返回值里只有 spec、status 这类字段会被采用,metadata 内容由 restoreObjectMeta 从原对象恢复。
实验读取同一个对象,得到两种表示:
| |
两种表示指向同一个对象(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)。这段注释还留了一个 TODO,讨论要不要给调用加一秒超时——说明截至这个版本,转换调用没有独立于 HTTP 客户端的自定义超时。批量转换时对象数量会放大这个开销,Webhook 里做重活会直接拖慢 API 请求。
切换 storage version 之后对象还在旧版本
实验先让 v1alpha1 同时 served 和 storage,然后修改 CRD:
v1alpha1仍 served,但不再 storage。v1beta1served 且 storage。
切换后,CRD 的 status.storedVersions 记录为:
| |
这表示 etcd 中可能仍存在旧版本写入的对象。storage version 的改变只决定后续写入和默认存储格式,不会在同一个操作里遍历所有对象并重写。即使 spec.versions 里 v1alpha1.storage 已经是 false,也不能假定旧版本对象已经全部迁移。storedVersions 的字段注释写明了两条约束:它列出所有曾经持久化过的版本,且只要某个版本还留在这个列表里,就不能把它从 spec.versions 删除(types.go)。
之后用 v1beta1 更新同一个对象,这次写入的版本就是 storage version,不需要转换,webhook-log.json 里也没有对应的 POST。这次更新把 etcd 中的对象重写成了 v1beta1。再停止服务 v1alpha1 后,旧的版本入口返回 NotFound,但通过 v1beta1 仍能读取对象:
| |
这个结果只证明在实验范围内,对象可以继续通过新版本访问。它不证明所有历史对象都已经按新版本重写,也不证明可以直接删除旧版本定义。
old-version-unserved.json 里还有一个容易被忽略的细节:停掉 v1alpha1 的 served 之后,status.storedVersions 仍然是 ["v1alpha1", "v1beta1"]。删掉 served 不会自动从 storedVersions 里去掉旧版本,这个列表只由迁移动作更新。所以此时的 CRD 处于一个看上去矛盾、实际合法的状态:旧版本不再对外服务,但仍被记录为“曾经落盘过”。
下一步想从 spec.versions 里删掉 v1alpha1 会被拒绝。v1.35.1 的 validation.go:260-285 逐项比对 storedVersions 与 spec.versions:storedVersions 里有、spec.versions 里没有的版本会报错,错误信息直接要求“先用存储迁移确认没有数据残留在该版本,再从 status.storedVersions 移除”。这一段是源码阅读结论,本次实验没有真的提交这次删除。生产迁移要结合 storedVersions、对象数量、Webhook 可用性和回滚窗口决定何时完成重写。
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 字段自动完成。
迁移时逐项验证
- 用两个版本分别读取同一对象,核对字段映射和默认值。
- 验证 schema 裁剪和必须保留的字段,避免把 pruning 当成转换错误。
- 检查 Webhook 证书 SAN、Service、CA bundle 和超时设置。
- 切换 storage version 后查看
status.storedVersions,不要只看served。 - 停止旧版本前,确认旧对象已重写且客户端不再依赖旧 endpoint。
字段归属和冲突合并与 CRD 多版本是两层问题:前者决定同一字段由谁管理,见 Server-Side Apply 文章;后者决定对象以哪个版本存储。准入阶段对 CRD 请求的拦截见 准入控制文章。
实验目录和 TLS 生成步骤见 k8sdev/article-crd-conversion。本次验证了字段裁剪、两版本转换、storage version 切换和停止服务旧版本;没有验证大规模对象重写、Webhook 高可用、失败回滚、证书错误场景或客户端跨版本并发写入。