Kubernetes Operator入门

2025-11-07T14:11:02+08:00 | 31分钟阅读 | 更新于 2025-11-07T14:11:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 用自己的话说清 Operator 是什么、解决什么问题——本质就是把"运维人员的操作经验代码化",把复杂有状态应用的部署、升级、备份、恢复、故障自愈变成声明式配置。
  2. 讲清 CRD 与 Controller 的分工,以及"期望状态 vs 实际状态 + 控制循环"这套核心心智,理清 Reconcile 的四种返回语义。
  3. 分清两大开发框架 kubebuilder 与 operator-sdk 的定位差异,并看懂 kubebuilder 的 Manager / Controller / Reconciler / Source / WorkQueue 内部架构。
  4. 用 kubebuilder 照着 KubeVela Application 的思路,实现一个能用一个 CR 同时管理 Deployment / Service / Ingress 三个资源的 Operator,并理解 ownerReference 的级联删除。
  5. 用 kubebuilder 模仿阿里云定时弹性伸缩器,实现一个带 Cron 表达式的 CronHPA 定时扩缩容 operator,通过 additionalPrinterColumns 让 kubectl get 展示更多列。
  6. 用 operator-sdk 的 Helm 模式把一个现成的 Helm Chart 直接变成 Operator,并把 operator 用 OLM(Operator Lifecycle Manager)打包、下发、部署。
  7. 掌握 Operator 开发的几条最佳实践(幂等 reconcile、只关注期望与当前状态),并用 envtest 做无需真实集群的端到端测试。

前置知识:

  • 基本 K8s 概念:Pod、Deployment、Service、Ingress、Workload 是什么。
  • 会用 kubectl apply、kubectl get、kubectl delete 操作资源。
  • 会写一点 Go(struct、interface、方法),不需要很熟——跟着抄就行。
  • 知道 YAML 基本格式,知道什么叫 apiVersion / kind / metadata / spec。
  • 听过 KubeVela、HPA、Helm、Cron 这些词即可,不熟也没关系,本章会展开。

本章你会动手做的事:

  1. 用 kind 在本地拉起一个测试集群,kubebuilder init + kubebuilder create api 生成 Operator 骨架。
  2. 实现一个 Application CRD,用一份配置同时生成 Deployment / Service / Ingress,并验证 ownerReference 的级联删除。
  3. 实现一个 CronHPA CRD,用 Cron 表达式定时扩缩容 Deployment,观察 make run 日志与副本数变化(还会踩一两个坑)。
  4. 用 operator-sdk 的 Helm 插件把 redis Chart 变成 Operator,处理权限告警。
  5. 用 OLM 把 operator 打包成 bundle 镜像并在集群下发。
  6. 用 envtest 写一个不需要真实集群的端到端测试。
Kubernetes Operator 入门与实战核心概念开发框架实战一·Application实战二·CronHPAHelm + OLM最佳实践Controller + CRD期望状态≠实际状态kubebuilder vs SDKManager·Reconciler·队列Deploy+Service+IngressownerReference 级联删除Cron 定时扩缩容printerColumns 展示Helm Chart 变 OperatorOLM 生命周期管理幂等 reconcileenvtest 端到端测试

先建立直觉:这一章的"主线"是什么

本章其实就是围绕一句话展开——把一个人肉运维的活,交给一个跑在 K8s 里的"机器人"去干,而这个机器人就是我们说的 Operator。前面的笔记我们学了很多 K8s 概念,但那些都是 K8s 原生的能力;这一章开始,我们进入"自己给 K8s 造插件"的阶段:定义你自己的资源(CRD),再写一个控制器去盯着它干活。

这章干货很多、有大量实战,但骨架并不复杂。你只要一直记住 Operator = Controller(大脑,负责对账)+ CRD(数据结构,负责定义期望) 这条主线,后面的例子全都是往这个骨架上填肉。


什么是 Operator

建立直觉:Operator 是一个"会自动收敛的"机器人

复习提示:类比:想象开车开了定速巡航。你把目标车速设定成 110 km/h(期望状态),但车现在是 80 km/h(实际状态)。汽车的定速巡航里其实就有一个"控制循环"——它不断检测当前车速,然后决定是提速还是减速,直到实际车速贴近设定期望。它不会一次猛踩到 110 让你毫无准备,而是快接近时慢慢微调(控制力度收敛)。Operator 干的就是同一件事,只不过对象从"车速"换成了"你的自定义资源"。

Operator 到底是什么

Operator 本质上是一种特殊的控制器。它把这个"控制循环"的机制应用到了**自定义资源(CRD)**的状态管理上。一句话:

Operator = Controller + CRD

这个概念最早由 CoreOS 公司在 2016 年前后提出(后来 CoreOS 被 Red Hat 收购)。它的工作流程大致是这样的:

用户 apply 一个 CR -> API Server 存进 etcd
    -> Operator Watch 到这个 CR 的变更
    -> Controller 创建/更新原生子资源(Deployment / ConfigMap / Service / StatefulSet)
    -> 监听创建结果 -> 根据结果更新 CR 的 status 字段

其中:

  • CRD(Custom Resource Definition):用它来描述一个全新的资源类型。它是一种声明式的扩展方式——你要声明一个新的 kind(比如一个定时伸缩的 kind),这个 kind 的 spec 里有哪些字段,全部都是通过声明出来的,而不是写代码硬编码的。
  • Controller:负责监听 CRD 的变更,执行对账逻辑,让期望状态与实际状态最终保持一致。

期望状态 vs 实际状态

在深入 Controller 之前,得先搞清楚两个贯穿始终的概念:

  • 期望状态(Desired State):就是资源对象里 spec 字段描述的情况。比如一个 Deployment 的 spec 里声明了"我要 3 个副本、镜像是 xxx",这就是期望状态。
  • 实际状态(Actual State):正常情况下我们要主动去查询工作负载当前的真实状态;但为了对账方便,我们通常还会在 CR 的 status 字段里把实际状态记录下来,这样期望状态和实际状态就能很容易地对比。

控制循环(Control Loop) 是 Controller 的核心,它实现的就是让"实际状态"和"期望状态"最终保持一致。注意是"最终"——因为收敛是有过程的,可能一开始副本是 2,期望是 5,控制循环会一步步把副本补到 5,并且越接近目标越精细。

用一张图总结这个闭环:

flowchart LR
    U[用户在 YAML 里声明 spec 期望状态] --> API[API Server 写入 etcd]
    API --> W[Controller Watch 变更]
    W --> C{实际状态 == 期望状态?}
    C -->|否| R[Reconcile 创建 更新 子资源]
    R --> S[读实际状态 写入 status]
    S --> C
    C -->|是| D[什么都不做 保持现状]

