[译]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。
1 严格验证
作为控制器开发者,你应仅允许 经过验证 和 完整 的自定义资源进入你的 API Server。
任何具有非法值或缺失字段的资源都可能导致后续问题。你的控制器不应对资源有隐式默认值。根据 Kubernetes API 约定 的建议:
一般来说,我们希望默认值在我们的 API 中显式表示,而不是声称“未指定字段采用默认行为”。
你不能长期地可靠地在controller中弥补缺失字段,也不应在reconciliation过程中处理非法值。
2 每个字段都显式标记为 +required
或 +optional
controller-gen 有多种标记字段为“optional”的方式:
Go 结构字段具有 omitempty 标记:
type Car struct { Brand json:"brand,omitempty"
结构体里的字段具有
//+optional
标记注释。type Car struct { //+optional Brand json:"brand"
结构体里的字段具有
//+kubebuilder:validation:Optional
标记注释。type Car struct { //+kubebuilder:validation:Optional Brand json:"brand"
…通常你可能认为就这些,但:
你可以在package上面添加一个标记,使所有字段“optional by default”(这个功能我还没有找到使用案例):
//+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
标记,以便所有结构字段默认都是必需的,作为兜底。
3 字段验证
3.1 零值与空值的陷阱
理解 CRD 验证的一个主要陷阱是 Go 类型系统允许零值通过 +required
检查:空字符串(""
)、零数值(0
、0.0
)、空切片([]
)或空映射({}
)都是各自类型的有效值。
OpenAPI schema 验证查找请求负载中字段的非空存在。
这个错误通常会被忽视,因为你可能在用 Go 编写测试。当 Go JSON 序列化器将测试对象转换为 JSON 负载时,请求体将包含 "field": ""
(因为该字段没有 omitempty
),服务器会将其视为有效资源。你的测试不会失败。
如果你确实想禁止字段的零值或空字符串,可以使用以下标记:
+kubebuilder:validation:MinLength=1
用于字符串+kubebuilder:validation:Minimum=1
用于整数。
注意
如果你想在语义上区分“未指定”字段与“零值”,请将 Go 结构字段定义为指针类型。(这是在自定义资源结构中使用指针字段的唯一可接受理由。)
3.2 嵌套字段并不总是经过验证
考虑这个具有必需 spec.brand
字段和枚举验证的 Car
自定义资源类型:
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的验证:
apiVersion: example.com/v1beta1
kind: Car
metadata:
name: my-car
就 API Server而言,这是一个有效对象,但这并不是你想要的。Brand
字段未经过验证,因为 Open API schema验证的工作方式:对象字段仅在请求负载中指定时才会被验证。
如果上面的 YAML 负载包含 spec: {}
,则会进行验证,这个请求将被拒绝。
3.3 标记并不总是被验证
虽然 controller-gen
工具对你编写的 Go 结构进行广泛验证以捕捉小错误,但它不会验证不被识别的标记:
type Car struct {
//+kubebuilder:validation:enum=BMW;Porsche;McLaren
Brand string `json:"brand,omitempty"`
}
如果你无法发现上述错误,你就是那些 认为 controller-gen
会报告拼写错误的几十个开发者之一,错误地将 :Enum
拼写为 :enum
。
不要依赖 controller-gen
严格验证每个标记,应该检查生成的 CustomResourceDefinition 清单。
4 字段默认值
4.1 嵌套结构的默认值
让我们扩展 Car
API,定义一个非必需的 Transmission.Type
字段,使用 +kubebuilder:default
标记默认为 Automatic
:
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
字段,如下所示:
spec:
brand: BMW
Transmission.Type
字段将不会默认设置为 Automatic
——原因与上述 OpenAPI schema验证对嵌套结构的工作方式相同:Transmission
类型的成员,仅在请求中Transmission
具有非空值时才会被默认。
为避免此陷阱,你可以将 Transmission
字段的默认值设置为空对象 ({}
),这样可以对其嵌套字段进行默认设置:
//+kubebuilder:default:={}
//+optional
Transmission Transmission `json:"transmission,omitempty"`
现在可以从请求体中省略 spec.transmission
字段,结果对象将包含 transmission: {type: Automatic}
。
4.2 同时进行默认设置和验证
继续之前的示例,将 spec.transmission.type
字段标记为 //+required
,如下所示:
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。
你可以通过在父结构中提供更完整的默认值来解决此问题:
type CarSpec struct {
//+kubebuilder:default:={type:Automatic}
Transmission Transmission `json:"transmission,omitempty"`
...
然而,这并不理想,因为我们将默认值 "Automatic"
重复定义了两次:一次在 CarSpec.Transmission.Type
字段,另一次在 CarSpec.Transmission
中。这可能会导致维护方面的困扰。如果你有更好的解决方案,请告诉我。但这是我所知道的唯一可行方法。
4.3 可零值字段的显式默认值
假设你正在实现 ReplicaSet API 及其controller,并且有像 .status.readyReplicas
这样的字段由controller更新。由于单个controller负责更新状态,你可能会发出 PATCH 请求。
然而,如果在计算Patch时候,“之前”和“之后”对象都具有 readyReplicas: 0
,生成的payload将不会有 readyReplicas
字段。
因此,Kubernetes API 机制将不会为此字段设置值,你的 status
将缺少此字段(这并不是你想要的,因为你的客户端会期望,即使这个值为 0 也会存在该字段)。该字段在controller将其更新为非零值前,不会存在status对象中,而更新有可能永远不会发生。
因此,你应该考虑在控制器管理的字段上显式配置默认值(如适用),以防controller发送的部分Patch补丁从未为字段设置值:
type MyWorkloadStatus struct {
//+kubebuilder:default:=0
ReadyReplicas int32 `json:"readyReplicas"`
从功能上讲,这并不是非常关键,但当你在 kubectl get
输出中看到空值或0 值,你就知道可能发生的原因。
5 结论
controller-gen
有其自身的奇特之处。就像任何弱类型系统一样,controller-gen
理想情况下应该配合更强大的静态分析工具或 linter,以在提交到代码库之前捕捉这些错误。我认为我们在这方面缺乏更多的工具(虽然一些 工具 已存在)。
基于注释的标记存在破坏 API 字段向后兼容性的风险(例如,让字段为必需,或字段删除枚举值)。人类的判断在这方面仅能起到有限的作用。
controller-gen项目由一个相对较小但活跃的开发者社区维护。如果你在公司中使用 CRD,请考虑为该项目做出贡献,让它变得更好。
如果你在项目中使用 controller-gen
,希望这篇文章能帮助你避免一些我们碰到的陷阱。如果你希望改善生态系统,希望这些问题能启发你构建静态分析工具,在问题被提交之前捕捉这些问题。