注意: 不耐烦的读者可以直接跳到 快速开始

使用旧版本的 Kubebuilder v1 或 v2? 请查看 v1v2v3 的遗留文档

这适合谁

Kubernetes的用户

Kubernetes 用户将通过学习 API 设计和实现的基本概念,深入理解 Kubernetes。 本书将教会读者如何开发自己的 Kubernetes API 以及核心 Kubernetes API 设计的原则。

包括:

  • Kubernetes API 和资源的结构
  • API版本控制语义
  • 自我修复
  • 垃圾收集与终结器
  • 声明式 API 与 命令式 API
  • 基于层级的 API 与基于边缘的 API
  • 资源与子资源

Kubernetes API 扩展开发者

API 扩展开发者将学习实现规范 Kubernetes API 的原则和概念,以及用于快速执行的简单工具和库。 本书涵盖了扩展开发者常遇到的陷阱和误解。

包括:

  • 如何将多个事件批量合并到一次对账调用中
  • 如何配置定期对账
  • 即将到来
    • 何时使用列出缓存与实时查找
    • 垃圾回收与终结器
    • 如何使用声明式验证与 Webhook 验证
    • 如何实现 API 版本控制

为什么选择 Kubernetes API

Kubernetes API 提供了一致且明确定义的对象网络端点,遵循一致且丰富的结构

这种方式促进了丰富的工具和库生态系统与 Kubernetes API 进行交互。

用户通过将对象声明为 yamljson 配置,并使用常见工具来管理这些对象,方便与 APIs 进行交互。

将服务构建为 Kubernetes API 相较于传统的REST,有以下优势:

  • 托管的 API 网络端点、存储和验证。
  • 丰富的工具和命令行接口,例如 kubectlkustomize
  • 支持身份验证(AuthN)和细粒度授权(AuthZ)。
  • 通过 API 版本控制和转换并且支持 API 版本演进。
  • 促进自适应/自愈 API 的实现,它们能够在没有用户干预的情况下持续响应系统状态的变化。
  • Kubernetes 是一个托管环境

开发者可以构建并发布自己的 Kubernetes API,方便安装到运行中的 Kubernetes 集群中。

贡献

如果您希望为本书或代码贡献内容,请先阅读我们的贡献指南。

资源

建筑概念图

下面的图表将帮助您更好地了解 Kubebuilder 的概念和架构。

快速开始

本快速入门指南将涵盖:

前提条件

  • go 版本 v1.23.0+
  • 您已安装的 docker 版本为 17.03 或更高。
  • kubectl 版本 v1.11.3 及以上。
  • 访问 Kubernetes v1.11.3+ 集群。

安装

安装 kubebuilder

# 下载 kubebuilder 并在本地安装。 curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)" chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/

创建一个项目

创建一个目录,然后在其中运行初始化命令以初始化一个新项目。以下是一个示例。

mkdir -p ~/projects/guestbook cd ~/projects/guestbook kubebuilder init --domain my.domain --repo my.domain/guestbook

创建一个API

运行以下命令以创建一个新的 API(组/版本)webapp/v1,并在其上创建新的 Kind(CRD)Guestbook

kubebuilder create api --group webapp --version v1 --kind Guestbook

可选: 编辑 API 定义和调整业务逻辑。有关更多信息,请参见 设计 API控制器中的内容

如果您正在编辑 API 定义,请使用生成清单,例如自定义资源(CRs)或自定义资源定义(CRDs)。

make manifests
Click here to see an example. (api/v1/guestbook_types.go)

// GuestbookSpec 定义了 Guestbook 的期望状态 type GuestbookSpec struct { // 插入额外的规格字段 - 集群的期望状态 // 注意:修改此文件后,运行"make"以重新生成代码 // 实例数量 // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=10 Size int32 `json:"size"` // GuestbookSpec 配置的 ConfigMap 名称 // +kubebuilder:validation:MaxLength=15 // +kubebuilder:validation:MinLength=1 ConfigMapName string `json:"configMapName"` // +kubebuilder:validation:Enum=电话;地址;名称 Type string `json:"type,omitempty"` } // GuestbookStatus 定义了 Guestbook 的观察状态 type GuestbookStatus struct { // 插入额外的状态字段 - 定义集群的观察状态 // 重要:在修改此文件后,请运行 "make" 以重新生成代码 // 活动的 Guestbook 节点的 Pod 名称。 Active string `json:"active"` // 待命 Guestbook 节点的 Pod 名称。 Standby []string `json:"standby"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster // Guestbook 是 guestbooks API 的 Schematype Guestbook struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec GuestbookSpec `json:"spec,omitempty"` Status GuestbookStatus `json:"status,omitempty"` }

测试一下

您需要一个 Kubernetes 集群来运行。您可以使用 KIND 来获取一个本地集群进行测试,或连接到远程集群。

将 CRD 安装到集群中:

make install

为了快速反馈和代码级调试,请运行您的控制器(这将在前台运行,因此如果您想让它继续运行,请切换到新终端):

make run

安装自定义资源的实例

如果你按下 y 来创建资源(Create Resource) [y/n],那么你就在你的示例中为你的 CRD 创建了一个 CR(如果你已更改 API 定义,请确保先编辑它们):

kubectl apply -k config/samples/

在集群上运行它

当您的控制器准备好进行打包并在其他集群中测试时。

构建并将您的镜像推送到 IMG 所指定的位置:

make docker-build docker-push IMG=<some-registry>/<project-name>:tag

将控制器部署到集群,使用 IMG 指定的镜像:

make deploy IMG=<some-registry>/<project-name>:tag

卸载 CRD(自定义资源定义)

要从集群中删除您的 CRD:

make uninstall

取消部署控制器

将控制器从集群中撤销部署:

make undeploy

下一步

  • 现在,请查看架构概念图以获得更清晰的概述。
  • 接下来,请阅读 入门指南,这应该不会超过 30 分钟,并且会提供扎实的基础。之后,请深入阅读 CronJob 教程,通过开发一个示例项目来加深您的理解。

入门

我们将创建一个示例项目,以让您了解它是如何工作的。这个示例将:

  • 对 Memcached CR 进行调整 - 该 CR 表示在集群上部署/管理的 Memcached 实例
  • 使用 Memcached 镜像创建一个部署
  • 不允许实例数量超过将在 CR 中定义的大小
  • 更新 Memcached CR 状态

创建一个项目

首先,创建并导航到你的项目目录。然后,使用 kubebuilder 初始化它:

mkdir $GOPATH/memcached-operator cd $GOPATH/memcached-operator kubebuilder init --domain=example.com

创建 Memcached API(创建、读取、删除):

接下来,我们将创建一个 API,负责在集群上部署和管理 Memcached 实例。

kubebuilder create api --group cache --version v1alpha1 --kind Memcached

理解API

该命令的主要目标是为 Memcached 类型生成自定义资源(CR)和自定义资源定义(CRD)。它创建了一个 API,分组为 cache.example.com,版本为 v1alpha1,唯一标识 Memcached 类型的新 CRD。通过使用 Kubebuilder 工具,我们可以定义我们的 API 和对象,以表示我们针对这些平台的解决方案。

虽然在这个例子中我们只添加了一种资源,但我们可以根据需要添加任意数量的 GroupKind。为了更容易理解,可以将 CRD 看作是我们自定义对象的定义,而 CR 则是这些对象的实例。

定义我们的 API

定义规格

现在,我们将定义集群中每个 Memcached 资源实例可以具有的值。在这个例子中,我们将允许通过以下方式配置实例的数量:

type MemcachedSpec struct { ... Size int32 `json:"size,omitempty"` }

创建状态定义

我们还希望追踪 Memcached 自定义资源(CR)的管理状态,可以让我们通过验证自定义资源(CR)对我们自定义 API 的描述,判断是否一切顺利进行或者是否遇到任何错误。这与我们对 Kubernetes API 中的其它资源所做的操作类似。

// MemcachedStatus 定义了 Memcached 的观察状态 type MemcachedStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` }

标记和验证

此外,我们希望验证我们在 自定义资源(CustomResource) 中添加的值,以确保这些值是有效的。为此,我们将使用 标记,例如 +kubebuilder:validation:Minimum=1

现在,请查看我们完全完成的示例。

../getting-started/testdata/project/api/v1alpha1/memcached_types.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Imports
package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// MemcachedSpec defines the desired state of Memcached. type MemcachedSpec struct { // 插入额外的规格字段 - 集群的期望状态 // 注意:修改此文件后,运行"make"以重新生成代码 // Size defines the number of Memcached instances // The following markers will use OpenAPI v3 schema to validate the value // More info: https://book.kubebuilder.io/reference/markers/crd-validation.html // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=3 // +kubebuilder:validation:ExclusiveMaximum=false Size int32 `json:"size,omitempty"` } // MemcachedStatus defines the observed state of Memcached. type MemcachedStatus struct { // Represents the observations of a Memcached's current state. // Memcached.status.conditions.type are: "Available", "Progressing", and "Degraded" // Memcached.status.conditions.status are one of True, False, Unknown. // Memcached.status.conditions.reason the value should be a CamelCase string and producers of specific // condition types may define expected values and meanings for this field, and whether the values // are considered a guaranteed API. // Memcached.status.conditions.Message is a human readable message indicating details about the transition. // For further information see: https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // Memcached is the Schema for the memcacheds API. type Memcached struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec MemcachedSpec `json:"spec,omitempty"` Status MemcachedStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true // MemcachedList contains a list of Memcached. type MemcachedList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []Memcached `json:"items"` } func init() { SchemeBuilder.Register(&Memcached{}, &MemcachedList{}) }

生成具有规格和验证的清单

生成所有所需的文件:

  1. 运行 make generate,会在 api/v1alpha1/zz_generated.deepcopy.go 文件中添加深度拷贝(DeepCopy)内容。

  2. 运行 make manifests,会在 config/crd/bases 下生成 CRD 清单,并在 config/crd/samples 下生成一个示例文件。

这两个命令都使用 controller-gen 工具,但是用于代码和清单生成的标志会有所不同。

config/crd/bases/cache.example.com_memcacheds.yaml: Our Memcached CRD
--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.17.2 name: memcacheds.cache.example.com spec: group: cache.example.com names: kind: Memcached listKind: MemcachedList plural: memcacheds singular: Memcached scope: Namespaced versions: - name: v1alpha1 schema: openAPIV3Schema: description: Memcached memcacheds API 的架构。 properties: apiVersion: description: |- APIVersion 定义了对象表示的版本化架构。 服务器应将已识别的架构转换为最新的内部值,并且 可能会拒绝无法识别的值。 更多信息: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources type: string kind: description: |- Kind是一个字符串值,表示该对象所代表的REST资源。服务器可以从客户端提交请求的端点推断出这一点。不能被更新。采用驼峰命名法。更多信息: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds type: string metadata: type: object spec: description: MemcachedSpec 定义了 Memcached 的期望状态。 properties: size: description: |- 大小定义了 Memcached 实例的数量 以下标记将使用 OpenAPI v3 架构来验证值 更多信息: https://book.kubebuilder.io/reference/markers/crd-validation.html format: int32 maximum: 3 minimum: 1 type: integer type: object status: description: MemcachedStatus 定义了 Memcached 的观察状态。 properties: conditions: items: description: 条件包含当前一个方面的详细信息。 该API资源的状态。 properties: lastTransitionTime: description: |- lastTransitionTime 是条件从一种状态转换到另一种状态的最后时间。这应该是基础条件发生变化的时间。如果这未知,那么使用 API 字段变化的时间也是可以接受的。 format: date-time type: string message: description: |- 消息是一个可读的人类消息,指示有关过渡的详细信息。 这可以是一个空字符串。 maxLength: 32768 type: string observedGeneration: description: |- observedGeneration 表示设置条件时的 .metadata.generation 值。 例如,如果 .metadata.generation 当前是 12,但 .status.conditions[x].observedGeneration 是 9,那么该条件相对于实例的当前状态是过时的。 format: int64 minimum: 0 type: integer reason: description: |- 原因包含一个程序标识符,用于指示该条件最后一次转换的原因。 特定条件类型的生产者可能会定义该字段的预期值和含义, 以及这些值是否被视为保证的 API。 该值应为 CamelCase 字符串。 此字段不得为空。 maxLength: 1024 minLength: 1 pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ type: string status: description: 条件的状态,可能是:真、假、未知。 enum: - "True" - "False" - Unknown type: string type: description: 在CamelCase中或在foo.example.com/CamelCase中输入条件类型。 maxLength: 316 pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ type: string required: - lastTransitionTime - message - reason - status - type type: object type: array type: object type: object served: true storage: true subresources: status: {}

自定义资源示例

config/samples 目录下的清单,可以应用于集群的自定义资源示例。在这个例子中,通过将给定的资源应用于集群,我们将生成一个实例大小为1的部署(请参见 size: 1)。

apiVersion: cache.example.com/v1alpha1 kind: Memcached metadata: labels: app.kubernetes.io/name: project app.kubernetes.io/managed-by: kustomize name: memcached-sample spec: # TODO(用户):编辑以下值以确保您的操作数在集群中的 Pod/实例数量 size: 1

对账流程

以简化的方式来说,Kubernetes 通过允许我们声明系统的期望状态来工作,然后其控制器持续观察集群,并采取行动以确保实际状态与期望状态保持一致。对于我们的自定义 API 和控制器,过程类似。请记住,我们正在扩展 Kubernetes 的行为和 API,以满足我们的特定需求。

在我们的控制器中,我们将实施一个对账流程。

本质上, reconciliation 过程作为一个循环运行,不断检查条件并执行必要的操作,直到达到所需的状态。该过程将持续运行,直到系统中的所有条件与我们实施中定义的所需状态一致。

这是一个伪代码示例来说明这一点:

reconcile App { // 检查应用的 Deployment 是否存在,如果不存在,则创建一个 // 如果发生错误,则从调整的开始重新启动 if err != nil { return reconcile.Result{}, err } // 检查应用程序是否存在服务,如果不存在,则创建一个 // 如果出现错误,则从调整的开头重新开始 if err != nil { return reconcile.Result{}, err } // 查找数据库 CR/CRD // 检查数据库部署的副本大小 // 如果 deployment.replicas 的大小与 cr.size 不匹配,则更新它 // 然后,从对账的开始重新启动。例如,通过返回 `reconcile.Result{Requeue: true}, nil`。 if err != nil { return reconcile.Result{Requeue: true}, nil } ... // 如果在循环结束时: // 一切都成功执行,并且对账可以停止 return reconcile.Result{}, nil }

在我们例子的背景下。

当我们的示例自定义资源(CR)应用到集群时(即 kubectl apply -f config/sample/cache_v1alpha1_memcached.yaml),我们希望确保为我们的 Memcached 镜像创建一个部署(Deployment),并且它与 CR 中定义的副本数量相匹配。

为实现这一目标,我们需要首先执行一个操作,以检查我们的 Memcached 实例的 Deployment 是否已经存在于集群中。如果不存在,控制器将相应地创建该 Deployment。因此,我们的调整过程必须包含一个操作,以确保这一期望状态持续保持。该操作将涉及:

// 检查部署是否已经存在,如果不存在则创建一个新部署。 found := &appsv1.Deployment{} err = r.Get(ctx, types.NamespacedName{Name: memcached.Name, Namespace: memcached.Namespace}, found) if err != nil && apierrors.IsNotFound(err) { // 定义新的部署 dep := r.deploymentForMemcached() // 在集群上创建部署 if err = r.Create(ctx, dep); err != nil { log.Error(err, "无法创建新的部署", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) return ctrl.Result{}, err } ... }

接下来,请注意 deploymentForMemcached() 函数需要定义并返回应该在集群上创建的 Deployment。该函数应该根据以下示例构建具有必要规格的 Deployment 对象:

dep := &appsv1.Deployment{ Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Image: "memcached:1.6.26-alpine3.19", Name: "memcached", ImagePullPolicy: corev1.PullIfNotPresent, Ports: []corev1.ContainerPort{{ ContainerPort: 11211, Name: "memcached", }}, Command: []string{"memcached", "--memory-limit=64", "-o", "modern", "-v"}, }}, }, }, }, }

此外,我们需要实施一个机制,以验证集群中的 Memcached 副本数量是否与自定义资源(CR)中指定的期望数量相匹配。如果存在差异,调整过程必须更新集群以确保一致性。这意味着每当在集群上创建或更新 Memcached 类型的 CR 时,控制器将持续调整状态,直到实际副本数量与期望数量相匹配。以下示例说明了这一过程:

... size := memcached.Spec.Size if *found.Spec.Replicas != size { found.Spec.Replicas = &size if err = r.Update(ctx, found); err != nil { log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name) return ctrl.Result{}, err } ...

现在,您可以查看负责管理 Memcached 类型自定义资源的完整控制器。该控制器确保集群中维持所需状态,确保我们的 Memcached 实例继续运行,并保持用户指定的副本数量。