为什么需要 Operator

K8s 原生自己带的控制器(比如 Deployment、StatefulSet 的内置控制器)能做的事是有限的——它只能处理 K8s 原生资源,处理不了你自定义的、跟具体业务相关的运维需求。所以当你需要扩展时,就可以用 Operator 这种模式去实现。

用 Operator 模式开发有这几大好处:

  1. 复用 Controller 的控制循环:控制循环的内部逻辑其实挺复杂的,但你用 Operator 框架之后,就不需要自己实现控制循环了——你只需要关注你自己的业务逻辑怎么写。
  2. 复用 K8s 声明式 + 资源管理能力:所有对象都存 etcd,CRD 本质上也是一个对象,你可以直接用 kubectl 对这个 CR 做增删改查。
  3. 简化有状态应用的管理:尤其是有状态应用(数据库、中间件),这是 Operator 最能发挥的地方。
  4. 继承 K8s 的应用管理能力:Operator 本身也是一个跑在 K8s 平台上的工作负载,所以它自动获得了 K8s 提供的自愈、滚动更新、自动重启等能力。

Operator 的典型使用场景

  • 把数据库/中间件实例定义成 CRD:比如 Redis、CockroachDB、ClickHouse……你只要声明一个 CR 并部署进集群,就能直接得到一个对应的数据库或中间件实例,甚至云资源(还记得之前讲的 Crossplane 吗?它就是把 VPC、云数据库这类云资源声明成 CRD)。类似 CapThet。
  • 自动化的运维任务:备份数据库、备份 K8s 上的任意资源等,任何业务逻辑都能借助 Operator 实现。
  • CI/CD workflow:把流水线也做成自定义资源。
  • 存储系统管理 等。

Operator 开发框架:kubebuilder 与 operator-sdk

主流的 Operator 开发框架有两个:

  • kubebuilder:K8s 社区(SIG)主导的官方框架,底层依赖 controller-runtime 和 controller-tools 这两个核心库。
  • operator-sdk:Red Hat 主导。它跟 kubebuilder 类似,底层其实复用了 kubebuilder 的代码生成能力,但额外封装了一些能力——最典型的就是提供 OLM(Operator Lifecycle Manager),让 operator 的打包、分发、部署更容易。

一个关键点:kubebuilder 是整个 Operator 开发的核心底座。无论你用 kubebuilder 还是 operator-sdk,本质底层都是那套东西。operator-sdk 更多是能力的叠加。

对比一下:

                     kubebuilder              operator-sdk
──────────────      ───────────────────      ─────────────────────
主导方               Kubernetes SIG           Red Hat
语言支持             Go                        Go / Ansible / Helm
底层依赖             controller-runtime       复用 kubebuilder + 更多封装
与 OLM 集成          弱                       强(OLM 本来就是它家的)
部署承载方式          打成 kustomize / helm    可直接打成 OCI bundle,配合 OLM
定位                 核心脚手架                更完整的一站式能力

operator-sdk 除了用 Go 开发,还支持 Ansible 和 Helm 两种特殊模式——可以基于一只 Ansible 脚本或一个 Helm Chart 直接生成一个 operator。这章最后一个实战就用 Helm 模式来做。

深入 kubebuilder 架构

既然 kubebuilder 是底座,我们把它的架构拆开看一下。它的架构主要分成这么几大模块:

  • Manager:用来初始化 Controller 使用的(Controller 的生命周期归它管)。它还管着 cache、client、metrics 这些公共组件。
  • Controller:是核心调度器,具备 cache(缓存)、工作队列(WorkQueue) 和失败重试的能力。它内部封装了 Reconciler。
  • Reconciler:这就是我们自己真正要写业务代码的地方。循环控制的业务逻辑全部写在这个 Reconcile 函数里。
  • Source(informers 缓存) + Client / Cache:一般我们不直接使用 Source、Client、Cache,了解即可。Source 主要基于 informers 监听资源变化。
  • Webhook:用来开发 admission webhook(准入控制器),本章不展开。

整个事件流转是这样的:

flowchart LR
    S[Source 基于 informers
监听资源变化] --> Q[工作队列 WorkQueue] Q --> W[Controller 订阅队列
processNextWorkItem 出队] W --> R[Reconciler
咱写的业务逻辑] R -->|返回结果决定是否重试| Q R -->|错误| W[重新入队 重试] R -->|RequeueAfter| Q[延迟入队]

Controller 从 Source 拿到变更后,把元素丢进工作队列,然后由 processNextWorkItem 方法出队,再去调用 Reconciler 的业务逻辑。Reconciler 的返回值决定了这条消息要不要重新入队——这跟你之前用 client-go 手写一个简单控制器很像,只是这里所有底层细节都帮你封装好了,还有自动代码生成帮你把架子搭好,你只管写业务。

Reconcile 的四种返回

触发 Reconcile 的过程,本质上就是从工作队列里取元素的过程。根据你业务逻辑的执行结果,会有四种情况,通过返回 ReconcileResult + error 来控制:

  1. 执行成功,且不需要再重试:返回 ctrl.Result{} 且 error = nil。此时这条消息会从工作队列里删除,事件消费完毕。
  2. 执行错误,需要重试:返回一个非 nil 的 error。消息会被重新放回工作队列,重新消费,并能拿到重试次数。
  3. 执行成功,但还有其他原因需要重试:在 Result 里给 Requeue: true,error 返回 nil。这相当于标记"把我重新入队",好处是你还能从队列里拿到这条消息被消费/重新入队的次数。
  4. 需要等一段时间再重试(延迟):在 Result 里给 RequeueAfter: 5 * time.Second 这种值,本质就是一个延迟队列。定时任务场景(比如后面 CronHPA)就靠它。
case 1  成功,无需重试     -> Result{} , nil           (从队列删除)
case 2  失败,需要重试     -> Result{},  err           (立即重新入队)
case 3  成功,但要重试     -> Result{Requeue:true}, nil(重新入队,可查次数)
case 4  延迟一段时间重试   -> Result{RequeueAfter:5s}, nil(延迟队列)

这张流程图把"四种返回"画成 Reconcile 结束时的一次决策:按返回值和 error 走向四条不同的路,每一条路都决定了这条消息是出队、立即重试,还是隔一段时间再回来。

