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:对外暴露的具体版本,例如 v1alpha1v1beta1v1

如果有三个版本:

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

这里 replicassize 表达的是同一个业务概念,但字段名不同。API Server 不知道这种业务语义,因此需要 Conversion Webhook。

转换的核心目标不是“把 JSON 改成另一个 JSON”,而是让两个版本表达同一个资源状态:

Cab(xa)=xbC_{a \rightarrow b}(x_a) = x_b

其中:

  • xax_a 是版本 a 的对象;
  • xbx_b 是版本 b 的对象;
  • CabC_{a \rightarrow b} 是从 ab 的转换函数。

对于可逆转换,还希望满足:

Cba(Cab(xa))xaC_{b \rightarrow a}(C_{a \rightarrow b}(x_a)) \approx x_a

这里的“约等于”很重要。版本转换后通常允许:

  • 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

因此:

CAB=CHBCAHC_{A \rightarrow B} = C_{H \rightarrow B} \circ C_{A \rightarrow H}

其中右侧函数先执行。

1. 为什么 Hub 能降低复杂度

如果每个版本都直接互转,版本数为 nn 时,最坏情况下需要维护:

n(n1)n(n-1)

个有方向的转换函数。

使用 Hub 后,每个版本只需实现到 Hub 和从 Hub 转出的转换:

2(n1)2(n-1)

例如四个版本:

直接互转:4 × 3 = 12 个方向
Hub 模式:2 × 3 = 6 个方向

这不仅减少代码量,更重要的是减少语义分叉。否则可能出现:

A -> B -> C
A -> C

两条路径得到不同结果,导致控制器观察到不一致状态。

2. Hub 转换必须满足的条件

对每个 Spoke 版本 SS,至少应满足:

CHS(CSH(xS))xSC_{H \rightarrow S}(C_{S \rightarrow H}(x_S)) \approx x_S

对 Hub 对象也应满足:

CSH(CHS(xH))xHC_{S \rightarrow H}(C_{H \rightarrow S}(x_H)) \approx x_H

第二个条件尤其容易被忽略。假设 v1alpha1 没有 spec.paused,但 v1 有:

spec:
  paused: true

如果 Hub 是 v1,从 v1 转到 v1alpha1 时不能简单删除 paused,否则再转回 Hub 时会变成默认值 false,产生信息丢失。

解决办法通常有三种:

  1. 让旧版本也增加该字段;
  2. 把字段语义编码到旧版本已有字段中;
  3. 接受该字段对旧版本不可见,但不能再声称转换完全无损。

第三种方案会带来读写风险:使用旧版本客户端读取并写回对象,可能覆盖新版本字段。


四、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 支持的 ConversionReview API 版本;
  • 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:对象当前的输入版本。

响应必须:

  1. 复制同一个 uid
  2. 返回与输入对象数量对应的 convertedObjects
  3. 将每个对象的 apiVersion 设置为 desiredAPIVersion
  4. 转换失败时返回错误状态,而不是伪造一个空对象。

响应示例:

{
  "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))
}

这个示例有几个重要限制,不能直接当作生产实现:

  1. status 使用 map[string]any,真实项目应定义完整类型;
  2. 没有 TLS;
  3. 没有保留未知字段;
  4. 没有进行资源级语义校验;
  5. 没有处理字段从整数变成枚举、列表排序、单位转换等复杂情况。

它的核心逻辑仍然展示了完整路径:

读取对象 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

如果旧版本允许两个同名元素,那么转换不是单射:

C(x1)=C(x2)C(x_1) = C(x_2)

两个不同对象映射成同一个新版本对象,反向转换不可能恢复原始对象。此时必须:

  • 在旧版本 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 设计阶段让两个版本拥有兼容的取值域。

一个简单的兼容条件是:

D(S)D(H)D(S) \subseteq D(H)

其中:

  • D(S)D(S) 是 Spoke 版本允许的业务值集合;
  • D(H)D(H) 是 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 版本切换
≠
存储数据重写

一般顺序是:

  1. 发布支持旧版本和新版本的 Conversion Webhook;
  2. 增加新版本并将其设为存储版本;
  3. 验证新旧版本读写和转换;
  4. 使用集群选定的存储迁移方案重写对象;
  5. 确认旧存储版本不再存在;
  6. 才考虑停止服务或移除旧 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: ...

这要求:

  1. Service 名称和命名空间正确;
  2. Service 的端口映射到 Webhook 容器实际监听端口;
  3. API Server 信任 caBundle 对应的 CA;
  4. Webhook 证书的 SAN 包含 Kubernetes Service DNS 名称;
  5. NetworkPolicy、防火墙和控制面网络允许 API Server 访问;
  6. 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 字符串,因为字段顺序没有语义。应反序列化后比较业务字段,或者比较规范化后的对象。


十五、metadatastatus 和未知字段的处理

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 只是把字段名写错,且已有对象仍能被旧代码理解,可以:

  1. 保留两个版本都 served: true
  2. 回滚 Webhook Deployment;
  3. 确保回滚版本仍支持 v1 -> v1alpha1v1alpha1 -> v1
  4. 验证读写;
  5. 再决定是否切回旧存储版本。

回滚 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 路径,而不是先修改数据:

  1. 恢复 Service 和 Endpoints;
  2. 恢复证书和 CA;
  3. 部署已知可用的 Webhook 版本;
  4. 验证 API Server 能完成双向转换;
  5. 再处理存储版本和客户端迁移。

因为在转换服务不可用时,盲目修改 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、CNCF 与相关项目官方文档重新梳理;正文和生产清单由 WR BLOG 编写。