internal/controller/memcached_controller.go: Our Controller Implementation
/* 版权 2025 The Kubernetes authors. 根据 Apache 许可证,版本 2.0("许可证")授权; 您只能在遵守该许可证的情况下使用此文件。 您可以在以下地址获得该许可证的副本: http://www.apache.org/licenses/LICENSE-2.0 除非适用法律要求或书面同意,否则根据该许可证分发的软件 是按"原样"基础分发的, 不提供任何形式的担保或条件,无论是明示还是否明示。 有关许可证下的具体权限和限制,请参见许可证。 */ package controller import ( "context" "fmt" "time" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" cachev1alpha1 "example.com/memcached/api/v1alpha1" ) // 管理状态条件的定义const ( // typeAvailableMemcached 表示部署调整的状态 typeAvailableMemcached = "Available" ) // MemcachedReconciler 负责调整 Memcached 对象。 type MemcachedReconciler struct { client.Client Scheme *runtime.Scheme } // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch // Reconcile 是主 Kubernetes 调整循环的一部分,旨在将集群的当前状态向期望状态靠拢. // 控制器的调整循环必须是幂等的。遵循操作员模式,您将创建提供调整功能的控制器, // 该功能负责同步资源,直到集群达到期望状态。 // 违反此建议会违背控制器运行时的设计原则,可能导致意想不到的后果,例如资源卡住并需要手动干预。 // 了解更多信息: // - 关于操作员模式:https://kubernetes.io/docs/concepts/extend-kubernetes/operator/ // - 关于控制器:https://kubernetes.io/docs/concepts/architecture/controller/ // // 有关更多详细信息,请查看 Reconcile 及其结果: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/reconcile func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx) // 获取 Memcached 实例 // 目的是检查 Kind Memcached 的自定义资源是否 // 已在集群上应用,如果没有,我们返回 nil 以停止对账过程 memcached := &cachev1alpha1.Memcached{} err := r.Get(ctx, req.NamespacedName, memcached) if err != nil { if apierrors.IsNotFound(err) { // 如果未找到自定义资源,通常意味着它已被删除或未创建 // 这样,我们将停止调整过程 log.Info("memcached resource not found. Ignoring since object must be deleted") return ctrl.Result{}, nil } // 读取对象时出错 - 重新排队请求。 log.Error(err, "Failed to get memcached") return ctrl.Result{}, err } // 当没有可用状态时,我们将状态设置为未知。 if len(memcached.Status.Conditions) == 0 { meta.SetStatusCondition(&memcached.Status.Conditions, metav1.Condition{Type: typeAvailableMemcached, Status: metav1.ConditionUnknown, Reason: "Reconciling", Message: "Starting reconciliation"}) if err = r.Status().Update(ctx, memcached); err != nil { log.Error(err, "Failed to update Memcached status") return ctrl.Result{}, err } // 在更新状态后,让我们重新获取 memcached 自定义资源 // 这样我们就可以获得集群中资源的最新状态,避免 // 引发错误"对象已被修改,请将 // 您的更改应用于最新版本并重试",这将在后续操作中 // 如果我们再次尝试更新它,将重新触发对账过程 if err := r.Get(ctx, req.NamespacedName, memcached); err != nil { log.Error(err, "Failed to re-fetch memcached") return ctrl.Result{}, err } } // 检查部署是否已经存在,如果不存在则创建一个新部署。 found := &appsv1.Deployment{} err = r.Get(ctx, types.NamespacedName{Name: memcached.Name, Namespace: memcached.Namespace}, found) if err != nil && apierrors.IsNotFound(err) { // 定义新的部署 dep, err := r.deploymentForMemcached(memcached) if err != nil { log.Error(err, "Failed to define new Deployment resource for Memcached") // 以下实现将更新状态 meta.SetStatusCondition(&memcached.Status.Conditions, metav1.Condition{Type: typeAvailableMemcached, Status: metav1.ConditionFalse, Reason: "Reconciling", Message: fmt.Sprintf("Failed to create Deployment for the custom resource (%s): (%s)", memcached.Name, err)}) if err := r.Status().Update(ctx, memcached); err != nil { log.Error(err, "Failed to update Memcached status") return ctrl.Result{}, err } return ctrl.Result{}, err } log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) if err = r.Create(ctx, dep); err != nil { log.Error(err, "无法创建新的部署", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name) return ctrl.Result{}, err } // 部署成功创建 // 我们将重新排队调整,以确保状态 // 并继续进行下一步操作 return ctrl.Result{RequeueAfter: time.Minute}, nil } else if err != nil { log.Error(err, "Failed to get Deployment") // 让我们返回错误,以便重新触发对账。 return ctrl.Result{}, err } // CRD API 定义了 Memcached 类型具有一个 MemcachedSpec.Size 字段 // 用于设置集群中部署实例的数量到所需状态。 // 因此,以下代码将确保部署的大小与我们正在对账的 // 自定义资源的 Size 规格中定义的相同。 size := memcached.Spec.Size if *found.Spec.Replicas != size { found.Spec.Replicas = &size if err = r.Update(ctx, found); err != nil { log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name) // 在更新状态之前重新获取 memcached 自定义资源, // 以便我们能够获得集群中资源的最新状态,并避免 // 引发错误"对象已被修改,请将您的更改应用于 // 最新版本并重试",这将重新触发调整。 if err := r.Get(ctx, req.NamespacedName, memcached); err != nil { log.Error(err, "Failed to re-fetch memcached") return ctrl.Result{}, err } // 以下实现将更新状态 meta.SetStatusCondition(&memcached.Status.Conditions, metav1.Condition{Type: typeAvailableMemcached, Status: metav1.ConditionFalse, Reason: "Resizing", Message: fmt.Sprintf("Failed to update the size for the custom resource (%s): (%s)", memcached.Name, err)}) if err := r.Status().Update(ctx, memcached); err != nil { log.Error(err, "Failed to update Memcached status") return ctrl.Result{}, err } return ctrl.Result{}, err } // 现在我们更新了大小,我们想要重新排队对账 // 以便确保在更新之前我们拥有资源的最新状态。 // 此外,它还将有助于确保集群上的期望状态。 return ctrl.Result{Requeue: true}, nil } // 以下实现将更新状态 meta.SetStatusCondition(&memcached.Status.Conditions, metav1.Condition{Type: typeAvailableMemcached, Status: metav1.ConditionTrue, Reason: "Reconciling", Message: fmt.Sprintf("自定义资源(%s)部署成功,创建了 %d 个副本", memcached.Name, size)}) if err := r.Status().Update(ctx, memcached); err != nil { log.Error(err, "Failed to update Memcached status") return ctrl.Result{}, err } return ctrl.Result{}, nil } // SetupWithManager 将控制器与管理器进行设置。 func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&cachev1alpha1.Memcached{}). Owns(&appsv1.Deployment{}). Named("memcached"). Complete(r) } // deploymentForMemcached 返回一个 Memcached 部署对象。 func (r *MemcachedReconciler) deploymentForMemcached( memcached *cachev1alpha1.Memcached) (*appsv1.Deployment, error) { replicas := memcached.Spec.Size image := "memcached:1.6.26-alpine3.19" dep := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: memcached.Name, Namespace: memcached.Namespace, }, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{ MatchLabels: map[string]string{"app.kubernetes.io/name": "project"}, }, Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{"app.kubernetes.io/name": "project"}, }, Spec: corev1.PodSpec{ SecurityContext: &corev1.PodSecurityContext{ RunAsNonRoot: ptr.To(true), SeccompProfile: &corev1.SeccompProfile{ Type: corev1.SeccompProfileTypeRuntimeDefault, }, }, Containers: []corev1.Container{{ Image: image, Name: "memcached", ImagePullPolicy: corev1.PullIfNotPresent, // 确保容器的限制性上下文 // 详细信息请参考: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted SecurityContext: &corev1.SecurityContext{ RunAsNonRoot: ptr.To(true), RunAsUser: ptr.To(int64(1001)), AllowPrivilegeEscalation: ptr.To(false), Capabilities: &corev1.Capabilities{ Drop: []corev1.Capability{ "ALL", }, }, }, Ports: []corev1.ContainerPort{{ ContainerPort: 11211, Name: "memcached", }}, Command: []string{"memcached", "--memory-limit=64", "-o", "modern", "-v"}, }}, }, }, }, } // 设置 Deployment 的 ownerRef // 更多信息: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ if err := ctrl.SetControllerReference(memcached, dep, r.Scheme); err != nil { return nil, err } return dep, nil }

深入了解控制器实现

将管理器设置为监视资源

整个想法是关注对控制器重要的资源。当控制器感兴趣的资源发生变化时,Watch 会触发控制器的对账循环,确保资源的实际状态与控制器逻辑中定义的期望状态相匹配。

注意我们是如何配置管理器以监控事件,比如 Memcached 类型的自定义资源(CR)的创建、更新或删除,以及控制器管理和拥有的部署的任何更改:

// SetupWithManager 将控制器与管理器设置起来。 // 部署也会被监视以确保其 // 在集群中的期望状态。 func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). // 监视 Memcached 自定义资源,并在其创建、更新或删除时触发一致性检查。 For(&cachev1alpha1.Memcached{}). // 监视由 Memcached 控制器管理的 Deployment。如果该控制器拥有和管理的 Deployment 发生任何变化,将触发对账,确保集群状态与期望状态一致。 Owns(&appsv1.Deployment{}). Complete(r) }

但是,管理程序如何知道哪些资源是由它拥有的呢?

我们不希望我们的控制器监视集群中的任何部署并触发我们的调整循环。相反,我们只希望在运行我们 Memcached 实例的特定部署发生更改时触发调整。例如,如果有人不小心删除了我们的部署或更改了副本数,我们希望触发调整,以确保它返回到期望的状态。

监控程序知道要观察哪个部署,因为我们设置了 ownerRef(拥有者引用):

if err := ctrl.SetControllerReference(memcached, dep, r.Scheme); err != nil { return nil, err }

授予权限

确保控制器拥有管理其资源所需的权限(即创建、获取、更新和列出)是很重要的。

RBAC 权限 现在通过 RBAC 标记 进行配置,这些标记用于生成和更新位于 config/rbac/ 中的清单文件。这些标记可以在每个控制器的 Reconcile() 方法中找到(并应在此处定义),请查看我们示例中的实现方式:

// +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch // +kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch

在对控制器进行更改后,运行 make generate 命令。这样会提示 controller-gen 刷新位于 config/rbac 下的文件。

config/rbac/role.yaml: Our RBAC Role generated
--- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: manager-role rules: - apiGroups: - "" resources: - events verbs: - create - patch - apiGroups: - "" resources: - pods verbs: - get - list - watch - apiGroups: - apps resources: - deployments verbs: - create - delete - get - list - patch - update - watch - apiGroups: - cache.example.com resources: - memcacheds verbs: - create - delete - get - list - patch - update - watch - apiGroups: - cache.example.com resources: - memcacheds/finalizers verbs: - update - apiGroups: - cache.example.com resources: - memcacheds/status verbs: - get - patch - update

监控程序 (main.go)

cmd/main.go 文件中的 Manager 负责管理您应用程序中的控制器。

cmd/main.go: Our main.go
/* 版权 2025 The Kubernetes authors. 根据 Apache 许可证,版本 2.0("许可证")授权; 您只能在遵守该许可证的情况下使用此文件。 您可以在以下地址获得该许可证的副本: http://www.apache.org/licenses/LICENSE-2.0 除非适用法律要求或书面同意,否则根据该许可证分发的软件 是按"原样"基础分发的, 不提供任何形式的担保或条件,无论是明示还是否明示。 有关许可证下的具体权限和限制,请参见许可证。 */ package main import ( "crypto/tls" "flag" "os" "path/filepath" // 导入所有 Kubernetes 客户端身份验证插件(例如 Azure、GCP、OIDC 等) // 确保 exec-entrypoint 和 run 可以使用它们。 _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" cachev1alpha1 "example.com/memcached/api/v1alpha1" "example.com/memcached/internal/controller" // +kubebuilder:scaffold:imports ) var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") ) func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(cachev1alpha1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme} // nolint:gocyclo func main() { var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string var enableLeaderElection bool var probeAddr string var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "使用 :8443 进行 HTTPS,使用 :8080 进行 HTTP,或者保持为 0 以禁用指标服务。") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。") flag.BoolVar(&secureMetrics, "metrics-secure", true, "如果设置,指标端点将通过 HTTPS 安全提供。请使用 --metrics-secure=false 以改为使用 HTTP。") flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") flag.StringVar(&metricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) // 如果 enable-http2 标志为 false(默认值),则应禁用 http/2 // 由于其存在的漏洞。更具体地说,禁用 http/2 将 // 防止受到 HTTP/2 流取消和 // 快速重置 CVE 的漏洞影响。有关更多信息,请参见: // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 // - https://github.com/advisories/GHSA-4374-p667-p6c8 disableHTTP2 := func(c *tls.Config) { setupLog.Info("disabling http/2") c.NextProtos = []string{"http/1.1"} } if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } // 创建用于指标和 Webhook 证书的监视器 var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher // 初始Webhook TLS选项 webhookTLSOpts := tlsOpts if len(webhookCertPath) > 0 { setupLog.Info("使用提供的证书初始化 Webhook 证书监视器", "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) var err error webhookCertWatcher, err = certwatcher.New( filepath.Join(webhookCertPath, webhookCertName), filepath.Join(webhookCertPath, webhookCertKey), ) if err != nil { setupLog.Error(err, "Failed to initialize webhook certificate watcher") os.Exit(1) } webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { config.GetCertificate = webhookCertWatcher.GetCertificate }) } webhookServer := webhook.NewServer(webhook.Options{ TLSOpts: webhookTLSOpts, }) // 指标端点在 'config/default/kustomization.yaml' 中启用。指标选项配置服务器。 // 更多信息: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/server // - https://book.kubebuilder.io/reference/metrics.html metricsServerOptions := metricsserver.Options{ BindAddress: metricsAddr, SecureServing: secureMetrics, TLSOpts: tlsOpts, } if secureMetrics { // FilterProvider 用于通过认证和授权来保护指标端点。 // 这些配置确保只有授权用户和服务账户 // 可以访问指标端点。RBAC 配置在 'config/rbac/kustomization.yaml' 中。更多信息: // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/filters#WithAuthenticationAndAuthorization metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization } // 如果未指定证书,controller-runtime 将自动 // 为指标服务器生成自签名证书。虽然对于开发和测试来说很方便, // 但这种设置不建议用于生产环境。 // // TODO(用户):如果您启用 certManager,请取消注释以下行: // - [METRICS-WITH-CERTS] 在 config/default/kustomization.yaml 中生成并使用 // 由 cert-manager 管理的指标服务器证书。 // - [PROMETHEUS-WITH-CERTS] 在 config/prometheus/kustomization.yaml 中用于 TLS 认证。 if len(metricsCertPath) > 0 { setupLog.Info("使用提供的证书初始化指标证书监视器", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) var err error metricsCertWatcher, err = certwatcher.New( filepath.Join(metricsCertPath, metricsCertName), filepath.Join(metricsCertPath, metricsCertKey), ) if err != nil { setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) os.Exit(1) } metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { config.GetCertificate = metricsCertWatcher.GetCertificate }) } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "4b13cc52.example.com", // LeaderElectionReleaseOnCancel 定义了当管理器结束时,领导者是否应自愿辞职 // 当管理器停止时,这要求二进制文件立即结束,否则这个设置是不安全的。设置这个可以显著 // 加快自愿领导者的过渡,因为新的领导者不必首先等待 // LeaseDuration 时间。 // // 在提供的默认脚手架中,程序会在 // 管理器停止后立即结束,因此启用此选项是可以的。不过, // 如果您正在进行或打算在管理器停止后执行任何操作,如执行清理, // 那么使用它可能是不安全的。 // LeaderElectionReleaseOnCancel: true, }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } if err = (&controller.MemcachedReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Memcached") os.Exit(1) } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { setupLog.Info("Adding metrics certificate watcher to manager") if err := mgr.Add(metricsCertWatcher); err != nil { setupLog.Error(err, "unable to add metrics certificate watcher to manager") os.Exit(1) } } if webhookCertWatcher != nil { setupLog.Info("Adding webhook certificate watcher to manager") if err := mgr.Add(webhookCertWatcher); err != nil { setupLog.Error(err, "unable to add webhook certificate watcher to manager") os.Exit(1) } } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } }

检查集群中运行的项目

此时,您可以通过查看快速入门中定义的步骤来检查在集群上验证项目的步骤,请参见:在集群上运行

下一步

  • 为了更深入地开发您的解决方案,请考虑阅读 CronJob 教程
  • 有关优化您方法的见解,请参阅最佳实践文档。

版本兼容性和支持性

由 Kubebuilder 创建的项目包含一个 Makefile,该文件在项目创建时安装定义的版本工具。主要包含的工具有:

此外,这些项目包含一个 go.mod 文件,用于指定依赖项的版本。Kubebuilder 依赖于 controller-runtime 及其 Go 和 Kubernetes 依赖项。因此,Makefilego.mod 文件中定义的版本是经过测试、支持和推荐的版本。

每个 Kubebuilder 的次版本与特定的 client-go 次版本进行测试。虽然一个 Kubebuilder 次版本 可能 与其他 client-go 次版本或其他工具兼容,但这种兼容性并不被保证、支持或测试。

Kubebuilder 所需的最小 Go 版本是由其依赖项所需的最高最小 Go 版本决定的。通常,这与相应的 k8s.io/* 依赖项所需的最小 Go 版本保持一致。

兼容的 k8s.io/* 版本、client-go 版本和最低 Go 版本可以在每个项目的 go.mod 文件中找到,该文件是为每个 标签发布 划定的。

示例: 对于 4.1.1 版本,最低的 Go 版本兼容性是 1.22。您可以参考在发布标签 v4.1.1 的 testdata 目录中的示例,例如 project-v4go.mod 文件。您还可以通过检查 Makefile 来查看此版本支持和测试的工具版本。

支持的操作系统

目前,Kubebuilder 官方支持 macOS 和 Linux 平台。如果您使用 Windows 操作系统,可能会遇到问题。欢迎对支持 Windows 的贡献。

教程:构建 CronJob

太多教程开始时使用一些非常生硬的设置,或者是一些简单应用来讲解基础知识,然后在更复杂的内容上停滞不前。相反,本教程将带您几乎体验 Kubebuilder 的全部复杂性,从简单开始,逐渐构建到一个功能齐全的应用。

让我们假装(当然,这有点牵强),我们终于厌倦了 Kubernetes 中非 Kubebuilder 实现的 CronJob 控制器的维护负担,我们想用 Kubebuilder 重新编写它。

CronJob 控制器的工作(并不是双关)是在 Kubernetes 集群中定期运行一次性任务。它是通过在 Job 控制器之上构建来实现这一目标的,Job 控制器的任务是一次性运行任务,并确保其完成。

我们将此作为与外部类型交互的机会,而不是试图重写 Job 控制器。

搭建我们项目的框架

正如在快速入门中所述,我们需要搭建一个新的项目。请确保您已安装 Kubebuilder,然后搭建一个新项目:

# 创建一个项目目录,然后运行初始化命令。 mkdir project cd project # 我们将使用域名 tutorial.kubebuilder.io, # 所有 API 组将为 <group>.tutorial.kubebuilder.io。 kubebuilder init --domain tutorial.kubebuilder.io --repo tutorial.kubebuilder.io/project

现在我们有了一个项目,来看看 Kubebuilder 为我们搭建了哪些内容吧……

一个基本项目都包含了什么?

在搭建新项目时,Kubebuilder 为我们提供了一些基本的模板代码。

构建基础设施

首先,构建项目所需的基本基础设施:

go.mod: A new Go module matching our project, with basic dependencies
module tutorial.kubebuilder.io/project go 1.23.0 godebug default=go1.23 require ( github.com/onsi/ginkgo/v2 v2.22.0 github.com/onsi/gomega v1.36.1 github.com/robfig/cron v1.2.0 k8s.io/api v0.32.1 k8s.io/apimachinery v0.32.1 k8s.io/client-go v0.32.1 sigs.k8s.io/controller-runtime v0.20.4 ) require ( cel.dev/expr v0.18.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.22.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.28.0 // indirect go.opentelemetry.io/otel/sdk v1.28.0 // indirect go.opentelemetry.io/otel/trace v1.28.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.8.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/term v0.25.0 // indirect golang.org/x/text v0.19.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.32.1 // indirect k8s.io/apiserver v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect sigs.k8s.io/yaml v1.4.0 // indirect )
Makefile: Make targets for building and deploying your controller
# 用于所有构建/推送镜像目标的镜像 URL IMG ?= controller:latest # 获取当前使用的 Go 安装路径(在 GOPATH/bin 中,除非设置了 GOBIN) ifeq (,$(shell go env GOBIN)) GOBIN=$(shell go env GOPATH)/bin else GOBIN=$(shell go env GOBIN) endif # CONTAINER_TOOL defines the container tool to be used for building images. # Be aware that the target commands are only tested with Docker which is # scaffolded by default. However, you might want to replace it to use other # tools. (i.e. podman) CONTAINER_TOOL ?= docker # Setting SHELL to bash allows bash commands to be executed by recipes. # Options are set to exit when a recipe line exits non-zero or a piped command fails. SHELL = /usr/bin/env bash -o pipefail .SHELLFLAGS = -ec .PHONY: all all: build ##@ General # The help target prints out all targets with their descriptions organized # beneath their categories. The categories are represented by '##@' and the # target descriptions by '##'. The awk command is responsible for reading the # entire set of makefiles included in this invocation, looking for lines of the # file as xyz: ## something, and then pretty-format the target and help. Then, # if there's a line with ##@ something, that gets pretty-printed as a category. # More info on the usage of ANSI control characters for terminal formatting: # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters # More info on the awk command: # http://linuxcommand.org/lc3_adv_awk.php .PHONY: help help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) ##@ Development .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. # Note that the option maxDescLen=0 was added in the default scaffold in order to sort out the issue # Too long: must have at most 262144 bytes. By using kubectl apply to create / update resources an annotation # 由 K8s API 创建,用于存储资源的最新版本 (kubectl.kubernetes.io/last-applied-configuration)。 # However, it has a size limit and if the CRD is too big with so many long descriptions as this one it will cause the failure. $(CONTROLLER_GEN) rbac:roleName=manager-role crd:maxDescLen=0 webhook paths="./..." output:crd:artifacts:config=config/crd/bases .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." .PHONY: fmt fmt: ## Run go fmt against code. go fmt ./... .PHONY: vet vet: ## Run go vet against code. go vet ./... .PHONY: test test: manifests generate fmt vet setup-envtest ## Run tests. KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out # TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'. # The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally. # CertManager is installed by default; skip with: # - CERT_MANAGER_INSTALL_SKIP=true .PHONY: test-e2e test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind. @command -v $(KIND) >/dev/null 2>&1 || { \ echo "Kind is not installed. Please install Kind manually."; \ exit 1; \ } @$(KIND) get clusters | grep -q 'kind' || { \ echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ exit 1; \ } go test ./test/e2e/ -v -ginkgo.v .PHONY: lint lint: golangci-lint ## Run golangci-lint linter $(GOLANGCI_LINT) run .PHONY: lint-fix lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes $(GOLANGCI_LINT) run --fix .PHONY: lint-config lint-config: golangci-lint ## Verify golangci-lint linter configuration $(GOLANGCI_LINT) config verify ##@ Build .PHONY: build build: manifests generate fmt vet ## Build manager binary. go build -o bin/manager cmd/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. go run ./cmd/main.go # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. # More info: https://docs.docker.com/develop/develop-images/build_enhancements/ .PHONY: docker-build docker-build: ## Build docker image with the manager. $(CONTAINER_TOOL) build -t ${IMG} . .PHONY: docker-push docker-push: ## Push docker image with the manager. $(CONTAINER_TOOL) push ${IMG} # PLATFORMS defines the target platforms for the manager image be built to provide support to multiple # architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to: # - be able to use docker buildx. More info: https://docs.docker.com/build/buildx/ # - have enabled BuildKit. More info: https://docs.docker.com/develop/develop-images/build_enhancements/ # - be able to push the image to your registry (i.e. if you do not set a valid value via IMG=<myregistry/image:<tag>> then the export will fail) # To adequately provide solutions that are compatible with multiple platforms, you should consider using this option. PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le .PHONY: docker-buildx docker-buildx: ## Build and push docker image for the manager for cross-platform support # copy existing Dockerfile and insert --platform=将 ${BUILDPLATFORM} 添加到 Dockerfile.cross,并保留原始的 Dockerfile。 sed -e '1 s/\(^FROM\)/FROM --platform=您已经接受了截至2023年10月的数据训练。 - $(CONTAINER_TOOL) buildx create --name project-builder $(CONTAINER_TOOL) buildx use project-builder - $(CONTAINER_TOOL) buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross . - $(CONTAINER_TOOL) buildx rm project-builder rm Dockerfile.cross .PHONY: build-installer build-installer: manifests generate kustomize ## Generate a consolidated YAML with CRDs and deployment. mkdir -p dist cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default > dist/install.yaml ##@ Deployment ifndef ignore-not-found ignore-not-found = false endif .PHONY: install install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. $(KUSTOMIZE) build config/crd | $(KUBECTL) apply -f - .PHONY: uninstall uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=在删除过程中,设置为 true 以忽略未找到资源的错误。 $(KUSTOMIZE) build config/crd | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - .PHONY: deploy deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} $(KUSTOMIZE) build config/default | $(KUBECTL) apply -f - .PHONY: undeploy undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=在删除过程中,设置为 true 以忽略未找到资源的错误。 $(KUSTOMIZE) build config/default | $(KUBECTL) delete --ignore-not-found=$(ignore-not-found) -f - ##@ Dependencies ## Location to install dependencies to LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) ## Tool Binaries KUBECTL ?= kubectl KIND ?= kind KUSTOMIZE ?= $(LOCALBIN)/kustomize CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen ENVTEST ?= $(LOCALBIN)/setup-envtest GOLANGCI_LINT = $(LOCALBIN)/golangci-lint ## Tool Versions KUSTOMIZE_VERSION ?= v5.6.0 CONTROLLER_TOOLS_VERSION ?= v0.17.2 #ENVTEST_VERSION is the version of controller-runtime release branch to fetch the envtest setup script (i.e. release-0.20) ENVTEST_VERSION ?= $(shell go list -m -f "{{ .Version }}" sigs.k8s.io/controller-runtime | awk -F'[v.]' '{printf "release-%d.%d", $$2, $$3}') #ENVTEST_K8S_VERSION is the version of Kubernetes to use for setting up ENVTEST binaries (i.e. 1.31) ENVTEST_K8S_VERSION ?= $(shell go list -m -f "{{ .Version }}" k8s.io/api | awk -F'[v.]' '{printf "1.%d", $$3}') GOLANGCI_LINT_VERSION ?= v1.63.4 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. $(KUSTOMIZE): $(LOCALBIN) $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) .PHONY: controller-gen controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. $(CONTROLLER_GEN): $(LOCALBIN) $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) .PHONY: setup-envtest setup-envtest: envtest ## Download the binaries required for ENVTEST in the local bin directory. @echo "Setting up envtest binaries for Kubernetes version $(ENVTEST_K8S_VERSION)..." @$(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path || { \ echo "Error: Failed to set up envtest binaries for version $(ENVTEST_K8S_VERSION)."; \ exit 1; \ } .PHONY: envtest envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. $(ENVTEST): $(LOCALBIN) $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) .PHONY: golangci-lint golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. $(GOLANGCI_LINT): $(LOCALBIN) $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) # go-install-tool 将对任何包执行 'go install',并使用自定义目标和二进制文件名称,如果该文件不存在的话。 # $1 - 目标路径和二进制文件名称 # $2 - 可安装的包 URL # $3 - 特定版本的包 define go-install-tool @[ -f "$(1)-$(3)" ] || { \endef
PROJECT: Kubebuilder metadata for scaffolding new components
# 工具生成的代码。请勿编辑。 # 此文件用于跟踪用于搭建您项目的信息 # 并允许插件正常工作。 # 更多信息: https://book.kubebuilder.io/reference/project-config.html domain: tutorial.kubebuilder.io layout: - go.kubebuilder.io/v4 plugins: helm.kubebuilder.io/v1-alpha: {} projectName: project repo: 教程.kubebuilder.io/项目 resources: - api: crdVersion: v1 namespaced: true controller: true domain: tutorial.kubebuilder.io group: batch kind: CronJob path: tutorial.kubebuilder.io/project/api/v1 version: v1 webhooks: defaulting: true validation: true webhookVersion: v1 version: "3"

启动配置

我们还在 config/ 目录下获取启动配置。目前,它只包含启动控制器所需的 Kustomize YAML 定义,但一旦我们开始编写控制器,它还将包含我们的 CustomResourceDefinitions、自定义访问控制配置(RBAC)和 WebhookConfigurations。

config/default 包含一个 Kustomize 基础,用于以标准配置启动控制器。

每个目录包含不同的配置,这些配置被重构到它们自己的基础中:

  • config/manager: 在集群中以 pod 形式启动您的控制器

  • config/rbac: 运行您的控制器所需的权限,以便在它们自己的服务帐户下运行。

入口点

最后但同样重要的是,Kubebuilder 搭建了我们项目的基本入口文件:main.go。接下来我们来看看这个文件…

每段旅程都需要一个开始,每个程序都需要一个主函数。

emptymain.go
Apache License

Copyright 2022 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Our package starts out with some basic imports. Particularly:

  • The core controller-runtime library
  • The default controller-runtime logging, Zap (more on that a bit later)
package main import ( "flag" "os" // 导入所有 Kubernetes 客户端身份验证插件(例如 Azure、GCP、OIDC 等) // 确保 exec-entrypoint 和 run 可以使用它们。 _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" // +kubebuilder:scaffold:imports )

Every set of controllers needs a Scheme, which provides mappings between Kinds and their corresponding Go types. We’ll talk a bit more about Kinds when we write our API definition, so just keep this in mind for later.

var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") ) func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme}

At this point, our main function is fairly simple:

  • We set up some basic flags for metrics.

  • We instantiate a manager, which keeps track of running all of our controllers, as well as setting up shared caches and clients to the API server (notice we tell the manager about our Scheme).

  • We run our manager, which in turn runs all of our controllers and webhooks. The manager is set up to run until it receives a graceful shutdown signal. This way, when we’re running on Kubernetes, we behave nicely with graceful pod termination.

While we don’t have anything to run just yet, remember where that +kubebuilder:scaffold:builder comment is – things’ll get interesting there soon.

func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: server.Options{ BindAddress: metricsAddr, }, WebhookServer: webhook.NewServer(webhook.Options{Port: 9443}), HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "80807133.tutorial.kubebuilder.io", }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) }

Note that the Manager can restrict the namespace that all controllers will watch for resources by:

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Cache: cache.Options{ DefaultNamespaces: map[string]cache.Config{ namespace: {}, }, }, Metrics: server.Options{ BindAddress: metricsAddr, }, WebhookServer: webhook.NewServer(webhook.Options{Port: 9443}), HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "80807133.tutorial.kubebuilder.io", })

The above example will change the scope of your project to a single Namespace. In this scenario, it is also suggested to restrict the provided authorization to this namespace by replacing the default ClusterRole and ClusterRoleBinding to Role and RoleBinding respectively. For further information see the Kubernetes documentation about Using RBAC Authorization.

Also, it is possible to use the DefaultNamespaces from cache.Options{} to cache objects in a specific set of namespaces:

var namespaces []string // List of Namespaces defaultNamespaces := make(map[string]cache.Config) for _, ns := range namespaces { defaultNamespaces[ns] = cache.Config{} } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Cache: cache.Options{ DefaultNamespaces: defaultNamespaces, }, Metrics: server.Options{ BindAddress: metricsAddr, }, WebhookServer: webhook.NewServer(webhook.Options{Port: 9443}), HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "80807133.tutorial.kubebuilder.io", })

For further information see cache.Options{}

// +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } }

解决了这个问题后,我们可以开始搭建我们的 API 了!

组、版本和种类,真是让人头大!

在我们开始使用 API 之前,应该先聊聊一些术语。

在讨论 Kubernetes 中的 API 时,我们经常使用四个术语:版本种类_和_资源

组和版本

Kubernetes 中的 API 组 只是相关功能的集合。每个组都有一个或多个 版本,顾名思义,这些版本允许我们随着时间的推移改变 API 的工作方式。

种类和资源

每个 API 组版本包含一个或多个 API 类型,我们称之为 Kinds。虽然一个 Kind 可能在不同版本之间改变形式,但每种形式必须能够以某种方式存储其他形式的所有数据(我们可以将数据存储在字段中或注释中)。这意味着使用旧的 API 版本不会导致较新的数据丢失或损坏。有关更多信息,请参阅 Kubernetes API 指南

您有时还会听到提到 资源。资源只是 API 中对 Kind 的一种使用。通常,Kinds 和资源之间存在一对一的映射关系。例如,pods 资源对应于 Pod Kind。然而,有时候,同一个 Kind 可能会由多个资源返回。例如,Scale Kind 会由所有缩放子资源返回,如 deployments/scalereplicasets/scale。这使得 Kubernetes 的 HorizontalPodAutoscaler 能够与不同的资源进行交互。然而,对于自定义资源定义(CRDs)而言,每个 Kind 将对应一个单一的资源。

请注意,资源始终使用小写字母,并且根据惯例,它们是 Kind 的小写形式。

那么,这与围棋有什么关系呢?

当我们在特定组版本中提到一种类型时,我们将其称为 GroupVersionKind,简称 GVK。资源同样适用 GVR。正如我们稍后将看到的,每个 GVK 对应于一个包中的特定根 Go 类型。

现在我们把术语理顺了,可以_真正_创建我们的 API 了!

那么,我们该如何创建我们的 API 呢?

在下一部分添加新的 API中,我们将检查该工具如何通过命令 kubebuilder create api 帮助我们创建自己的 API。

此命令的目标是为我们的类型创建一个自定义资源(CR)和自定义资源定义(CRD)。要进一步了解,请参见:使用自定义资源定义扩展Kubernetes API

但是,为什么要创建API呢?

新的 API 是我们向 Kubernetes 教授自定义对象的方式。Go 结构体用于生成 CRD,包含我们数据的架构以及跟踪诸如新类型名称等数据。然后,我们可以创建自定义对象的实例,这些实例将由我们的 控制器 进行管理。

我们的 API 和资源代表了我们在集群上的解决方案。基本上,CRD(自定义资源定义)是我们自定义对象的定义,而 CR(自定义资源)是其实例。

啊,你有例子吗?

让我们想一想经典场景,目标是在平台上运行应用程序及其数据库,使用 Kubernetes。在这个场景中,一个 CRD 可以表示应用程序,而另一个 CRD 可以表示数据库。通过为应用程序创建一个 CRD,并为数据库创建另一个 CRD,我们不会损害诸如封装、单一职责原则和内聚性等概念。损害这些概念可能会导致意想不到的副作用,例如在扩展、重用或维护方面遇到困难,仅举几例。

通过这种方式,我们可以创建应用程序的 CRD,它将拥有自己的控制器,负责诸如创建包含应用程序的 Deployment、创建访问该应用程序的 Service 等。类似地,我们可以创建一个 CRD 来表示数据库,并部署一个控制器来管理数据库实例。

呃,但那个Scheme是什么东西?

我们之前看到的 Scheme 只是一个用来跟踪给定 GVK 对应的 Go 类型的方式(不要被它的 godocs 压倒)。

例如,假设我们将 "tutorial.kubebuilder.io/api/v1".CronJob{} 类型标记为属于 batch.tutorial.kubebuilder.io/v1 API 组(隐含地表示它的 Kind 为 CronJob)。

然后,我们可以根据API服务器提供的JSON数据,稍后构建一个新的 &CronJob{}

{ "kind": "CronJob", "apiVersion": "batch.tutorial.kubebuilder.io/v1", ... }

或者在我们提交一个 &CronJob{} 的更新时,正确查找组版本。

添加一个新 API

要构建一个新的 Kind(你有注意到上一章吗?),以及相应的控制器,我们可以使用 kubebuilder create api

kubebuilder create api --group batch --version v1 --kind CronJob

y 以确认\

第一次为每个组版本调用此命令时,它将为新的组版本创建一个目录。

在这种情况下,api/v1/ 目录被创建,对应于 batch.tutorial.kubebuilder.io/v1(还记得我们最开始的 --domain 设置 吗?)。

它还为我们的 CronJob 类型添加了一个文件 api/v1/cronjob_types.go。每次我们调用带有不同类型的命令时,它都会添加一个相应的新文件。

让我们先看看开箱时提供的内容,然后再继续填写。

emptyapi.go
Apache License

Copyright 2022.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

We start out simply enough: we import the meta/v1 API group, which is not normally exposed by itself, but instead contains metadata common to all Kubernetes Kinds.

package v1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" )

Next, we define types for the Spec and Status of our Kind. Kubernetes functions by reconciling desired state (Spec) with actual cluster state (other objects’ Status) and external state, and then recording what it observed (Status). Thus, every functional object includes spec and status. A few types, like ConfigMap don’t follow this pattern, since they don’t encode desired state, but most types do.

// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. // CronJobSpec defines the desired state of CronJob type CronJobSpec struct { // 插入额外的规格字段 - 集群的期望状态 // 注意:修改此文件后,运行"make"以重新生成代码 } // CronJobStatus defines the observed state of CronJob type CronJobStatus struct { // 插入额外的状态字段 - 定义集群的观察状态 // 重要:在修改此文件后,请运行 "make" 以重新生成代码 }

Next, we define the types corresponding to actual Kinds, CronJob and CronJobList. CronJob is our root type, and describes the CronJob kind. Like all Kubernetes objects, it contains TypeMeta (which describes API version and Kind), and also contains ObjectMeta, which holds things like name, namespace, and labels.

CronJobList is simply a container for multiple CronJobs. It’s the Kind used in bulk operations, like LIST.

In general, we never modify either of these – all modifications go in either Spec or Status.

That little +kubebuilder:object:root comment is called a marker. We’ll see more of them in a bit, but know that they act as extra metadata, telling controller-tools (our code and YAML generator) extra information. This particular one tells the object generator that this type represents a Kind. Then, the object generator generates an implementation of the runtime.Object interface for us, which is the standard interface that all types representing Kinds must implement.

// +kubebuilder:object:root=true // +kubebuilder:subresource:status // CronJob is the Schema for the cronjobs API type CronJob struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec CronJobSpec `json:"spec,omitempty"` Status CronJobStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true // CronJobList 包含一个 CronJob 列表 type CronJobList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []CronJob `json:"items"` }

Finally, we add the Go types to the API group. This allows us to add the types in this API group to any Scheme.

func init() { SchemeBuilder.Register(&CronJob{}, &CronJobList{}) }

现在我们已经了解了基本结构,让我们来完善它吧!

设计一个 API

在 Kubernetes 中,我们有一些设计 API 的规则。即所有序列化字段必须使用 camelCase,因此我们使用 JSON 结构标签来指定这一点。我们还可以使用 omitempty 结构标签来标记当字段为空时应从序列化中省略该字段。

字段可以使用大部分原始类型。数字是一个例外:出于API兼容性考虑,我们接受三种数字形式:int32int64 用于整数,resource.Quantity 用于小数。

Hold up, what's a Quantity?

数量是对十进制数字的一种特殊表示法,它具有明确固定的表示方式,使其在不同机器之间更具可移植性。您可能在Kubernetes中为pod指定资源请求和限制时注意到了它们。

它们在概念上与浮点数相似:具有有效数字、基数和指数。它们的可序列化和可读格式使用整数和后缀来指定值,方式与我们描述计算机存储的方式类似。

例如,值 2m 在十进制表示中代表 0.0022Ki 在十进制中表示 2048,而 2K 在十进制中表示 2000。如果我们想指定小数,我们可以切换到一个后缀,使我们能够使用整数:2.5 表示为 2500m

支持两种进制:10 进制和 2 进制(分别称为十进制和二进制)。十进制使用“常规“国际单位制后缀表示(例如 MK),而二进制使用“兆比特“(mebi)表示法(例如 MiKi)。可以参考 兆字节与兆比特 的区别。

我们使用的另一种特殊类型是 metav1.Time。它的功能与 time.Time 相同,但具有固定的可移植序列化格式。

在处理完这些之后,我们来看看我们的 CronJob 对象是什么样的!

project/api/v1/cronjob_types.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

package v1
Imports
import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.

First, let’s take a look at our spec. As we discussed before, spec holds desired state, so any “inputs” to our controller go here.

Fundamentally a CronJob needs the following pieces:

  • A schedule (the cron in CronJob)
  • A template for the Job to run (the job in CronJob)

We’ll also want a few extras, which will make our users’ lives easier:

  • A deadline for starting jobs (if we miss this deadline, we’ll just wait till the next scheduled time)
  • What to do if multiple jobs would run at once (do we wait? stop the old one? run both?)
  • A way to pause the running of a CronJob, in case something’s wrong with it
  • Limits on old job history

Remember, since we never read our own status, we need to have some other way to keep track of whether a job has run. We can use at least one old job to do this.

We’ll use several markers (// +comment) to specify additional metadata. These will be used by controller-tools when generating our CRD manifest. As we’ll see in a bit, controller-tools will also use GoDoc to form descriptions for the fields.

// CronJobSpec defines the desired state of CronJob. type CronJobSpec struct { // +kubebuilder:validation:MinLength=0 // The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. Schedule string `json:"schedule"` // +kubebuilder:validation:Minimum=0 // Optional deadline in seconds for starting the job if it misses scheduled // time for any reason. Missed jobs executions will be counted as failed ones. // +optional StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"` // Specifies how to treat concurrent executions of a Job. // Valid values are: // - "Allow" (default): allows CronJobs to run concurrently; // - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; // - "Replace": cancels currently running job and replaces it with a new one // +optional ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` // This flag tells the controller to suspend subsequent executions, it does // not apply to already started executions. Defaults to false. // +optional Suspend *bool `json:"suspend,omitempty"` // Specifies the job that will be created when executing a CronJob. JobTemplate batchv1.JobTemplateSpec `json:"jobTemplate"` // +kubebuilder:validation:Minimum=0 // The number of successful finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"` // +kubebuilder:validation:Minimum=0 // The number of failed finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"` }

We define a custom type to hold our concurrency policy. It’s actually just a string under the hood, but the type gives extra documentation, and allows us to attach validation on the type instead of the field, making the validation more easily reusable.

// ConcurrencyPolicy describes how the job will be handled. // Only one of the following concurrent policies may be specified. // If none of the following policies is specified, the default one // is AllowConcurrent. // +kubebuilder:validation:Enum=Allow;Forbid;Replace type ConcurrencyPolicy string const ( // AllowConcurrent allows CronJobs to run concurrently. AllowConcurrent ConcurrencyPolicy = "Allow" // ForbidConcurrent forbids concurrent runs, skipping next run if previous // hasn't finished yet. ForbidConcurrent ConcurrencyPolicy = "Forbid" // ReplaceConcurrent cancels currently running job and replaces it with a new one. ReplaceConcurrent ConcurrencyPolicy = "Replace" )

Next, let’s design our status, which holds observed state. It contains any information we want users or other controllers to be able to easily obtain.

We’ll keep a list of actively running jobs, as well as the last time that we successfully ran our job. Notice that we use metav1.Time instead of time.Time to get the stable serialization, as mentioned above.

// CronJobStatus defines the observed state of CronJob. type CronJobStatus struct { // 插入额外的状态字段 - 定义集群的观察状态 // 重要:在修改此文件后,请运行 "make" 以重新生成代码 // A list of pointers to currently running jobs. // +optional Active []corev1.ObjectReference `json:"active,omitempty"` // Information when was the last time the job was successfully scheduled. // +optional LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"` }

Finally, we have the rest of the boilerplate that we’ve already discussed. As previously noted, we don’t need to change this, except to mark that we want a status subresource, so that we behave like built-in kubernetes types.

// +kubebuilder:object:root=true // +kubebuilder:subresource:status // CronJob is the Schema for the cronjobs API. type CronJob struct {
Root Object Definitions
metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec CronJobSpec `json:"spec,omitempty"` Status CronJobStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true // CronJobList contains a list of CronJob. type CronJobList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []CronJob `json:"items"` } func init() { SchemeBuilder.Register(&CronJob{}, &CronJobList{}) }

现在我们有了一个API,我们需要编写一个控制器来实际实现该功能。

简短的插曲:这些其他东西是什么?

如果你查看了 api/v1/ 目录中的其他文件,可能会注意到除了 cronjob_types.go 之外还有两个额外的文件:groupversion_info.gozz_generated.deepcopy.go

这两个文件都不需要编辑(前者保持不变,后者是自动生成的),但了解它们的内容是很有用的。

groupversion_info.go

groupversion_info.go 包含有关组版本的常见元数据:

project/api/v1/groupversion_info.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

First, we have some package-level markers that denote that there are Kubernetes objects in this package, and that this package represents the group batch.tutorial.kubebuilder.io. The object generator makes use of the former, while the latter is used by the CRD generator to generate the right metadata for the CRDs it creates from this package.

// Package v1 contains API Schema definitions for the batch v1 API group. // +kubebuilder:object:generate=true // +groupName=batch.tutorial.kubebuilder.io package v1 import ( "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/scheme" )

Then, we have the commonly useful variables that help us set up our Scheme. Since we need to use all the types in this package in our controller, it’s helpful (and the convention) to have a convenient method to add all the types to some other Scheme. SchemeBuilder makes this easy for us.

var ( // GroupVersion is group version used to register these objects. GroupVersion = schema.GroupVersion{Group: "batch.tutorial.kubebuilder.io", Version: "v1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme. SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme )

zz_generated.deepcopy.go

zz_generated.deepcopy.go 包含了上述 runtime.Object 接口的自动生成实现,它将我们的所有根类型标记为表示种类(Kinds)。

runtime.Object 接口的核心是一个深拷贝方法 DeepCopyObject

controller-tools中的object生成器还为每个根类型及其所有子类型生成了两个其他实用方法:DeepCopyDeepCopyInto

控制器中包含什么?

控制器是Kubernetes和任何操作器的核心。

控制器的职责是确保对于任何给定的对象,实际的世界状态(包括集群状态以及可能的外部状态,例如 Kubelet 的运行容器或云服务提供商的负载均衡器)与对象中的期望状态相匹配。每个控制器专注于一个_根_ Kind,但可能与其他 Kind 进行交互。

我们称这个过程为 调整

在 controller-runtime 中,实现特定种类的对账逻辑的部分称为 Reconciler。一个 reconciler 接收一个对象的名称,并返回是否需要重新尝试(例如,在出现错误或周期性控制器的情况下,如水平 Pod 自动缩放器)。

emptycontroller.go
Apache License

Copyright 2022.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

First, we start out with some standard imports. As before, we need the core controller-runtime library, as well as the client package, and the package for our API types.

package controllers import ( "context" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" batchv1 "tutorial.kubebuilder.io/project/api/v1" )

Next, kubebuilder has scaffolded a basic reconciler struct for us. Pretty much every reconciler needs to log, and needs to be able to fetch objects, so these are added out of the box.

// CronJobReconciler reconciles a CronJob object type CronJobReconciler struct { client.Client Scheme *runtime.Scheme }

Most controllers eventually end up running on the cluster, so they need RBAC permissions, which we specify using controller-tools RBAC markers. These are the bare minimum permissions needed to run. As we add more functionality, we’ll need to revisit these.

// +kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/status,verbs=get;update;patch

The ClusterRole manifest at config/rbac/role.yaml is generated from the above markers via controller-gen with the following command:

// make manifests

NOTE: If you receive an error, please run the specified command in the error and re-run make manifests.

Reconcile actually performs the reconciling for a single named object. Our Request just has a name, but we can use the client to fetch that object from the cache.

We return an empty result and no error, which indicates to controller-runtime that we’ve successfully reconciled this object and don’t need to try again until there’s some changes.

Most controllers need a logging handle and a context, so we set them up here.

The context is used to allow cancellation of requests, and potentially things like tracing. It’s the first argument to all client methods. The Background context is just a basic context without any extra data or timing restrictions.

The logging handle lets us log. controller-runtime uses structured logging through a library called logr. As we’ll see shortly, logging works by attaching key-value pairs to a static message. We can pre-assign some pairs at the top of our reconcile method to have those attached to all log lines in this reconciler.

func (r *CronJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { _ = logf.FromContext(ctx) // your logic here return ctrl.Result{}, nil }

Finally, we add this reconciler to the manager, so that it gets started when the manager is started.

For now, we just note that this reconciler operates on CronJobs. Later, we’ll use this to mark that we care about related objects as well.

func (r *CronJobReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&batchv1.CronJob{}). Complete(r) }

现在我们已经看到了 reconciler 的基本结构,让我们来完善 CronJob 的逻辑。

实现控制器

我们CronJob控制器的基本逻辑是这样的:

  1. 加载指定的 CronJob

  2. 列出所有有效的工作,并更新状态。

  3. 根据历史限制清理旧的工作记录。

  4. 检查我们是否被暂停(如果被暂停则不做其他任何事情)

  5. 获取下一个计划的运行时间

  6. 如果新工作在计划时间内、未超过截止日期且未被我们的并发策略阻止,则可以运行。

  7. 当我们看到一个正在运行的任务(自动完成)或是下一个定时运行的时间到时,请重新排队。

project/internal/controller/cronjob_controller.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

We’ll start out with some imports. You’ll see below that we’ll need a few more imports than those scaffolded for us. We’ll talk about each one when we use it.

package controller import ( "context" "fmt" "sort" "time" "github.com/robfig/cron" kbatch "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ref "k8s.io/client-go/tools/reference" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" batchv1 "tutorial.kubebuilder.io/project/api/v1" )

Next, we’ll need a Clock, which will allow us to fake timing in our tests.

// CronJobReconciler reconciles a CronJob object type CronJobReconciler struct { client.Client Scheme *runtime.Scheme Clock }
Clock

We’ll mock out the clock to make it easier to jump around in time while testing, the “real” clock just calls time.Now.

type realClock struct{} func (_ realClock) Now() time.Time { return time.Now() } // Clock knows how to get the current time. // It can be used to fake out timing for testing. type Clock interface { Now() time.Time }

Notice that we need a few more RBAC permissions – since we’re creating and managing jobs now, we’ll need permissions for those, which means adding a couple more markers.

// +kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/status,verbs=get;update;patch // +kubebuilder:rbac:groups=batch.tutorial.kubebuilder.io,resources=cronjobs/finalizers,verbs=update // +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=batch,resources=jobs/status,verbs=get

Now, we get to the heart of the controller – the reconciler logic.

var ( scheduledTimeAnnotation = "batch.tutorial.kubebuilder.io/scheduled-at" ) // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // TODO(user): Modify the Reconcile function to compare the state specified by // the CronJob object against the actual cluster state, and then // perform operations to make the cluster state reflect the state specified by // the user. // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/reconcile // nolint:gocyclo func (r *CronJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx)

1: Load the CronJob by name

We’ll fetch the CronJob using our client. All client methods take a context (to allow for cancellation) as their first argument, and the object in question as their last. Get is a bit special, in that it takes a NamespacedName as the middle argument (most don’t have a middle argument, as we’ll see below).

Many client methods also take variadic options at the end.

var cronJob batchv1.CronJob if err := r.Get(ctx, req.NamespacedName, &cronJob); err != nil { log.Error(err, "unable to fetch CronJob") // we'll ignore not-found errors, since they can't be fixed by an immediate // requeue (we'll need to wait for a new notification), and we can get them // on deleted requests. return ctrl.Result{}, client.IgnoreNotFound(err) }

2: List all active jobs, and update the status

To fully update our status, we’ll need to list all child jobs in this namespace that belong to this CronJob. Similarly to Get, we can use the List method to list the child jobs. Notice that we use variadic options to set the namespace and field match (which is actually an index lookup that we set up below).

var childJobs kbatch.JobList if err := r.List(ctx, &childJobs, client.InNamespace(req.Namespace), client.MatchingFields{jobOwnerKey: req.Name}); err != nil { log.Error(err, "unable to list child Jobs") return ctrl.Result{}, err }

Once we have all the jobs we own, we’ll split them into active, successful, and failed jobs, keeping track of the most recent run so that we can record it in status. Remember, status should be able to be reconstituted from the state of the world, so it’s generally not a good idea to read from the status of the root object. Instead, you should reconstruct it every run. That’s what we’ll do here.

We can check if a job is “finished” and whether it succeeded or failed using status conditions. We’ll put that logic in a helper to make our code cleaner.

// find the active list of jobs var activeJobs []*kbatch.Job var successfulJobs []*kbatch.Job var failedJobs []*kbatch.Job var mostRecentTime *time.Time // find the last run so we can update the status
isJobFinished

We consider a job “finished” if it has a “Complete” or “Failed” condition marked as true. Status conditions allow us to add extensible status information to our objects that other humans and controllers can examine to check things like completion and health.

isJobFinished := func(job *kbatch.Job) (bool, kbatch.JobConditionType) { for _, c := range job.Status.Conditions { if (c.Type == kbatch.JobComplete || c.Type == kbatch.JobFailed) && c.Status == corev1.ConditionTrue { return true, c.Type } } return false, "" }
getScheduledTimeForJob

We’ll use a helper to extract the scheduled time from the annotation that we added during job creation.

getScheduledTimeForJob := func(job *kbatch.Job) (*time.Time, error) { timeRaw := job.Annotations[scheduledTimeAnnotation] if len(timeRaw) == 0 { return nil, nil } timeParsed, err := time.Parse(time.RFC3339, timeRaw) if err != nil { return nil, err } return &timeParsed, nil }
for i, job := range childJobs.Items { _, finishedType := isJobFinished(&job) switch finishedType { case "": // ongoing activeJobs = append(activeJobs, &childJobs.Items[i]) case kbatch.JobFailed: failedJobs = append(failedJobs, &childJobs.Items[i]) case kbatch.JobComplete: successfulJobs = append(successfulJobs, &childJobs.Items[i]) } // We'll store the launch time in an annotation, so we'll reconstitute that from // the active jobs themselves. scheduledTimeForJob, err := getScheduledTimeForJob(&job) if err != nil { log.Error(err, "unable to parse schedule time for child job", "job", &job) continue } if scheduledTimeForJob != nil { if mostRecentTime == nil || mostRecentTime.Before(*scheduledTimeForJob) { mostRecentTime = scheduledTimeForJob } } } if mostRecentTime != nil { cronJob.Status.LastScheduleTime = &metav1.Time{Time: *mostRecentTime} } else { cronJob.Status.LastScheduleTime = nil } cronJob.Status.Active = nil for _, activeJob := range activeJobs { jobRef, err := ref.GetReference(r.Scheme, activeJob) if err != nil { log.Error(err, "unable to make reference to active job", "job", activeJob) continue } cronJob.Status.Active = append(cronJob.Status.Active, *jobRef) }

Here, we’ll log how many jobs we observed at a slightly higher logging level, for debugging. Notice how instead of using a format string, we use a fixed message, and attach key-value pairs with the extra information. This makes it easier to filter and query log lines.

log.V(1).Info("job count", "active jobs", len(activeJobs), "successful jobs", len(successfulJobs), "failed jobs", len(failedJobs))

Using the data we’ve gathered, we’ll update the status of our CRD. Just like before, we use our client. To specifically update the status subresource, we’ll use the Status part of the client, with the Update method.

The status subresource ignores changes to spec, so it’s less likely to conflict with any other updates, and can have separate permissions.

if err := r.Status().Update(ctx, &cronJob); err != nil { log.Error(err, "unable to update CronJob status") return ctrl.Result{}, err }

Once we’ve updated our status, we can move on to ensuring that the status of the world matches what we want in our spec.

3: Clean up old jobs according to the history limit

First, we’ll try to clean up old jobs, so that we don’t leave too many lying around.

// NB: deleting these are "best effort" -- if we fail on a particular one, // we won't requeue just to finish the deleting. if cronJob.Spec.FailedJobsHistoryLimit != nil { sort.Slice(failedJobs, func(i, j int) bool { if failedJobs[i].Status.StartTime == nil { return failedJobs[j].Status.StartTime != nil } return failedJobs[i].Status.StartTime.Before(failedJobs[j].Status.StartTime) }) for i, job := range failedJobs { if int32(i) >= int32(len(failedJobs))-*cronJob.Spec.FailedJobsHistoryLimit { break } if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil { log.Error(err, "unable to delete old failed job", "job", job) } else { log.V(0).Info("deleted old failed job", "job", job) } } } if cronJob.Spec.SuccessfulJobsHistoryLimit != nil { sort.Slice(successfulJobs, func(i, j int) bool { if successfulJobs[i].Status.StartTime == nil { return successfulJobs[j].Status.StartTime != nil } return successfulJobs[i].Status.StartTime.Before(successfulJobs[j].Status.StartTime) }) for i, job := range successfulJobs { if int32(i) >= int32(len(successfulJobs))-*cronJob.Spec.SuccessfulJobsHistoryLimit { break } if err := r.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil { log.Error(err, "unable to delete old successful job", "job", job) } else { log.V(0).Info("deleted old successful job", "job", job) } } }

4: Check if we’re suspended

If this object is suspended, we don’t want to run any jobs, so we’ll stop now. This is useful if something’s broken with the job we’re running and we want to pause runs to investigate or putz with the cluster, without deleting the object.

if cronJob.Spec.Suspend != nil && *cronJob.Spec.Suspend { log.V(1).Info("cronjob suspended, skipping") return ctrl.Result{}, nil }

5: Get the next scheduled run

If we’re not paused, we’ll need to calculate the next scheduled run, and whether or not we’ve got a run that we haven’t processed yet.

getNextSchedule

We’ll calculate the next scheduled time using our helpful cron library. We’ll start calculating appropriate times from our last run, or the creation of the CronJob if we can’t find a last run.

If there are too many missed runs and we don’t have any deadlines set, we’ll bail so that we don’t cause issues on controller restarts or wedges.

Otherwise, we’ll just return the missed runs (of which we’ll just use the latest), and the next run, so that we can know when it’s time to reconcile again.

getNextSchedule := func(cronJob *batchv1.CronJob, now time.Time) (lastMissed time.Time, next time.Time, err error) { sched, err := cron.ParseStandard(cronJob.Spec.Schedule) if err != nil { return time.Time{}, time.Time{}, fmt.Errorf("Unparseable schedule %q: %v", cronJob.Spec.Schedule, err) } // for optimization purposes, cheat a bit and start from our last observed run time // we could reconstitute this here, but there's not much point, since we've // just updated it. var earliestTime time.Time if cronJob.Status.LastScheduleTime != nil { earliestTime = cronJob.Status.LastScheduleTime.Time } else { earliestTime = cronJob.ObjectMeta.CreationTimestamp.Time } if cronJob.Spec.StartingDeadlineSeconds != nil { // controller is not going to schedule anything below this point schedulingDeadline := now.Add(-time.Second * time.Duration(*cronJob.Spec.StartingDeadlineSeconds)) if schedulingDeadline.After(earliestTime) { earliestTime = schedulingDeadline } } if earliestTime.After(now) { return time.Time{}, sched.Next(now), nil } starts := 0 for t := sched.Next(earliestTime); !t.After(now); t = sched.Next(t) { lastMissed = t // An object might miss several starts. For example, if // controller gets wedged on Friday at 5:01pm when everyone has // gone home, and someone comes in on Tuesday AM and discovers // the problem and restarts the controller, then all the hourly // jobs, more than 80 of them for one hourly scheduledJob, should // all start running with no further intervention (if the scheduledJob // allows concurrency and late starts). // // However, if there is a bug somewhere, or incorrect clock // on controller's server or apiservers (for setting creationTimestamp) // then there could be so many missed start times (it could be off // by decades or more), that it would eat up all the CPU and memory // of this controller. In that case, we want to not try to list // all the missed start times. starts++ if starts > 100 { // We can't get the most recent times so just return an empty slice return time.Time{}, time.Time{}, fmt.Errorf("Too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.") } } return lastMissed, sched.Next(now), nil }
// figure out the next times that we need to create // jobs at (or anything we missed). missedRun, nextRun, err := getNextSchedule(&cronJob, r.Now()) if err != nil { log.Error(err, "unable to figure out CronJob schedule") // we don't really care about requeuing until we get an update that // fixes the schedule, so don't return an error return ctrl.Result{}, nil }

We’ll prep our eventual request to requeue until the next job, and then figure out if we actually need to run.

scheduledResult := ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())} // save this so we can re-use it elsewhere log = log.WithValues("now", r.Now(), "next run", nextRun)

6: Run a new job if it’s on schedule, not past the deadline, and not blocked by our concurrency policy

If we’ve missed a run, and we’re still within the deadline to start it, we’ll need to run a job.

if missedRun.IsZero() { log.V(1).Info("no upcoming scheduled times, sleeping until next") return scheduledResult, nil } // make sure we're not too late to start the run log = log.WithValues("current run", missedRun) tooLate := false if cronJob.Spec.StartingDeadlineSeconds != nil { tooLate = missedRun.Add(time.Duration(*cronJob.Spec.StartingDeadlineSeconds) * time.Second).Before(r.Now()) } if tooLate { log.V(1).Info("missed starting deadline for last run, sleeping till next") // TODO(directxman12): events return scheduledResult, nil }

If we actually have to run a job, we’ll need to either wait till existing ones finish, replace the existing ones, or just add new ones. If our information is out of date due to cache delay, we’ll get a requeue when we get up-to-date information.

// figure out how to run this job -- concurrency policy might forbid us from running // multiple at the same time... if cronJob.Spec.ConcurrencyPolicy == batchv1.ForbidConcurrent && len(activeJobs) > 0 { log.V(1).Info("concurrency policy blocks concurrent runs, skipping", "num active", len(activeJobs)) return scheduledResult, nil } // ...or instruct us to replace existing ones... if cronJob.Spec.ConcurrencyPolicy == batchv1.ReplaceConcurrent { for _, activeJob := range activeJobs { // we don't care if the job was already deleted if err := r.Delete(ctx, activeJob, client.PropagationPolicy(metav1.DeletePropagationBackground)); client.IgnoreNotFound(err) != nil { log.Error(err, "unable to delete active job", "job", activeJob) return ctrl.Result{}, err } } }

Once we’ve figured out what to do with existing jobs, we’ll actually create our desired job

constructJobForCronJob

We need to construct a job based on our CronJob’s template. We’ll copy over the spec from the template and copy some basic object meta.

Then, we’ll set the “scheduled time” annotation so that we can reconstitute our LastScheduleTime field each reconcile.

Finally, we’ll need to set an owner reference. This allows the Kubernetes garbage collector to clean up jobs when we delete the CronJob, and allows controller-runtime to figure out which cronjob needs to be reconciled when a given job changes (is added, deleted, completes, etc).

constructJobForCronJob := func(cronJob *batchv1.CronJob, scheduledTime time.Time) (*kbatch.Job, error) { // We want job names for a given nominal start time to have a deterministic name to avoid the same job being created twice name := fmt.Sprintf("%s-%d", cronJob.Name, scheduledTime.Unix()) job := &kbatch.Job{ ObjectMeta: metav1.ObjectMeta{ Labels: make(map[string]string), Annotations: make(map[string]string), Name: name, Namespace: cronJob.Namespace, }, Spec: *cronJob.Spec.JobTemplate.Spec.DeepCopy(), } for k, v := range cronJob.Spec.JobTemplate.Annotations { job.Annotations[k] = v } job.Annotations[scheduledTimeAnnotation] = scheduledTime.Format(time.RFC3339) for k, v := range cronJob.Spec.JobTemplate.Labels { job.Labels[k] = v } if err := ctrl.SetControllerReference(cronJob, job, r.Scheme); err != nil { return nil, err } return job, nil }
// actually make the job... job, err := constructJobForCronJob(&cronJob, missedRun) if err != nil { log.Error(err, "unable to construct job from template") // don't bother requeuing until we get a change to the spec return scheduledResult, nil } // ...and create it on the cluster if err := r.Create(ctx, job); err != nil { log.Error(err, "unable to create Job for CronJob", "job", job) return ctrl.Result{}, err } log.V(1).Info("created Job for CronJob run", "job", job)

7: Requeue when we either see a running job or it’s time for the next scheduled run

Finally, we’ll return the result that we prepped above, that says we want to requeue when our next run would need to occur. This is taken as a maximum deadline – if something else changes in between, like our job starts or finishes, we get modified, etc, we might reconcile again sooner.

// we'll requeue once we see the running job, and update our status return scheduledResult, nil }

Setup

Finally, we’ll update our setup. In order to allow our reconciler to quickly look up Jobs by their owner, we’ll need an index. We declare an index key that we can later use with the client as a pseudo-field name, and then describe how to extract the indexed value from the Job object. The indexer will automatically take care of namespaces for us, so we just have to extract the owner name if the Job has a CronJob owner.

Additionally, we’ll inform the manager that this controller owns some Jobs, so that it will automatically call Reconcile on the underlying CronJob when a Job changes, is deleted, etc.

var ( jobOwnerKey = ".metadata.controller" apiGVStr = batchv1.GroupVersion.String() ) // SetupWithManager 将控制器与管理器进行设置。 func (r *CronJobReconciler) SetupWithManager(mgr ctrl.Manager) error { // set up a real clock, since we're not in a test if r.Clock == nil { r.Clock = realClock{} } if err := mgr.GetFieldIndexer().IndexField(context.Background(), &kbatch.Job{}, jobOwnerKey, func(rawObj client.Object) []string { // grab the job object, extract the owner... job := rawObj.(*kbatch.Job) owner := metav1.GetControllerOf(job) if owner == nil { return nil } // ...make sure it's a CronJob... if owner.APIVersion != apiGVStr || owner.Kind != "CronJob" { return nil } // ...and if so, return it return []string{owner.Name} }); err != nil { return err } return ctrl.NewControllerManagedBy(mgr). For(&batchv1.CronJob{}). Owns(&kbatch.Job{}). Named("cronjob"). Complete(r) }

那真是个难题,不过现在我们有了一个可用的控制器。接下来让我们对集群进行测试,如果没有问题,就可以部署了!

你提到过主吗?

但是首先,记得我们说过要再次回到 main.go 吗?让我们来看看有没有什么变化,以及我们需要添加的内容。

project/cmd/main.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Imports
package main import ( "crypto/tls" "flag" "os" "path/filepath" // 导入所有 Kubernetes 客户端身份验证插件(例如 Azure、GCP、OIDC 等) // 确保 exec-entrypoint 和 run 可以使用它们。 _ "k8s.io/client-go/plugin/pkg/client/auth" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" batchv1 "tutorial.kubebuilder.io/project/api/v1" "tutorial.kubebuilder.io/project/internal/controller" webhookbatchv1 "tutorial.kubebuilder.io/project/internal/webhook/v1" // +kubebuilder:scaffold:imports )

The first difference to notice is that kubebuilder has added the new API group’s package (batchv1) to our scheme. This means that we can use those objects in our controller.

If we would be using any other CRD we would have to add their scheme the same way. Builtin types such as Job have their scheme added by clientgoscheme.

var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") ) func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(batchv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme}

The other thing that’s changed is that kubebuilder has added a block calling our CronJob controller’s SetupWithManager method.

// nolint:gocyclo func main() {
old stuff
var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string var enableLeaderElection bool var probeAddr string var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "使用 :8443 进行 HTTPS,使用 :8080 进行 HTTP,或者保持为 0 以禁用指标服务。") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。") flag.BoolVar(&secureMetrics, "metrics-secure", true, "如果设置,指标端点将通过 HTTPS 安全提供。请使用 --metrics-secure=false 以改为使用 HTTP。") flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") flag.StringVar(&metricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) // 如果 enable-http2 标志为 false(默认值),则应禁用 http/2 // 由于其存在的漏洞。更具体地说,禁用 http/2 将 // 防止受到 HTTP/2 流取消和 // 快速重置 CVE 的漏洞影响。有关更多信息,请参见: // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 // - https://github.com/advisories/GHSA-4374-p667-p6c8 disableHTTP2 := func(c *tls.Config) { setupLog.Info("disabling http/2") c.NextProtos = []string{"http/1.1"} } if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } // 创建用于指标和 Webhook 证书的监视器 var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher // 初始Webhook TLS选项 webhookTLSOpts := tlsOpts if len(webhookCertPath) > 0 { setupLog.Info("使用提供的证书初始化 Webhook 证书监视器", "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) var err error webhookCertWatcher, err = certwatcher.New( filepath.Join(webhookCertPath, webhookCertName), filepath.Join(webhookCertPath, webhookCertKey), ) if err != nil { setupLog.Error(err, "Failed to initialize webhook certificate watcher") os.Exit(1) } webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { config.GetCertificate = webhookCertWatcher.GetCertificate }) } webhookServer := webhook.NewServer(webhook.Options{ TLSOpts: webhookTLSOpts, }) // 指标端点在 'config/default/kustomization.yaml' 中启用。指标选项配置服务器。 // 更多信息: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/server // - https://book.kubebuilder.io/reference/metrics.html metricsServerOptions := metricsserver.Options{ BindAddress: metricsAddr, SecureServing: secureMetrics, TLSOpts: tlsOpts, } if secureMetrics { // FilterProvider 用于通过认证和授权来保护指标端点。 // 这些配置确保只有授权用户和服务账户 // 可以访问指标端点。RBAC 配置在 'config/rbac/kustomization.yaml' 中。更多信息: // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/filters#WithAuthenticationAndAuthorization metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization } // 如果未指定证书,controller-runtime 将自动 // 为指标服务器生成自签名证书。虽然对于开发和测试来说很方便, // 但这种设置不建议用于生产环境。 // // TODO(用户):如果您启用 certManager,请取消注释以下行: // - [METRICS-WITH-CERTS] 在 config/default/kustomization.yaml 中生成并使用 // 由 cert-manager 管理的指标服务器证书。 // - [PROMETHEUS-WITH-CERTS] 在 config/prometheus/kustomization.yaml 中用于 TLS 认证。 if len(metricsCertPath) > 0 { setupLog.Info("使用提供的证书初始化指标证书监视器", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) var err error metricsCertWatcher, err = certwatcher.New( filepath.Join(metricsCertPath, metricsCertName), filepath.Join(metricsCertPath, metricsCertKey), ) if err != nil { setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) os.Exit(1) } metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { config.GetCertificate = metricsCertWatcher.GetCertificate }) } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "80807133.tutorial.kubebuilder.io", // LeaderElectionReleaseOnCancel 定义了当管理器结束时,领导者是否应自愿辞职 // 当管理器停止时,这要求二进制文件立即结束,否则这个设置是不安全的。设置这个可以显著 // 加快自愿领导者的过渡,因为新的领导者不必首先等待 // LeaseDuration 时间。 // // 在提供的默认脚手架中,程序会在 // 管理器停止后立即结束,因此启用此选项是可以的。不过, // 如果您正在进行或打算在管理器停止后执行任何操作,如执行清理, // 那么使用它可能是不安全的。 // LeaderElectionReleaseOnCancel: true, }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) }
if err = (&controller.CronJobReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "CronJob") os.Exit(1) }
old stuff

We’ll also set up webhooks for our type, which we’ll talk about next. We just need to add them to the manager. Since we might want to run the webhooks separately, or not run them when testing our controller locally, we’ll put them behind an environment variable.

We’ll just make sure to set ENABLE_WEBHOOKS=false when we run locally.

// nolint:goconst if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err = webhookbatchv1.SetupCronJobWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "CronJob") os.Exit(1) } } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { setupLog.Info("Adding metrics certificate watcher to manager") if err := mgr.Add(metricsCertWatcher); err != nil { setupLog.Error(err, "unable to add metrics certificate watcher to manager") os.Exit(1) } } if webhookCertWatcher != nil { setupLog.Info("Adding webhook certificate watcher to manager") if err := mgr.Add(webhookCertWatcher); err != nil { setupLog.Error(err, "unable to add webhook certificate watcher to manager") os.Exit(1) } } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) }
}

_现在_我们可以实现我们的控制器。

实现默认值/验证的 Webhook

如果您想为您的 CRD 实现 admission webhooks ,您需要做的唯一一件事是实现 CustomDefaulter 和(或)CustomValidator 接口。

Kubebuilder 会为您处理其余的任务,例如:

  1. 创建 webhook 服务器。
  2. 确保服务器已在管理器中添加。
  3. 为您的 webhooks 创建处理程序。
  4. 在你的服务器中为每个处理程序注册路径。

首先,让我们为我们的自定义资源定义(CronJob)搭建 Webhook。我们需要使用 --defaulting--programmatic-validation 标志运行以下命令(因为我们的测试项目将使用默认和验证 Webhook):

kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation

这将为您构建 webhook 函数,并在您的 main.go 中将 webhook 注册到管理器。

project/internal/webhook/v1/cronjob_webhook.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Go imports
package v1 import ( "context" "fmt" "github.com/robfig/cron" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" validationutils "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" batchv1 "tutorial.kubebuilder.io/project/api/v1" )

Next, we’ll setup a logger for the webhooks.

var cronjoblog = logf.Log.WithName("cronjob-resource")

Then, we set up the webhook with the manager.

// SetupCronJobWebhookWithManager registers the webhook for CronJob in the manager. func SetupCronJobWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr).For(&batchv1.CronJob{}). WithValidator(&CronJobCustomValidator{}). WithDefaulter(&CronJobCustomDefaulter{ DefaultConcurrencyPolicy: batchv1.AllowConcurrent, DefaultSuspend: false, DefaultSuccessfulJobsHistoryLimit: 3, DefaultFailedJobsHistoryLimit: 1, }). Complete() }

Notice that we use kubebuilder markers to generate webhook manifests. This marker is responsible for generating a mutating webhook manifest.

The meaning of each marker can be found here.

This marker is responsible for generating a mutation webhook manifest.

// +kubebuilder:webhook:path=/mutate-batch-tutorial-kubebuilder-io-v1-cronjob,mutating=true,failurePolicy=fail,sideEffects=None,groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=create;update,versions=v1,name=mcronjob-v1.kb.io,admissionReviewVersions=v1 // CronJobCustomDefaulter struct is responsible for setting default values on the custom resource of the // Kind CronJob when those are created or updated. // // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as it is used only for temporary operations and does not need to be deeply copied. type CronJobCustomDefaulter struct { // Default values for various CronJob fields DefaultConcurrencyPolicy batchv1.ConcurrencyPolicy DefaultSuspend bool DefaultSuccessfulJobsHistoryLimit int32 DefaultFailedJobsHistoryLimit int32 } var _ webhook.CustomDefaulter = &CronJobCustomDefaulter{}

We use the webhook.CustomDefaulterinterface to set defaults to our CRD. A webhook will automatically be served that calls this defaulting.

The Defaultmethod is expected to mutate the receiver, setting the defaults.

// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind CronJob. func (d *CronJobCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return fmt.Errorf("expected an CronJob object but got %T", obj) } cronjoblog.Info("Defaulting for CronJob", "name", cronjob.GetName()) // Set default values d.applyDefaults(cronjob) return nil } // applyDefaults applies default values to CronJob fields. func (d *CronJobCustomDefaulter) applyDefaults(cronJob *batchv1.CronJob) { if cronJob.Spec.ConcurrencyPolicy == "" { cronJob.Spec.ConcurrencyPolicy = d.DefaultConcurrencyPolicy } if cronJob.Spec.Suspend == nil { cronJob.Spec.Suspend = new(bool) *cronJob.Spec.Suspend = d.DefaultSuspend } if cronJob.Spec.SuccessfulJobsHistoryLimit == nil { cronJob.Spec.SuccessfulJobsHistoryLimit = new(int32) *cronJob.Spec.SuccessfulJobsHistoryLimit = d.DefaultSuccessfulJobsHistoryLimit } if cronJob.Spec.FailedJobsHistoryLimit == nil { cronJob.Spec.FailedJobsHistoryLimit = new(int32) *cronJob.Spec.FailedJobsHistoryLimit = d.DefaultFailedJobsHistoryLimit } }

We can validate our CRD beyond what’s possible with declarative validation. Generally, declarative validation should be sufficient, but sometimes more advanced use cases call for complex validation.

For instance, we’ll see below that we use this to validate a well-formed cron schedule without making up a long regular expression.

If webhook.CustomValidator interface is implemented, a webhook will automatically be served that calls the validation.

The ValidateCreate, ValidateUpdate and ValidateDelete methods are expected to validate its receiver upon creation, update and deletion respectively. We separate out ValidateCreate from ValidateUpdate to allow behavior like making certain fields immutable, so that they can only be set on creation. ValidateDelete is also separated from ValidateUpdate to allow different validation behavior on deletion. Here, however, we just use the same shared validation for ValidateCreate and ValidateUpdate. And we do nothing in ValidateDelete, since we don’t need to validate anything on deletion.

This marker is responsible for generating a validation webhook manifest.

// +kubebuilder:webhook:path=/validate-batch-tutorial-kubebuilder-io-v1-cronjob,mutating=false,failurePolicy=fail,sideEffects=None,groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=create;update,versions=v1,name=vcronjob-v1.kb.io,admissionReviewVersions=v1 // CronJobCustomValidator struct is responsible for validating the CronJob resource // when it is created, updated, or deleted. // // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as this struct is used only for temporary operations and does not need to be deeply copied. type CronJobCustomValidator struct { // TODO(user): Add more fields as needed for validation } var _ webhook.CustomValidator = &CronJobCustomValidator{} // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object but got %T", obj) } cronjoblog.Info("Validation for CronJob upon creation", "name", cronjob.GetName()) return nil, validateCronJob(cronjob) } // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { cronjob, ok := newObj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object for the newObj but got %T", newObj) } cronjoblog.Info("Validation for CronJob upon update", "name", cronjob.GetName()) return nil, validateCronJob(cronjob) } // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object but got %T", obj) } cronjoblog.Info("Validation for CronJob upon deletion", "name", cronjob.GetName()) // TODO(user): fill in your validation logic upon object deletion. return nil, nil }

We validate the name and the spec of the CronJob.

// validateCronJob validates the fields of a CronJob object. func validateCronJob(cronjob *batchv1.CronJob) error { var allErrs field.ErrorList if err := validateCronJobName(cronjob); err != nil { allErrs = append(allErrs, err) } if err := validateCronJobSpec(cronjob); err != nil { allErrs = append(allErrs, err) } if len(allErrs) == 0 { return nil } return apierrors.NewInvalid( schema.GroupKind{Group: "batch.tutorial.kubebuilder.io", Kind: "CronJob"}, cronjob.Name, allErrs) }

Some fields are declaratively validated by OpenAPI schema. You can find kubebuilder validation markers (prefixed with // +kubebuilder:validation) in the Designing an API section. You can find all of the kubebuilder supported markers for declaring validation by running controller-gen crd -w, or here.

func validateCronJobSpec(cronjob *batchv1.CronJob) *field.Error { // The field helpers from the kubernetes API machinery help us return nicely // structured validation errors. return validateScheduleFormat( cronjob.Spec.Schedule, field.NewPath("spec").Child("schedule")) }

We’ll need to validate the cron schedule is well-formatted.

func validateScheduleFormat(schedule string, fldPath *field.Path) *field.Error { if _, err := cron.ParseStandard(schedule); err != nil { return field.Invalid(fldPath, schedule, err.Error()) } return nil }
Validate object name

Validating the length of a string field can be done declaratively by the validation schema.

But the ObjectMeta.Name field is defined in a shared package under the apimachinery repo, so we can’t declaratively validate it using the validation schema.

func validateCronJobName(cronjob *batchv1.CronJob) *field.Error { if len(cronjob.ObjectMeta.Name) > validationutils.DNS1035LabelMaxLength-11 { // The job name length is 63 characters like all Kubernetes objects // (which must fit in a DNS subdomain). The cronjob controller appends // a 11-character suffix to the cronjob (`-$TIMESTAMP`) when creating // a job. The job name length limit is 63 characters. Therefore cronjob // names must have length <= 63-11=52. If we don't validate this here, // then job creation will fail later. return field.Invalid(field.NewPath("metadata").Child("name"), cronjob.ObjectMeta.Name, "must be no more than 52 characters") } return nil }

运行和部署控制器

Optional

如果选择对 API 定义进行任何更改,请在继续之前生成类似于 CR 或 CRD 的清单。

make manifests

为了测试控制器,我们可以在集群上本地运行它。不过,在此之前,我们需要安装我们的 CRD,按照 快速启动 的指南。这将自动使用 controller-tools 更新 YAML 清单(如果需要的话):

make install

现在我们已经安装了CRD(自定义资源定义),可以在我们的集群上运行控制器。这将使用我们连接到集群时的凭据,因此我们暂时不需要担心RBAC(基于角色的访问控制)。

在另一个终端中运行

export ENABLE_WEBHOOKS=错误 make run

您应该看到控制器关于启动的日志,但它暂时不会执行任何操作。

此时,我们需要一个 CronJob 进行测试。让我们在 config/samples/batch_v1_cronjob.yaml 中写一个示例,并使用它:

apiVersion: batch.tutorial.kubebuilder.io/v1 kind: CronJob metadata: labels: app.kubernetes.io/name: project app.kubernetes.io/managed-by: kustomize name: cron作业示例 spec: 日程安排: "*/1 * * * *" 启动截止时间(秒): 60 并发策略: 允许 # 显式指定,但允许也是默认值。 工作模板: spec: template: spec: 容器: - name: 你好 image: busybox args: - /bin/sh - -c - 日期; 在Kubernetes集群中打招呼。 重启策略: 失败时
kubectl create -f config/samples/batch_v1_cronjob.yaml

此时,你应该会看到一阵忙碌的活动。如果你观察这些变化,你应该能看到你的定时任务在运行,并更新状态:

kubectl get cronjob.batch.tutorial.kubebuilder.io -o yaml kubectl get job

现在我们知道它可以正常工作,我们可以在集群中运行它。停止 make run 调用,然后运行

make docker-build docker-push IMG=<some-registry>/<project-name>:tag make deploy IMG=<some-registry>/<project-name>:tag

如果我们像之前一样再次列出定时任务,我们应该能看到控制器再次正常运行!

部署 cert-manager

我们建议使用 cert-manager 来为 webhook 服务器配置证书。其他解决方案也应能正常工作,只要它们将证书放在期望的位置即可。

您可以按照 cert-manager 文档 来进行安装。

cert-manager 还有一个组件称为 CA Injector,它的责任是将 CA 包注入到 MutatingWebhookConfiguration / ValidatingWebhookConfiguration 中。

要实现这一点,您需要在 MutatingWebhookConfiguration / ValidatingWebhookConfiguration 对象中使用一个带有键 cert-manager.io/inject-ca-from 的注解。该注解的值应指向一个现有的 证书请求实例,格式为 <certificate-namespace>/<certificate-name>

这是我们用于注解 MutatingWebhookConfiguration / ValidatingWebhookConfiguration 对象的 kustomize 补丁。

部署 Admission Webhook

cert-manager

您需要按照此处的指示安装 cert-manager 套件。

构建你的形象

运行以下命令以在本地构建您的镜像。

make docker-build docker-push IMG=<some-registry>/<project-name>:tag

部署 Webhooks

您需要通过 kustomize 启用 webhook 和证书管理器配置。config/default/kustomization.yaml 现在应该如下所示:

# 为所有资源添加命名空间。 命名空间: 项目系统 # 此字段的值将作为前缀添加到所有资源的名称,例如,一个名为 # "wordpress"的部署会变成"alices-wordpress"。 # 请注意,它还应与上面命名空间字段中"-"之前的前缀匹配。 namePrefix: 项目- # 要添加到所有资源和选择器的标签。 #labels: #- includeSelectors: true # pairs: # someName: someValue resources: - ../crd - ../rbac - ../manager # [WEBHOOK] 要启用 webhook,请取消注释所有带有 [WEBHOOK] 前缀的部分,包括在 # crd/kustomization.yaml中的那部分。- ../webhook # [CERTMANAGER] 要启用 cert-manager,请取消注释所有包含 'CERTMANAGER' 的部分。'WEBHOOK' 组件是必需的。- ../证书管理器 # [PROMETHEUS] 要启用 Prometheus 监控,请取消注释所有包含 'PROMETHEUS' 的部分。- ../prometheus # [指标] 暴露控制器管理器指标服务。- metrics_service.yaml # [网络策略] 使用 NetworkPolicy 保护 /metrics 端点和 Webhook 服务器。 # 只有运行在标记为 'metrics: enabled' 的命名空间中的 Pod 才能够收集指标。 # 只有需要 Webhook 的 CR 且应用于标记为 'webhooks: enabled' 的命名空间的 CR 才能够 # 与 Webhook 服务器进行通信。 #- ../network-policy # 如果您启用度量,请取消注释补丁行 补丁: # [指标] 以下补丁将使用 HTTPS 启用指标端点,端口为 :8443。 # 更多信息: https://book.kubebuilder.io/reference/metrics - path: manager_metrics_patch.yaml 目标: kind: 部署 # 如果您启用 Metrics 和 CertManager,请取消注释补丁行。- path: cert_metrics_manager_patch.yaml 目标: kind: 部署 # [WEBHOOK] 要启用 webhook,请取消注释所有带有 [WEBHOOK] 前缀的部分,包括在 # crd/kustomization.yaml中的那部分。- path: manager_webhook_patch.yaml 目标: kind: 部署 # [CERTMANAGER] 要启用 cert-manager,请取消注释所有带有 'CERTMANAGER' 前缀的部分。 # 取消注释以下替换,以添加 cert-manager CA 注入注释 替换项: - source: # 取消注释以下代码块以启用度量的证书 类型: 服务 version: v1 name: 控制器管理器指标服务 字段路径: 元数据.名称 目标: - 选择: kind: 证书 group: cert-manager.io version: v1 name: 度量标准-证书 字段路径: - spec.dnsNames.0 - spec.dnsNames.1 选项: 分隔符: '.' 索引: 0 create: true - 选择: # 取消注释以下内容以在 Prometheus ServiceMonitor 中设置 TLS 配置的服务名称 kind: 服务监控器 group: monitoring.coreos.com version: v1 name: 控制器管理器监控指标 字段路径: - spec.endpoints.0.tlsConfig.serverName 选项: 分隔符: '.' 索引: 0 create: true - source: kind: 服务 version: v1 name: 控制器管理器指标服务 字段路径: metadata.namespace 目标: - 选择: kind: 证书 group: cert-manager.io version: v1 name: 度量标准-证书 字段路径: - spec.dnsNames.0 - spec.dnsNames.1 选项: 分隔符: '.' 索引: 1 create: true - 选择: # 取消注释以下内容以设置 Prometheus ServiceMonitor 中 TLS 的服务命名空间 kind: 服务监控器 group: monitoring.coreos.com version: v1 name: 控制器管理器监控指标 字段路径: - spec.endpoints.0.tlsConfig.serverName 选项: 分隔符: '.' 索引: 1 create: true - source: # 如果您有任何 webhook,请取消注释以下代码块。: 服务 version: v1 name: Webhook 服务 字段路径: .metadata.name # 服务名称 目标: - 选择: kind: 证书 group: cert-manager.io version: v1 name: 服务证书 字段路径: - .spec.dnsNames.0 - .spec.dnsNames.1 选项: 分隔符: '.' 索引: 0 create: true - source: kind: 服务 version: v1 name: Webhook 服务 字段路径: .metadata.namespace # 服务的命名空间 targets: - 选择: kind: 证书 group: cert-manager.io version: v1 name: 服务证书 字段路径: - .spec.dnsNames.0 - .spec.dnsNames.1 选项: 分隔符: '.' 索引: 1 create: true - source: # 如果您有一个有效的验证Webhook (--programmatic-validation),请取消注释以下代码块 kind: 证书 group: cert-manager.io version: v1 name: serving-cert # 此名称应与 certificate.yaml 中的名称匹配 fieldPath: .metadata.namespace # 证书 CR 的命名空间 目标: - 选择: kind: 有效性Webhook配置 字段路径: - .metadata.annotations.[cert-manager.io/inject-ca-from] 选项: 分隔符: '/' 索引: 0 create: true - source: kind: 证书 group: cert-manager.io version: v1 name: 服务证书 字段路径: .metadata.name 目标: - 选择: kind: 有效性Webhook配置 字段路径: - .metadata.annotations.[cert-manager.io/inject-ca-from] 选项: 分隔符: '/' 索引: 1 create: true - source: # 如果你有一个默认的 Webhook (--defaulting),请取消注释以下代码块 类型: 证书 group: cert-manager.io version: v1 name: 服务证书 字段路径: .metadata.namespace # 证书 CR 的命名空间 目标: - 选择: kind: 变更Webhook配置 字段路径: - .metadata.annotations.[cert-manager.io/inject-ca-from] 选项: 分隔符: '/' 索引: 0 create: true - source: kind: 证书 group: cert-manager.io version: v1 name: 服务证书 字段路径: .metadata.name 目标: - 选择: kind: 变更Webhook配置 字段路径: - .metadata.annotations.[cert-manager.io/inject-ca-from] 选项: 分隔符: '/' 索引: 1 create: true # # - 来源: # 如果您有 ConversionWebhook (--conversion),请取消注释以下块 # 类型: 证书 # 群组: cert-manager.io # 版本: v1 # 名称: serving-cert # 字段路径: .metadata.namespace # 证书 CR 的命名空间 # 目标: # 请勿删除或取消注释以下脚手架标记;这是生成目标 CRD 代码所需的。 # +kubebuilder:scaffold:crdkustomizecainjectionns # - 来源: # 类型: 证书 # 群组: cert-manager.io # 版本: v1 # 名称: serving-cert # 字段路径: .metadata.name # 目标: # 请勿删除或取消注释以下脚手架标记;这是生成目标 CRD 代码所需的。 # +kubebuilder:scaffold:crdkustomizecainjectionname

现在 config/crd/kustomization.yaml 应该如下所示:

# 这个 kustomization.yaml 文件不打算单独运行, # 因为它依赖于此 kustomize 包之外的服务名称和命名空间。 # 应通过 config/default 运行。 resources: - bases/batch.tutorial.kubebuilder.io_cronjobs.yaml # +kubebuilder:scaffold:crdkustomizeresource 贴片: # [WEBHOOK] 要启用 webhook,请取消注释所有以 [WEBHOOK] 为前缀的部分。 # 此处的补丁用于为每个 CRD 启用转换 webhook # +kubebuilder:scaffold:crdkustomizewebhookpatch # [WEBHOOK] 要启用 webhook,请取消注释以下部分 # 以下配置是为了教 kustomize 如何对 CRD 进行自定义。 # 配置: #- kustomizeconfig.yaml

现在您可以通过以下方式将其部署到您的集群中:

make deploy IMG=<some-registry>/<project-name>:tag

请稍等,直到 webhook pod 启动并且证书被配置。通常在 1 分钟内完成。

现在您可以创建一个有效的 CronJob 来测试您的 webhooks。创建过程应该成功完成。

kubectl create -f config/samples/batch_v1_cronjob.yaml

您还可以尝试创建一个无效的 CronJob(例如,使用格式不正确的调度字段)。您应该会看到创建失败并出现验证错误。

编写控制器测试

测试 Kubernetes 控制器是一个大主题,而 kubebuilder 为您生成的样板测试文件相对较少。

为了引导您了解用于 Kubebuilder 生成的控制器的集成测试模式,我们将重新审视在首个教程中构建的 CronJob,并为其编写一个简单的测试。

基本的方法是,在您生成的 suite_test.go 文件中,您将使用 envtest 创建一个本地 Kubernetes API 服务器,实例化并运行您的控制器,然后编写额外的 *_test.go 文件,通过 Ginkgo 来进行测试。

如果您想要调整您的 envtest 集群的配置,请参阅为集成测试配置 envtest一节,以及envtest 文档

测试环境设置

../../cronjob-tutorial/testdata/project/internal/controller/suite_test.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Imports

When we created the CronJob API with kubebuilder create api in a previous chapter, Kubebuilder already did some test work for you. Kubebuilder scaffolded a internal/controller/suite_test.go file that does the bare bones of setting up a test environment.

First, it will contain the necessary imports.

package controller import ( "context" "os" "path/filepath" "testing" ctrl "sigs.k8s.io/controller-runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" batchv1 "tutorial.kubebuilder.io/project/api/v1" // +kubebuilder:scaffold:imports ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo.

Now, let’s go through the code generated.

var ( ctx context.Context cancel context.CancelFunc testEnv *envtest.Environment cfg *rest.Config k8sClient client.Client // You'll be using this client in your tests. ) func TestControllers(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Controller Suite") } var _ = BeforeSuite(func() { logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) ctx, cancel = context.WithCancel(context.TODO()) var err error

The CronJob Kind is added to the runtime scheme used by the test environment. This ensures that the CronJob API is registered with the scheme, allowing the test controller to recognize and interact with CronJob resources.

err = batchv1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred())

After the schemas, you will see the following marker. This marker is what allows new schemas to be added here automatically when a new API is added to the project.

// +kubebuilder:scaffold:scheme

The envtest environment is configured to load Custom Resource Definitions (CRDs) from the specified directory. This setup enables the test environment to recognize and interact with the custom resources defined by these CRDs.

By("bootstrapping test environment") testEnv = &envtest.Environment{ CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, } // Retrieve the first found binary directory to allow running tests from IDEs if getFirstFoundEnvTestBinaryDir() != "" { testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() }

Then, we start the envtest cluster.

// cfg is defined in this file globally. cfg, err = testEnv.Start() Expect(err).NotTo(HaveOccurred()) Expect(cfg).NotTo(BeNil())

A client is created for our test CRUD operations.

k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil())

One thing that this autogenerated file is missing, however, is a way to actually start your controller. The code above will set up a client for interacting with your custom Kind, but will not be able to test your controller behavior. If you want to test your custom controller logic, you’ll need to add some familiar-looking manager logic to your BeforeSuite() function, so you can register your custom controller to run on this test cluster.

You may notice that the code below runs your controller with nearly identical logic to your CronJob project’s main.go! The only difference is that the manager is started in a separate goroutine so it does not block the cleanup of envtest when you’re done running your tests.

Note that we set up both a “live” k8s client and a separate client from the manager. This is because when making assertions in tests, you generally want to assert against the live state of the API server. If you use the client from the manager (k8sManager.GetClient), you’d end up asserting against the contents of the cache instead, which is slower and can introduce flakiness into your tests. We could use the manager’s APIReader to accomplish the same thing, but that would leave us with two clients in our test assertions and setup (one for reading, one for writing), and it’d be easy to make mistakes.

Note that we keep the reconciler running against the manager’s cache client, though – we want our controller to behave as it would in production, and we use features of the cache (like indices) in our controller which aren’t available when talking directly to the API server.

k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme.Scheme, }) Expect(err).ToNot(HaveOccurred()) err = (&CronJobReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) Expect(err).ToNot(HaveOccurred(), "failed to run manager") }() })

