[译]Kubernetes CRD生成中的那些坑

本文讲述了使用controller-gen生成CRD在生产环境中的经验教训,翻译自 https://ahmet.im/blog/crd-generation-pitfalls/

此文为个人翻译,仅供参考,不代表我个人立场。翻译过程中可能有删改或遗漏,如需了解原文,请自行查阅。如有疏漏,欢迎指正。

在sourcegraph中的 代码搜索查询 显示,在开源代码库中至少有 7000 个 Kubernetes 自定义资源定义,其中大多数可能是使用 controller-gen 生成的——这是一种将带有 注释标记 的 Go 结构转换为 Kubernetes CRD 清单的工具,最终成为 Kubernetes API Server提供的自定义 API。

在 LinkedIn,我们开发了许多自定义 Kubernetes API 和控制器,以运行工作负载或管理基础设施。在此过程中,我们高度依赖自定义资源机制和 controller-gen 来生成我们的 CRD。

作为控制器开发者,你应仅允许 经过验证完整 的自定义资源进入你的 API Server。

任何具有非法值或缺失字段的资源都可能导致后续问题。你的控制器不应对资源有隐式默认值。根据 Kubernetes API 约定 的建议:

一般来说,我们希望默认值在我们的 API 中显式表示,而不是声称“未指定字段采用默认行为”。

你不能长期地可靠地在controller中弥补缺失字段,也不应在reconciliation过程中处理非法值。