flowchart TB
    R[Reconcile 执行完] --> D{取哪种返回}
    D -->|Result 空 且 nil| S[从队列删除
事件消费完毕] D -->|error 非 nil| E[立即重新入队
可取重试次数] D -->|Requeue: true| Q[重新入队
标记再试一次] D -->|RequeueAfter: 5s| W[延迟再入队
定时对账循环]

实战一:kubebuilder 实现类似 KubeVela Application 的定义

理论讲完了,开始动手。第一个实战是模仿 KubeVela 的 Application 定义:我们用一个 CRD 资源,一次性管理三个子资源——Deployment、Service、Ingress。你创建这个 CR,Operator 就帮你创建这三个资源;你删掉这个 CR,它就帮你删掉这三个资源。是不是很像 KubeVela 帮你"一键编排应用"的思路?

准备环境

先在本地用 kind 起一个集群(一句话就得到一个集群):

# 按官方文档 install kind(前提有 docker)
kind create cluster
kubectl get nodes   # 能看到一个单节点集群

# 安装 kubebuilder CLI
# 按官方文档安装即可(示例 3.x 版本)
kubebuilder version

初始化项目骨架

mkdir application && cd application

# 初始化 Go module
go mod init application.co/application

# 用 kubebuilder 初始化项目布局,--domain 决定 API group 的后缀
# 这里演示用 co 作为 domain,生产环境换成你自己的域名段
kubebuilder init --domain co \
    --repo application.co/application \
    --license apache2

初始化完,项目里会出现一大堆目录和文件——这些都是 kubebuilder 自动生成的,后面我们会一一解释。

创建 API(CRD + Controller 一起生成)

kubebuilder create api --group application --version v1 --kind Application

它会交互式问你两个问题,都要选 yes:

Create Resource under [application.co/application]?  # 生成 CRD -> y
Create Controller under [application.co/application]? # 生成 Controller -> y

这样就会生成 API 目录,里面核心就是我们定义自定义资源数据结构的 api/v1/application_types.go 文件。我们只需要改这个文件和 Controller 里的 Reconcile 业务逻辑,其他文件都是自动生成、不用手改的。

设计 CRD:写 types.go

打开 api/v1/application_types.go。我们需要的是 spec 里有三个字段:deployment、service、ingress。其中 service 和 ingress 我们直接引用 K8s 原生的 Service / Ingress 的 Spec 结构体,省得重复定义。

package v1