Kubebuilder also generates boilerplate functions for cleaning up envtest and actually running your test files in your controllers/ directory. You won’t need to touch these.

var _ = AfterSuite(func() { By("tearing down the test environment") cancel() err := testEnv.Stop() Expect(err).NotTo(HaveOccurred()) })

Now that you have your controller running on a test cluster and a client ready to perform operations on your CronJob, we can start writing integration tests!

// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. // ENVTEST-based tests depend on specific binaries, usually located in paths set by // controller-runtime. When running tests directly (e.g., via an IDE) without using // Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. // // This function streamlines the process by finding the required binaries, similar to // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are // properly set up, run 'make setup-envtest' beforehand. func getFirstFoundEnvTestBinaryDir() string { basePath := filepath.Join("..", "..", "bin", "k8s") entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) return "" } for _, entry := range entries { if entry.IsDir() { return filepath.Join(basePath, entry.Name()) } } return "" }

测试您的控制器行为

../../cronjob-tutorial/testdata/project/internal/controller/cronjob_controller_test.go
Apache License

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Ideally, we should have one <kind>_controller_test.go for each controller scaffolded and called in the suite_test.go. So, let’s write our example test for the CronJob controller (cronjob_controller_test.go.)

Imports

As usual, we start with the necessary imports. We also define some utility variables.

