jimyag's Blog

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 保存并返回的对象只有:

1
2
3
4
5
6
{
  "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)。顺序是“先转换、再按目标版本 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 从原对象恢复。

实验读取同一个对象,得到两种表示:

1
2
3
4
{
  "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)。这段注释还留了一个 TODO,讨论要不要给调用加一秒超时——说明截至这个版本,转换调用没有独立于 HTTP 客户端的自定义超时。批量转换时对象数量会放大这个开销,Webhook 里做重活会直接拖慢 API 请求。

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

实验先让 v1alpha1 同时 served 和 storage,然后修改 CRD:

  • v1alpha1 仍 served,但不再 storage。
  • v1beta1 served 且 storage。

切换后,CRD 的 status.storedVersions 记录为:

1
["v1alpha1", "v1beta1"]

这表示 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 仍能读取对象:

1
2
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 逐项比对 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 字段自动完成。

迁移时逐项验证

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

字段归属和冲突合并与 CRD 多版本是两层问题:前者决定同一字段由谁管理,见 Server-Side Apply 文章;后者决定对象以哪个版本存储。准入阶段对 CRD 请求的拦截见 准入控制文章。

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

#Kubernetes #源码分析 #Kind #CRD