controller-gen 有多种标记字段为“optional”的方式:

  1. Go 结构字段具有 omitempty 标记:

    go

    type Car struct {
        Brand json:"brand,omitempty"
  2. 结构体里的字段具有 //+optional 标记注释。

    go

    type Car struct {
         //+optional
        Brand json:"brand"
  3. 结构体里的字段具有 //+kubebuilder:validation:Optional 标记注释。

    go

    type Car struct {
         //+kubebuilder:validation:Optional
        Brand json:"brand"

    …通常你可能认为就这些,但:

  4. 你可以在package上面添加一个标记,使所有字段“optional by default”(这个功能我还没有找到使用案例):

    go

    //+kubebuilder:validation:Optional
    package v1beta1

这显然有太多不同的方法来实现相同的功能,并且可能因配置错误而导致 API 的验证变得更加宽松。

直到 controller-tools v0.16(上月发布),无法可靠地将字段标记为 必需。 (即使你在字段上指定了 +required 标记,但是json tag中 omitempty也会悄悄地将字段变为可选。)

因此,我强烈建议升级到 controller-tools v0.16+ ,并开始 在你 API 的每个字段上显式指定 +required+optional 标记。如果你这样做,你可能已经发现你的某些API字段错误地标记为可选。

我建议仍然在package级别保留 //+kubebuilder:validation:Required 标记,以便所有结构字段默认都是必需的,作为兜底。

理解 CRD 验证的一个主要陷阱是 Go 类型系统允许零值通过 +required 检查:空字符串("")、零数值(00.0)、空切片([])或空映射({})都是各自类型的有效值。

OpenAPI schema 验证查找请求负载中字段的非空存在。

这个错误通常会被忽视,因为你可能在用 Go 编写测试。当 Go JSON 序列化器将测试对象转换为 JSON 负载时,请求体将包含 "field": ""(因为该字段没有 omitempty),服务器会将其视为有效资源。你的测试不会失败。

如果你确实想禁止字段的零值或空字符串,可以使用以下标记:

  • +kubebuilder:validation:MinLength=1 用于字符串
  • +kubebuilder:validation:Minimum=1 用于整数。
Note

注意

如果你想在语义上区分“未指定”字段与“零值”,请将 Go 结构字段定义为指针类型。(这是在自定义资源结构中使用指针字段的唯一可接受理由。)

考虑这个具有必需 spec.brand 字段和枚举验证的 Car 自定义资源类型:

go

package v1beta1
//+kubebuilder:object:root=true
type Car struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    Spec CarSpec `json:"spec,omitempty"`
}

type CarSpec struct {
    //+kubebuilder:validation:Enum=BMW;Porsche;McLaren
    //+required
    Brand string `json:"brand"`
}

仍然可以创建如下资源并跳过 API Server的验证:

yaml

apiVersion: example.com/v1beta1
kind: Car
metadata:
    name: my-car

就 API Server而言,这是一个有效对象,但这并不是你想要的。Brand 字段未经过验证,因为 Open API schema验证的工作方式:对象字段仅在请求负载中指定时才会被验证。

如果上面的 YAML 负载包含 spec: {},则会进行验证,这个请求将被拒绝。

虽然 controller-gen 工具对你编写的 Go 结构进行广泛验证以捕捉小错误,但它不会验证不被识别的标记:

go

type Car struct {
    //+kubebuilder:validation:enum=BMW;Porsche;McLaren
    Brand string `json:"brand,omitempty"`
}

如果你无法发现上述错误,你就是那些 认为 controller-gen 会报告拼写错误的几十个开发者之一,错误地将 :Enum 拼写为 :enum

不要依赖 controller-gen 严格验证每个标记,应该检查生成的 CustomResourceDefinition 清单。

让我们扩展 Car API,定义一个非必需的 Transmission.Type 字段,使用 +kubebuilder:default 标记默认为 Automatic

go

type CarSpec struct {
    //+kubebuilder:validation:Enum=BMW;Porsche;McLaren
    //+required
    Brand        string        `json:"brand"`

    //+optional
    Transmission Transmission  `json:"transmission,omitempty"`
}

type Transmission struct {
    //+kubebuilder:default:=Automatic
    Type string `json:"type,omitempty"`
}

如果你创建一个 Car 资源的请求,且请求体缺少 transmission 字段,如下所示:

yaml

spec:
  brand: BMW

Transmission.Type 字段将不会默认设置为 Automatic——原因与上述 OpenAPI schema验证对嵌套结构的工作方式相同:Transmission 类型的成员,仅在请求中Transmission具有非空值时才会被默认。

为避免此陷阱,你可以将 Transmission 字段的默认值设置为空对象 ({}),这样可以对其嵌套字段进行默认设置:

go

//+kubebuilder:default:={}
//+optional
Transmission Transmission `json:"transmission,omitempty"`

现在可以从请求体中省略 spec.transmission 字段,结果对象将包含 transmission: {type: Automatic}

继续之前的示例,将 spec.transmission.type 字段标记为 //+required,如下所示:

go

type CarSpec struct {
    //+kubebuilder:default:={}
    //+optional
    Transmission Transmission `json:"transmission,omitempty"`
}

type Transmission struct {
    //+kubebuilder:default:=Automatic
    //+required
    Type string `json:"type"`
}

如果你运行 controller-gen crd 从中生成 CRD,你会看到它明确失败:

The CustomResourceDefinition "cars.example.com" is invalid: spec.validation.openAPIV3Schema.properties[spec].properties[transmission].default.type: Required value

在此错误中,API Server告知你默认值 transmission: {} 不是该字段的有效值,拒绝接受该 CustomResourceDefinition。

你可以通过在父结构中提供更完整的默认值来解决此问题:

go

type CarSpec struct {
    //+kubebuilder:default:={type:Automatic}
    Transmission Transmission `json:"transmission,omitempty"`
    ...

然而,这并不理想,因为我们将默认值 "Automatic" 重复定义了两次:一次在 CarSpec.Transmission.Type 字段,另一次在 CarSpec.Transmission 中。这可能会导致维护方面的困扰。如果你有更好的解决方案,请告诉我。但这是我所知道的唯一可行方法。

假设你正在实现 ReplicaSet API 及其controller,并且有像 .status.readyReplicas 这样的字段由controller更新。由于单个controller负责更新状态,你可能会发出 PATCH 请求。

然而,如果在计算Patch时候,“之前”和“之后”对象都具有 readyReplicas: 0 ,生成的payload将不会有 readyReplicas 字段。

因此,Kubernetes API 机制将不会为此字段设置值,你的 status 将缺少此字段(这并不是你想要的,因为你的客户端会期望,即使这个值为 0 也会存在该字段)。该字段在controller将其更新为非零值前,不会存在status对象中,而更新有可能永远不会发生。

因此,你应该考虑在控制器管理的字段上显式配置默认值(如适用),以防controller发送的部分Patch补丁从未为字段设置值:

go

type MyWorkloadStatus struct {
    //+kubebuilder:default:=0
    ReadyReplicas int32 `json:"readyReplicas"`

从功能上讲,这并不是非常关键,但当你在 kubectl get 输出中看到空值或0 值,你就知道可能发生的原因。

controller-gen 有其自身的奇特之处。就像任何弱类型系统一样,controller-gen 理想情况下应该配合更强大的静态分析工具或 linter,以在提交到代码库之前捕捉这些错误。我认为我们在这方面缺乏更多的工具(虽然一些 工具 已存在)。

基于注释的标记存在破坏 API 字段向后兼容性的风险(例如,让字段为必需,或字段删除枚举值)。人类的判断在这方面仅能起到有限的作用。

controller-gen项目由一个相对较小但活跃的开发者社区维护。如果你在公司中使用 CRD,请考虑为该项目做出贡献,让它变得更好。

如果你在项目中使用 controller-gen,希望这篇文章能帮助你避免一些我们碰到的陷阱。如果你希望改善生态系统,希望这些问题能启发你构建静态分析工具,在问题被提交之前捕捉这些问题。

相关内容