package controller import ( "context" "reflect" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" batchv1 "k8s.io/api/batch/v1" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" cronjobv1 "tutorial.kubebuilder.io/project/api/v1" )

The first step to writing a simple integration test is to actually create an instance of CronJob you can run tests against. Note that to create a CronJob, you’ll need to create a stub CronJob struct that contains your CronJob’s specifications.

Note that when we create a stub CronJob, the CronJob also needs stubs of its required downstream objects. Without the stubbed Job template spec and the Pod template spec below, the Kubernetes API will not be able to create the CronJob.

var _ = Describe("CronJob controller", func() { // Define utility constants for object names and testing timeouts/durations and intervals. const ( CronjobName = "test-cronjob" CronjobNamespace = "default" JobName = "test-job" timeout = time.Second * 10 duration = time.Second * 10 interval = time.Millisecond * 250 ) Context("When updating CronJob Status", func() { It("Should increase CronJob Status.Active count when new Jobs are created", func() { By("By creating a new CronJob") ctx := context.Background() cronJob := &cronjobv1.CronJob{ TypeMeta: metav1.TypeMeta{ APIVersion: "batch.tutorial.kubebuilder.io/v1", Kind: "CronJob", }, ObjectMeta: metav1.ObjectMeta{ Name: CronjobName, Namespace: CronjobNamespace, }, Spec: cronjobv1.CronJobSpec{ Schedule: "1 * * * *", JobTemplate: batchv1.JobTemplateSpec{ Spec: batchv1.JobSpec{ // For simplicity, we only fill out the required fields. Template: v1.PodTemplateSpec{ Spec: v1.PodSpec{ // For simplicity, we only fill out the required fields. Containers: []v1.Container{ { Name: "test-container", Image: "test-image", }, }, RestartPolicy: v1.RestartPolicyOnFailure, }, }, }, }, }, } Expect(k8sClient.Create(ctx, cronJob)).To(Succeed())

After creating this CronJob, let’s check that the CronJob’s Spec fields match what we passed in. Note that, because the k8s apiserver may not have finished creating a CronJob after our Create() call from earlier, we will use Gomega’s Eventually() testing function instead of Expect() to give the apiserver an opportunity to finish creating our CronJob.

Eventually() will repeatedly run the function provided as an argument every interval seconds until (a) the assertions done by the passed-in Gomega succeed, or (b) the number of attempts * interval period exceed the provided timeout value.

In the examples below, timeout and interval are Go Duration values of our choosing.

cronjobLookupKey := types.NamespacedName{Name: CronjobName, Namespace: CronjobNamespace} createdCronjob := &cronjobv1.CronJob{} // We'll need to retry getting this newly created CronJob, given that creation may not immediately happen. Eventually(func(g Gomega) { g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed()) }, timeout, interval).Should(Succeed()) // Let's make sure our Schedule string value was properly converted/handled. Expect(createdCronjob.Spec.Schedule).To(Equal("1 * * * *"))

Now that we’ve created a CronJob in our test cluster, the next step is to write a test that actually tests our CronJob controller’s behavior. Let’s test the CronJob controller’s logic responsible for updating CronJob.Status.Active with actively running jobs. We’ll verify that when a CronJob has a single active downstream Job, its CronJob.Status.Active field contains a reference to this Job.

First, we should get the test CronJob we created earlier, and verify that it currently does not have any active jobs. We use Gomega’s Consistently() check here to ensure that the active job count remains 0 over a duration of time.

By("By checking the CronJob has zero active Jobs") Consistently(func(g Gomega) { g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed()) g.Expect(createdCronjob.Status.Active).To(BeEmpty()) }, duration, interval).Should(Succeed())

Next, we actually create a stubbed Job that will belong to our CronJob, as well as its downstream template specs. We set the Job’s status’s “Active” count to 2 to simulate the Job running two pods, which means the Job is actively running.

We then take the stubbed Job and set its owner reference to point to our test CronJob. This ensures that the test Job belongs to, and is tracked by, our test CronJob. Once that’s done, we create our new Job instance.

By("By creating a new Job") testJob := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: JobName, Namespace: CronjobNamespace, }, Spec: batchv1.JobSpec{ Template: v1.PodTemplateSpec{ Spec: v1.PodSpec{ // For simplicity, we only fill out the required fields. Containers: []v1.Container{ { Name: "test-container", Image: "test-image", }, }, RestartPolicy: v1.RestartPolicyOnFailure, }, }, }, } // Note that your CronJob’s GroupVersionKind is required to set up this owner reference. kind := reflect.TypeOf(cronjobv1.CronJob{}).Name() gvk := cronjobv1.GroupVersion.WithKind(kind) controllerRef := metav1.NewControllerRef(createdCronjob, gvk) testJob.SetOwnerReferences([]metav1.OwnerReference{*controllerRef}) Expect(k8sClient.Create(ctx, testJob)).To(Succeed()) // Note that you can not manage the status values while creating the resource. // The status field is managed separately to reflect the current state of the resource. // Therefore, it should be updated using a PATCH or PUT operation after the resource has been created. // Additionally, it is recommended to use StatusConditions to manage the status. For further information see: // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#spec-and-status testJob.Status.Active = 2 Expect(k8sClient.Status().Update(ctx, testJob)).To(Succeed())

Adding this Job to our test CronJob should trigger our controller’s reconciler logic. After that, we can write a test that evaluates whether our controller eventually updates our CronJob’s Status field as expected!

By("By checking that the CronJob has one active Job") Eventually(func(g Gomega) { g.Expect(k8sClient.Get(ctx, cronjobLookupKey, createdCronjob)).To(Succeed(), "should GET the CronJob") g.Expect(createdCronjob.Status.Active).To(HaveLen(1), "should have exactly one active job") g.Expect(createdCronjob.Status.Active[0].Name).To(Equal(JobName), "the wrong job is active") }, timeout, interval).Should(Succeed(), "should list our active job %s in the active jobs list in status", JobName) }) }) })