import (
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	networkingv1 "k8s.io/api/networking/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ApplicationDeploymentSpec 自定义的 Deployment 定义(简化)
type ApplicationDeploymentSpec struct {
	Image   string `json:"image"`
	Replicas *int32 `json:"replicas,omitempty"`
	Port    int32  `json:"port"`
}

// ApplicationSpec 定义 Spec:deployment / service / ingress
type ApplicationSpec struct {
	// Foo 是 kubebuilder 给的示例,删掉
	Deployment *ApplicationDeploymentSpec `json:"deployment"`
	// 直接复用原生 Service 的 Spec
	Service   *corev1.ServiceSpec         `json:"service,omitempty"`
	// 直接复用原生 Ingress 的 Spec
	Ingress   *networkingv1.IngressSpec    `json:"ingress,omitempty"`
}

// ApplicationStatus 记录实际状态,这里放一个可用副本数
type ApplicationStatus struct {
	AvailableReplicas int32 `json:"availableReplicas"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
type Application struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   ApplicationSpec   `json:"spec,omitempty"`
	Status ApplicationStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true
type ApplicationList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items []Application `json:"items"`
}

func init() {
	SchemeBuilder.Register(&Application{}, &ApplicationList{})
}

下面这个 ApplicationDeploymentSpec 之外,ApplicationList 这类都是自动生成样板,照抄即可。

生成 CRD YAML

写完 types.go,改好字段,就需要让 kubebuilder 根据类型定义自动生成 CRD 文件:

make manifests

跑完后,config/crd/bases/ 下就会多出一个自动生成的 CustomResourceDefinition YAML(application.co_applications.yaml 之类)。这个文件我们不需要管,它包含了我们刚定义的 image、replicas,也把我们引用的整个 ServiceSpec、IngressSpec 都带了进来。以后真要改字段,改 types.go 再重跑 make manifests 就行。

编写 Reconcile 业务逻辑

现在来到本节最重要的一步:在 internal/controller/application_controller.go 里写 Reconcile。kubebuilder 已经给我们预留好了这个函数,并且帮我们封装好了获取 CRD、监听变更等所有琐事。我们只需要:

  1. 把 CR 获取到手;
  2. 根据 CR 里的 spec,创建或更新 Deployment / Service / Ingress 三个对象;
  3. 删除逻辑不用单独写——后面用 ownerReference 自动处理。
package controller

import (
	"context"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	networkingv1 "k8s.io/api/networking/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
	"sigs.k8s.io/controller-runtime/pkg/log"

	applicationv1 "application.co/application/api/v1"
	"github.com/go-logr/logr"
)

type ApplicationReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

func (r *ApplicationReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx)

	// 1. 获取我们的 CR(Application)
	var app applicationv1.Application
	if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
		// 拿不到可能是被删,不用重试
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}
	log.Info("application fetched", "name", app.Name)

	// 给子资源准备统一的 label
	labels := map[string]string{"app": app.Name}

	// 2. 创建或更新 Deployment
	if app.Spec.Deployment != nil {
		dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
		_, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
			replicas := app.Spec.Deployment.Replicas
			if replicas == nil {
				t := int32(1)
				replicas = &t
			}
			dep.Spec.Replicas = replicas
			dep.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
			dep.Spec.Template = corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{Labels: labels},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{{
						Name:  app.Name,
						Image: app.Spec.Deployment.Image,
						Ports: []corev1.ContainerPort{{ContainerPort: app.Spec.Deployment.Port}},
					}},
				},
			}
			// 标记归属:让 CR 成为这个 Deployment 的所有者
			return controllerutil.SetControllerReference(&app, dep, r.Scheme)
		})
		if err != nil {
			return ctrl.Result{}, err
		}
		log.Info("deployment created/updated")
	}

	// 3. 创建或更新 Service(直接复用 CRD 里定义的 ServiceSpec)
	if app.Spec.Service != nil {
		svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
		_, err := controllerutil.CreateOrUpdate(ctx, r.Client, svc, func() error {
			svc.Spec = *app.Spec.Service
			return controllerutil.SetControllerReference(&app, svc, r.Scheme)
		})
		if err != nil {
			return ctrl.Result{}, err
		}
		log.Info("service created/updated")
	}

	// 4. 创建或更新 Ingress
	if app.Spec.Ingress != nil {
		ing := &networkingv1.Ingress{ObjectMeta: metav1.ObjectMeta{Name: app.Name, Namespace: app.Namespace}}
		_, err := controllerutil.CreateOrUpdate(ctx, r.Client, ing, func() error {
			ing.Spec = *app.Spec.Ingress
			return controllerutil.SetControllerReference(&app, ing, r.Scheme)
		})
		if err != nil {
			return ctrl.Result{}, err
		}
		log.Info("ingress created/updated")
	}

	// 5. 更新 status(把 Deployment 的实际副本数写进去)
	_ = r.updateStatus(ctx, &app)

	return ctrl.Result{}, nil
}

func (r *ApplicationReconciler) updateStatus(ctx context.Context, app *applicationv1.Application) error {
	var dep appsv1.Deployment
	if err := r.Get(ctx, types.NamespacedName{
		Name: app.Name, Namespace: app.Namespace}, &dep); err == nil {
		app.Status.AvailableReplicas = dep.Status.AvailableReplicas
		_ = r.Status().Update(ctx, app)
	}
	return nil
}

// SetupWithManager 注册 controller:盯 Application 这个 kind,并 Owns Deployment
func (r *ApplicationReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&applicationv1.Application{}).
		Owns(&appsv1.Deployment{}).
		Complete(r)
}

需要 metav1 的 import,这里为了排版省略,实际补上即可。

几个关键点:

  • controllerutil.CreateOrUpdate:它帮你判断"资源不存在就创建、存在就更新",这是幂等对账的核心工具。它会先尝试 Get,拿不到就走 create 的 mutate,拿到了就走 update 的 mutate。
  • controllerutil.SetControllerReference:给子资源打上 ownerReference,标记"我是被这个 CR 管的"。这是级联删除的关键——你删 CR,子资源会自动跟着删,所以我们的 Reconcile 里根本不用写删除逻辑。这是 ownerReference 自带的好处。

部署到本地集群

先装 CRD,再起本地运行,最后 apply 示例:

# 1. 把 CRD 安装进 kind 集群(用 kustomize 方式部署 config/crd)
make install

# 2. 本地跑 operator(还没打成镜像,就在本机 make run)
make run

# 3. 写一个示例 CR:config/samples/application_v1_application.yaml

示例 CR 长这样(对应前面设计的三个字段):

apiVersion: application.co/v1
kind: Application
metadata:
  name: application-sample
spec:
  deployment:
    image: nginx:latest
    replicas: 1
    port: 80
  service:
    ports:
      - port: 80
        targetPort: 80
        protocol: TCP
  ingress:
    ingressClassName: nginx
    rules:
      - host: aiops.example.com
        http:
          paths:
            - path: /
              pathType: Prefix
              backend:
                service:
                  name: application-sample
                  port:
                    number: 80

apply 之后验证:

kubectl apply -f config/samples/application_v1_application.yaml
kubectl get deployment
kubectl get service
kubectl get ingress
# 三个名为 application-sample 的资源都出来了,说明 operator 符合预期

再验证更新和级联删除:

# 更新:改镜像版本后再次 apply,Deployment 的 image 应该跟着变
kubectl get deployment -o yaml

# 级联删除:这时 operator 并没写删除逻辑,但删 CR 三个资源会一起消失
kubectl delete -f config/samples/application_v1_application.yaml
kubectl get deployment   # 没了
kubectl get service      # 没了
kubectl get ingress      # 没了

亲眼看到 ownerReference 的威力了吧?这就是实战一的核心收获。

实战二:kubebuilder 实现阿里云定时弹性伸缩器

第二个实战,做一个类似阿里云定时弹性伸缩器(或者说模拟 K8s 原生 HPA 之外的一种"定时水平扩容")的 Operator,我们给它起名叫 CronHPA:让一个 Deployment 按照 Cron 表达式定时扩缩容。

CRD 设计

kind: CronHPA
spec:
  scaleTargetRef:      # 要操作哪个工作负载
    apiVersion: apps/v1
    kind: Deployment
    name: nginx
  jobs:                # 定时伸缩任务(数组)
    - name: scaleUp
      schedule: "*/1 * * * *"   # 每分钟执行(测试用,生产可写每天11点等)
      targetSize: 3

这样一个 CRD 就可以定时地把目标 Deployment 扩容到指定副本数。

创建项目与 API

mkdir cronhpa && cd cronhpa
go mod init autoscaling.co/cronhpa
kubebuilder init --domain co --repo autoscaling.co/cronhpa --license apache2

kubebuilder create api --group autoscaling --version v1 --kind CronHPA
# Create Resource -> y, Create Controller -> y

写 types.go

package v1

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

// ScaleTargetReference 指定目标工作负载
type ScaleTargetReference struct {
	APIVersion string `json:"apiVersion"`
	Kind       string `json:"kind"`
	Name       string `json:"name"`
}

// CronJobSpec 一个定时任务
type CronJobSpec struct {
	Name       string `json:"name"`
	Schedule   string `json:"schedule"`   // cron 表达式
	TargetSize int32  `json:"targetSize"` // 目标副本数
}

// CronHPASpec 定义 spec
type CronHPASpec struct {
	ScaleTargetRef ScaleTargetReference `json:"scaleTargetRef"`
	Jobs           []CronJobSpec         `json:"jobs"`
}

// CronHPAStatus 记录实际状态
type CronHPAStatus struct {
	CurrentReplicas int32               `json:"currentReplicas"`
	LastScaleTime   *metav1.Time        `json:"lastScaleTime,omitempty"`
	// 每个 job 各自的最后运行时间
	LastRunTime     map[string]string    `json:"lastRunTime,omitempty"`
}

为了让 kubectl get cronhpa 时能展示更多列(而不是只有 NAME / AGE),我们给 status 字段加上 kubebuilder 的 printcolumn 注解。可以换个位置,直接把注解写在 CronHPA 结构体上方:

// +kubebuilder:object:root=true
// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=".spec.scaleTargetRef.name",description="目标工作负载"
// +kubebuilder:printcolumn:name="Schedule",type=string,JSONPath=".spec.jobs[0].schedule",description="Cron 表达式"
// +kubebuilder:printcolumn:name="TargetSize",type=integer,JSONPath=".spec.jobs[0].targetSize",description="目标副本数"
type CronHPA struct { ... }

然后重新生成:

make manifests

编写 CronHPA 的 Reconcile

创建/更新逻辑跟实战一类似,重点是定时的对账逻辑。我们先引入一个解析 cron 的 Go 库(音频里说的 “CRN” 其实是 cron):

go get github.com/robfig/cron/v3

核心思路:遍历 Jobs,读取该 job 上次运行的 lastRunTime(存在 status 里),算出"下一次调度时间",判断当前时间是否已经到达/超过计划时间;是就去更新目标 Deployment 的副本数。并设置 RequeueAfter,让它像一个"定时器"一样周期性地被触发。

这张时序图展示 CronHPA 如何把自己变成"定时器":每次 Reconcile 都算出下一个调度时间并设置 RequeueAfter,触发下一次,形成周而复始的对账循环。

sequenceDiagram
    participant K as Kubernetes
    participant CR as CronHPA CR
    participant R as Reconciler
    participant D as Deployment
    K->>CR: 生成/更新 CronHPA
    CR->>R: 触发 Reconcile
    R->>R: 遍历 Jobs 算 nextScheduleTime
    R->>D: 到达计划时间则更新副本数
    R->>R: RequeueAfter=距下次的间隔
    R-->>K: 返回 Result 延迟入队
    K->>CR: 时间到 再次触发 Reconcile
    CR->>R: 周而复始对账循环
func (r *CronHPAReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	log := log.FromContext(ctx)
	log.Info("cronhpa reconciling")

	// 获取 CR
	var chp autoscalingv1.CronHPA
	if err := r.Get(ctx, req.NamespacedName, &chp); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	now := time.Now()
	// 记录下一次应该运行的时间
	var nextRunTime *time.Time

	// 遍历每个 job,检查调度时间并更新目标副本数
	for _, job := range chp.Spec.Jobs {
		// 取上次运行时间(可能有不同 job 分别记录)
		var lastRunTime *time.Time
		if t := chp.Status.LastRunTime[job.Name]; t != "" {
			if parsed, err := time.Parse(time.RFC3339, t); err == nil {
				lastRunTime = &parsed
			}
		}
		log.Info("check job", "job", job.Name, "lastRunTime", lastRunTime)

		nextScheduleTime, err := r.getNextScheduleTime(job.Schedule, lastRunTime)
		if err != nil {
			return ctrl.Result{}, err
		}

		// 当前时间已到达/超过计划时间 -> 更新副本数
		if now.After(*nextScheduleTime) || now.Equal(*nextScheduleTime) {
			log.Info("update job", "name", job.Name, "targetSize", job.TargetSize)
			if err := r.updateDeploymentReplicas(ctx, &chp, &job); err != nil {
				log.Error(err, "failed to update replicas")
				return ctrl.Result{}, err
			}
			chp.Status.LastScaleTime = &metav1.Time{Time: now}
			chp.Status.CurrentReplicas = job.TargetSize
			// 记录该 job 的最后运行时间
			if chp.Status.LastRunTime == nil {
				chp.Status.LastRunTime = map[string]string{}
			}
			chp.Status.LastRunTime[job.Name] = now.Format(time.RFC3339)
		}

		// 计算下一次运行时间(从现在起)
		next := r.getNextScheduleTime(job.Schedule, &now)
		if next == nil { continue }
		if nextRunTime == nil || next.Before(nextRunTime) {
			nextRunTime = next
		}
	}

	_ = r.Status().Update(ctx, &chp)

	// 关键!用 RequeueAfter 实现"定时器":到了时间再触发一次
	requeueAfter := time.Hour
	if nextRunTime != nil {
		d := nextRunTime.Sub(now)
		if d < 0 {
			d = time.Second // 已经过期,1 秒后就再试
		}
		requeueAfter = d
	}
	log.Info("will requeue", "after", requeueAfter)
	return ctrl.Result{RequeueAfter: requeueAfter}, nil
}

// getNextScheduleTime 用 robfig/cron 解析并计算下次调度时间
func (r *CronHPAReconciler) getNextScheduleTime(schedule string, after *time.Time) (*time.Time, error) {
	parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
	sched, err := parser.Parse(schedule)
	if err != nil {
		return nil, err
	}
	t := sched.Next(*after)
	return &t, nil
}

// updateDeploymentReplicas 更新目标 Deployment 的副本数
func (r *CronHPAReconciler) updateDeploymentReplicas(ctx context.Context, chp *autoscalingv1.CronHPA, job *autoscalingv1.CronJobSpec) error {
	dep := &appsv1.Deployment{}
	if err := r.Get(ctx, types.NamespacedName{
		Name: chp.Spec.ScaleTargetRef.Name, Namespace: chp.Namespace}, dep); err != nil {
		return err
	}
	// 已经是目标值就什么都不做(幂等)
	if dep.Spec.Replicas != nil && *dep.Spec.Replicas == job.TargetSize {
		return nil
	}
	dep.Spec.Replicas = &job.TargetSize
	return r.Update(ctx, dep)
}

部署和验证(会踩两个坑)

make install
make run

先造一个 nginx Deployment(副本 1),再 apply 一个 CronHPA:

apiVersion: autoscaling.co/v1
kind: CronHPA
metadata:
  name: cronhpa-sample
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: nginx
  jobs:
    - name: scaleUp
      schedule: "*/1 * * * *"   # 每分钟
      targetSize: 3

坑一:Cron 表达式报错 expected exactly 5 fields, found 5 类错误。第一次演示时我把表达式写成了七段(多了秒那段),会报 expected exactly 5 fields, found ...。标准 CronHPA 常规写法是五段(分 时 日 月 周),别多写。

坑二:漏写了 RequeueAfter / 收尾状态更新。第一次跑的时候,我只写了"到点更新副本",忘掉了最后那段"计算 nextRunTime 并 RequeueAfter 重新入队"的逻辑,结果一分多钟后不会再次伸缩。补上 return ctrl.Result{RequeueAfter: ...} 之后,日志里就会出现 RequeueAfter,Deployment 会严格按分钟反复把副本数拉回目标值。

验证现象:

kubectl get cronhpa            # 能看到 Target / Schedule / TargetSize 列(printcolumn 生效)
kubectl get deploy nginx       # 副本从 1 -> 3(定时扩容)
kubectl scale deploy nginx --replicas=1   # 人为缩到 1
# 等一分钟,它又被拉回 3 —— 定时伸缩能力达成

这个实战里 RequeueAfter + cron 解析 + LastRunTime 状态记录是三个核心点,逻辑比实战一复杂不少。若要上生产,还要处理时区、任务去重、并发等更多细节。

把 kubebuilder Operator 打包部署到集群

实战一、二我们都是在本机 make run 跑 operator。真正生产要把它打成镜像丢到集群里。步骤:

# 1. 设 IMG 环境变量(含镜像仓库路径 + 版本号)。
#    省略 docker.io 前缀时默认推 Docker Hub。
export IMG=docker.io/<your-name>/application-operator:v0.1

# 2. 构建并推送 operator 镜像(项目根目录有自动生成的 Dockerfile,make 帮你简化)
make docker-build # 若本机有 docker-daemon
make docker-push

# 3. 生成一个"一键部署"的 install 清单文件,输出到 dist/ 目录
make build-installer

# dist/install.yaml 里包含 namespace、CRD、ServiceAccount、ClusterRole、
# ClusterRoleBinding、operator 自身的工作负载(Deployment)……整套都齐了
kubectl apply -f dist/install.yaml

这样 operator 就作为一个 Deployment 跑进集群了。你拿着 dist/install.yaml 这一个文件,就可以在任何集群里安装这个 operator。

实战三:operator-sdk 基于 Helm 开发 Operator

operator-sdk 支持一种很特殊的构建方式:直接把一个现成的 Helm Chart 变成一个 Operator。你只要声明一个 CR,就能把一个数据库/中间件实例(比如 Redis)自动部署进集群。它会把 Helm Chart 里 values.yaml 的配置自动映射到 CRD 的 spec 下。

初始化 Helm-based operator

# 先安装 operator-sdk CLI
# 创建一个 redis-operator 目录
mkdir redis-operator && cd redis-operator
go mod init redis.co/redis-operator

# 初始化,注意插件要指定为 helm
operator-sdk init --domain co --plugins=helm

# 创建 API:_--helm-chart 指定一个 Helm Chart。这里先给仓库地址下载
# 第一次可能因依赖构建报错:进 chart 目录手动 build 依赖
operator-sdk create api --group app --version v1 \
    --kind Redis --helm-chart=embed/prefix?  # 略,实际写 chart 源

视频中因为下载 chart 时依赖没构建,报了一个错误,处理方式是进 helm-charts/redis/ 目录执行 helm dependency build 把子依赖补上,然后重新 create api 时直接指定本地的 chart 目录即可修复。

第二步正确写法(指定本地 chart 目录):

operator-sdk create api --group app --version v1 \
    --kind Redis --helm-chart=helm-charts/redis

完成之后,项目里自动生成了:

  • helm-charts/redis/values.yaml:chart 的配置,就是 operator 的"安装参数"。
  • config/samples/app_v1_redis.yaml:示例 CR,其 spec 内容全部来自 values.yaml,你看它的 image、replicas、auth、master 等字段,跟 values 一一对应。

也就是说,你不用写任何 reconcile 业务代码——operator-sdk 会帮你:当 CR 创建或变化时,自动用 CR 的 spec(即安装参数)去部署对应 Helm Release、更新配置、管理实例。

构建并部署

# 构建 operator 镜像并 push(直接通过 --image 传 IMG)
operator-sdk build --image docker.io/<name>/redis-operator:v0.1
docker push docker.io/<name>/redis-operator:v0.1

# 部署 operator 到集群
make deploy IMG=docker.io/<name>/redis-operator:v0.1
kubectl get ns     # 会新增一个 redis-system,operator 工作负载部署在这里

# 部署示例 CR(相当于声明一个 redis 实例)
kubectl apply -f config/samples/app_v1_redis.yaml
kubectl get pods    # redis 应该被拉起来

可能遇到的权限坑

我第一次部署后,Redis 没能拉起来。查 operator 日志看到:

is forbidden: usercannot get resource ... API group ...

意思是 operator 拉起的 Redis 部署,其绑定的 ServiceAccount 权限不够(这些特殊安装可能需要更宽的 RBAC)。解决:找到它实际绑定的 ServiceAccount(一般是 redis-operator-controller-manager),给它创建一个 ClusterRoleBinding 绑上 cluster-admin 之类的权限,然后重启 operator Pod:

kubectl get deployment -n redis-system -o yaml | grep serviceAccount

# 把该 SA 绑到集群级权限上(演示用,正式环境应最小授权)
kubectl create clusterrolebinding redis-operator-cluster-admin \
    --clusterrole=cluster-admin \
    --serviceaccount=redis-system:redis-operator-controller-manager

kubectl rollout restart deployment -n redis-system

重启后 Redis 就出来了——一般一个 master、一个 slave 副本。到这里我们就亲眼看到了"一个 Helm Chart 变成 operator"的神奇能力,整个过程中一行业务代码都没写:CRD 是自动生成的、values 映射是自动的、实例生命周期是 operator-sdk 管理的。

operator-sdk 的 OLM:Operator 生命周期管理

OLM 全称 Operator Lifecycle Manager(Operator 生命周期管理器)。它负责管理 operator 的完整生命周期,能做安装、卸载、升级。你可以把它理解成"operator 的应用商店 + 装机师傅",而它部署的单位是 bundle(打包镜像)。

OLM 与 kubebuilder 打包的差别

用 kubebuilder 写完 operator,要打包成一个很复杂的 install.yaml。OLM 更进一步:把这个"软件部署清单"打包成一个标准的 OCI 镜像(bundle),可以推到任意镜像仓库。部署时你直接 operator-sdk run bundle 引用一个 OCI bundle 即可,都不用去处理那个 install.yaml 文件的存储问题。

使用步骤

# 0. 先把之前手动部署的 redis-operator 卸载干净
make uninstall

# 1. 在集群里安装 OLM(会安装一批工作负载)
operator-sdk olm install

# 2. 设两个环境变量:operator 镜像 + bundle 镜像
export IMG=docker.io/<your-name>/redis-operator:v0.1
export BUNDLE_IMG=quay.io/<your-name>/redis-operator-bundle:v0.0.1

# 3. 生成 bundle(交互式,会问你一堆元信息)
make bundle
#    它会问你 operator 名称(redis-operator)、description、keywords、developer、email
#    生成完成后 bundle 名为 docker.io/xxx/redis-operator-bundle:v0.0.1

# 4. 构建并推送 bundle 镜像
make bundle-build bundle-push

# 5. 校验 bundle 是否合法
operator-sdk bundle validate "$BUNDLE_IMG"

# 6. 一条命令在任意集群安装 operator
operator-sdk run bundle "$BUNDLE_IMG"

一个演示中的坑

视频演示里最后 operator-sdk run bundle 报了个 creating catalog 相关的错,原因是之前手动部署过的 operator 在集群里留下了"残留资源"污染了环境(比如 CatalogSource、Subscription 状态不对、旧的 CRD 冲突)。最暴力的解决办法:把 kind 集群直接删掉重建(kind delete cluster && kind create cluster),再跑一遍就干净了。

这个现象也提醒我们:OLM 这类工具对环境状态比较敏感,反复实验前最好从干净集群开始。

Operator 开发最佳实践

最后一个专题,讲几条开发 operator 时的金科玉律,出自 K8s 官方 / Red Hat 的实践总结:

  1. Reconcile 不要关注具体事件:千万不要针对 Create / Update / Delete 分别写不同逻辑。因为触发 reconcile 的事件可能会重复(网络抖动、重启、周期 requeue),区分事件会让你的逻辑不稳。而是应该设计一个幂等的 reconcile——不管运行多少次,结果都应该一样。幂等是第一条要素。
  2. 只关注期望状态与当前状态:尽量简化业务逻辑,只做一件事——拿到期望状态(spec),读当前实际状态,然后让实际去对齐期望。其他各种特殊判断能砍则砍,否则 operator 很容易出 bug。
  3. 端到端测试用 envtest。

用 envtest 做端到端测试(不需要真实集群)

envtest 是一个假的 API Server:你给它发请求它能真正返回内容、还带 etcd 持久化,但它没有控制器,也不生成真实工作负载。它是单机运行的一个二进制,跑起来就能用。它非常适合做"跟 CRD 进行交互、验证字段是否正确"的端到端测试。

使用 envtest 的步骤:

# 1. 安装并 setup(会装 kube-apiserver 和 etcd 两个二进制)
setup-envtest use latest

# 2. 让测试环境能读取到 envtest 的二进制目录
#    默认从某个目录读 kube-apiserver 和 etcd,用 setup-envtest 设置 KUBEBUILDER_ASSETS 环境变量

kubebuilder 初始化的项目里自带测试框架:

internal/controller/
├── suite_test.go     # 测试入口(BeforeSuite / AfterSuite)
└── <name>_controller_test.go   # 写测试用例的地方

以一个简单的 suite_test.go 为例:

package controller

import (
	"path/filepath"
	"testing"

	. "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"
	applicationv1 "application.co/application/api/v1"
	ctrl "sigs.k8s.io/controller-runtime"
)

var (
	cfg       *rest.Config
	k8sClient client.Client
	testEnv   *envtest.Environment
)

func TestControllers(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
	ctrl.SetLogger(zap.New(zap.UseDevMode(true)))

	// 初始化 envtest 环境,把我们的 CRD 目录挂进来
	testEnv = &envtest.Environment{
		CRDDirectoryPaths:     []string{filepath.Join("..", "..", "config", "crd", "bases")},
		ErrorIfCRDPathMissing: true,
	}

	var err error
	cfg, err = testEnv.Start() // 启动了一个 K8s 集群(无控制器)
	Expect(err).NotTo(HaveOccurred())

	Expect(applicationv1.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred())

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

var _ = AfterSuite(func() {
	_ = testEnv.Stop()
})

testEnv.Start() 就相当于启动了一个 K8s 集群,它会复用我们 specs 目录里的 CRD。然后我们就可以写一个简单测试用例:创建自定义资源并重新获取,验证字段正确:

var _ = Describe("CRD e2e", func() {
	Context("create and fetch", func() {
		It("should create and fetch CRD", func() {
			myResource := &applicationv1.Application{
				ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
				Spec: applicationv1.ApplicationSpec{ /* 填 spec */ },
			}
			Expect(k8sClient.Create(context.Background(), myResource)).To(Succeed())

			var got applicationv1.Application
			Expect(k8sClient.Get(context.Background(), client.ObjectKeyFromObject(myResource), &got)).To(Succeed())
			// 这里还可以继续验证 spec 字段 / status 字段
		})
	}
})

跑测试:

make test   # 或按项目 Makefile 里的 target

看输出,能看到 starting control plane、installed CRD,然后 PASS。这样我们就用不启动任何真实集群的方式,完成了对 CRD + operator 交互的端到端验证。需要注意:因为 envtest 没有控制器、不会真正生成 Pod,它本质上是"数据的增删改 + 字段校验"层面的验证。

课后作业

给实战一的 Application CRD 增加一个 configmap 字段,让 operator 能在一个 CR 里同时生成 Deployment + Service + Ingress + ConfigMap 四种资源。练完你就会对 operator 的 resource 编排有一个完整的把握。

总结

本章从"什么是 Operator"一路做到"把它打包、下发、测试"。核心是这几条主线:

  • Operator = Controller + CRD,本质是把人肉运维经验代码化,复用 K8s 控制循环,实现声明式管理复杂有状态应用。
  • 控制循环的心智:期望状态(spec)vs 实际状态(status),Controller 让两者最终一致;Reconcile 有四种返回控制重试策略。
  • kubebuilder:核心底座,架构由 Manager / Controller / Reconciler / Source / WorkQueue 组成,我们只写 types.go 和 Reconcile 两处。两个实战分别学会了"多资源编排"(Application)和"定时对账"(CronHPA + RequeueAfter + cron 解析)。
  • operator-sdk:底层复用 kubebuilder,额外提供 Helm / Ansible 模式和 OLM。可以把现成 Chart 直接变 operator,一行业务代码不写。
  • 最佳实践:幂等 reconcile、只关注期望/当前状态、用 envtest 做无集群端到端测试。

这套能力搭建好之后,后续就能在这个骨架上叠加 AIOps 的智能能力——比如让 operator 结合告警数据自己决策、自己扩容/缩容/自愈,实现"系统自己诊断自己修"。

自测题与动手练习

面试官
用一句话说明 Operator 是什么?它的两个组成部分各负责什么?
候选人

Operator = Controller + CRD。

  • CRD(Custom Resource Definition):声明式地定义一个新的资源类型,描述这个 kind 的 spec/status 字段结构,相当于给 K8s API 加一张新表。
  • Controller:监听 CR 的变更,执行 reconcile 对账逻辑,让"期望状态(spec)“与"实际状态(status)“最终保持一致。
面试官
什么是"期望状态"和"实际状态"?控制循环的核心目标是什么?用定速巡航举例。
候选人
  • 期望状态就是资源 spec 里声明的目标,比如 Deployment 声明"3 副本、某镜像"。
  • 实际状态是对象当前的真实情况,通常记录在 status 字段里(也会主动查询获得)。
  • 控制循环让实际状态最终收敛到期望状态。就像定速巡航:你设定 110(期望),车现在 80(实际),系统不断提速、接近时再微调(控制力度收敛),直到达到 110。
面试官
Reconcile 有哪四种返回情况?分别怎么写、什么语义?
候选人
  1. 成功无需重试:Result{} + nil,消息从工作队列删除。
  2. 失败需重试:Result{} + 非 nil error,立即重新入队。
  3. 成功但要重试:Result{Requeue:true} + nil,重新入队且可取到入队次数。
  4. 延迟重试:Result{RequeueAfter: 5*time.Second} + nil,相当于延迟队列,定时任务就靠它。
面试官
kubebuilder 与 operator-sdk 是什么关系?各自擅长什么?
候选人

kubebuilder 是核心底座(底层依赖 controller-runtime / controller-tools),operator-sdk 底层复用了 kubebuilder 的代码生成,并额外封装能力——最典型是 OLM 和 Helm/Ansible 模式。

  • 纯 Go 写 controller,两者都能用,产物布局类似;
  • 想让 operator 更好地配合 OLM 分发、或想直接用 Helm Chart / Ansible playbook 生成 operator,就用 operator-sdk。
面试官
实战一中,为什么 Reconcile 没写删除逻辑,删 CR 时子资源却一起被删了?
候选人
因为创建每个子资源时都调用了 controllerutil.SetControllerReference(&app, &dep, r.Scheme),给子资源打上了 ownerReference,标记"我是这个 CR 管理的"。删除 CR 时,K8s 会根据 ownerReference 级联删除所有从属资源。所以 operator 只需准备"创建/更新",删除交给 ownerReference 自动完成。
面试官
实战二 CronHPA 里,实现"定时持续对账"最关键的是哪一段代码?第一次演示漏了什么?
候选人
最关键的是结尾的那句 return ctrl.Result{RequeueAfter: nextRunTime.Sub(now)},以及 getNextScheduleTime 用 cron 库算出每个 job 的下一次调度时间(存在 status.LastRunTime 里做去重)。
第一次演示漏掉了 RequeueAfter 那部分,导致只扩容一次、之后不再触发;补上后日志里出现 RequeueAfter,Deployment 就按分钟反复把副本拉回目标值。还踩过一个坑:cron 表达式多写了字段,报 expected exactly 5 fields——标准写法是 5 段。
面试官
operator-sdk 的 Helm 模式能替你省掉什么?部署 Redis 时遇到的权限问题怎么解决?
候选人
Helm 模式会把 chart 的 values.yaml 自动映射成 CRD 的 spec,生成 CRD、sample、operator 骨架,你几乎不用写业务代码——CR 创建/变化时它自动按安装参数去部署和更新对应实例。
若部署后 Redis 拉不起来,查 operator 日志看 forbidden,找到 operator 实际绑定的 ServiceAccount(如 redis-operator-controller-manager),给该 SA 创建 ClusterRoleBinding 绑定足够权限,再重启 operator Pod 即可。
面试官
OLM 相比直接 apply install.yaml 的优势是什么?它用什么单位下发 operator?
候选人
OLM 把繁琐的安装清单进一步封装成标准的 OCI bundle 镜像,推到镜像仓库。部署时一行 operator-sdk run bundle <bundle镜像> 就能在任何集群安装/卸载/升级,不用再维护 install.yaml 那个大文件的存储。
注意:bundle 对环境状态敏感,反复实验时尽量从干净集群开始,避免残留 CatalogSource / CRD 冲突。
面试官
Operator 开发的第一条最佳实践是什么?为什么 reconcile 必须设计成幂等的?
候选人
第一条:Reconcile 不要关注具体事件,也不要把 Create/Update/Delete 分开写逻辑,而是设计成幂等的——无论运行多少次结果都一致。
因为触发 reconcile 的事件可能因网络等原因重复触发,并且 controller 重启后要能基于当前状态重新对账。只关注"期望状态 vs 当前状态",让实际对齐期望,既幂等又简单。
面试官
envtest 是什么?它和真实集群有什么差别?适合做什么测试?
候选人
envtest 是一个没有控制器的假 API Server(带 etcd、单机二进制)。它能真实响应 API 请求、有数据的增删改查,但不会真正生成 Pod、不会跑 Workload。
适合做"创建/获取自定义资源、验证 spec/status 字段是否正确"的端到端测试,不需要启动任何真实集群。验证的是 CRD 与 operator 交互层面的正确性,而非真实资源是否拉起。

动手练习(建议真做一遍):

  1. 用 kind 起本地集群,kubebuilder init --domain co + kubebuilder create api --group application --version v1 --kind Application 生成骨架,改 types.go + Reconcile 实现"Application 管三个资源",验证创建/更新/级联删除。
  2. 给实战一加一个 configmap 字段(课后作业),让一个 CR 生成四种资源。
  3. 实现 CronHPA,用一个 nginx Deployment 测试定时扩缩容;故意把 cron 表达式写成七段,亲眼看看 expected exactly 5 fields 报错。
  4. 给 CronHPA 的字段加 printcolumn 注解,重跑 make manifests,kubectl get cronhpa 看多出来的列。
  5. 用 operator-sdk Helm 模式把一个 redis Chart 变成 operator,练习处理 SA 权限问题。
  6. 用 envtest 写一个"创建并获取 Application"的测试,make test 跑通,观察 starting control plane + PASS。

本章小结

复习提示:
  • Operator = Controller + CRD:把运维领域知识代码化,复用 K8s 控制循环实现复杂应用/中间件的声明式管理。
  • 核心心智:期望状态(spec)vs 实际状态(status),Controller 通过 reconcile 让两者最终一致;reconcile 有"成功 / 失败-重试 / 成功-重试 / 延迟重试"四种返回。
  • 两大框架:kubebuilder 是核心底座(Manager/Controller/Reconciler/Source/WorkQueue 架构),operator-sdk 复用 kubebuilder 并叠加 OLM 与 Helm/Ansible 模式。
  • 实战一:用 CreateOrUpdate + SetControllerReference 用一个 CR 管 Deployment/Service/Ingress,ownerReference 自动级联删除。
  • 实战二:CronHPA 用 cron 解析 + LastRunTime 状态 + RequeueAfter 定时对账实现定时弹性伸缩;printcolumn 让 kubectl get 展示更多列。
  • 打包:kubebuilder 用 make build-installer 产出 dist/install.yaml 一键部署;operator-sdk + OLM 用 OCI bundle 下发。
  • 最佳实践:幂等 reconcile、只关注期望/当前状态,用 envtest 做无集群端到端测试。
复习提示:下一阶段可以在这个 Operator 骨架上叠加 AIOps 智能能力——让 operator 对接告警/指标,自己诊断故障、自己决定并执行扩缩容或自愈动作,这才是把本章练熟之后真正的大招。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!