Kubernetes 基础体系 · 第 80/83 篇。示例基于 Kubernetes 当前稳定 API;弃用、版本偏差、云厂商差异和生产风险会明确说明。
Kubernetes CRD Conversion Webhook:Hub、Spoke、兼容、存储和回滚
Kubernetes 自定义资源定义(CustomResourceDefinition,CRD)允许同一种自定义资源同时暴露多个 API 版本。例如:
example.com/v1alpha1
example.com/v1
多个版本解决的是 API 演进问题:旧客户端继续使用 v1alpha1,新客户端使用 v1,而 API Server 仍然需要把它们视为同一种资源。
CRD Conversion Webhook 就是这层版本转换的扩展点。它不负责“是否允许创建对象”,也不负责普通 Admission Webhook 的变更或校验,而是负责:
客户端版本 <-> API Server 存储版本
本文基于当前稳定的 apiextensions.k8s.io/v1 CRD API,重点说明:
- Hub 与 Spoke 的模型;
- Conversion Webhook 的请求、响应和生命周期;
- 版本之间什么叫“兼容”;
- API Server 如何选择存储版本;
- Schema、Defaulting、Validation 与 Conversion 的边界;
- CRD 版本升级、存储迁移和回滚;
- 转换失败、证书错误、版本删除等生产故障如何诊断。
一、先区分四个容易混淆的版本概念
一个 CRD 中至少存在四种“版本”概念。
1. 客户端请求版本
客户端请求 URL 中的版本,例如:
/apis/example.com/v1alpha1/namespaces/default/widgets/demo
这表示客户端希望 API Server 按 v1alpha1 的形状返回或接收对象。
2. 暴露版本
CRD 的 spec.versions 中,served: true 的版本对外暴露。
spec:
group: example.com
names:
plural: widgets
singular: widget
kind: Widget
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: false
- name: v1
served: true
storage: true
served 表示该版本是否可以通过 API 访问。它不表示该版本是否写入 etcd。
3. 存储版本
同一个 CRD 必须且只能有一个版本设置:
storage: true
这个版本是 API Server 用来序列化到 etcd 的版本。假设上面的配置中 v1 是存储版本,那么客户端即使使用 v1alpha1 创建对象,API Server 也需要先将其转换为 v1,再存储。
4. Hub 与 Spoke 版本
Hub 和 Spoke 是版本转换的设计模式,不是 CRD YAML 中的字段。
- Hub:内部规范版本,所有转换通常经过它;
- Spoke:对外暴露的具体版本,例如
v1alpha1、v1beta1、v1。
如果有三个版本:
v1alpha1
v1beta1
v1
可以直接实现:
v1alpha1 <-> v1
v1beta1 <-> v1
其中 v1 是 Hub。
Hub 不一定必须是存储版本,但工程上通常让较稳定、信息表达能力最完整的版本同时作为 Hub 和存储版本。这样可以减少额外转换和数据丢失风险。
二、为什么需要 Conversion Webhook
如果不同 API 版本只是字段名称完全相同,API Server 可以直接使用简单转换逻辑。但真实版本通常会发生结构变化,例如:
v1alpha1:
apiVersion: example.com/v1alpha1
kind: Widget
spec:
replicas: 3
v1:
apiVersion: example.com/v1
kind: Widget
spec:
size: 3
这里 replicas 与 size 表达的是同一个业务概念,但字段名不同。API Server 不知道这种业务语义,因此需要 Conversion Webhook。
转换的核心目标不是“把 JSON 改成另一个 JSON”,而是让两个版本表达同一个资源状态:
其中:
- 是版本
a的对象; - 是版本
b的对象; - 是从
a到b的转换函数。
对于可逆转换,还希望满足:
这里的“约等于”很重要。版本转换后通常允许:
apiVersion改变;- 字段顺序改变;
- JSON 数字或空值经过规范化;
- API Server 管理的某些元数据重新编码。
但不应丢失用户可观察的业务语义。
例如下面的转换就是有损的:
v1alpha1.spec.replicas = 3
转换为:
v1.spec.size = "small"
如果 small 不能唯一还原为 3,那么从 v1 再转换回 v1alpha1 时就无法恢复原值。这种版本设计不适合作为透明转换,除非明确规定这是不可逆的语义迁移,而不是普通 API 版本转换。
三、Hub-Spoke 的形式化条件
假设有三个版本:
A = v1alpha1
B = v1beta1
H = v1
使用 Hub 模式时,主要实现两个方向:
A -> H
H -> A
B -> H
H -> B
从 A 转换到 B 时,路径是:
A -> H -> B
因此:
其中右侧函数先执行。
1. 为什么 Hub 能降低复杂度
如果每个版本都直接互转,版本数为 时,最坏情况下需要维护:
个有方向的转换函数。
使用 Hub 后,每个版本只需实现到 Hub 和从 Hub 转出的转换:
例如四个版本:
直接互转:4 × 3 = 12 个方向
Hub 模式:2 × 3 = 6 个方向
这不仅减少代码量,更重要的是减少语义分叉。否则可能出现:
A -> B -> C
A -> C
两条路径得到不同结果,导致控制器观察到不一致状态。
2. Hub 转换必须满足的条件
对每个 Spoke 版本 ,至少应满足:
对 Hub 对象也应满足:
第二个条件尤其容易被忽略。假设 v1alpha1 没有 spec.paused,但 v1 有:
spec:
paused: true
如果 Hub 是 v1,从 v1 转到 v1alpha1 时不能简单删除 paused,否则再转回 Hub 时会变成默认值 false,产生信息丢失。
解决办法通常有三种:
- 让旧版本也增加该字段;
- 把字段语义编码到旧版本已有字段中;
- 接受该字段对旧版本不可见,但不能再声称转换完全无损。
第三种方案会带来读写风险:使用旧版本客户端读取并写回对象,可能覆盖新版本字段。
四、CRD Conversion Webhook 的 API 配置
一个使用 Webhook 转换的 CRD 结构如下:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: widgets.example.com
spec:
group: example.com
scope: Namespaced
names:
plural: widgets
singular: widget
kind: Widget
shortNames:
- wgt
conversion:
strategy: Webhook
webhook:
conversionReviewVersions:
- v1
clientConfig:
service:
namespace: widget-system
name: widget-conversion
path: /convert
port: 443
caBundle: BASE64_ENCODED_CA_CERTIFICATE
versions:
- name: v1alpha1
served: true
storage: false
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
replicas:
type: integer
format: int32
minimum: 0
status:
type: object
properties:
availableReplicas:
type: integer
format: int32
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
size:
type: integer
format: int32
minimum: 0
status:
type: object
properties:
availableReplicas:
type: integer
format: int32
几个字段的含义必须分开理解:
strategy: Webhook:转换由外部 Webhook 执行;conversionReviewVersions:Webhook 支持的ConversionReviewAPI 版本;clientConfig:API Server 如何连接转换服务;served:客户端能否访问该 CRD 版本;storage:该版本是否是唯一存储版本;schema:该版本对象的 OpenAPI Schema,不是转换规则。
conversionReviewVersions 不是业务对象版本。它描述的是 API Server 与 Webhook 之间的请求协议版本。当前稳定 API 通常使用:
conversionReviewVersions:
- v1
Webhook 必须实际解析和返回对应版本的 ConversionReview。
五、ConversionReview 请求和响应
转换 Webhook 收到的不是单个裸对象,而是一个 ConversionReview。典型请求形状如下:
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "ConversionReview",
"request": {
"uid": "conversion-request-123",
"desiredAPIVersion": "example.com/v1",
"objects": [
{
"apiVersion": "example.com/v1alpha1",
"kind": "Widget",
"metadata": {
"name": "demo",
"namespace": "default"
},
"spec": {
"replicas": 3
}
}
]
}
}
含义如下:
request.uid:本次转换请求的唯一标识;desiredAPIVersion:API Server 希望得到的目标版本;objects:待转换对象数组;- 每个对象的
apiVersion:对象当前的输入版本。
响应必须:
- 复制同一个
uid; - 返回与输入对象数量对应的
convertedObjects; - 将每个对象的
apiVersion设置为desiredAPIVersion; - 转换失败时返回错误状态,而不是伪造一个空对象。
响应示例:
{
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "ConversionReview",
"response": {
"uid": "conversion-request-123",
"convertedObjects": [
{
"apiVersion": "example.com/v1",
"kind": "Widget",
"metadata": {
"name": "demo",
"namespace": "default"
},
"spec": {
"size": 3
}
}
],
"result": {
"status": "Success"
}
}
}
对象通常是批量传递的,因此服务端不能只实现“单对象请求”假设。对每个对象都应独立解析和转换,但整批请求应在发现错误时返回失败,不能返回数量不完整的成功结果。
六、一个最小可运行的 Go 转换服务
下面的示例使用标准库实现 HTTP 服务,演示:
v1alpha1.spec.replicas <-> v1.spec.size
它没有实现 TLS,因此适合本地理解协议;部署到 Kubernetes 时,API Server 连接转换服务必须使用可信 TLS 配置。
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
type ConversionReview struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Request *ConversionRequest `json:"request,omitempty"`
Response *ConversionResponse `json:"response,omitempty"`
}
type ConversionRequest struct {
UID string `json:"uid"`
DesiredAPIVersion string `json:"desiredAPIVersion"`
Objects []json.RawMessage `json:"objects"`
}
type ConversionResponse struct {
UID string `json:"uid"`
ConvertedObjects []json.RawMessage `json:"convertedObjects,omitempty"`
Result Status `json:"result"`
}
type Status struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"`
}
type ObjectMeta struct {
Name string `json:"name,omitempty"`
Namespace string `json:"namespace,omitempty"`
}
type V1Alpha1 struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata ObjectMeta `json:"metadata"`
Spec struct {
Replicas int32 `json:"replicas"`
} `json:"spec"`
Status map[string]any `json:"status,omitempty"`
}
type V1 struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata ObjectMeta `json:"metadata"`
Spec struct {
Size int32 `json:"size"`
} `json:"spec"`
Status map[string]any `json:"status,omitempty"`
}
func convert(raw json.RawMessage, desired string) ([]byte, error) {
var header struct {
APIVersion string `json:"apiVersion"`
}
if err := json.Unmarshal(raw, &header); err != nil {
return nil, fmt.Errorf("decode apiVersion: %w", err)
}
switch {
case header.APIVersion == "example.com/v1alpha1" &&
desired == "example.com/v1":
var src V1Alpha1
if err := json.Unmarshal(raw, &src); err != nil {
return nil, err
}
var dst V1
dst.APIVersion = "example.com/v1"
dst.Kind = src.Kind
dst.Metadata = src.Metadata
dst.Spec.Size = src.Spec.Replicas
dst.Status = src.Status
return json.Marshal(dst)
case header.APIVersion == "example.com/v1" &&
desired == "example.com/v1alpha1":
var src V1
if err := json.Unmarshal(raw, &src); err != nil {
return nil, err
}
var dst V1Alpha1
dst.APIVersion = "example.com/v1alpha1"
dst.Kind = src.Kind
dst.Metadata = src.Metadata
dst.Spec.Replicas = src.Spec.Size
dst.Status = src.Status
return json.Marshal(dst)
case header.APIVersion == desired:
// 同版本请求不应改变业务内容,只返回原对象。
return raw, nil
default:
return nil, fmt.Errorf(
"unsupported conversion: %s -> %s",
header.APIVersion, desired,
)
}
}
func convertHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var review ConversionReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, "invalid ConversionReview", http.StatusBadRequest)
return
}
if review.Request == nil {
http.Error(w, "missing request", http.StatusBadRequest)
return
}
response := &ConversionResponse{
UID: review.Request.UID,
Result: Status{
Status: "Success",
},
}
for _, object := range review.Request.Objects {
converted, err := convert(
object,
review.Request.DesiredAPIVersion,
)
if err != nil {
response.Result = Status{
Status: "Failure",
Reason: "ConversionFailed",
Message: err.Error(),
}
response.ConvertedObjects = nil
break
}
response.ConvertedObjects = append(
response.ConvertedObjects,
converted,
)
}
out := ConversionReview{
APIVersion: "apiextensions.k8s.io/v1",
Kind: "ConversionReview",
Response: response,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
log.Printf("write response: %v", err)
}
}
func main() {
http.HandleFunc("/convert", convertHandler)
log.Println("conversion webhook listening on :8443")
log.Fatal(http.ListenAndServe(":8443", nil))
}
这个示例有几个重要限制,不能直接当作生产实现:
status使用map[string]any,真实项目应定义完整类型;- 没有 TLS;
- 没有保留未知字段;
- 没有进行资源级语义校验;
- 没有处理字段从整数变成枚举、列表排序、单位转换等复杂情况。
它的核心逻辑仍然展示了完整路径:
读取对象 apiVersion
↓
按输入版本反序列化
↓
映射到目标版本
↓
设置目标 apiVersion
↓
返回 convertedObjects
七、字段转换不是简单重命名
1. 可逆字段重命名
以下转换通常可逆:
v1alpha1.spec.replicas -> v1.spec.size
v1.spec.size -> v1alpha1.spec.replicas
前提是两者类型、取值范围和语义完全一致。
2. 类型转换的边界
下面的转换可能有风险:
v1alpha1.spec.timeoutSeconds: integer
v1.spec.timeout: duration string
例如:
30 -> "30s"
如果 v1 允许:
"30.5s"
"1m"
那么转换回整数秒时就需要定义:
- 是否拒绝非整数秒;
- 是否向下取整;
- 是否四舍五入;
- 是否保留原始值。
不能把这些规则隐含在代码中,否则不同版本客户端写回对象后会产生不可预测的变化。
3. 列表语义转换
假设旧版本有:
spec:
endpoints:
- name: a
port: 80
新版本改成以名称为键的 map:
spec:
endpoints:
a:
port: 80
如果旧版本允许两个同名元素,那么转换不是单射:
两个不同对象映射成同一个新版本对象,反向转换不可能恢复原始对象。此时必须:
- 在旧版本 Schema 中限制名称唯一;
- 转换时检测重复并返回失败;
- 或设计一个能保留重复项的目标结构。
返回失败通常比静默覆盖更安全。
八、Schema、Validation、Defaulting 与 Conversion 的关系
Conversion Webhook 不是 Schema 的替代品。每个暴露版本都应有自己的结构化 Schema。
1. Schema 负责什么
Schema 主要负责:
- 字段类型;
- 必填字段;
- 数值范围;
- 枚举值;
- 列表和 map 的结构;
- 未知字段是否被裁剪;
- OpenAPI 发现信息。
例如:
schema:
openAPIV3Schema:
type: object
required:
- spec
properties:
spec:
type: object
required:
- size
properties:
size:
type: integer
format: int32
minimum: 0
2. Conversion 负责什么
Conversion 负责的是版本间语义映射:
replicas -> size
旧字段结构 -> 新字段结构
旧枚举 -> 新枚举
它不应承担本来属于 Schema 的通用校验。例如,不应因为没有 Schema 就在 Conversion Webhook 中实现全部字段类型检查。
3. 未知字段裁剪带来的信息丢失
如果某版本的 Schema 不允许某字段,API Server 可能在对象进入后续流程前将该字段裁剪。Conversion Webhook 无法恢复已经被裁剪的字段。
因此,下面的做法不能保证数据保留:
v1 有新字段 advancedMode
v1alpha1 Schema 不声明 advancedMode
v1 -> v1alpha1 时希望 Webhook 暂存 advancedMode
如果字段需要在旧版本读写后仍然保留,应把它纳入旧版本的 Schema,或者采用明确的保留机制,而不是依赖未知字段。
4. Defaulting 的版本一致性
默认值应在版本 API 设计中明确。假设:
v1alpha1 未设置 replicas,默认 1
v1 未设置 size,默认 3
那么同一个对象在两个版本之间转换时会发生语义变化。尤其是“字段未设置”和“字段设置为零”可能不同:
replicas: null
replicas: 0
转换代码必须定义:
- 缺失值如何处理;
- 零值是否有效;
- 默认值是在输入版本应用,还是目标版本应用;
- 控制器是否会把默认值写回对象。
不要假设 Conversion Webhook 可以替代 Defaulting Webhook。Defaulting 和 Conversion 是不同阶段,且具体处理顺序与 API Server 版本、对象路径有关。可靠设计应使每个版本的 Schema 和默认语义自洽,并在转换测试中覆盖“缺失、显式零值、默认值”三种状态。
5. Validation 的边界
如果 v1alpha1 允许:
replicas: 0
而 v1 Schema 要求:
size >= 1
那么 v1alpha1 -> v1 不是单纯字段转换,而是存在不可表示状态。Webhook 应该返回失败,或者在 API 设计阶段让两个版本拥有兼容的取值域。
一个简单的兼容条件是:
其中:
- 是 Spoke 版本允许的业务值集合;
- 是 Hub 版本能表达的业务值集合。
如果 Spoke 能表达 Hub 不能表达的值,转换就必然存在失败或丢失。
九、API Server 的实际数据流
以客户端使用 v1alpha1 创建对象、CRD 存储版本为 v1 为例:
sequenceDiagram
participant C as 客户端
participant A as kube-apiserver
participant W as Conversion Webhook
participant E as etcd
C->>A: POST /apis/example.com/v1alpha1/...
A->>A: 按 v1alpha1 Schema 解码、校验
A->>W: desiredAPIVersion=v1
W-->>A: 返回 v1 对象
A->>E: 以 v1 存储
A-->>C: 返回 v1alpha1 对象
返回给客户端时,API Server 还需要把存储对象转换回请求版本:
sequenceDiagram
participant C as 客户端
participant A as kube-apiserver
participant W as Conversion Webhook
participant E as etcd
C->>A: GET /apis/example.com/v1alpha1/...
A->>E: 读取存储对象 v1
E-->>A: v1 对象
A->>W: desiredAPIVersion=v1alpha1
W-->>A: 返回 v1alpha1 对象
A-->>C: v1alpha1 响应
因此,Webhook 不只在写入时被调用,读请求、列表请求、Watch 等涉及版本转换的路径也可能触发它。对象数组是因为 List 或批量处理可能一次转换多个对象。
这带来一个直接结论:
Conversion Webhook 是 API Server 正常读写链路的一部分,不是只在 CRD 升级时临时执行的迁移脚本。
十、存储版本与 etcd 中的真实状态
1. storage: true 的含义
下面的配置中,v1 是唯一存储版本:
versions:
- name: v1alpha1
served: true
storage: false
- name: v1
served: true
storage: true
客户端通过 v1alpha1 写入时,物理存储通常仍然是 v1 编码。客户端通过 v1alpha1 读取时,API Server 再把 v1 转换成 v1alpha1。
这里的“存储版本”指 API Server 的 API 编码版本,不是控制器内部 Go 结构体版本,也不是 metadata.generation。
2. 修改存储版本不会自动重写全部对象
把 CRD 改成:
v1alpha1.storage = false
v1.storage = true
并不等于 etcd 中所有旧对象已经立即被重新编码为 v1。已有对象可能仍以旧存储版本存在,直到被读取后重新写入,或者使用专门的存储版本迁移机制进行重写。
因此,升级存储版本时通常要保留:
served: true
以及旧版本出现在 CRD 的相关存储版本状态中,直到迁移完成。直接删除旧版本可能导致 CRD 更新被拒绝,或者使旧数据无法通过正常转换路径读取。
3. StorageVersion 与迁移工具
Kubernetes 有 StorageVersion 相关 API,用于记录资源当前观察到的存储版本。集群中是否自动完成重写,取决于部署的组件和运维方案;不能仅凭修改 CRD 就假定迁移已经完成。
生产升级应明确区分:
API 版本切换
≠
存储数据重写
一般顺序是:
- 发布支持旧版本和新版本的 Conversion Webhook;
- 增加新版本并将其设为存储版本;
- 验证新旧版本读写和转换;
- 使用集群选定的存储迁移方案重写对象;
- 确认旧存储版本不再存在;
- 才考虑停止服务或移除旧 API 版本。
十一、Conversion Webhook 与 Admission Webhook 的区别
二者都通过 HTTP 服务扩展 API Server,但职责完全不同。
| 项目 | Conversion Webhook | Admission Webhook |
|---|---|---|
| 目标 | 版本间转换 | 准入决策或修改对象 |
| 请求类型 | ConversionReview |
AdmissionReview |
| 触发原因 | API Server 需要目标版本 | 对象创建、更新、删除等 |
| 是否返回转换后的对象 | 是 | Mutating 可以,Validating 不可以 |
是否有 failurePolicy |
Conversion 配置不同,不等同于 Admission | 常见配置项 |
| 是否用于业务拒绝 | 不应作为主要职责 | Validating 的职责 |
| 是否适合修改业务字段 | 仅为版本语义转换 | Mutating 可修改 |
一个常见错误是把 Conversion Webhook 当作 Mutating Webhook,在转换过程中填充动态默认值、调用外部系统或改变业务状态。这样会使读请求产生副作用,也会让同一对象因调用时机不同而出现不一致结果。
转换函数应尽量满足:
相同输入 + 相同目标版本 = 相同输出
它应该是确定性的、幂等的,并且不依赖外部 API 的瞬时状态。
十二、Webhook 服务的网络与证书要求
API Server 连接 Conversion Webhook 时,常见配置是同一集群内的 Service:
clientConfig:
service:
namespace: widget-system
name: widget-conversion
path: /convert
port: 443
caBundle: ...
这要求:
- Service 名称和命名空间正确;
- Service 的端口映射到 Webhook 容器实际监听端口;
- API Server 信任
caBundle对应的 CA; - Webhook 证书的 SAN 包含 Kubernetes Service DNS 名称;
- NetworkPolicy、防火墙和控制面网络允许 API Server 访问;
- Webhook Pod 在升级和节点故障时仍有可用副本。
常见 DNS 名称包括:
widget-conversion.widget-system.svc
widget-conversion.widget-system.svc.cluster.local
证书如果只包含 Pod IP,而 API Server 通过 Service DNS 访问,TLS 校验仍然会失败。
转换 Webhook 不应依赖它正在转换的 CRD 才能启动,也不应把关键启动配置存储在该 CRD 的对象中。否则会形成循环依赖:
API Server 需要 Webhook 转换 CRD 对象
Webhook 启动又需要读取该 CRD 对象
十三、故障路径:Webhook 不可用时发生什么
转换服务不可达、TLS 校验失败、返回非法 JSON 或返回不支持的目标版本时,API Server 无法完成需要的版本转换。
典型表现包括:
failed calling webhook
conversion webhook for ... failed
no kind "ConversionReview" is registered
x509: certificate signed by unknown authority
客户端可能在以下操作中失败:
- 创建或更新某个版本的对象;
- 读取另一个版本的对象;
- List 或 Watch;
- 控制器通过非存储版本访问资源;
- 某些依赖对象转换的 API 操作。
这与 Admission Webhook 的一个重要差异是:Conversion 失败没有一个简单的“忽略失败继续执行”语义。API Server 如果不能得到目标版本,通常不能安全地完成请求,因为继续执行可能返回错误结构或丢失字段。
因此,转换服务的可用性直接影响该 CRD 的 API 可用性。
诊断顺序
先确认 CRD 配置:
kubectl get crd widgets.example.com -o yaml
检查:
spec.conversion.strategy
spec.conversion.webhook.clientConfig
spec.conversion.webhook.conversionReviewVersions
spec.versions[].served
spec.versions[].storage
status.conditions
status.storedVersions
再检查 Service 和 Endpoint:
kubectl -n widget-system get svc widget-conversion
kubectl -n widget-system get endpointslice \
-l kubernetes.io/service-name=widget-conversion
确认 Pod 日志:
kubectl -n widget-system logs deploy/widget-conversion
最后从能够验证网络和证书的环境测试服务端点。不要只在 Pod 内访问 Service,因为真正的调用方是 API Server,控制面到 Service 的网络路径可能不同。
十四、如何设计版本兼容性
“兼容”至少有三种含义,不能混为一谈。
1. 协议兼容
Webhook 能否解析 API Server 发来的 ConversionReview:
apiextensions.k8s.io/v1
这是 Webhook 协议兼容。
2. 结构兼容
两个版本是否包含对应字段、类型和结构。例如:
int32 -> int32
属于简单兼容;而:
int32 -> string duration
需要额外语义规则。
3. 行为兼容
同一个业务对象经过版本转换后,控制器和用户观察到的行为是否相同。
例如旧版本:
spec:
replicas: 3
新版本:
spec:
size: 3
如果控制器在两个版本中都将其解释为运行三个实例,则行为兼容。
但如果新版本改变了字段语义:
replicas:期望副本数
size:实例规格,small/medium/large
即使 JSON 能转换,也不是行为兼容。
兼容测试的最小集合
对于每个版本,应测试:
Spoke -> Hub
Hub -> Spoke
Spoke -> Hub -> Spoke
Hub -> Spoke -> Hub
还应覆盖:
- 字段缺失;
- 显式零值;
- 空列表与未设置列表;
- 空 map 与未设置 map;
- 最大值和最小值;
- 未知字段;
- status;
- metadata;
- 多对象批量请求;
- 非法对象;
- 不支持的目标版本。
转换测试不应只比较 JSON 字符串,因为字段顺序没有语义。应反序列化后比较业务字段,或者比较规范化后的对象。
十五、metadata、status 和未知字段的处理
1. apiVersion 必须修改
转换成功后,对象必须使用目标版本:
{
"apiVersion": "example.com/v1",
"kind": "Widget"
}
不能只转换 spec 而保留旧的 apiVersion。
2. kind 通常保持不变
同一 CRD 的版本转换通常只改变 API group/version,不改变 Kind:
Widget -> Widget
如果 Kind 也需要改变,通常说明这已经超出普通版本转换,应重新评估 API 设计。
3. Metadata 不应被业务逻辑重写
转换代码不应主动修改:
name;namespace;uid;resourceVersion;generation;creationTimestamp;ownerReferences;finalizers;managedFields。
这些字段由 API Server 或客户端管理。Webhook 的职责是保证它们在转换过程中不被意外删除或改变。实际实现应使用 Kubernetes API 类型或保留原始 metadata,而不是只定义少数字段后重新构造对象。
4. Status 也属于对象内容
ConversionReview 的 objects 通常包含完整对象,因此 status 也必须考虑转换。不能只转换 spec 后返回一个没有 status 的对象。
如果 status 中包含版本特有字段,应同样满足可逆性要求,或者明确规定旧版本查看时哪些信息不可见。
5. 未知字段不是安全的“扩展槽”
如果 Go 结构体直接反序列化再序列化,未声明字段可能丢失。对于需要保留未知字段的 API,应:
- 使用完整 Kubernetes 类型和
runtime.RawExtension等适当结构; - 或在转换设计中明确保留策略;
- 对未知字段进行 round-trip 测试。
不能默认 encoding/json 会自动帮你保留所有字段。
十六、版本升级的推荐时序
假设当前版本为:
v1alpha1:served=true, storage=true
v1:未发布
目标是:
v1alpha1:served=true, storage=false
v1:served=true, storage=true
可以按以下逻辑执行。
第一步:先部署能处理双向转换的 Webhook
在 CRD 切换前,服务至少要支持:
v1alpha1 -> v1
v1 -> v1alpha1
如果先修改 CRD、后部署服务,API Server 可能立即开始调用一个不存在的端点。
第二步:扩展 CRD 版本列表
增加 v1,并暂时保持旧版本可用:
v1alpha1:
served: true
storage: true
v1:
served: true
storage: false
此阶段可以验证:
kubectl get widgets.example.com/v1
kubectl get widgets.example.com/v1alpha1
第三步:切换存储版本
确认转换服务稳定后,切换:
v1alpha1:
served: true
storage: false
v1:
served: true
storage: true
这个切换决定新写入对象的存储编码,但不代表所有历史对象已经重写。
第四步:执行存储迁移并验证
使用集群采用的存储版本迁移方案重写对象,然后检查:
kubectl get crd widgets.example.com \
-o jsonpath='{.status.storedVersions}{"\n"}'
status.storedVersions 反映 API Server 观察到的存储版本信息。它不是简单的“当前 preferred API version”字段。
第五步:最后停止旧版本
只有在以下条件成立后,才考虑:
v1alpha1:
served: false
并最终从 CRD 中移除旧版本:
- 所有客户端和控制器已迁移;
- 历史对象已完成存储迁移;
- Webhook 不再需要旧版本;
- 备份和恢复流程已验证;
- 不存在仍使用旧 GVK 的自动化任务。
十七、回滚不是简单把 storage: true 改回去
假设升级后:
v1alpha1:served=true, storage=false
v1:served=true, storage=true
现在发现 v1 转换逻辑有错误。回滚需要先判断错误属于哪一类。
情况一:代码错误,但存储语义兼容
如果 v1 只是把字段名写错,且已有对象仍能被旧代码理解,可以:
- 保留两个版本都
served: true; - 回滚 Webhook Deployment;
- 确保回滚版本仍支持
v1 -> v1alpha1和v1alpha1 -> v1; - 验证读写;
- 再决定是否切回旧存储版本。
回滚 Webhook 代码之前不能直接删除 v1,因为已有对象可能已经按 v1 存储或仍通过 v1 被 API Server 读取。
情况二:新版本丢失了旧版本无法表达的字段
例如 v1 引入:
spec:
placement:
zone: zone-a
而 v1alpha1 没有任何可逆表达方式。若用户通过旧版本读取并更新对象,可能导致 placement 丢失。
这时简单回滚无法恢复已经丢失的数据。需要:
- 从备份恢复;
- 根据审计日志或控制器状态重建;
- 使用专门的数据修复程序;
- 暂停会破坏字段的旧客户端写入。
情况三:已经停止服务旧版本
如果升级时把:
v1alpha1.served = false
甚至删除了旧版本,回滚会更复杂。至少需要先恢复:
v1alpha1.served: true
并恢复支持双向转换的 Webhook。若 CRD 当前 status.storedVersions 仍包含旧版本,直接删除旧版本可能被 API Server 拒绝。
情况四:Webhook 本身不可用
如果回滚过程中 Webhook Deployment、证书或 Service 失败,应优先恢复 API 路径,而不是先修改数据:
- 恢复 Service 和 Endpoints;
- 恢复证书和 CA;
- 部署已知可用的 Webhook 版本;
- 验证 API Server 能完成双向转换;
- 再处理存储版本和客户端迁移。
因为在转换服务不可用时,盲目修改 CRD 可能使故障范围扩大到所有读写操作。
十八、常见错误与对应表现
错误一:只实现单向转换
例如只实现:
v1alpha1 -> v1
创建请求可能成功,但客户端使用 v1alpha1 读取时需要:
v1 -> v1alpha1
结果会出现 GET、LIST 或 Watch 失败。
错误二:忽略批量对象
Webhook 只读取:
request.Objects[0]
会导致 List 等请求只返回一个对象,或者返回数量不匹配的结果。转换服务必须遍历整个 objects 数组。
错误三:返回错误 UID
API Server 通过 UID 将响应关联到请求。响应中的 UID 与请求不一致时,响应会被视为无效。
错误四:只修改 apiVersion
把:
example.com/v1alpha1
替换成:
example.com/v1
但不改变字段结构,不是转换。API Server 之后会按目标 Schema 处理对象,最终表现为字段缺失、校验失败或控制器行为错误。
错误五:在转换中访问外部依赖
转换可能发生在读请求中。若 Webhook 每次转换都访问数据库或其他 API:
GET CR -> Conversion Webhook -> 外部数据库
外部依赖故障就会变成 CRD API 故障。转换逻辑应尽量只依赖请求对象本身。
错误六:把 Hub 当作“临时缓存版本”
Hub 必须是稳定的规范模型。如果 Hub 字段不断随控制器内部实现变化,所有 Spoke 都会受到影响。Hub 更接近公共语义中间层,而不是某个控制器的私有 Go struct。
十九、生产实现中的高可用边界
Conversion Webhook 通常至少需要:
- 多个 Pod 副本;
- Pod 分散到不同节点;
- Service 稳定指向健康端点;
- 就绪探针确保未完成证书或配置加载的 Pod 不接流量;
- 证书轮换机制;
- 监控请求延迟、错误率和拒绝数;
- 兼容旧版本的滚动升级策略。
滚动升级时,新旧 Webhook Pod 应同时支持相同的转换协议和版本集合。不能先让一部分 Pod 只支持 v1 -> v1alpha1,另一部分只支持 v1alpha1 -> v1,因为请求会随机落到任一 Pod,产生间歇性失败。
一个安全的兼容窗口通常是:
旧版本 Webhook:支持 A <-> H
新版本 Webhook:支持 A <-> H,并修复实现
待所有请求验证通过后,再移除旧版本代码。
二十、最终验证方法
可以用一个对象验证最基本的双向路径:
apiVersion: example.com/v1alpha1
kind: Widget
metadata:
name: demo
namespace: default
spec:
replicas: 3
创建后分别读取两个版本:
kubectl apply -f widget-v1alpha1.yaml
kubectl get widget demo \
-n default \
-o yaml \
--api-version=example.com/v1alpha1
kubectl get widget demo \
-n default \
-o yaml \
--api-version=example.com/v1
预期业务语义应分别表现为:
# v1alpha1
spec:
replicas: 3
# v1
spec:
size: 3
然后通过 v1 更新,再通过 v1alpha1 读取;再反向执行一次。验证的不是两个 YAML 是否文本完全相同,而是:
名称、命名空间、spec 语义、status 语义和控制器行为是否保持一致
还应检查存储状态:
kubectl get crd widgets.example.com \
-o jsonpath='{range .spec.versions[*]}{.name}{" served="}{.served}{" storage="}{.storage}{"\n"}{end}'
kubectl get crd widgets.example.com \
-o jsonpath='{.status.storedVersions}{"\n"}'
前一个命令确认配置,后一个命令帮助判断历史存储版本是否仍存在。二者不能互相替代。
CRD Conversion 的核心不是“为旧版本改几个字段名”,而是维护一个长期存在的、可验证的 API 语义映射。Hub 提供规范中间层,Spoke 提供客户端兼容面,served 决定访问路径,storage 决定新的物理编码,Schema 决定每个版本能表达和接受什么,而回滚则要求旧版本、Webhook 和历史存储数据在同一时间窗口内仍然互相兼容。只要其中一个边界被忽略,版本升级就可能从 API 演进问题变成数据可读性问题。
系列导航与关联阅读
- 系列入口:Kubernetes 完整学习路线:从 Pod 与控制面到安全、运维和 Operator
- 上一篇:Kubernetes Admission Webhook 开发:Mutating、Validating、证书和高可用
- 下一篇:Kubernetes Server-Side Apply:Field Manager、冲突、所有权和 Controller
- 延伸:Kubernetes CRD:Schema、版本、Defaulting、Validation、Conversion 和存储
官方资料
本文依据 Kubernetes、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。

评论
0 条讨论