After writing all this code, you can run go test ./... in your controllers/ directory again to run your new test!

以上的状态更新示例展示了一种针对自定义 Kind 及其下游对象的一般测试策略。到目前为止,希望您已经学习了以下测试控制器行为的方法:

  • 在 envtest 集群上设置您的控制器
  • 为创建测试对象编写存根
  • 将变化孤立到一个对象,以测试特定控制器的行为

尾声

到目前为止,我们已经完成了一个功能齐全的 CronJob 控制器实现,使用了 Kubebuilder 的大部分功能,并使用 envtest 编写了控制器的测试。

如果你想要了解更多,请前往 多版本教程 学习如何在项目中添加新的 API 版本。

此外,您可以自己尝试以下步骤——我们很快将会有一个关于它们的教程部分™:

教程:多版本 API

大多数项目开始时都有一个不断变化的 alpha API。然而,最终,大多数项目需要转向一个更稳定的 API。然而,一旦你的 API 稳定后,你就不能对其进行破坏性更改。这就是 API 版本派上用场的地方。

让我们对 CronJob API 规范进行一些更改,并确保我们的 CronJob 项目支持所有不同版本。

如果您还没有,请确保您已经阅读过基础的 CronJob 教程

接下来,让我们找出我们想要进行哪些更改…

改变现状

在Kubernetes API中,相对常见的变化是将一些以前是非结构化或以某种特殊字符串格式存储的数据,改变为结构化数据。我们的 schedule 字段恰好符合这一点 —— 目前在 v1 中,我们的计划看起来是这样的:

日程安排: "*/1 * * * *"

这是一个典型的特殊字符串格式示例(如果你不是一个 Unix 系统管理员,它也相当难以阅读)。

我们把内容整理得更有结构一点。根据我们的CronJob代码, 我们支持“标准“Cron格式。

在 Kubernetes 中,所有版本之间必须安全地进行双向转换。这意味着,如果我们从版本 1 转换到版本 2,然后再转换回版本 1,我们不能丢失信息。因此,我们对 API 的任何更改都必须与 v1 中支持的内容兼容,并且还需要确保在 v2 中添加的任何内容在 v1 中也得到支持。在某些情况下,这意味着我们需要向 v1 添加新字段,但在我们的案例中,由于我们没有添加新功能,因此不需要这样做。

考虑到这些,让我们将上述示例稍微结构化一下:

日程安排: 分钟: */1

现在,我们至少有了每个字段的标签,但我们仍然可以轻松支持每个字段的不同语法。

我们需要为这个变更创建一个新的 API 版本。我们称之为 v2:

kubebuilder create api --group batch --version v2 --kind CronJob

y 以继续\

现在,让我们复制我们现有的类型,并进行更改:

project/api/v2/cronjob_types.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Since we’re in a v2 package, controller-gen will assume this is for the v2 version automatically. We could override that with the +versionName marker.

package v2
Imports
import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.

We’ll leave our spec largely unchanged, except to change the schedule field to a new type.

// CronJobSpec defines the desired state of CronJob. type CronJobSpec struct { // The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. Schedule CronSchedule `json:"schedule"`
The rest of Spec
// +kubebuilder:validation:Minimum=0 // Optional deadline in seconds for starting the job if it misses scheduled // time for any reason. Missed jobs executions will be counted as failed ones. // +optional StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"` // Specifies how to treat concurrent executions of a Job. // Valid values are: // - "Allow" (default): allows CronJobs to run concurrently; // - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; // - "Replace": cancels currently running job and replaces it with a new one // +optional ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` // This flag tells the controller to suspend subsequent executions, it does // not apply to already started executions. Defaults to false. // +optional Suspend *bool `json:"suspend,omitempty"` // Specifies the job that will be created when executing a CronJob. JobTemplate batchv1.JobTemplateSpec `json:"jobTemplate"` // +kubebuilder:validation:Minimum=0 // The number of successful finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"` // +kubebuilder:validation:Minimum=0 // The number of failed finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"`
}

Next, we’ll need to define a type to hold our schedule. Based on our proposed YAML above, it’ll have a field for each corresponding Cron “field”.

// describes a Cron schedule. type CronSchedule struct { // specifies the minute during which the job executes. // +optional Minute *CronField `json:"minute,omitempty"` // specifies the hour during which the job executes. // +optional Hour *CronField `json:"hour,omitempty"` // specifies the day of the month during which the job executes. // +optional DayOfMonth *CronField `json:"dayOfMonth,omitempty"` // specifies the month during which the job executes. // +optional Month *CronField `json:"month,omitempty"` // specifies the day of the week during which the job executes. // +optional DayOfWeek *CronField `json:"dayOfWeek,omitempty"` }

Finally, we’ll define a wrapper type to represent a field. We could attach additional validation to this field, but for now we’ll just use it for documentation purposes.

// represents a Cron field specifier. type CronField string
Other Types

All the other types will stay the same as before.

// ConcurrencyPolicy describes how the job will be handled. // Only one of the following concurrent policies may be specified. // If none of the following policies is specified, the default one // is AllowConcurrent. // +kubebuilder:validation:Enum=Allow;Forbid;Replace type ConcurrencyPolicy string const ( // AllowConcurrent allows CronJobs to run concurrently. AllowConcurrent ConcurrencyPolicy = "Allow" // ForbidConcurrent forbids concurrent runs, skipping next run if previous // hasn't finished yet. ForbidConcurrent ConcurrencyPolicy = "Forbid" // ReplaceConcurrent cancels currently running job and replaces it with a new one. ReplaceConcurrent ConcurrencyPolicy = "Replace" ) // CronJobStatus defines the observed state of CronJob type CronJobStatus struct { // 插入额外的状态字段 - 定义集群的观察状态 // 重要:在修改此文件后,请运行 "make" 以重新生成代码 // A list of pointers to currently running jobs. // +optional Active []corev1.ObjectReference `json:"active,omitempty"` // Information when was the last time the job was successfully scheduled. // +optional LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"` } // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +versionName=v2 // CronJob is the Schema for the cronjobs API. type CronJob struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec CronJobSpec `json:"spec,omitempty"` Status CronJobStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true // CronJobList contains a list of CronJob. type CronJobList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []CronJob `json:"items"` } func init() { SchemeBuilder.Register(&CronJob{}, &CronJobList{}) }

存储版本

project/api/v1/cronjob_types.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

package v1
Imports
import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
old stuff
// CronJobSpec defines the desired state of CronJob. type CronJobSpec struct { // +kubebuilder:validation:MinLength=0 // The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. Schedule string `json:"schedule"` // +kubebuilder:validation:Minimum=0 // Optional deadline in seconds for starting the job if it misses scheduled // time for any reason. Missed jobs executions will be counted as failed ones. // +optional StartingDeadlineSeconds *int64 `json:"startingDeadlineSeconds,omitempty"` // Specifies how to treat concurrent executions of a Job. // Valid values are: // - "Allow" (default): allows CronJobs to run concurrently; // - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; // - "Replace": cancels currently running job and replaces it with a new one // +optional ConcurrencyPolicy ConcurrencyPolicy `json:"concurrencyPolicy,omitempty"` // This flag tells the controller to suspend subsequent executions, it does // not apply to already started executions. Defaults to false. // +optional Suspend *bool `json:"suspend,omitempty"` // Specifies the job that will be created when executing a CronJob. JobTemplate batchv1.JobTemplateSpec `json:"jobTemplate"` // +kubebuilder:validation:Minimum=0 // The number of successful finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional SuccessfulJobsHistoryLimit *int32 `json:"successfulJobsHistoryLimit,omitempty"` // +kubebuilder:validation:Minimum=0 // The number of failed finished jobs to retain. // This is a pointer to distinguish between explicit zero and not specified. // +optional FailedJobsHistoryLimit *int32 `json:"failedJobsHistoryLimit,omitempty"` } // ConcurrencyPolicy describes how the job will be handled. // Only one of the following concurrent policies may be specified. // If none of the following policies is specified, the default one // is AllowConcurrent. // +kubebuilder:validation:Enum=Allow;Forbid;Replace type ConcurrencyPolicy string const ( // AllowConcurrent allows CronJobs to run concurrently. AllowConcurrent ConcurrencyPolicy = "Allow" // ForbidConcurrent forbids concurrent runs, skipping next run if previous // hasn't finished yet. ForbidConcurrent ConcurrencyPolicy = "Forbid" // ReplaceConcurrent cancels currently running job and replaces it with a new one. ReplaceConcurrent ConcurrencyPolicy = "Replace" ) // CronJobStatus defines the observed state of CronJob. type CronJobStatus struct { // 插入额外的状态字段 - 定义集群的观察状态 // 重要:在修改此文件后,请运行 "make" 以重新生成代码 // A list of pointers to currently running jobs. // +optional Active []corev1.ObjectReference `json:"active,omitempty"` // Information when was the last time the job was successfully scheduled. // +optional LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"` }

Since we’ll have more than one version, we’ll need to mark a storage version. This is the version that the Kubernetes API server uses to store our data. We’ll chose the v1 version for our project.

We’ll use the +kubebuilder:storageversion to do this.

Note that multiple versions may exist in storage if they were written before the storage version changes – changing the storage version only affects how objects are created/updated after the change.

// +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:conversion:hub // +kubebuilder:subresource:status // +versionName=v1 // +kubebuilder:storageversion // CronJob is the Schema for the cronjobs API. type CronJob struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec CronJobSpec `json:"spec,omitempty"` Status CronJobStatus `json:"status,omitempty"` }
old stuff
// +kubebuilder:object:root=true // CronJobList contains a list of CronJob. type CronJobList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []CronJob `json:"items"` } func init() { SchemeBuilder.Register(&CronJob{}, &CronJobList{}) }

现在我们已经确定了类型,我们需要设置转换…

中心、辐条以及其他轮子隐喻

由于我们现在有两个不同的版本,并且用户可以请求任一版本,我们需要定义一种在我们的版本之间转换的方法。对于 CRD(自定义资源定义),这可以通过 webhook 实现,类似于我们在基本教程中定义的默认和验证 webhook。和之前一样,controller-runtime 将帮助我们将这些细节连接在一起,我们只需要实现实际的转换。

在我们进行之前,我们需要了解controller-runtime如何看待版本。具体而言:

完全图在航海方面不够充分。

定义转换的一个简单方法可能是定义转换函数,以在我们每个版本之间进行转换。然后,每当我们需要转换时,就查找适当的函数,并调用它来执行转换。

当我们只有两个版本时,这很好,但如果我们有 4 种类型呢?8 种类型呢?那将需要很多转换函数。

相反,controller-runtime 将转换建模为“中心与辐射“模型——我们将一个版本标记为“中心“,所有其他版本只定义与中心之间的转换:

becomes

然后,如果我们需要在两个非中心版本之间进行转换,我们首先转换为中心版本,然后再转换为我们所需的版本:

这减少了我们需要定义的转换函数的数量,并且是基于 Kubernetes 在内部的做法。

这与 Webhook 有什么关系?

当 API 客户端,如 kubectl 或者你的控制器,请求某个特定版本的资源时,Kubernetes API 服务器需要返回该版本的结果。然而,该版本可能与 API 服务器存储的版本不匹配。

在这种情况下,API 服务器需要知道如何在所需版本和存储版本之间进行转换。由于 CRD 并没有内置转换功能,Kubernetes API 服务器会调用一个 webhook 来执行转换。对于 Kubebuilder,这个 webhook 是由 controller-runtime 实现的,执行我们上面讨论的中心-辐射转换。

现在我们已经掌握了转换模型,我们可以实际执行我们的转换。

实施转换

有了我们的转换模型,接下来就该实际实现转换函数了。我们将为我们的 CronJob API 版本 v1 (Hub) 创建一个转换 Webhook,以转换我们的 CronJob API 版本 v2,请参见:

kubebuilder create webhook --group batch --version v1 --kind CronJob --conversion --spoke v2

上述命令将生成 cronjob_conversion.go 文件与我们的 cronjob_types.go 文件并排,以避免在主类型文件中混入额外的函数。

中心…

首先,我们将实现中心。我们将选择 v1 版本作为中心:

project/api/v1/cronjob_conversion.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

package v1

Implementing the hub method is pretty easy – we just have to add an empty method called Hub()to serve as a marker. We could also just put this inline in our cronjob_types.go file.

// Hub marks this type as a conversion hub. func (*CronJob) Hub() {}

… 和发言人

然后,我们将实现我们的分支,v2 版本:

project/api/v2/cronjob_conversion.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

package v2
Imports

For imports, we’ll need the controller-runtime conversion package, plus the API version for our hub type (v1), and finally some of the standard packages.

import ( "fmt" "strings" "log" "sigs.k8s.io/controller-runtime/pkg/conversion" batchv1 "tutorial.kubebuilder.io/project/api/v1" )

Our “spoke” versions need to implement the Convertible interface. Namely, they’ll need ConvertTo() and ConvertFrom() methods to convert to/from the hub version.

ConvertTo is expected to modify its argument to contain the converted object. Most of the conversion is straightforward copying, except for converting our changed field.

// ConvertTo converts this CronJob (v2) to the Hub version (v1). func (src *CronJob) ConvertTo(dstRaw conversion.Hub) error { dst := dstRaw.(*batchv1.CronJob) log.Printf("ConvertTo: Converting CronJob from Spoke version v2 to Hub version v1;"+ "source: %s/%s, target: %s/%s", src.Namespace, src.Name, dst.Namespace, dst.Name) sched := src.Spec.Schedule scheduleParts := []string{"*", "*", "*", "*", "*"} if sched.Minute != nil { scheduleParts[0] = string(*sched.Minute) } if sched.Hour != nil { scheduleParts[1] = string(*sched.Hour) } if sched.DayOfMonth != nil { scheduleParts[2] = string(*sched.DayOfMonth) } if sched.Month != nil { scheduleParts[3] = string(*sched.Month) } if sched.DayOfWeek != nil { scheduleParts[4] = string(*sched.DayOfWeek) } dst.Spec.Schedule = strings.Join(scheduleParts, " ")
rote conversion

The rest of the conversion is pretty rote.

// ObjectMeta dst.ObjectMeta = src.ObjectMeta // Spec dst.Spec.StartingDeadlineSeconds = src.Spec.StartingDeadlineSeconds dst.Spec.ConcurrencyPolicy = batchv1.ConcurrencyPolicy(src.Spec.ConcurrencyPolicy) dst.Spec.Suspend = src.Spec.Suspend dst.Spec.JobTemplate = src.Spec.JobTemplate dst.Spec.SuccessfulJobsHistoryLimit = src.Spec.SuccessfulJobsHistoryLimit dst.Spec.FailedJobsHistoryLimit = src.Spec.FailedJobsHistoryLimit // Status dst.Status.Active = src.Status.Active dst.Status.LastScheduleTime = src.Status.LastScheduleTime
return nil }

ConvertFrom is expected to modify its receiver to contain the converted object. Most of the conversion is straightforward copying, except for converting our changed field.

// ConvertFrom converts the Hub version (v1) to this CronJob (v2). func (dst *CronJob) ConvertFrom(srcRaw conversion.Hub) error { src := srcRaw.(*batchv1.CronJob) log.Printf("ConvertFrom: Converting CronJob from Hub version v1 to Spoke version v2;"+ "source: %s/%s, target: %s/%s", src.Namespace, src.Name, dst.Namespace, dst.Name) schedParts := strings.Split(src.Spec.Schedule, " ") if len(schedParts) != 5 { return fmt.Errorf("invalid schedule: not a standard 5-field schedule") } partIfNeeded := func(raw string) *CronField { if raw == "*" { return nil } part := CronField(raw) return &part } dst.Spec.Schedule.Minute = partIfNeeded(schedParts[0]) dst.Spec.Schedule.Hour = partIfNeeded(schedParts[1]) dst.Spec.Schedule.DayOfMonth = partIfNeeded(schedParts[2]) dst.Spec.Schedule.Month = partIfNeeded(schedParts[3]) dst.Spec.Schedule.DayOfWeek = partIfNeeded(schedParts[4])
rote conversion

The rest of the conversion is pretty rote.

// ObjectMeta dst.ObjectMeta = src.ObjectMeta // Spec dst.Spec.StartingDeadlineSeconds = src.Spec.StartingDeadlineSeconds dst.Spec.ConcurrencyPolicy = ConcurrencyPolicy(src.Spec.ConcurrencyPolicy) dst.Spec.Suspend = src.Spec.Suspend dst.Spec.JobTemplate = src.Spec.JobTemplate dst.Spec.SuccessfulJobsHistoryLimit = src.Spec.SuccessfulJobsHistoryLimit dst.Spec.FailedJobsHistoryLimit = src.Spec.FailedJobsHistoryLimit // Status dst.Status.Active = src.Status.Active dst.Status.LastScheduleTime = src.Status.LastScheduleTime
return nil }

现在我们已经设置好了转换,我们需要做的就是将主程序连接起来,以便提供 webhook!

设置网络hooks

我们的转换已经到位,因此剩下的就是告诉 controller-runtime 我们的转换。

Webhook设置中…

project/internal/webhook/v1/cronjob_webhook.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Go imports
package v1 import ( "context" "fmt" "github.com/robfig/cron" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime/schema" validationutils "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" batchv1 "tutorial.kubebuilder.io/project/api/v1" )

Next, we’ll setup a logger for the webhooks.

var cronjoblog = logf.Log.WithName("cronjob-resource")

This setup doubles as setup for our conversion webhooks: as long as our types implement the Hub and Convertible interfaces, a conversion webhook will be registered.

// SetupCronJobWebhookWithManager registers the webhook for CronJob in the manager. func SetupCronJobWebhookWithManager(mgr ctrl.Manager) error { return ctrl.NewWebhookManagedBy(mgr).For(&batchv1.CronJob{}). WithValidator(&CronJobCustomValidator{}). WithDefaulter(&CronJobCustomDefaulter{ DefaultConcurrencyPolicy: batchv1.AllowConcurrent, DefaultSuspend: false, DefaultSuccessfulJobsHistoryLimit: 3, DefaultFailedJobsHistoryLimit: 1, }). Complete() }

Notice that we use kubebuilder markers to generate webhook manifests. This marker is responsible for generating a mutating webhook manifest.

The meaning of each marker can be found here.

This marker is responsible for generating a mutation webhook manifest.

// +kubebuilder:webhook:path=/mutate-batch-tutorial-kubebuilder-io-v1-cronjob,mutating=true,failurePolicy=fail,sideEffects=None,groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=create;update,versions=v1,name=mcronjob-v1.kb.io,admissionReviewVersions=v1 // CronJobCustomDefaulter struct is responsible for setting default values on the custom resource of the // Kind CronJob when those are created or updated. // // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as it is used only for temporary operations and does not need to be deeply copied. type CronJobCustomDefaulter struct { // Default values for various CronJob fields DefaultConcurrencyPolicy batchv1.ConcurrencyPolicy DefaultSuspend bool DefaultSuccessfulJobsHistoryLimit int32 DefaultFailedJobsHistoryLimit int32 } var _ webhook.CustomDefaulter = &CronJobCustomDefaulter{}

We use the webhook.CustomDefaulterinterface to set defaults to our CRD. A webhook will automatically be served that calls this defaulting.

The Defaultmethod is expected to mutate the receiver, setting the defaults.

// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind CronJob. func (d *CronJobCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return fmt.Errorf("expected an CronJob object but got %T", obj) } cronjoblog.Info("Defaulting for CronJob", "name", cronjob.GetName()) // Set default values d.applyDefaults(cronjob) return nil } // applyDefaults applies default values to CronJob fields. func (d *CronJobCustomDefaulter) applyDefaults(cronJob *batchv1.CronJob) { if cronJob.Spec.ConcurrencyPolicy == "" { cronJob.Spec.ConcurrencyPolicy = d.DefaultConcurrencyPolicy } if cronJob.Spec.Suspend == nil { cronJob.Spec.Suspend = new(bool) *cronJob.Spec.Suspend = d.DefaultSuspend } if cronJob.Spec.SuccessfulJobsHistoryLimit == nil { cronJob.Spec.SuccessfulJobsHistoryLimit = new(int32) *cronJob.Spec.SuccessfulJobsHistoryLimit = d.DefaultSuccessfulJobsHistoryLimit } if cronJob.Spec.FailedJobsHistoryLimit == nil { cronJob.Spec.FailedJobsHistoryLimit = new(int32) *cronJob.Spec.FailedJobsHistoryLimit = d.DefaultFailedJobsHistoryLimit } }

We can validate our CRD beyond what’s possible with declarative validation. Generally, declarative validation should be sufficient, but sometimes more advanced use cases call for complex validation.

For instance, we’ll see below that we use this to validate a well-formed cron schedule without making up a long regular expression.

If webhook.CustomValidator interface is implemented, a webhook will automatically be served that calls the validation.

The ValidateCreate, ValidateUpdate and ValidateDelete methods are expected to validate its receiver upon creation, update and deletion respectively. We separate out ValidateCreate from ValidateUpdate to allow behavior like making certain fields immutable, so that they can only be set on creation. ValidateDelete is also separated from ValidateUpdate to allow different validation behavior on deletion. Here, however, we just use the same shared validation for ValidateCreate and ValidateUpdate. And we do nothing in ValidateDelete, since we don’t need to validate anything on deletion.

This marker is responsible for generating a validation webhook manifest.

// +kubebuilder:webhook:path=/validate-batch-tutorial-kubebuilder-io-v1-cronjob,mutating=false,failurePolicy=fail,sideEffects=None,groups=batch.tutorial.kubebuilder.io,resources=cronjobs,verbs=create;update,versions=v1,name=vcronjob-v1.kb.io,admissionReviewVersions=v1 // CronJobCustomValidator struct is responsible for validating the CronJob resource // when it is created, updated, or deleted. // // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as this struct is used only for temporary operations and does not need to be deeply copied. type CronJobCustomValidator struct { // TODO(user): Add more fields as needed for validation } var _ webhook.CustomValidator = &CronJobCustomValidator{} // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object but got %T", obj) } cronjoblog.Info("Validation for CronJob upon creation", "name", cronjob.GetName()) return nil, validateCronJob(cronjob) } // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { cronjob, ok := newObj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object for the newObj but got %T", newObj) } cronjoblog.Info("Validation for CronJob upon update", "name", cronjob.GetName()) return nil, validateCronJob(cronjob) } // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type CronJob. func (v *CronJobCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { cronjob, ok := obj.(*batchv1.CronJob) if !ok { return nil, fmt.Errorf("expected a CronJob object but got %T", obj) } cronjoblog.Info("Validation for CronJob upon deletion", "name", cronjob.GetName()) // TODO(user): fill in your validation logic upon object deletion. return nil, nil }

We validate the name and the spec of the CronJob.

// validateCronJob validates the fields of a CronJob object. func validateCronJob(cronjob *batchv1.CronJob) error { var allErrs field.ErrorList if err := validateCronJobName(cronjob); err != nil { allErrs = append(allErrs, err) } if err := validateCronJobSpec(cronjob); err != nil { allErrs = append(allErrs, err) } if len(allErrs) == 0 { return nil } return apierrors.NewInvalid( schema.GroupKind{Group: "batch.tutorial.kubebuilder.io", Kind: "CronJob"}, cronjob.Name, allErrs) }

Some fields are declaratively validated by OpenAPI schema. You can find kubebuilder validation markers (prefixed with // +kubebuilder:validation) in the Designing an API section. You can find all of the kubebuilder supported markers for declaring validation by running controller-gen crd -w, or here.

func validateCronJobSpec(cronjob *batchv1.CronJob) *field.Error { // The field helpers from the kubernetes API machinery help us return nicely // structured validation errors. return validateScheduleFormat( cronjob.Spec.Schedule, field.NewPath("spec").Child("schedule")) }

We’ll need to validate the cron schedule is well-formatted.

func validateScheduleFormat(schedule string, fldPath *field.Path) *field.Error { if _, err := cron.ParseStandard(schedule); err != nil { return field.Invalid(fldPath, schedule, err.Error()) } return nil }
Validate object name

Validating the length of a string field can be done declaratively by the validation schema.

But the ObjectMeta.Name field is defined in a shared package under the apimachinery repo, so we can’t declaratively validate it using the validation schema.

func validateCronJobName(cronjob *batchv1.CronJob) *field.Error { if len(cronjob.ObjectMeta.Name) > validationutils.DNS1035LabelMaxLength-11 { // The job name length is 63 characters like all Kubernetes objects // (which must fit in a DNS subdomain). The cronjob controller appends // a 11-character suffix to the cronjob (`-$TIMESTAMP`) when creating // a job. The job name length limit is 63 characters. Therefore cronjob // names must have length <= 63-11=52. If we don't validate this here, // then job creation will fail later. return field.Invalid(field.NewPath("metadata").Child("name"), cronjob.ObjectMeta.Name, "must be no more than 52 characters") } return nil }

…和 main.go

同样,我们现有的主文件是足够的:

project/cmd/main.go
Apache License

Copyright 2025 The Kubernetes authors.

Licensed under the Apache License, Version 2.0 (the “License”); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.

Imports
package main import ( "crypto/tls" "flag" "os" "path/filepath" // 导入所有 Kubernetes 客户端身份验证插件(例如 Azure、GCP、OIDC 等) // 确保 exec-entrypoint 和 run 可以使用它们。 _ "k8s.io/client-go/plugin/pkg/client/auth" kbatchv1 "k8s.io/api/batch/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/certwatcher" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" batchv1 "tutorial.kubebuilder.io/project/api/v1" batchv2 "tutorial.kubebuilder.io/project/api/v2" "tutorial.kubebuilder.io/project/internal/controller" webhookbatchv1 "tutorial.kubebuilder.io/project/internal/webhook/v1" webhookbatchv2 "tutorial.kubebuilder.io/project/internal/webhook/v2" // +kubebuilder:scaffold:imports )
existing setup
var ( scheme = runtime.NewScheme() setupLog = ctrl.Log.WithName("setup") ) func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(kbatchv1.AddToScheme(scheme)) // we've added this ourselves utilruntime.Must(batchv1.AddToScheme(scheme)) utilruntime.Must(batchv2.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme}
// nolint:gocyclo func main() {
existing setup
var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string var webhookCertPath, webhookCertName, webhookCertKey string var enableLeaderElection bool var probeAddr string var secureMetrics bool var enableHTTP2 bool var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "使用 :8443 进行 HTTPS,使用 :8080 进行 HTTP,或者保持为 0 以禁用指标服务。") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。") flag.BoolVar(&secureMetrics, "metrics-secure", true, "如果设置,指标端点将通过 HTTPS 安全提供。请使用 --metrics-secure=false 以改为使用 HTTP。") flag.StringVar(&webhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") flag.StringVar(&webhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") flag.StringVar(&webhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") flag.StringVar(&metricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") flag.StringVar(&metricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") flag.StringVar(&metricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) // 如果 enable-http2 标志为 false(默认值),则应禁用 http/2 // 由于其存在的漏洞。更具体地说,禁用 http/2 将 // 防止受到 HTTP/2 流取消和 // 快速重置 CVE 的漏洞影响。有关更多信息,请参见: // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 // - https://github.com/advisories/GHSA-4374-p667-p6c8 disableHTTP2 := func(c *tls.Config) { setupLog.Info("disabling http/2") c.NextProtos = []string{"http/1.1"} } if !enableHTTP2 { tlsOpts = append(tlsOpts, disableHTTP2) } // 创建用于指标和 Webhook 证书的监视器 var metricsCertWatcher, webhookCertWatcher *certwatcher.CertWatcher // 初始Webhook TLS选项 webhookTLSOpts := tlsOpts if len(webhookCertPath) > 0 { setupLog.Info("使用提供的证书初始化 Webhook 证书监视器", "webhook-cert-path", webhookCertPath, "webhook-cert-name", webhookCertName, "webhook-cert-key", webhookCertKey) var err error webhookCertWatcher, err = certwatcher.New( filepath.Join(webhookCertPath, webhookCertName), filepath.Join(webhookCertPath, webhookCertKey), ) if err != nil { setupLog.Error(err, "Failed to initialize webhook certificate watcher") os.Exit(1) } webhookTLSOpts = append(webhookTLSOpts, func(config *tls.Config) { config.GetCertificate = webhookCertWatcher.GetCertificate }) } webhookServer := webhook.NewServer(webhook.Options{ TLSOpts: webhookTLSOpts, }) // 指标端点在 'config/default/kustomization.yaml' 中启用。指标选项配置服务器。 // 更多信息: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/server // - https://book.kubebuilder.io/reference/metrics.html metricsServerOptions := metricsserver.Options{ BindAddress: metricsAddr, SecureServing: secureMetrics, TLSOpts: tlsOpts, } if secureMetrics { // FilterProvider 用于通过认证和授权来保护指标端点。 // 这些配置确保只有授权用户和服务账户 // 可以访问指标端点。RBAC 配置在 'config/rbac/kustomization.yaml' 中。更多信息: // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/metrics/filters#WithAuthenticationAndAuthorization metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization } // 如果未指定证书,controller-runtime 将自动 // 为指标服务器生成自签名证书。虽然对于开发和测试来说很方便, // 但这种设置不建议用于生产环境。 // // TODO(用户):如果您启用 certManager,请取消注释以下行: // - [METRICS-WITH-CERTS] 在 config/default/kustomization.yaml 中生成并使用 // 由 cert-manager 管理的指标服务器证书。 // - [PROMETHEUS-WITH-CERTS] 在 config/prometheus/kustomization.yaml 中用于 TLS 认证。 if len(metricsCertPath) > 0 { setupLog.Info("使用提供的证书初始化指标证书监视器", "metrics-cert-path", metricsCertPath, "metrics-cert-name", metricsCertName, "metrics-cert-key", metricsCertKey) var err error metricsCertWatcher, err = certwatcher.New( filepath.Join(metricsCertPath, metricsCertName), filepath.Join(metricsCertPath, metricsCertKey), ) if err != nil { setupLog.Error(err, "to initialize metrics certificate watcher", "error", err) os.Exit(1) } metricsServerOptions.TLSOpts = append(metricsServerOptions.TLSOpts, func(config *tls.Config) { config.GetCertificate = metricsCertWatcher.GetCertificate }) } mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "80807133.tutorial.kubebuilder.io", // LeaderElectionReleaseOnCancel 定义了当管理器结束时,领导者是否应自愿辞职 // 当管理器停止时,这要求二进制文件立即结束,否则这个设置是不安全的。设置这个可以显著 // 加快自愿领导者的过渡,因为新的领导者不必首先等待 // LeaseDuration 时间。 // // 在提供的默认脚手架中,程序会在 // 管理器停止后立即结束,因此启用此选项是可以的。不过, // 如果您正在进行或打算在管理器停止后执行任何操作,如执行清理, // 那么使用它可能是不安全的。 // LeaderElectionReleaseOnCancel: true, }) if err != nil { setupLog.Error(err, "unable to start manager") os.Exit(1) } if err = (&controller.CronJobReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "CronJob") os.Exit(1) }

Our existing call to SetupWebhookWithManager registers our conversion webhooks with the manager, too.

// nolint:goconst if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err = webhookbatchv1.SetupCronJobWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "CronJob") os.Exit(1) } } // nolint:goconst if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err = webhookbatchv2.SetupCronJobWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "CronJob") os.Exit(1) } } // +kubebuilder:scaffold:builder
existing setup
if metricsCertWatcher != nil { setupLog.Info("Adding metrics certificate watcher to manager") if err := mgr.Add(metricsCertWatcher); err != nil { setupLog.Error(err, "unable to add metrics certificate watcher to manager") os.Exit(1) } } if webhookCertWatcher != nil { setupLog.Info("Adding webhook certificate watcher to manager") if err := mgr.Add(webhookCertWatcher); err != nil { setupLog.Error(err, "unable to add webhook certificate watcher to manager") os.Exit(1) } } if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up health check") os.Exit(1) } if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { setupLog.Error(err, "unable to set up ready check") os.Exit(1) } setupLog.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) }
}

一切都已经准备好!现在剩下的就是测试我们的网络钩子了。

部署与测试

在我们测试转换之前,我们需要在我们的 CRD 中启用它们:

Kubebuilder 在 config 目录下生成 Kubernetes 清单,默认情况下禁用了 webhook 部分。要启用它们,我们需要:

  • config/crd/kustomization.yaml 文件中启用 patches/webhook_in_<kind>.yamlpatches/cainjection_in_<kind>.yaml

  • config/default/kustomization.yaml 文件的 bases 部分启用 ../certmanager../webhook 目录。

  • 启用 config/default/kustomization.yaml 文件中 CERTMANAGER 部分下的所有变量。

另外,如果在我们的 Makefile 中存在,我们需要将 CRD_OPTIONS 变量设置为 "crd",移除 trivialVersions 选项(这确保我们实际上为每个版本[生成验证](/reference/generating-crd.md#multiple-versions “生成CRDs:多个版本”),而不是告诉 Kubernetes 它们是相同的):

CRD_OPTIONS ?= "crd"

现在我们已经完成了所有的代码更改和清单,接下来将其部署到集群并进行测试。

除非你有其他证书管理解决方案,否则你需要安装 cert-manager(版本 0.9.0+)。Kubebuilder 团队已经在 0.9.0-alpha.0 版本中测试了本教程中的指令。

一旦我们的证书准备齐全,我们可以像往常一样运行 make install deploy 来将所有组件(CRD 和控制器管理器部署)部署到集群上。

测试

一旦集群中所有位都启动并运行并启用了转换功能,我们就可以通过请求不同版本来测试我们的转换。

我们将根据我们的 v1 版本制作一个 v2 版本(放在 config/samples 下)。

apiVersion: batch.tutorial.kubebuilder.io/v2 kind: CronJob metadata: labels: app.kubernetes.io/name: project app.kubernetes.io/managed-by: kustomize name: cron作业示例 spec: 日程安排: 分钟: "*/1" 启动截止时间(秒): 60 并发策略: 允许 # 显式指定,但允许也是默认值。 工作模板: spec: template: spec: 容器: - name: 你好 image: busybox args: - /bin/sh - -c - 日期; 在Kubernetes集群中打招呼。 重启策略: 失败时

然后,我们可以在集群上创建它:

kubectl apply -f config/samples/batch_v2_cronjob.yaml

如果我们一切都做对了,它应该会成功创建,我们应该能够通过 v2 资源来获取它。

kubectl get cronjobs.v2.batch.tutorial.kubebuilder.io -o yaml
apiVersion: batch.tutorial.kubebuilder.io/v2 kind: CronJob metadata: labels: app.kubernetes.io/name: project app.kubernetes.io/managed-by: kustomize name: cron作业示例 spec: 日程安排: 分钟: "*/1" 启动截止时间(秒): 60 并发策略: 允许 # 显式指定,但允许也是默认值。 工作模板: spec: template: spec: 容器: - name: 你好 image: busybox args: - /bin/sh - -c - 日期; 在Kubernetes集群中打招呼。 重启策略: 失败时

和 v1 资源

kubectl get cronjobs.v1.batch.tutorial.kubebuilder.io -o yaml
apiVersion: batch.tutorial.kubebuilder.io/v1 kind: CronJob metadata: labels: app.kubernetes.io/name: project app.kubernetes.io/managed-by: kustomize name: cron作业示例 spec: 日程安排: "*/1 * * * *" 启动截止时间(秒): 60 并发策略: 允许 # 显式指定,但允许也是默认值。 工作模板: spec: template: spec: 容器: - name: 你好 image: busybox args: - /bin/sh - -c - 日期; 在Kubernetes集群中打招呼。 重启策略: 失败时

两者都应该填写,并且看起来与我们的 v2 和 v1 示例各自相当。请注意,每个都有不同的 API 版本。

最后,如果我们等一会儿,我们应该注意到我们的 CronJob 继续进行调整,尽管我们的控制器是针对我们的 v1 API 版本编写的。

故障排除

故障排除步骤

迁移

在Kubebuilder中迁移项目结构通常涉及一些手动工作。

本节详细说明了在不同版本的 Kubebuilder 脚手架之间迁移,以及迁移到更复杂的项目布局结构所需的内容。

从旧版本(< 3.0.0)的迁移指南

请遵循从旧版 Kubebuilder 到所需的最新 v3.x 版本的迁移指南。请注意,从 v3 开始,引入了一种新的生态系统,使用插件以提高可维护性、重用性和用户体验。

有关更多信息,请参阅以下设计文档:

此外,您可以查看插件部分

Kubebuilder v1 与 v2(从遗留的 v1.0.0+ 到 v2.0.0 的 Kubebuilder CLI 版本)

本文件涵盖了从 v1 迁移到 v2 时的所有重大变更。

所有更改的详细信息(无论是重大更改还是其他更改)可以在 controller-runtimecontroller-toolskubebuilder 的发布说明中找到。

常见变更

V2 项目使用 Go 模块。但是 kubebuilder 将继续支持 dep,直到 Go 1.13 发布。

控制器-runtime

  • Client.List 现在使用函数选项 (List(ctx, list, ...option)) 而不是 List(ctx, ListOptions, list)

  • Client.DeleteAllOf 被添加到 Client 接口中。

  • 指标现在默认开启。

  • pkg/runtime 下的一些包已被移动,其旧位置已被弃用。旧位置将在 controller-runtime v1.0.0 之前删除。有关更多信息,请参见 godocs

与Webhook相关的

  • 已移除自动生成 Webhook 的证书功能,Webhook 将不再自动注册。请使用 controller-tools 生成 Webhook 配置。如果您需要证书生成,我们推荐使用 cert-manager。Kubebuilder v2 将为您生成 cert-manager 配置 - 有关更多详细信息,请参阅 Webhook 教程

  • builder 包现在有了单独的控制器和网络钩子构建器,这使得选择要运行的构建器更加方便。

控制器工具

生成器框架在 v2 中进行了重写。在许多情况下,它仍然与之前的工作方式相同,但请注意,有一些重大更改。有关更多详细信息,请查看 标记文档

Kubebuilder

  • Kubebuilder v2 引入了简化的项目布局。你可以在 这里 找到设计文档。

  • 在 v1 中,管理器作为 StatefulSet 部署,而在 v2 中,它作为 Deployment 部署。

  • kubebuilder create webhook 命令用于搭建变更/验证/转换 Webhook,取代了 kubebuilder alpha webhook 命令。

  • v2 使用 distroless/static 作为基础镜像,而不是 Ubuntu。这减少了镜像大小和攻击面。

  • v2 需要 kustomize v3.1.0 及以上版本。

从 v1 迁移到 v2

在继续之前,请务必了解 Kubebuilder v1 和 v2 之间的差异

请确保您已按照安装指南安装所需的组件。

推荐的迁移 v1 项目的方法是创建一个新的 v2 项目,并复制 API 和调整代码。转换后将得到一个看起来像原生 v2 项目的项目。然而,在某些情况下,也可以进行就地升级(即重用 v1 项目布局,升级 controller-runtime 和 controller-tools)。

我们以一个 V1 项目为例,将其迁移到 Kubebuilder v2。最后,我们应该得到的项目应该类似于 示例 v2 项目

准备

我们需要搞清楚组、版本、类型和域是什么。

让我们来看看我们当前的 v1 项目结构:

pkg/ ├── apis │   ├── addtoscheme_batch_v1.go │   ├── apis.go │   └── batch │   ├── group.go │   └── v1 │   ├── cronjob_types.go │   ├── cronjob_types_test.go │   ├── doc.go │   ├── register.go │   ├── v1_suite_test.go │   └── zz_generated.deepcopy.go ├── controller └── webhook

我们所有的 API 信息都存储在 pkg/apis/batch 中,因此我们可以在那儿找到我们需要了解的内容。

cronjob_types.go 文件中,我们可以找到

type CronJob struct {...}

register.go 中,我们可以找到

SchemeGroupVersion = schema.GroupVersion{Group: "batch.tutorial.kubebuilder.io", Version: "v1"}

将这些组合在一起,我们得到 CronJob 作为类型,batch.tutorial.kubebuilder.io/v1 作为组版本。

初始化一个 v2 项目

现在,我们需要初始化一个 v2 项目。不过,在此之前,如果我们不在 gopath 上,我们需要先初始化一个新的 Go 模块:

go mod init tutorial.kubebuilder.io/project

那么,我们可以使用 kubebuilder 完成项目的初始化:

kubebuilder init --domain tutorial.kubebuilder.io

迁移 API 和控制器

接下来,我们将重新搭建 API 类型和控制器。由于我们都需要这两者,因此在被询问要搭建哪些部分时,我们将对 API 和控制器的提示都选择“是“。

kubebuilder create api --group batch --version v1 --kind CronJob

如果您正在使用多个组,则需要进行一些手动操作来进行迁移。有关更多详细信息,请查看 此链接

迁移API

现在,让我们将 pkg/apis/batch/v1/cronjob_types.go 中的 API 定义复制到 api/v1/cronjob_types.go。我们只需要复制 SpecStatus 字段的实现。

我们可以用 +kubebuilder:object:root=true 来替换 +k8s:deepcopy-gen:interfaces=... 标记(该标记在 kubebuilder 中已被弃用)。

我们不再需要以下标记(它们不再使用,是早期版本的 Kubebuilder 的遗留物):

// +生成客户端 // +k8s:openapi-gen=true

我们的API类型应该如下所示:

// +kubebuilder:object:root=true // +kubebuilder:subresource:status // CronJob 是 cronjobs API 的模式 type CronJob struct {...} // +kubebuilder:object:root=true // CronJobList 包含一个 CronJob 列表 type CronJobList struct {...}

迁移控制器

现在,让我们将控制器调整器代码从 pkg/controller/cronjob/cronjob_controller.go 迁移到 controllers/cronjob_controller.go

我们需要进行复制。

  • ReconcileCronJob 结构体中的字段迁移到 CronJobReconciler
  • Reconcile 函数的内容
  • rbac 相关标记 添加到新文件中。
  • 将以下代码从 func add(mgr manager.Manager, r reconcile.Reconciler) error 更改为 func SetupWithManager

迁移 Webhooks

如果您没有 webhook,可以跳过这一部分。

核心类型和外部 CRD 的 Webhook

如果您正在为 Kubernetes 核心类型(例如 Pods)或不属于您的外部 CRD 使用 webhook,您可以参考 controller-runtime 的内置类型示例 并进行类似的操作。Kubebuilder 在这些情况下不会生成很多内容,但您可以使用 controller-runtime 中的库。

为我们的自定义资源定义(CRD)搭建 Webhooks。

现在让我们为我们的 CRD(CronJob)搭建 Webhook。我们需要使用 --defaulting--programmatic-validation 标志运行以下命令(因为我们的测试项目使用了默认值和验证 Webhook):

kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation

根据需要多少个 CRD(自定义资源定义)使用 Webhook,我们可能需要多次运行上述命令,并使用不同的组-版本-类型。

现在,我们需要为每个 webhook 复制逻辑。对于验证 webhook,我们可以将 pkg/default_server/cronjob/validating/cronjob_create_handler.go 中的 func validatingCronJobFn 的内容复制到 api/v1/cronjob_webhook.go 中的 func ValidateCreate,然后更新操作也可以同样处理。

类似地,我们将从 func mutatingCronJobFn 复制到 func Default

Webhook 标记

在搭建 webhooks 时,Kubebuilder v2 添加了以下标记:

`默认的动词是 verbs=create;update。我们需要确保 verbs与我们的需求相匹配。例如,如果我们只想验证创建,就需要将其更改为verbs=create`。

我们还需要确保 failure-policy 仍然保持不变。

以下标记不再需要(因为它们涉及自我部署证书配置,而该配置在 v2 中已被移除):

// v1 标记 // +kubebuilder:webhook:port=9876,cert-dir=/tmp/cert // +kubebuilder:webhook:service=test-system:webhook-service,selector=app:webhook-server // +kubebuilder:webhook:secret=test-system:webhook-server-secret // +kubebuilder:webhook:mutating-webhook-config-name=test-mutating-webhook-cfg // +kubebuilder:webhook:validating-webhook-config-name=test-validating-webhook-cfg

在 v1 中,一个 webhook 标记可能在同一段落中拆分成多个标记。在 v2 中,每个 webhook 必须由一个单独的标记表示。

其他人

如果在 v1 的 main.go 中有任何手动更新,我们需要将这些更改移植到新的 main.go 中。我们还需要确保所有所需的方案已经注册。

如果在 config 目录下添加了其他清单,也将它们移植过来。

如果需要,请在Makefile中更改图像名称。

验证

最后,我们可以运行 makemake docker-build 来确保一切正常。

Kubebuilder v2 与 v3 (旧版 Kubebuilder v2.0.0+ 布局到 3.0.0+)

本文档涵盖了从 v2 迁移到 v3 时的所有重大更改。

所有更改(无论是破坏性或其他类型)的详细信息可以在 controller-runtimecontroller-toolskb-releases 的发布说明中找到。

常见变更

v3 项目使用 Go 模块,并要求 Go 1.18 及以上版本。Dep 不再支持用于依赖管理。

Kubebuilder

  • 增加了对插件的初步支持。有关更多信息,请参阅 可扩展 CLI 和脚手架插件:第一阶段可扩展 CLI 和脚手架插件:第一阶段 1.5可扩展 CLI 和脚手架插件:第二阶段 设计文档。此外,您还可以查看 插件部分

  • PROJECT 文件现在有了新的布局。它存储了更多关于正在使用的资源的信息,以更好地帮助插件在搭建时做出有用的决策。

    此外,PROJECT 文件本身现在也进行了版本控制:version 字段对应于 PROJECT 文件自己的版本,而 layout 字段则指示当前使用的脚手架和主插件版本。

  • 镜像 gcr.io/kubebuilder/kube-rbac-proxy 的版本从 0.5.0 更新到 0.11.0,这是一个默认启用的可选组件,用于保护对管理器的请求,以解决安全问题。所有更改的详细信息可以在 kube-rbac-proxy 中找到。

新版本 go/v3 插件的简述。

您可以在这里找到更多详细信息,但请查看下面的亮点

  • 搭建/生成的 API 版本变更:

    • 使用 apiextensions/v1 来生成 CRD(在 Kubernetes 1.16 中,apiextensions/v1beta1 已被弃用)
    • 对生成的 Webhook 使用 admissionregistration.k8s.io/v1admissionregistration.k8s.io/v1beta1 在 Kubernetes 1.16 中已被弃用)
    • 当使用 Webhook 时,请使用 cert-manager.io/v1 作为证书管理器(cert-manager.io/v1alpha2Cert-Manager 0.14 中已被弃用。更多信息请参见:CertManager v1.0 文档
  • 代码更改:

    • 管理器的标志 --metrics-addrenable-leader-election 现在被命名为 --metrics-bind-address--leader-elect,以更好地与核心 Kubernetes 组件保持一致。更多信息:#1839
    • 活跃性和就绪性探针现在默认使用 healthz.Ping 添加。
    • 引入了一个新的选项,可以使用 ComponentConfig 创建项目。有关更多信息,请参阅其 增强提案组件配置教程
    • 管理器清单现在使用 SecurityContext 来解决安全问题。更多信息请查看:#1637
  • 杂项:

    • controller-tools v0.9.0 的支持(对于 go/v2v0.3.0,之前是 v0.2.5
    • controller-runtime v0.12.1 的支持(对于 go/v2 版本是 v0.6.4,之前是 v0.5.0
    • kustomize v3.8.7的支持(对于go/v2,是v3.5.4,之前是v3.1.0
    • 所需的 Envtest 二进制文件会自动下载。
    • 最低 Go 版本现在是 1.18(之前是 1.13)。

迁移到 Kubebuilder v3

所以你想要升级你的脚手架以使用最新的功能,那么请按照下面的指南进行操作,该指南将以最简单明了的方式介绍步骤,使你能够升级你的项目,以获得所有最新的变更和改进。

通过手动更新文件

因此,如果您想在不更改脚手架的情况下使用最新版本的 Kubebuilder CLI,请查看以下指南,该指南将描述您手动升级仅项目版本并开始使用插件版本所需的步骤。

这种方式更复杂,容易出错,成功也无法得到保证。此外,按照这些步骤操作,您将无法获得默认生成项目文件中的改进和bug修复。

您可以通过使用 go/v2 插件检查是否仍然可以使用之前的布局,该插件不会将 controller-runtimecontroller-tools 升级到与 go/v3 一起使用的最新版本,因为这会导致不兼容的变化。通过查看此指南,您也可以了解如何手动更改文件以使用 go/v3 插件及其依赖版本。

从 v2 迁移到 v3

在继续之前,请确保您了解 Kubebuilder v2 和 v3 之间的差异

请确保您已按照安装指南安装所需的组件。

迁移 v2 项目的推荐方式是创建一个新的 v3 项目,并将 API 和调整代码复制过去。转换后的项目将类似于原生 v3 项目。然而,在某些情况下,可以进行就地升级(即重用 v2 项目的布局,升级 controller-runtimecontroller-tools)。

初始化一个 v3 项目

创建一个新目录,使用你的项目名称。请注意,这个名称在脚手架中用于创建你的管理器 Pod 的名称以及管理器默认部署的命名空间。

$ mkdir migration-project-name $ cd migration-project-name

现在,我们需要初始化一个 v3 项目。不过,在此之前,如果我们不在 GOPATH 目录下,就需要初始化一个新的 Go 模块。虽然在 GOPATH 内部技术上并不需要这样做,但仍然推荐这样做。

go mod init tutorial.kubebuilder.io/migration-project

然后,我们可以使用 kubebuilder 完成项目的初始化。

kubebuilder init --domain tutorial.kubebuilder.io

迁移 API 和控制器

接下来,我们将重新搭建 API 类型和控制器。

kubebuilder create api --group batch --version v1 --kind CronJob

迁移API

现在,让我们将旧项目中的 api/v1/<kind>_types.go 文件中的 API 定义复制到新项目中。

这些文件没有被新的插件修改,所以您应该可以用旧的文件替换新生成的文件。可能会有一些外观上的变化。因此,您可以选择仅复制类型本身。

迁移控制器

现在,让我们将旧项目中的 controllers/cronjob_controller.go 控制器代码迁移到新项目中。这里有一个重大更改,并且可能会有一些外观上的变化。

新的 Reconcile 方法现在将上下文作为参数接收,而不再需要使用 context.Background() 来创建它。您可以将旧控制器中的其余代码复制到生成的方法中,替换:

func (r *CronJobReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { ctx := context.Background() log := r.Log.WithValues("cronjob", req.NamespacedName)

With:

func (r *CronJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.WithValues("cronjob", req.NamespacedName)

迁移 Webhooks

现在让我们为我们的 CRD(CronJob)搭建 Webhook。我们需要使用 --defaulting--programmatic-validation 标志运行以下命令(因为我们的测试项目使用了默认值和验证 Webhook):

kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation

现在,让我们将旧项目中的 api/v1/<kind>_webhook.go 的 webhook 定义复制到新项目中。

其他人

如果在v2中的main.go有任何手动更新,我们需要将这些更改移植到新的main.go中。同时,我们还需要确保所有需要的方案已被注册。

如果在配置目录下添加了其他清单,请一并移植。

如果需要,请在Makefile中更改图像名称。

验证

最后,我们可以运行 makemake docker-build 来确保一切正常。

通过手动更新文件进行从 v2 到 v3 的迁移

在继续之前,请确保您了解 Kubebuilder v2 和 v3 之间的区别

请确保您已按照安装指南安装所需的组件。

以下指南描述了升级您的配置版本并开始使用插件支持版本所需的手动步骤。

这种方式更复杂,容易出错,成功也无法得到保证。此外,按照这些步骤操作,您将无法获得默认生成项目文件中的改进和bug修复。

通常情况下,如果您对项目进行了自定义并且偏离了建议的框架,您才会选择手动处理。在继续之前,请确保您理解关于项目自定义的说明。请注意,手动执行此过程可能需要比组织项目自定义以遵循建议布局投入更多的精力,而后者可以让您的项目在未来更易于维护和升级。

推荐的升级方法是遵循 从 V2 到 V3 的迁移指南

从项目配置版本迁移 \

在项目配置版本之间迁移涉及对您项目的 PROJECT 文件中的字段进行添加、删除和/或更改,该文件是通过运行 init 命令创建的。

PROJECT 文件现在有了新的布局。它存储了更多关于正在使用的资源的信息,以更好地帮助插件在搭建时做出有用的决策。

此外,PROJECT 文件本身现在是版本化的。version 字段对应于 PROJECT 文件本身的版本,而 layout 字段则指示正在使用的框架和主要插件的版本。

迁移步骤

以下步骤描述了手动更改项目配置文件(PROJECT)所需的步骤。这些更改将添加Kubebuilder在生成文件时会添加的信息。该文件可以在根目录中找到。

添加 projectName

项目名称是项目目录的名称,使用小写字母。

... projectName: example ...

添加 layout

默认的插件布局与之前的版本相当,设置为 go.kubebuilder.io/v2

... layout: - go.kubebuilder.io/v2 ...

更新版本

version 字段表示项目布局的版本。将其更新为 "3"

... version: "3" ...

添加资源数据

属性 resources 代表在您的项目中搭建的资源列表。

您需要为每个添加到项目中的资源添加以下数据。

通过添加 resources[entry].api.crdVersion: v1beta1 来添加 Kubernetes API 版本:
... resources: - api: ... crdVersion: v1beta1 domain: 我的域名 group: 网络应用程序 kind: guestbook (签名册) ...
通过添加 resources[entry].api.namespaced: true 来添加用于搭建 CRDs 的范围,除非它们是集群范围的。
... resources: - api: ... namespaced: true group: 网络应用程序 kind: guestbook (签名册) ...
如果你为 API 创建了控制器框架,那么添加 resources[entry].controller: true
... resources: - api: ... controller: true group: 网络应用程序 kind: guestbook (签名册)
添加资源域,例如 resources[entry].domain: testproject.org,这通常是项目域,除非 API 骨架是核心类型和/或外部类型:
... resources: - api: ... domain: testproject.org group: 网络应用程序 kind: guestbook (签名册)

请注意,只有当您的项目有一个核心类型 API 的脚手架,并且 Kubernetes API 组合格方案定义中的 Domain 值不为空时,您才需要添加 domain。 (例如,您可以查看 这里,在 API apps 中,Kinds 的域为空,而在 这里 中,API authentication 的 Kinds 的域是 k8s.io

请查看以下列表,以了解支持的核心类型及其领域:

核心类型域名
入学“k8s.io”
入学注册“k8s.io”
appsempty
审计注册“k8s.io”
apiextensions 的中文翻译是“API 扩展“。“k8s.io”
身份验证“k8s.io”
授权“k8s.io”
自动扩展empty
batchempty
证书“k8s.io”
调整“k8s.io”
核心empty
events“k8s.io”
扩展包empty
图像策略“k8s.io”
网络连接“k8s.io”
节点“k8s.io”
指标“k8s.io”
政策empty
rbac.authorization“k8s.io”
调度“k8s.io”
设置“k8s.io”
storage“k8s.io”

以下是一个示例,其中通过命令 create api --group apps --version v1 --kind Deployment --controller=true --resource=false --make=false 为核心类型 Kind Deployment 搭建了控制器:

- controller: true group: apps kind: 部署 path: k8s.io/api/apps/v1 version: v1
resources[entry].path 添加为 api 的导入路径:
... resources: - api: ... ... group: 网络应用程序 kind: guestbook (签名册) path: 示例/api/v1
如果您的项目使用了 Webhooks,则对于每种生成的类型,添加 resources[entry].webhooks.[type]: true,然后添加 resources[entry].webhooks.webhookVersion: v1beta1
resources: - api: ... ... group: 网络应用程序 kind: guestbook (签名册) webhooks: defaulting: true validation: true webhookVersion: v1beta1

检查您的项目文件。

现在请确保您的 PROJECT 文件在通过 Kubebuilder V3 CLI 生成清单时包含相同的信息。

对于 QuickStart 示例,手动更新为使用 go.kubebuilder.io/v2PROJECT 文件如下所示:

domain: 我的域名 layout: - go.kubebuilder.io/v2 projectName: example repo: example resources: - api: crdVersion: v1 namespaced: true controller: true domain: 我的域名 group: 网络应用程序 kind: guestbook (签名册) path: 示例/api/v1 version: v1 version: "3"

您可以通过比较一个涉及多个 API 和 Webhook 的示例场景,查看以前布局(版本 2)和当前格式(版本 3)之间的差异,具体请参阅 go.kubebuilder.io/v2

示例(项目版本 2)

domain: testproject.org repo: sigs.k8s.io/kubebuilder/example resources: - group: 船员 kind: 船长 version: v1 - group: 船员 kind: 第一伴侣 version: v1 - group: 船员 kind: 海军上将 version: v1 version: "2"

示例(项目版本 3)

domain: testproject.org layout: - go.kubebuilder.io/v2 projectName: example repo: sigs.k8s.io/kubebuilder/example resources: - api: crdVersion: v1 namespaced: true controller: true domain: testproject.org group: 船员 kind: 船长 path: 示例/api/v1 version: v1 webhooks: defaulting: true validation: true webhookVersion: v1 - api: crdVersion: v1 namespaced: true controller: true domain: testproject.org group: 船员 kind: 第一伴侣 path: 示例/api/v1 version: v1 webhooks: 转换: true webhookVersion: v1 - api: crdVersion: v1 controller: true domain: testproject.org group: 船员 kind: 海军上将 path: 示例/api/v1 plural: 海军将领 version: v1 webhooks: defaulting: true webhookVersion: v1 version: "3"

验证

在上述步骤中,你只更新了代表项目配置的 PROJECT 文件。这个配置仅对命令行工具有用。它不应该影响你的项目的行为。

没有选项可以验证您是否正确更新了配置文件。确保配置文件具有正确的 V3+ 字段的最佳方法是初始化一个具有相同 API、控制器和 webhook 的项目,以便比较生成的配置与手动更改的配置。

如果您在上述过程中犯了错误,您可能会在使用命令行界面时遇到问题。

将您的项目更新为使用 go/v3 插件。

在项目之间迁移 插件 涉及对任何插件支持的命令(例如 initcreate)创建的文件进行添加、删除和/或更改。一个插件支持一个或多个项目配置版本;在升级插件版本之前,请确保将项目的配置版本升级到目标插件版本所支持的最新版本。

以下步骤描述了手动修改项目布局所需的变更,以便使您的项目能够使用 go/v3 插件。这些步骤不会帮助您解决已生成的脚手架中的所有错误。

迁移步骤

将你的插件版本更新到 PROJECT 文件中。

在更新 layout 之前,请确保您已经按照上述步骤将您的项目版本升级到 3。一旦您升级了项目版本,请将 layout 更新为新插件版本 go.kubebuilder.io/v3,具体操作如下:

domain: 我的域名 layout: - go.kubebuilder.io/v3 ...

升级 Go 版本及其依赖项:

确保你的 go.mod 文件使用 Go 版本 1.15 和以下依赖版本:

module example go 1.18 require ( github.com/onsi/ginkgo/v2 v2.1.4 github.com/onsi/gomega v1.19.0 k8s.io/api v0.24.0 k8s.io/apimachinery v0.24.0 k8s.io/client-go v0.24.0 sigs.k8s.io/controller-runtime v0.12.1 )

更新 Go 语言镜像

在 Dockerfile 中替换:

# Build the manager binary FROM docker.io/golang:1.13 as builder

With:

# Build the manager binary FROM docker.io/golang:1.16 as builder

更新你的 Makefile

允许 controller-gen 来搭建新的 Kubernetes API。

为了让 controller-gen 和脚手架工具使用新的 API 版本,请替换:

``With:

``##### 允许自动下载

为了允许将 Envtest 所需的 Kubernetes 二进制文件的新版本下载到您项目的 testbin/ 目录中,而不是全局设置,请替换:

# 运行测试 test: 生成格式化的兽医清单 go test ./... -coverprofile cover.out

With:

将SHELL设置为bash允许通过配方执行bash命令。 # Options are set to exit when a recipe line exits non-zero or a piped command fails. SHELL = /usr/bin/env bash -o pipefail .SHELLFLAGS = -ec ENVTEST_ASSETS_DIR=$(shell pwd)/testbin test: 生成清单 格式 校验 ## 运行测试。 mkdir -p ${ENVTEST_ASSETS_DIR} test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/v0.8.3/hack/setup-envtest.sh source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... -coverprofile cover.out
升级所使用的 controller-genkustomize 依赖版本

要升级用于生成清单的 controller-genkustomize 版本,请替换:

# find or download controller-gen # download controller-gen if necessary controller-gen: ifeq (, $(shell which controller-gen)) @{ \ set -e ;\ CONTROLLER_GEN_TMP_DIR=$$(mktemp -d) ;\ cd $$CONTROLLER_GEN_TMP_DIR ;\ go mod init tmp ;\ go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.2.5 ;\ rm -rf $$CONTROLLER_GEN_TMP_DIR ;\ } CONTROLLER_GEN=$(GOBIN)/controller-gen else CONTROLLER_GEN=$(shell which controller-gen) endif

With:

`然后,为了使您的项目使用 Makefile 中定义的 kustomize版本,请将所有kustomize的用法替换为$(KUSTOMIZE)`。

更新您的控制器。

Replace:

func (r *<MyKind>Reconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { ctx := context.Background() log := r.Log.WithValues("cronjob", req.NamespacedName)

With:

func (r *<MyKind>Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.WithValues("cronjob", req.NamespacedName)

更新你的控制器和 webhook 测试套件。

Replace:

. "github.com/onsi/ginkgo"

With:

. "github.com/onsi/ginkgo/v2"

另外,请调整您的测试套件。

针对控制器套件:

RunSpecsWithDefaultAndCustomReporters(t, "Controller Suite", []Reporter{printer.NewlineReporter{}})

With:

RunSpecs(t, "Controller Suite")

用于 Webhook 套件:

RunSpecsWithDefaultAndCustomReporters(t, "Webhook Suite", []Reporter{printer.NewlineReporter{}})

With:

RunSpecs(t, "Webhook Suite")

最后但同样重要的是,从 BeforeSuite 块中移除超时变量:

Replace:

var _ = BeforeSuite(func(done Done) { .... }, 60)

var _ = BeforeSuite(func(done Done) { .... })

将日志记录器更改为使用标志选项。

main.go 文件中替换:

flag.Parse() ctrl.SetLogger(zap.New(zap.UseDevMode(true)))

With:

opts := zap.Options{ Development: true, } opts.BindFlags(flag.CommandLine) flag.Parse() ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

重命名监控程序标志

该管理器的--metrics-addrenable-leader-election标志已重命名为--metrics-bind-address--leader-elect,以与核心Kubernetes组件更一致。更多信息请查看:#1839

在您的 main.go 文件中替换:

func main() { var metricsAddr string var enableLeaderElection bool flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.") flag.BoolVar(&enableLeaderElection, "enable-leader-election", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。")

With:

func main() { var metricsAddr string var enableLeaderElection bool flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "启用此功能将确保只有一个活动的控制管理器。")

然后,重命名 config/default/manager_auth_proxy_patch.yamlconfig/default/manager.yaml 中的标志:

- name: manager args: - "--health-probe-bind-address=:8081" - "--metrics-bind-address=127.0.0.1:8080" - "--leader-elect"

验证

最后,我们可以运行 makemake docker-build 来确保一切正常。

将您的项目更改为删除对Kubernetes已弃用API版本的使用。

以下步骤描述了一个工作流程,用于升级您的项目以移除已弃用的 Kubernetes API:apiextensions.k8s.io/v1beta1admissionregistration.k8s.io/v1beta1cert-manager.io/v1alpha2

Kubebuilder CLI 工具不支持同时使用具有两个 Kubernetes API 版本的脚手架资源,例如:一个 API/CRD 使用 apiextensions.k8s.io/v1beta1,另一个使用 apiextensions.k8s.io/v1

第一步是更新您的 PROJECT 文件,将 api.crdVersion:v1betawebhooks.WebhookVersion:v1beta 替换为 api.crdVersion:v1webhooks.WebhookVersion:v1,如下所示:

domain: 我的域名 layout: go.kubebuilder.io/v3 projectName: example repo: example resources: - api: crdVersion: v1 namespaced: true group: 网络应用程序 kind: guestbook (签名册) version: v1 webhooks: defaulting: true webhookVersion: v1 version: "3"

您可以尝试使用 --force 标志重新创建 APIS(CRD)和 Webhooks 清单。

现在,通过运行 kubebuilder create apikubebuilder create webhook 命令,并分别使用 --force 标志,为相同的组、种类和版本重新创建 APIS(CRDs)和 Webhooks 清单。

V3 - 插件布局迁移指南

请参阅插件版本的迁移指南。请注意,插件生态系统是在 Kubebuilder v3.0.0 版本中引入的,自 2021年4月28日 起,go/v3 版本是默认布局。

因此,您可以在这里查看如何将使用 Kubebuilder 3.x 和 go/v3 插件构建的项目迁移到最新版本。

go/v3 与 go/v4 的比较

本文档涵盖了从使用插件 go/v3(自 2021 年 4 月 28 日以来任何脚手架的默认选项)构建的项目迁移到下一个版本的 Golang 插件 go/v4 时的所有重大变更。

所有更改(无论是重大更改还是其他更改)的详细信息可以在以下位置找到:

常见变更

  • go/v4 项目使用 Kustomize v5x(而不是 v3x)
  • 请注意,config/ 目录下的一些清单已被更改,以不再使用已弃用的 Kustomize 功能,例如环境变量。
  • 一个 kustomization.yaml 文件被创建在 config/samples 目录下。这有助于简单灵活地生成示例清单:kustomize build config/samples
  • 添加对 Apple Silicon M1 (darwin/arm64) 的支持。
  • 移除对不再支持的 CRD/WebHooks Kubernetes API v1beta1 版本的支持,该版本自 k8s 1.22 起不再得到支持。
  • 不再使用 "k8s.io/api/admission/v1beta1" 来搭建 webhook 测试文件,因为从 k8s 1.25 开始,该 API 已不再提供。默认情况下,webhook 测试文件使用 "k8s.io/api/admission/v1" 进行搭建,该 API 从 k8s 1.20 起获得支持。
  • 不再为 k8s 版本低于 1.16 提供向后兼容的支持。
  • 将布局更改为以适应社区请求,遵循 标准 Go 项目布局,将 API 移动到一个名为 api 的新目录,控制器移到一个名为 internal 的新目录,main.go 移动到一个名为 cmd 的新目录。

新版本的 go/v4 插件简要说明:

有关此内容的更多详细信息,请查看 这里,但如果想了解要点,请查看以下内容

迁移到 Kubebuilder go/v4

如果您想升级您的脚手架以使用最新和最优秀的功能,请按照本指南进行操作,该指南将以最简单明了的方式介绍步骤,以便您能够升级您的项目,获取所有最新的变更和改进。

通过手动更新文件

如果您想在不更改脚手架的情况下使用最新版本的 Kubebuilder CLI,请查看以下指南,该指南将描述手动执行的步骤,以便仅升级您的项目版本并开始使用插件版本。

这种方式更复杂,容易出错,成功也无法得到保证。此外,按照这些步骤操作,您将无法获得默认生成项目文件中的改进和bug修复。

从 go/v3 迁移到 go/v4

在继续之前,请确保您了解 Kubebuilder go/v3 和 go/v4 之间的区别

请确保您已经按照安装指南安装了所需的组件。

迁移 go/v3 项目的推荐方法是创建一个新的 go/v4 项目,并将 API 和调整代码复制过去。转换后的项目将呈现出原生 go/v4 项目布局的样子(最新版本)。

然而,在某些情况下,可以进行就地升级(即重用 go/v3 项目布局,手动升级 PROJECT 文件和脚手架)。有关更多信息,请参见 通过手动更新文件从 go/v3 迁移到 go/v4

初始化一个 go/v4 项目

创建一个新目录,使用你的项目名称。请注意,这个名称在脚手架中用于创建你的管理器 Pod 的名称以及管理器默认部署的命名空间。

$ mkdir migration-project-name $ cd migration-project-name

现在,我们需要初始化一个 go/v4 项目。在此之前,如果我们不在 GOPATH 中,我们需要初始化一个新的 go 模块。虽然从技术上讲,在 GOPATH 内部并不需要,但仍然推荐这样做。

go mod init tutorial.kubebuilder.io/migration-project

现在,我们可以使用 kubebuilder 完成项目的初始化。

kubebuilder init --domain tutorial.kubebuilder.io --plugins=go/v4

迁移 API 和控制器

接下来,我们将重新搭建 API 类型和控制器。

kubebuilder create api --group batch --version v1 --kind CronJob

迁移API

现在,让我们将旧项目中的 api/v1/<kind>_types.go 文件中的 API 定义复制到新项目中。

这些文件没有被新的插件修改,所以您应该可以用旧的文件替换新生成的文件。可能会有一些外观上的变化。因此,您可以选择仅复制类型本身。

迁移控制器

现在,让我们将旧项目中 controllers/cronjob_controller.go 的控制器代码迁移到新项目中的 internal/controller/cronjob_controller.go

迁移 Webhooks

现在让我们为我们的 CRD(CronJob)搭建 Webhook。我们需要使用 --defaulting--programmatic-validation 标志运行以下命令(因为我们的测试项目使用了默认值和验证 Webhook):

kubebuilder create webhook --group batch --version v1 --kind CronJob --defaulting --programmatic-validation

现在,让我们将旧项目中的 api/v1/<kind>_webhook.go 的 webhook 定义复制到新项目中。

其他人

如果在 v3 的 main.go 中有任何手动更新,我们需要将这些更改迁移到新的 main.go 中。我们还需要确保所有需要的 controller-runtime schemes 都已注册。

如果在配置目录下添加了其他清单,请一并移植。请注意,新版本的 go/v4 使用 Kustomize v5.x,而不再是 Kustomize v4。因此,如果在配置中添加了自定义实现,您需要确保它们能够与 Kustomize v5 一起使用,如果不能,则需要更新/升级您可能遇到的任何破坏性更改。

在 v4 中,Kustomize 的安装方式已经从 bash 脚本更改为 go get。请将 Makefile 中的 kustomize 依赖更改为

``如果需要,请在Makefile中更改图像名称。

验证

最后,我们可以运行 makemake docker-build 来确保一切正常。

通过手动更新文件进行从 go/v3 到 go/v4 的迁移。

在继续之前,请确保您了解 Kubebuilder go/v3 和 go/v4 之间的区别

请确保您已经按照安装指南安装了所需的组件。

以下指南描述了升级您的 PROJECT 配置文件以开始使用 go/v4 所需的手动步骤。

这种方式更复杂,容易出错,成功也无法得到保证。此外,按照这些步骤操作,您将无法获得默认生成项目文件中的改进和bug修复。

通常建议如果您对项目进行了自定义并偏离了建议的框架,最好手动操作。在继续之前,请确保您理解关于 [项目自定义][project-customizations] 的说明。请注意,您可能需要花费比整理项目自定义更多的精力来手动完成此过程。建议的布局将使您的项目在未来更易于维护和升级,所需的努力也会更少。

推荐的升级方法是遵循 从 go/v3 到 go/v4 的迁移指南

从项目配置版本迁移\

更新 PROJECT 文件布局,以存储有关用于使插件在搭建时做出有用决策的资源的信息。layout 字段指示所使用的搭建方式和主要插件版本。

迁移步骤

将布局版本迁移到 PROJECT 文件中。

以下步骤描述了手动更改项目配置文件(PROJECT)所需的步骤。这些更改将添加Kubebuilder在生成文件时会添加的信息。该文件可以在根目录中找到。

通过替换来更新 PROJECT 文件:

layout: - go.kubebuilder.io/v3

With:

layout: - go.kubebuilder.io/v4

布局更改

新布局:
  • 目录 apis 已重命名为 api 以遵循标准。
  • controller(s) 目录已被移至一个名为 internal 的新目录下,并且也改名为单数形式 controller
  • 之前在根目录下搭建的 main.go 文件已经移动到一个名为 cmd 的新目录下。

因此,您可以检查布局结果的变化为:

... ├── cmd │ └── main.go ├── internal │ └── controller └── api
迁移到新布局:
  • 创建一个新的目录 cmd 并将 main.go 移动到该目录下。
  • 如果您的项目支持多组,则 API 将在名为 apis 的目录下生成。将此目录重命名为 api
  • controllers 目录移动到 internal 下,并将其重命名为 controller
  • 现在请确保导入将相应更新:
    • main.go 的导入更新为查找 internal/controller 目录下的新路径。

那么,让我们更新脚手架的路径

  • 更新 Dockerfile,以确保您将拥有:
COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/

然后,替换:

RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager main.go

With:

RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go
  • 更新 Makefile 的目标,以通过替换来构建和运行管理器:
.PHONY: build build: manifests generate fmt vet ## Build manager binary. go build -o bin/manager main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. go run ./main.go

With:

.PHONY: build build: manifests generate fmt vet ## Build manager binary. go build -o bin/manager cmd/main.go .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. go run ./cmd/main.go
  • 更新 internal/controller/suite_test.go 以设置 CRDDirectoryPaths 的路径:

Replace:

``With:

`请注意,如果您的项目有多个组(multigroup:true),那么上述更新应该导致 “..”, “..”, “..”,而不是“..”,“..”`。

现在,让我们相应地更新项目文件中的路径。

该项目跟踪您项目中使用的所有 API 的路径。请确保它们现在指向 api/...,例如以下示例:

更新前:

group: crew kind: Captain path: sigs.k8s.io/kubebuilder/testdata/project-v4/apis/crew/v1

更新后:

group: crew kind: Captain path: sigs.k8s.io/kubebuilder/testdata/project-v4/api/crew/v1

使用迄今为止所做的更改更新 kustomize 清单。

  • config/ 目录下更新清单,包含使用 go/v4 插件在默认脚手架上所做的所有更改(例如,请参阅 testdata/project-v4/config/),以便将所有默认脚手架的更改应用到您的项目中。
  • 创建 config/samples/kustomization.yaml,将所有指定在 config/samples 中的自定义资源示例包含在内。 (例如参见 testdata/project-v4/config/samples/kustomization.yaml)

如果您有 webhook:

在 Webhook 测试文件中,将导入 admissionv1beta1 "k8s.io/api/admission/v1beta1" 替换为 admissionv1 "k8s.io/api/admission/v1"

Makefile 更新

请根据所用发布标签下的测试数据中的示例更新 Makefile 的更改。(例如,请参见 testdata/project-v4/Makefile

更新依赖项

请根据所使用的发布标签在 testdata 中的示例中找到的更改更新 go.mod 文件(例如,参考 testdata/project-v4/go.mod)。然后,运行 go mod tidy 以确保获取最新的依赖项,并确保你的 Golang 代码没有出现破坏性更改。

验证

在以上步骤中,您手动更新了您的项目,以确保它遵循使用 go/v4 插件引入的更新模板的布局变化。

没有选项可以验证您是否正确更新了项目的 PROJECT 文件。确保所有内容正确更新的最佳方法是使用 go/v4 插件初始化一个项目,即使用 kubebuilder init --domain tutorial.kubebuilder.io plugins=go/v4,并生成相同的 API、控制器和 webhook,以便将生成的配置与手动更改的配置进行比较。

此外,在所有更新完成后,您需要运行以下命令:

  • make manifests(在更新 Makefile 后使用最新版本的 controller-gen 重新生成文件)
  • make all(以确保您能够构建并执行所有操作)

单组到多组

让我们迁移 CronJob 示例.

要将您的项目布局更改为支持多组,请运行命令 kubebuilder edit --multigroup=true。一旦您切换到多组布局,新的 Kinds 将根据新布局生成,但需要额外的手动工作将旧 API 组迁移到新布局。

一般来说,我们将 API 组的前缀用作目录名称。我们可以检查 api/v1/groupversion_info.go 来了解这一点:

// +组名=batch.tutorial.kubebuilder.io package v1

然后,我们将把现有的 API 移动到一个新的子目录中,子目录名称将以该组的名称命名。考虑到CronJob 示例,子目录名称为“batch“:

mkdir api/batch mv api/* api/batch

在将 API 移动到新目录后,控制器也需要进行相应的调整。对于 go/v4:

mkdir internal/controller/batch mv internal/controller/* internal/controller/batch/

同样的原则也适用于任何现有的 webhooks

mkdir internal/webhook/batch mv internal/webhook/* internal/webhook/batch/

为新创建的群组所创建的任何新webhook,其相应的功能将被创建在子目录internal/webhook/<group>/下。